diff --git a/Cargo.lock b/Cargo.lock index 2431723e10..3047650ba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7913,9 +7913,9 @@ dependencies = [ [[package]] name = "tinyplace" -version = "0.10.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abf658d89a0e986a4dc5cabfbd005ba63455c70348aca60d836e333010acc9e2" +checksum = "a96c2478e3407780674721fde873ca9f188d71f507f01885eb95e9024f07ac34" dependencies = [ "aes", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index c75fdb0f10..dd89a8312a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ crate-type = ["rlib"] [dependencies] # tiny.place A2A social network SDK — published on crates.io (tinyhumansai/tiny.place) -tinyplace = "0.10.0" +tinyplace = "1.0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_repr = "0.1" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index b4e7f2a0c8..a61aef3de0 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -8803,9 +8803,9 @@ dependencies = [ [[package]] name = "tinyplace" -version = "0.10.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abf658d89a0e986a4dc5cabfbd005ba63455c70348aca60d836e333010acc9e2" +checksum = "a96c2478e3407780674721fde873ca9f188d71f507f01885eb95e9024f07ac34" dependencies = [ "aes", "async-trait", diff --git a/app/src/agentworld/iso/Agent.ts b/app/src/agentworld/iso/Agent.ts new file mode 100644 index 0000000000..68bd0a5ba2 --- /dev/null +++ b/app/src/agentworld/iso/Agent.ts @@ -0,0 +1,261 @@ +/** + * A single agent in the world. + * + * Agents are reconciled, not driven: an external controller sets an + * authoritative {@link AgentState} and the agent interpolates toward it. Motion + * is pure linear interpolation between tile centres at a configurable speed, so + * there are no teleports — an agent always slides along its resolved path. The + * sprite is a single shared body texture, tinted, plus a soft ground shadow and + * a bitmap nameplate. + */ +import { BitmapText, Container, Sprite } from 'pixi.js'; + +import { depthAt, LAYER_AGENT, lerp, tileCenterToScreen } from './geometry'; +import type { BakedTexture } from './textures'; +import type { AgentAction, Facing, WalkNode } from './types'; + +const DEFAULT_SPEED = 2.6; +const ARRIVAL_EPSILON = 0.0001; +// Agents render a little larger than their baked size so they read clearly +// against the tiles. Nearest filtering keeps the upscale crisp. +const AGENT_SCALE = 1.35; + +interface AgentOptions { + id: string; + body: BakedTexture; + face: BakedTexture; + shadow: BakedTexture; + accessory: BakedTexture; + accessoryTint: number; + fontName: string; + tint: number; + label: string; + spawn: WalkNode; +} + +export class Agent extends Container { + public readonly agentId: string; + + private readonly figure = new Container(); + private readonly body: Sprite; + private readonly face: Sprite; + private readonly accessory: Sprite; + private readonly shadow: Sprite; + private readonly nameplate: BitmapText; + + private tileX: number; + private tileY: number; + private level: number; + + private readonly queue: Array = []; + private segmentFrom: WalkNode; + private segmentTo: WalkNode; + private segmentProgress = 1; + private speed = DEFAULT_SPEED; + + private action: AgentAction = 'idle'; + private facing: Facing = 'right'; + private arrivalAction: AgentAction = 'idle'; + private arrivalFacing: Facing | null = null; + private arrivalSeatDropY = 0; + + private seatDropY = 0; + private bobPhase = 0; + + public constructor(options: AgentOptions) { + super(); + this.agentId = options.id; + this.tileX = options.spawn.x; + this.tileY = options.spawn.y; + this.level = options.spawn.level; + this.segmentFrom = { ...options.spawn }; + this.segmentTo = { ...options.spawn }; + // Vary the idle bob so a crowd never breathes in lockstep. + this.bobPhase = (options.spawn.x * 1.7 + options.spawn.y * 2.3) % (Math.PI * 2); + + this.shadow = new Sprite(options.shadow.texture); + this.shadow.anchor.set(0.5, 0.5); + this.shadow.position.set(0, -2); + + this.body = new Sprite(options.body.texture); + this.body.pivot.set(options.body.anchorX, options.body.anchorY); + this.body.tint = options.tint; + + // The face is an untinted overlay sitting on the head. + this.face = new Sprite(options.face.texture); + this.face.pivot.set(options.face.anchorX, options.face.anchorY); + this.face.position.set(0, -27); + + // A head accessory (hat, glasses, antenna, ...) attached at the head top. + this.accessory = new Sprite(options.accessory.texture); + this.accessory.pivot.set(options.accessory.anchorX, options.accessory.anchorY); + this.accessory.position.set(0, -38); + this.accessory.tint = options.accessoryTint; + + // Body + face + accessory live in one figure so facing flips them together. + this.figure.addChild(this.body, this.face, this.accessory); + + this.nameplate = new BitmapText({ + text: options.label, + style: { fontFamily: options.fontName, fontSize: 11, align: 'center' }, + }); + this.nameplate.anchor.set(0.5, 1); + + this.addChild(this.shadow, this.figure, this.nameplate); + this.syncScreenPosition(); + } + + public get currentTile(): WalkNode { + return { x: this.tileX, y: this.tileY, level: this.level }; + } + + public get currentAction(): AgentAction { + return this.action; + } + + public get destinationTile(): WalkNode { + return this.queue.at(-1) ?? this.segmentTo; + } + + public setLabel(label: string): void { + this.nameplate.text = label; + } + + public setTint(tint: number): void { + this.body.tint = tint; + } + + public setSpeed(speed: number): void { + this.speed = Math.max(0.4, speed); + } + + /** Instantly relocate (used when a brand-new agent first appears). */ + public teleportTo(node: WalkNode): void { + this.queue.length = 0; + this.tileX = node.x; + this.tileY = node.y; + this.level = node.level; + this.segmentFrom = { ...node }; + this.segmentTo = { ...node }; + this.segmentProgress = 1; + this.action = 'idle'; + this.seatDropY = 0; + this.syncScreenPosition(); + } + + /** + * Walk along a resolved path, then settle into `arrivalAction`. An empty + * path means the agent is already where it should be, so we only apply the + * arrival pose. + */ + public walkPath( + path: Array, + arrivalAction: AgentAction, + arrivalFacing: Facing | null, + arrivalSeatDropY: number + ): void { + this.arrivalAction = arrivalAction; + this.arrivalFacing = arrivalFacing; + this.arrivalSeatDropY = arrivalSeatDropY; + + if (path.length === 0) { + this.settleAtDestination(); + return; + } + + this.queue.length = 0; + this.queue.push(...path); + this.beginNextSegment(true); + } + + public tick(deltaSeconds: number): void { + this.bobPhase += deltaSeconds * (this.action === 'walking' ? 9 : 1.6); + + if (this.action === 'walking') { + this.advanceMovement(deltaSeconds); + } + this.applyPose(); + this.syncScreenPosition(); + } + + /** Local screen position of the agent's head, for anchoring chat bubbles. */ + public get headOffsetY(): number { + return this.figure.y - 46 * AGENT_SCALE; + } + + private beginNextSegment(initial: boolean): void { + const next = this.queue.shift(); + if (!next) { + this.settleAtDestination(); + return; + } + this.segmentFrom = initial + ? { x: this.tileX, y: this.tileY, level: this.level } + : { ...this.segmentTo }; + this.segmentTo = { ...next }; + this.segmentProgress = 0; + this.action = 'walking'; + this.seatDropY = 0; + this.updateFacingFromSegment(); + } + + private advanceMovement(deltaSeconds: number): void { + const distance = Math.hypot( + this.segmentTo.x - this.segmentFrom.x, + this.segmentTo.y - this.segmentFrom.y + ); + const step = distance < ARRIVAL_EPSILON ? 1 : (this.speed * deltaSeconds) / distance; + this.segmentProgress += step; + + if (this.segmentProgress >= 1) { + this.tileX = this.segmentTo.x; + this.tileY = this.segmentTo.y; + this.level = this.segmentTo.level; + if (this.queue.length > 0) { + this.beginNextSegment(false); + } else { + this.settleAtDestination(); + } + return; + } + + this.tileX = lerp(this.segmentFrom.x, this.segmentTo.x, this.segmentProgress); + this.tileY = lerp(this.segmentFrom.y, this.segmentTo.y, this.segmentProgress); + this.level = lerp(this.segmentFrom.level, this.segmentTo.level, this.segmentProgress); + } + + private settleAtDestination(): void { + this.action = this.arrivalAction; + if (this.arrivalFacing) { + this.facing = this.arrivalFacing; + } + this.seatDropY = this.arrivalAction === 'sitting' ? this.arrivalSeatDropY : 0; + } + + private updateFacingFromSegment(): void { + const deltaScreenX = + this.segmentTo.x - this.segmentTo.y - (this.segmentFrom.x - this.segmentFrom.y); + if (deltaScreenX > ARRIVAL_EPSILON) { + this.facing = 'right'; + } else if (deltaScreenX < -ARRIVAL_EPSILON) { + this.facing = 'left'; + } + } + + private applyPose(): void { + const isWalking = this.action === 'walking'; + const bob = isWalking ? Math.abs(Math.sin(this.bobPhase)) * 3 : Math.sin(this.bobPhase) * 1.2; + this.figure.y = this.seatDropY - bob; + this.figure.scale.x = (this.facing === 'left' ? -1 : 1) * AGENT_SCALE; + // Squash a touch while seated so the agent reads as "sitting". + this.figure.scale.y = (this.action === 'sitting' ? 0.92 : 1) * AGENT_SCALE; + this.nameplate.y = this.figure.y - 44 * AGENT_SCALE; + this.shadow.scale.set(AGENT_SCALE * (isWalking ? 1.05 : 1), AGENT_SCALE); + } + + private syncScreenPosition(): void { + const screen = tileCenterToScreen(this.tileX, this.tileY, this.level); + this.position.set(screen.x, screen.y); + this.zIndex = depthAt(this.tileX, this.tileY, this.level, LAYER_AGENT); + } +} diff --git a/app/src/agentworld/iso/BaseRoom.ts b/app/src/agentworld/iso/BaseRoom.ts new file mode 100644 index 0000000000..93cae55772 --- /dev/null +++ b/app/src/agentworld/iso/BaseRoom.ts @@ -0,0 +1,465 @@ +/** + * Abstract isometric room. + * + * A room is built entirely from its {@link RoomDefinition}: a 2D matrix of tile + * codes (void / floor / wall / dais), a five-shade palette, and a list of + * furniture placements. `BaseRoom` turns that data into a depth-sorted scene + * graph and the navigation state the simulation needs — a walkable set, a + * per-tile elevation lookup, and a map of interaction stations. + * + * Concrete rooms (poker, courthouse, office, home) extend this class and supply + * their definition, so new room types are pure data. + */ +import { Container, Sprite } from 'pixi.js'; + +import { shadeColor } from './color'; +import { FurnitureSprite, type FurnitureStation } from './furniture'; +import { + depthAt, + ELEVATION_HEIGHT, + LAYER_DECAL, + LAYER_FLOOR, + LAYER_WALL, + type ScreenPoint, + tileToScreen, +} from './geometry'; +import type { BakedTexture, TextureFactory } from './textures'; +import { type RoomDefinition, type RoomPalette, TileCode, type WalkNode } from './types'; + +const WALL_HEIGHT = 112; +const PARTITION_HEIGHT = 34; +const PAVEMENT_TINT = 0x9aa3ad; +const NEIGHBOR_STEPS: ReadonlyArray = [ + [0, -1], + [1, 0], + [0, 1], + [-1, 0], + [1, -1], + [1, 1], + [-1, 1], + [-1, -1], +]; + +function tileKey(tileX: number, tileY: number): string { + return `${tileX},${tileY}`; +} + +/** A furniture footprint plus its render depth, used to sort agents around it. */ +export interface DepthObstacle { + minX: number; + maxX: number; + minY: number; + maxY: number; + zIndex: number; +} + +interface PixelSize { + width: number; + height: number; +} + +interface RoomBounds extends PixelSize { + centerX: number; + centerY: number; +} + +interface PathQueueNode { + x: number; + y: number; +} + +export abstract class BaseRoom { + public readonly view: Container = new Container(); + public readonly definition: RoomDefinition; + + // Two depth-sorted layers. The ground layer (floors, dais risers, rugs) is + // always drawn beneath the structure layer (walls, furniture, agents), so a + // floor tile can never render on top of a building, and agents interleave + // correctly with the things they walk among. + private readonly groundLayer = new Container(); + private readonly structureLayer = new Container(); + + private readonly columns: number; + private readonly rows: number; + private readonly levelGrid: Array> = []; + private readonly walkable = new Set(); + private readonly seats = new Set(); + private readonly stationsByTile = new Map(); + private readonly pieceByTile = new Map(); + private readonly obstacles: Array = []; + private readonly center: ScreenPoint; + private readonly size: PixelSize; + + protected constructor(definition: RoomDefinition, factory: TextureFactory) { + this.definition = definition; + this.groundLayer.sortableChildren = true; + this.structureLayer.sortableChildren = true; + this.view.addChild(this.groundLayer, this.structureLayer); + this.rows = definition.matrix.length; + this.columns = definition.matrix.reduce((widest, row) => Math.max(widest, row.length), 0); + + this.buildTiles(factory, definition.palette); + this.buildFurniture(factory, definition); + const bounds = this.computeBounds(); + this.center = { x: bounds.centerX, y: bounds.centerY }; + this.size = { width: bounds.width, height: bounds.height }; + } + + // ---- Scene construction ------------------------------------------------- + + private buildTiles(factory: TextureFactory, palette: RoomPalette): void { + const floor = factory.floorTile(); + const road = factory.roadTile(); + for (let row = 0; row < this.rows; row++) { + this.levelGrid.push(new Array(this.columns).fill(-1)); + const matrixRow = this.definition.matrix[row] ?? []; + for (let column = 0; column < this.columns; column++) { + const code = matrixRow[column] ?? TileCode.Void; + if (code === TileCode.Void) { + continue; + } + if (code === TileCode.Wall) { + this.placeWall(factory, palette, column, row); + continue; + } + if (code === TileCode.Partition) { + // A divider stands on visible floor but is not walkable. + this.placeFloor(floor, palette.floorTop, column, row, 0, true); + this.placePartition(factory, palette, column, row); + continue; + } + if (code === TileCode.Road) { + this.placeFloor(road, 0xffffff, column, row, 0, false); + this.levelGrid[row]![column] = 0; + this.walkable.add(tileKey(column, row)); + continue; + } + if (code === TileCode.Pavement) { + this.placeFloor(floor, PAVEMENT_TINT, column, row, 0, true); + this.levelGrid[row]![column] = 0; + this.walkable.add(tileKey(column, row)); + continue; + } + const level = code === TileCode.Dais ? 1 : 0; + if (level === 1) { + this.placeDaisRiser(factory, palette, column, row); + } + this.placeFloor(floor, palette.floorTop, column, row, level, true); + this.levelGrid[row]![column] = level; + this.walkable.add(tileKey(column, row)); + } + } + } + + private placeFloor( + baked: BakedTexture, + tint: number, + column: number, + row: number, + level: number, + checker: boolean + ): void { + const sprite = new Sprite(baked.texture); + sprite.pivot.set(baked.anchorX, baked.anchorY); + const screen = tileToScreen(column, row, level); + sprite.position.set(screen.x, screen.y); + // A faint checker keeps large floors from looking flat. + const darken = checker && (column + row) % 2 === 0; + sprite.tint = darken ? shadeColor(tint, 0.93) : tint; + sprite.zIndex = depthAt(column, row, level, LAYER_FLOOR); + this.groundLayer.addChild(sprite); + } + + private placeDaisRiser( + factory: TextureFactory, + palette: RoomPalette, + column: number, + row: number + ): void { + const riser = factory.cuboid(1, 1, ELEVATION_HEIGHT, 'dais-riser'); + const sprite = new Sprite(riser.texture); + sprite.pivot.set(riser.anchorX, riser.anchorY); + const screen = tileToScreen(column, row, 0); + sprite.position.set(screen.x, screen.y); + sprite.tint = palette.dais; + sprite.zIndex = depthAt(column, row, 0, LAYER_DECAL); + this.groundLayer.addChild(sprite); + } + + private placeWall( + factory: TextureFactory, + palette: RoomPalette, + column: number, + row: number + ): void { + const wall = factory.wallBlock(WALL_HEIGHT); + const sprite = new Sprite(wall.texture); + sprite.pivot.set(wall.anchorX, wall.anchorY); + const screen = tileToScreen(column, row, 0); + sprite.position.set(screen.x, screen.y); + sprite.tint = palette.wall; + sprite.zIndex = depthAt(column, row, 0, LAYER_WALL); + this.structureLayer.addChild(sprite); + this.obstacles.push({ + minX: column, + maxX: column, + minY: row, + maxY: row, + zIndex: sprite.zIndex, + }); + } + + private placePartition( + factory: TextureFactory, + palette: RoomPalette, + column: number, + row: number + ): void { + const partition = factory.wallBlock(PARTITION_HEIGHT); + const sprite = new Sprite(partition.texture); + sprite.pivot.set(partition.anchorX, partition.anchorY); + const screen = tileToScreen(column, row, 0); + sprite.position.set(screen.x, screen.y); + sprite.tint = palette.wall; + sprite.zIndex = depthAt(column, row, 0, LAYER_WALL); + this.structureLayer.addChild(sprite); + this.obstacles.push({ + minX: column, + maxX: column, + minY: row, + maxY: row, + zIndex: sprite.zIndex, + }); + } + + /** The depth-sorted layer agents live in, alongside walls and furniture. */ + public get entityLayer(): Container { + return this.structureLayer; + } + + private buildFurniture(factory: TextureFactory, definition: RoomDefinition): void { + for (const config of definition.furniture) { + const piece = new FurnitureSprite(config, factory); + // Flat decals (rugs) belong to the ground; everything else stands up + // in the structure layer. + (piece.flat ? this.groundLayer : this.structureLayer).addChild(piece); + for (const tile of piece.footprintTiles()) { + this.pieceByTile.set(tileKey(tile.x, tile.y), piece); + } + // Solid tiles leave the transit graph entirely... + for (const tile of piece.solidTiles()) { + this.walkable.delete(tileKey(tile.x, tile.y)); + } + // ...but seat tiles remain reachable as a terminal sit step. + for (const tile of piece.seatTiles()) { + this.seats.add(tileKey(tile.x, tile.y)); + } + for (const station of piece.stations()) { + this.stationsByTile.set(tileKey(station.tileX, station.tileY), station); + } + // Solid pieces occlude agents; record their footprint + depth so the + // renderer can sort agents around them by proper point-vs-box order. + if (piece.solid) { + this.obstacles.push({ + minX: piece.tileX, + maxX: piece.tileX + piece.footprintWidth - 1, + minY: piece.tileY, + maxY: piece.tileY + piece.footprintHeight - 1, + zIndex: piece.zIndex, + }); + } + } + } + + /** Solid furniture footprints + depths, for agent depth resolution. */ + public depthObstacles(): ReadonlyArray { + return this.obstacles; + } + + private computeBounds(): RoomBounds { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (let row = 0; row < this.rows; row++) { + for (let column = 0; column < this.columns; column++) { + if ((this.definition.matrix[row]?.[column] ?? 0) === TileCode.Void) { + continue; + } + // Sample all four diamond corners so the extents are exact. + for (const [cornerX, cornerY] of [ + [column, row], + [column + 1, row], + [column + 1, row + 1], + [column, row + 1], + ]) { + const screen = tileToScreen(cornerX!, cornerY!, 0); + minX = Math.min(minX, screen.x); + maxX = Math.max(maxX, screen.x); + minY = Math.min(minY, screen.y); + maxY = Math.max(maxY, screen.y); + } + } + } + // Pad vertically for wall/building height and overhead chat bubbles. + const topMargin = this.definition.topMargin ?? WALL_HEIGHT; + return { + centerX: (minX + maxX) / 2, + centerY: (minY + maxY) / 2 - (topMargin - WALL_HEIGHT) / 2, + width: maxX - minX, + height: maxY - minY + topMargin, + }; + } + + // ---- Navigation API ----------------------------------------------------- + + public get pixelCenter(): ScreenPoint { + return this.center; + } + + public get pixelSize(): PixelSize { + return this.size; + } + + public levelAt(tileX: number, tileY: number): number { + return this.levelGrid[tileY]?.[tileX] ?? 0; + } + + public isWalkable(tileX: number, tileY: number): boolean { + return this.walkable.has(tileKey(tileX, tileY)); + } + + /** A seat tile is occupiable only as the final step of a sit. */ + public isSeat(tileX: number, tileY: number): boolean { + return this.seats.has(tileKey(tileX, tileY)); + } + + /** Interaction stations of the furniture occupying a tile, if any. */ + public pieceStationsAt(tileX: number, tileY: number): Array { + return this.pieceByTile.get(tileKey(tileX, tileY))?.stations() ?? []; + } + + public node(tileX: number, tileY: number): WalkNode { + return { x: tileX, y: tileY, level: this.levelAt(tileX, tileY) }; + } + + public stationAt(tileX: number, tileY: number): FurnitureStation | undefined { + return this.stationsByTile.get(tileKey(tileX, tileY)); + } + + public stations(): Array { + return [...this.stationsByTile.values()]; + } + + public spawnNode(): WalkNode { + const { x, y } = this.definition.spawnTile; + if (this.isWalkable(x, y)) { + return this.node(x, y); + } + return this.walkableNodes()[0] ?? { x, y, level: 0 }; + } + + public walkableNodes(): Array { + const nodes: Array = []; + for (const key of this.walkable) { + const [tileX, tileY] = key.split(',').map(Number); + nodes.push(this.node(tileX!, tileY!)); + } + return nodes; + } + + /** + * Breadth-first path between two tiles. Returns the steps *after* the start + * (empty when already there), or `null` if unreachable. Diagonal moves are + * blocked from cutting through solid corners, and elevation can only change + * by one level per step. + */ + public findPath( + startX: number, + startY: number, + endX: number, + endY: number, + blocked?: Set + ): Array | null { + const canStand = (x: number, y: number): boolean => this.isWalkable(x, y) || this.isSeat(x, y); + if (!canStand(startX, startY) || !canStand(endX, endY)) { + return null; + } + if (startX === endX && startY === endY) { + return []; + } + + const startKey = tileKey(startX, startY); + const visited = new Set([startKey]); + const previousByKey = new Map(); + const nodeByKey = new Map([[startKey, this.node(startX, startY)]]); + const queue: Array = [{ x: startX, y: startY }]; + let queueIndex = 0; + + while (queueIndex < queue.length) { + const current = queue[queueIndex++]!; + const currentKey = tileKey(current.x, current.y); + for (const [stepX, stepY] of NEIGHBOR_STEPS) { + const nextX = current.x + stepX; + const nextY = current.y + stepY; + const key = tileKey(nextX, nextY); + if (visited.has(key)) { + continue; + } + const terminal = nextX === endX && nextY === endY; + // Seats may only be entered as the destination; otherwise the tile + // must be normal walkable floor. + if (this.isSeat(nextX, nextY)) { + if (!terminal) { + continue; + } + } else if (!this.isWalkable(nextX, nextY)) { + continue; + } + if (blocked?.has(key) && !terminal) { + continue; + } + // Disallow diagonal corner-cutting past blocking tiles. + if (stepX !== 0 && stepY !== 0) { + if ( + !this.isWalkable(current.x + stepX, current.y) || + !this.isWalkable(current.x, current.y + stepY) + ) { + continue; + } + } + if (Math.abs(this.levelAt(nextX, nextY) - this.levelAt(current.x, current.y)) > 1) { + continue; + } + const next = this.node(nextX, nextY); + previousByKey.set(key, currentKey); + nodeByKey.set(key, next); + if (terminal) { + return this.reconstructPath(key, startKey, previousByKey, nodeByKey); + } + visited.add(key); + queue.push({ x: nextX, y: nextY }); + } + } + return null; + } + + private reconstructPath( + endKey: string, + startKey: string, + previousByKey: ReadonlyMap, + nodeByKey: ReadonlyMap + ): Array { + const path: Array = []; + let cursor: string | undefined = endKey; + while (cursor && cursor !== startKey) { + const node = nodeByKey.get(cursor); + if (!node) { + return []; + } + path.push(node); + cursor = previousByKey.get(cursor); + } + return path.reverse(); + } +} diff --git a/app/src/agentworld/iso/ChatBubble.ts b/app/src/agentworld/iso/ChatBubble.ts new file mode 100644 index 0000000000..0877dbb4bc --- /dev/null +++ b/app/src/agentworld/iso/ChatBubble.ts @@ -0,0 +1,127 @@ +/** + * Hyper-lightweight chat bubble. + * + * Text is rendered with {@link BitmapText} against a pre-installed bitmap font, + * so speaking never uploads a fresh text texture to the GPU — glyphs are drawn + * from a cached atlas. The rounded background is a {@link NineSliceSprite} over + * one shared texture, which means a bubble of any width costs no extra memory. + * + * Each bubble owns its own lifecycle (pop-in, hold, fade-out) and reports when + * it is finished so the world can recycle it without stalling the render loop. + */ +import { BitmapText, Container, NineSliceSprite, Sprite } from 'pixi.js'; + +import type { BakedTexture } from './textures'; + +const HORIZONTAL_PADDING = 11; +const VERTICAL_PADDING = 7; +const SLICE_INSET = 14; +const MAX_TEXT_WIDTH = 168; +const POP_IN_MS = 130; +const FADE_OUT_MS = 320; +const DEFAULT_HOLD_MS = 3200; + +interface ChatBubbleOptions { + background: BakedTexture; + tail: BakedTexture; + fontName: string; + text: string; + durationMs?: number; +} + +type Phase = 'in' | 'hold' | 'out' | 'done'; + +export class ChatBubble extends Container { + private phase: Phase = 'in'; + private elapsedMs = 0; + private readonly holdMs: number; + private readonly bubbleWidth: number; + private readonly bubbleHeight: number; + + public constructor(options: ChatBubbleOptions) { + super(); + this.holdMs = options.durationMs ?? DEFAULT_HOLD_MS; + + const label = new BitmapText({ + text: options.text, + style: { + fontFamily: options.fontName, + fontSize: 15, + wordWrap: true, + wordWrapWidth: MAX_TEXT_WIDTH, + align: 'center', + }, + }); + + this.bubbleWidth = Math.max(SLICE_INSET * 2, Math.ceil(label.width) + HORIZONTAL_PADDING * 2); + this.bubbleHeight = Math.max(SLICE_INSET * 2, Math.ceil(label.height) + VERTICAL_PADDING * 2); + + const background = new NineSliceSprite({ + texture: options.background.texture, + leftWidth: SLICE_INSET, + topHeight: SLICE_INSET, + rightWidth: SLICE_INSET, + bottomHeight: SLICE_INSET, + }); + background.width = this.bubbleWidth; + background.height = this.bubbleHeight; + + const tail = new Sprite(options.tail.texture); + tail.anchor.set(0.5, 0); + tail.position.set(this.bubbleWidth / 2, this.bubbleHeight - 1); + + label.anchor.set(0.5, 0.5); + label.position.set(this.bubbleWidth / 2, this.bubbleHeight / 2); + + this.addChild(background, tail, label); + + // Pivot at the tail tip so positioning the bubble at the agent's head + // makes the tail point straight down at it. + this.pivot.set(this.bubbleWidth / 2, this.bubbleHeight + 8); + this.alpha = 0; + this.scale.set(0.7); + } + + /** Advance the bubble's lifecycle. Returns `false` once it is finished. */ + public update(deltaMs: number): boolean { + this.elapsedMs += deltaMs; + switch (this.phase) { + case 'in': { + const amount = Math.min(1, this.elapsedMs / POP_IN_MS); + this.alpha = amount; + this.scale.set(0.7 + amount * 0.3); + if (amount >= 1) { + this.phase = 'hold'; + this.elapsedMs = 0; + } + return true; + } + case 'hold': { + if (this.elapsedMs >= this.holdMs) { + this.phase = 'out'; + this.elapsedMs = 0; + } + return true; + } + case 'out': { + const amount = Math.min(1, this.elapsedMs / FADE_OUT_MS); + this.alpha = 1 - amount; + this.y -= deltaMs * 0.012; + if (amount >= 1) { + this.phase = 'done'; + } + return true; + } + default: + return false; + } + } + + /** Begin an early dismissal (e.g. when the agent speaks again). */ + public dismiss(): void { + if (this.phase !== 'out' && this.phase !== 'done') { + this.phase = 'out'; + this.elapsedMs = 0; + } + } +} diff --git a/app/src/agentworld/iso/GameWorld.ts b/app/src/agentworld/iso/GameWorld.ts new file mode 100644 index 0000000000..d819d24d16 --- /dev/null +++ b/app/src/agentworld/iso/GameWorld.ts @@ -0,0 +1,1017 @@ +/** + * The top-level world controller. + * + * `GameWorld` owns the PixiJS application (WebGPU-preferred), the active room, + * every agent, and the render loop. It exposes the authoritative entry point + * {@link GameWorld.updateAgentState} for external/AI control, plus click-to-move + * for human debugging. The 600x600 native scene lives inside a single "viewport" + * container that is scaled to fill its parent, so the world stays crisp pixel + * art at any size. + */ +import { + Application, + BitmapFont, + Container, + type FederatedPointerEvent, + Graphics, + Rectangle, + TextureSource, + type Ticker, +} from 'pixi.js'; + +import { Agent } from './Agent'; +import type { BaseRoom, DepthObstacle } from './BaseRoom'; +import { ChatBubble } from './ChatBubble'; +import { FurnitureSprite, type FurnitureStation } from './furniture'; +import { + depthAt, + LAYER_DECAL, + LAYER_FURNITURE, + NATIVE_RESOLUTION, + screenToTile, + tileToScreen, +} from './geometry'; +import { ROOM_REGISTRY, type RoomEntry } from './rooms'; +import { TextureFactory } from './textures'; +import { type AgentState, type ChatMessage, TileCode, type WalkNode } from './types'; + +const NAMEPLATE_FONT = 'iso-body'; +const BUBBLE_FONT = 'iso-bubble'; +const FONT_CHARACTERS: Array | string> = [ + ['a', 'z'], + ['A', 'Z'], + ['0', '9'], + ' .,!?:;@#%&()\'"-_/+*', +]; + +const CAMERA_PADDING = 28; +// Pull the camera back a little now that the world renders full-screen, so the +// room reads at a comfortable scale instead of filling every pixel. +const ZOOM_BOOST = 0.5; +const TAP_THRESHOLD = 6; +const AGENT_PICK_RADIUS = 26; +const PAN_LIMIT = 360; +const TILE_EPSILON = 0.001; + +// Live traffic: cars cruise along the city's horizontal street rows (where the +// 2x1 car sprite is already correctly oriented). Only the "outside" city has +// roads, so traffic is a no-op in the indoor rooms. +const TRAFFIC_ROOM_KEY = 'outside'; +const CARS_PER_LANE = 3; +const CAR_TINTS = [ + 0xc0392b, 0x2e86c1, 0x8e44ad, 0x27ae60, 0xd35400, 0xe6b800, 0x34495e, 0x16a085, 0xbdc3c7, +]; + +interface CarState { + sprite: FurnitureSprite; + row: number; + x: number; + direction: number; + speed: number; +} + +const AGENT_TINTS = [ + 0xf2a154, 0x5aa9e6, 0x7fc8a9, 0xe88ec2, 0xc3a6ff, 0xffd166, 0x9ad0ec, 0xf28482, 0x84dcc6, + 0xbdb2ff, +]; +// "antenna" is weighted heavier so it stays the common plain look. +const ACCESSORY_KINDS = [ + 'antenna', + 'antenna', + 'antenna', + 'cap', + 'beanie', + 'party', + 'bow', + 'crown', + 'glasses', + 'headphones', + 'flower', +]; +const ACCESSORY_TINTABLE = new Set(['cap', 'beanie', 'party', 'bow']); +const ACCESSORY_COLORS = [0xff6b6b, 0x4ecdc4, 0xffd166, 0xa78bfa, 0xff8fab, 0x6bcb77, 0x5aa9e6]; +const AGENT_NAMES = [ + 'Atlas', + 'Vega', + 'Juno', + 'Nova', + 'Pixel', + 'Echo', + 'Sol', + 'Iris', + 'Kai', + 'Lumen', + 'Orbit', + 'Sage', + 'Bishop', + 'Wren', + 'Dot', + 'Cleo', +]; +const AMBIENT_LINES = [ + 'gm', + 'any seats open?', + 'running the numbers...', + 'all in!', + 'let me think', + 'objection!', + "who's dealing?", + 'brb, coffee', + 'nice play', + 'on my way', + 'wen payout', + 'this rug ties the room together', +]; + +function clamp(value: number, low: number, high: number): number { + return Math.max(low, Math.min(high, value)); +} + +/** + * Isometric point-vs-box ordering. Returns +1 if the agent point should render + * in front of the footprint, -1 if behind, 0 if they are separated on the + * screen anti-diagonal (their order does not matter). This is what makes an + * agent correctly pass behind one corner of a multi-tile piece and in front of + * the opposite corner — a single `x + y` scalar cannot. + */ +function boxRelation(pointX: number, pointY: number, box: DepthObstacle): number { + const xLow = box.minX; + const xHigh = box.maxX + 1; + const yLow = box.minY; + const yHigh = box.maxY + 1; + const withinX = pointX >= xLow && pointX < xHigh; + const withinY = pointY >= yLow && pointY < yHigh; + if (pointX >= xHigh && pointY >= yHigh) { + return 1; + } + if (pointX < xLow && pointY < yLow) { + return -1; + } + if (withinX) { + if (pointY >= yHigh) { + return 1; + } + if (pointY < yLow) { + return -1; + } + } + if (withinY) { + if (pointX >= xHigh) { + return 1; + } + if (pointX < xLow) { + return -1; + } + } + return 0; +} + +// Bitmap fonts are global to PixiJS, so they only need installing once even if +// several worlds mount over a session (e.g. React strict-mode double-mounts). +let fontsInstalled = false; + +export interface AgentSummary { + id: string; + label: string; +} + +export class GameWorld { + private app: Application | null = null; + private factory: TextureFactory | null = null; + + private readonly viewport = new Container(); + private readonly camera = new Container(); + private readonly world = new Container(); + private readonly bubbleLayer = new Container(); + private readonly glow = new Graphics(); + private readonly selectionRing = new Graphics(); + + private room: BaseRoom | null = null; + private roomKey = ROOM_REGISTRY[0]!.key; + private readonly agents = new Map(); + private readonly bubbles = new Map(); + private readonly wanderTimers = new Map(); + private cars: Array = []; + private lanes: Array<{ row: number; direction: number }> = []; + private trafficLength = 0; + private selectedId: string | null = null; + private autonomous = false; + private resizeObserver: ResizeObserver | null = null; + private changeListener: (() => void) | null = null; + + private pointerActive = false; + private pointerMoved = 0; + private lastPointerX = 0; + private lastPointerY = 0; + private panRangeX = PAN_LIMIT; + private panRangeY = PAN_LIMIT; + // "contain" frames the whole world inside the viewport (the world page); + // "cover" fills the viewport, cropping the overflow (the home banner strip). + private readonly fillMode: 'contain' | 'cover'; + + public constructor(options: { fillMode?: 'contain' | 'cover' } = {}) { + this.fillMode = options.fillMode ?? 'contain'; + } + + // ---- Lifecycle ---------------------------------------------------------- + + public async init(parent: HTMLElement): Promise { + if (this.app) { + return; + } + TextureSource.defaultOptions.scaleMode = 'nearest'; + + const app = new Application(); + await app.init({ + width: NATIVE_RESOLUTION, + height: NATIVE_RESOLUTION, + antialias: true, + preference: 'webgpu', + powerPreference: 'high-performance', + resolution: globalThis.devicePixelRatio || 1, + autoDensity: true, + backgroundAlpha: 1, + background: 0x0c0f16, + }); + this.app = app; + this.factory = new TextureFactory(app.renderer); + + this.installFonts(); + + this.selectionRing.visible = false; + // The selection ring and agents are parented into the active room's + // structure layer (see setRoom) so they depth-sort with walls/furniture. + this.camera.addChild(this.glow, this.world, this.bubbleLayer); + this.viewport.addChild(this.camera); + app.stage.addChild(this.viewport); + + app.stage.eventMode = 'static'; + app.stage.hitArea = app.screen; + app.stage.on('pointerdown', this.onPointerDown); + app.stage.on('pointermove', this.onPointerMove); + app.stage.on('pointerup', this.onPointerUp); + app.stage.on('pointerupoutside', this.onPointerUp); + + parent.appendChild(app.canvas); + this.observeResize(parent); + this.setRoom(this.roomKey); + app.ticker.add(this.tick); + } + + private installFonts(): void { + if (fontsInstalled) { + return; + } + fontsInstalled = true; + BitmapFont.install({ + name: NAMEPLATE_FONT, + style: { + fontFamily: 'system-ui, Segoe UI, Roboto, sans-serif', + fontSize: 26, + fontWeight: '700', + fill: 0xffffff, + stroke: { color: 0x10131c, width: 4 }, + }, + chars: FONT_CHARACTERS, + resolution: 2, + textureStyle: { scaleMode: 'nearest' }, + }); + BitmapFont.install({ + name: BUBBLE_FONT, + style: { + fontFamily: 'system-ui, Segoe UI, Roboto, sans-serif', + fontSize: 26, + fontWeight: '600', + fill: 0x10131c, + }, + chars: FONT_CHARACTERS, + resolution: 2, + textureStyle: { scaleMode: 'nearest' }, + }); + } + + public destroy(): void { + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.clearTraffic(); + this.factory?.destroy(); + const app = this.app; + this.app = null; + if (app) { + app.ticker.remove(this.tick); + app.destroy(true, { children: true, texture: true }); + } + } + + public setChangeListener(listener: (() => void) | null): void { + this.changeListener = listener; + } + + // ---- Room management ---------------------------------------------------- + + public get rooms(): ReadonlyArray { + return ROOM_REGISTRY; + } + + public get currentRoomKey(): string { + return this.roomKey; + } + + public setRoom(key: string): void { + const entry = ROOM_REGISTRY.find(candidate => candidate.key === key); + const factory = this.factory; + const app = this.app; + if (!entry || !factory || !app) { + return; + } + this.clearAgents(); + this.clearTraffic(); + if (this.room) { + // Detach the persistent selection ring before the old layer is freed. + this.selectionRing.removeFromParent(); + this.world.removeChild(this.room.view); + this.room.view.destroy({ children: true }); + } + this.roomKey = key; + this.room = entry.create(factory); + this.world.addChild(this.room.view); + this.room.entityLayer.addChild(this.selectionRing); + + const palette = this.room.definition.palette; + app.renderer.background.color = palette.background; + this.paintGlow(palette.accent); + + const center = this.room.pixelCenter; + this.world.position.set(-center.x, -center.y); + this.bubbleLayer.position.copyFrom(this.world.position); + + const size = this.room.pixelSize; + // Fit the room into the viewport, then nudge it up a touch so the world + // fills more of the frame (panning covers any slight overflow). + const zoom = clamp( + Math.min( + (NATIVE_RESOLUTION - CAMERA_PADDING * 2) / size.width, + (NATIVE_RESOLUTION - CAMERA_PADDING * 2) / size.height + ) * ZOOM_BOOST, + 0.34, + 1.6 + ); + this.camera.scale.set(zoom); + this.camera.position.set(NATIVE_RESOLUTION / 2, NATIVE_RESOLUTION / 2); + // Allow panning far enough to reach the edges of a large room/city. + this.panRangeX = Math.max(PAN_LIMIT, (size.width * zoom) / 2); + this.panRangeY = Math.max(PAN_LIMIT, (size.height * zoom) / 2); + this.spawnTraffic(); + this.notifyChange(); + } + + // ---- Traffic ------------------------------------------------------------ + + /** Remove every live car and free its sprite. */ + private clearTraffic(): void { + for (const car of this.cars) { + car.sprite.removeFromParent(); + car.sprite.destroy({ children: true }); + } + this.cars = []; + this.lanes = []; + this.trafficLength = 0; + } + + /** + * Populate the city's horizontal streets with cars. A "street row" is any + * matrix row that is mostly road; the two rows of each 2-wide road band drive + * in opposite directions, so traffic reads as oncoming lanes. + */ + private spawnTraffic(): void { + const room = this.room; + if (!room || !this.factory || room.definition.key !== TRAFFIC_ROOM_KEY) { + return; + } + const matrix = room.definition.matrix; + const columns = matrix.reduce((max, row) => Math.max(max, row.length), 0); + this.trafficLength = columns; + this.lanes = []; + + for (let row = 0; row < matrix.length; row++) { + const cells = matrix[row]!; + let roadCells = 0; + for (let column = 0; column < columns; column++) { + if (cells[column] === TileCode.Road) { + roadCells++; + } + } + // Skip cross-street stubs; only full-width through-roads carry traffic. + if (roadCells < columns * 0.6) { + continue; + } + // Even/odd rows of a road band travel in opposite directions. + const direction = row % 2 === 0 ? 1 : -1; + this.lanes.push({ row, direction }); + const spacing = columns / CARS_PER_LANE; + for (let index = 0; index < CARS_PER_LANE; index++) { + this.addCar(row, direction, index * spacing + Math.random() * spacing); + } + } + } + + /** Put a single car on a lane at a given fractional column. */ + private addCar(row: number, direction: number, x: number): void { + const room = this.room; + const factory = this.factory; + if (!room || !factory) { + return; + } + const tint = CAR_TINTS[Math.floor(Math.random() * CAR_TINTS.length)]!; + const sprite = new FurnitureSprite({ kind: 'car', tileX: 0, tileY: row, tint }, factory); + room.entityLayer.addChild(sprite); + const car: CarState = { sprite, row, x, direction, speed: 1.4 + Math.random() * 1.1 }; + this.positionCar(car); + this.cars.push(car); + } + + /** Send a fresh car in from the entry edge of a random lane. */ + private spawnReplacementCar(): void { + if (this.lanes.length === 0) { + return; + } + const lane = this.lanes[Math.floor(Math.random() * this.lanes.length)]!; + const entryX = lane.direction > 0 ? -2 : this.trafficLength + 2; + this.addCar(lane.row, lane.direction, entryX); + } + + /** Re-place a car's sprite for its current fractional position. */ + private positionCar(car: CarState): void { + const screen = tileToScreen(car.x, car.row, 0); + car.sprite.position.set(screen.x, screen.y); + // Depth-sort at the 2-tile car's centre, like a parked FurnitureSprite. + car.sprite.zIndex = depthAt(car.x + 0.5, car.row, 0, LAYER_FURNITURE); + } + + /** + * True if a human stands on the road just ahead of the car, so it should + * brake rather than drive over them. Looks one tile past the leading edge, + * plus one more, for a short stopping buffer. + */ + private carBlocked(car: CarState): boolean { + const lead = car.direction > 0 ? Math.floor(car.x + 2) : Math.floor(car.x) - 1; + return this.tileOccupied(lead, car.row) || this.tileOccupied(lead + car.direction, car.row); + } + + private updateTraffic(deltaSeconds: number): void { + if (this.cars.length === 0) { + return; + } + const high = this.trafficLength + 2; + const survivors: Array = []; + let retired = 0; + + for (const car of this.cars) { + // Stop short of any human crossing or standing on the road ahead. + if (this.carBlocked(car)) { + survivors.push(car); + continue; + } + car.x += car.direction * car.speed * deltaSeconds; + if (car.x > high || car.x < -2) { + // Drove off the end of the road — retire this car entirely. + car.sprite.removeFromParent(); + car.sprite.destroy({ children: true }); + retired++; + continue; + } + this.positionCar(car); + survivors.push(car); + } + + this.cars = survivors; + // Keep the streets populated by sending one fresh car in per retirement. + for (let index = 0; index < retired; index++) { + this.spawnReplacementCar(); + } + } + + private paintGlow(accent: number): void { + this.glow.clear(); + this.glow.ellipse(0, 0, 320, 200).fill({ color: accent, alpha: 0.1 }); + this.glow.position.set(0, 0); + } + + // ---- Agent control ------------------------------------------------------ + + /** + * Authoritative reconciliation entry point. A controller pushes where an + * agent should be and what it should do; the world spawns it if new and + * slides it toward that state. Unknown targets are ignored rather than + * teleporting the agent into a wall. + */ + public updateAgentState(agentId: string, state: AgentState): void { + const room = this.room; + if (!room) { + return; + } + let agent = this.agents.get(agentId); + if (!agent) { + const spawn = room.isWalkable(state.x, state.y) + ? room.node(state.x, state.y) + : room.spawnNode(); + agent = this.createAgent( + agentId, + state.tint ?? this.pickTint(agentId), + state.label ?? agentId, + spawn + ); + } else { + if (state.label !== undefined) { + agent.setLabel(state.label); + } + if (state.tint !== undefined) { + agent.setTint(state.tint); + } + } + if (state.speed !== undefined) { + agent.setSpeed(state.speed); + } + + this.routeAgent(agent, state); + + if (state.say) { + this.speak(agentId, state.say); + } + } + + private routeAgent(agent: Agent, state: AgentState): void { + const room = this.room!; + const start = agent.currentTile; + + let arrivalAction = state.action ?? 'idle'; + let facing = state.facing ?? null; + let seatDrop = 0; + const station = room.stationAt(state.x, state.y); + if (station && (state.action === 'sitting' || state.action === 'inspecting')) { + arrivalAction = station.point.action === 'sit' ? 'sitting' : 'inspecting'; + facing = station.point.facing ?? facing; + seatDrop = station.point.seatDropY ?? 6; + } + + const alreadyAtTarget = + Math.abs(start.x - state.x) < TILE_EPSILON && Math.abs(start.y - state.y) < TILE_EPSILON; + if (alreadyAtTarget) { + agent.walkPath([], arrivalAction, facing, seatDrop); + return; + } + + const destination = agent.destinationTile; + if ( + agent.currentAction === 'walking' && + destination.x === state.x && + destination.y === state.y + ) { + return; + } + + const startX = Math.round(start.x); + const startY = Math.round(start.y); + const path = room.findPath(startX, startY, state.x, state.y); + if (path) { + agent.walkPath(path, arrivalAction, facing, seatDrop); + } + } + + public speak(agentId: string, message: ChatMessage): void { + const factory = this.factory; + const agent = this.agents.get(agentId); + if (!factory || !agent) { + return; + } + this.bubbles.get(agentId)?.dismiss(); + const bubble = new ChatBubble({ + background: factory.bubbleBackground(), + tail: factory.bubbleTail(), + fontName: BUBBLE_FONT, + text: message.text, + durationMs: message.durationMs, + }); + this.bubbleLayer.addChild(bubble); + this.bubbles.set(agentId, bubble); + } + + /** Make a random handful of agents pipe up — used by the demo controls. */ + public nudgeChatter(): void { + for (const agent of this.agents.values()) { + if (Math.random() < 0.6) { + this.speak(agent.agentId, { text: this.pickAmbientLine() }); + } + } + } + + public removeAgent(agentId: string): void { + const agent = this.agents.get(agentId); + if (!agent) { + return; + } + this.bubbles.get(agentId)?.dismiss(); + agent.destroy({ children: true }); + this.agents.delete(agentId); + this.wanderTimers.delete(agentId); + if (this.selectedId === agentId) { + this.selectedId = null; + this.selectionRing.visible = false; + } + this.notifyChange(); + } + + public clearAgents(): void { + for (const agent of this.agents.values()) { + agent.destroy({ children: true }); + } + this.agents.clear(); + this.wanderTimers.clear(); + for (const bubble of this.bubbles.values()) { + bubble.destroy({ children: true }); + } + this.bubbles.clear(); + this.selectedId = null; + this.selectionRing.visible = false; + this.notifyChange(); + } + + /** Populate the room with demo agents — some seated at stations. */ + public spawnAgents(count: number): void { + const room = this.room; + if (!room) { + return; + } + const freeStations = room + .stations() + .filter(station => !this.tileOccupied(station.tileX, station.tileY)); + const walkable = room.walkableNodes(); + for (let index = 0; index < count; index++) { + const id = `agent-${Date.now().toString(36)}-${index}`; + const station = freeStations.shift(); + if (station) { + const agent = this.createAgent( + id, + this.pickTint(id), + this.pickName(), + room.node(station.tileX, station.tileY) + ); + agent.walkPath( + [], + station.point.action === 'sit' ? 'sitting' : 'inspecting', + station.point.facing ?? null, + station.point.seatDropY ?? 6 + ); + } else { + const node = this.randomFreeNode(walkable); + if (node) { + this.createAgent(id, this.pickTint(id), this.pickName(), node); + } + } + } + } + + public setAutonomous(enabled: boolean): void { + this.autonomous = enabled; + } + + public get agentSummaries(): Array { + return [...this.agents.values()].map(agent => ({ id: agent.agentId, label: agent.agentId })); + } + + public get agentCount(): number { + return this.agents.size; + } + + private createAgent(id: string, tint: number, label: string, spawn: WalkNode): Agent { + const factory = this.factory!; + const accessoryKind = ACCESSORY_KINDS[this.hash(id) % ACCESSORY_KINDS.length]!; + const accessoryTint = ACCESSORY_TINTABLE.has(accessoryKind) + ? ACCESSORY_COLORS[this.hash(`${id}:acc`) % ACCESSORY_COLORS.length]! + : 0xffffff; + const agent = new Agent({ + id, + body: factory.agentBody(), + face: factory.agentFace(), + shadow: factory.agentShadow(), + accessory: factory.accessory(accessoryKind), + accessoryTint, + fontName: NAMEPLATE_FONT, + tint, + label, + spawn, + }); + // Agents live in the room's structure layer so they sort against walls + // and furniture; they always render above the ground layer. + (this.room?.entityLayer ?? this.world).addChild(agent); + this.agents.set(id, agent); + this.wanderTimers.set(id, this.randomWanderDelay()); + this.notifyChange(); + return agent; + } + + // ---- Render loop -------------------------------------------------------- + + private readonly tick = (ticker: Ticker): void => { + const deltaMs = ticker.deltaMS; + const deltaSeconds = deltaMs / 1000; + + this.updateTraffic(deltaSeconds); + + for (const agent of this.agents.values()) { + if (this.autonomous) { + this.stepWander(agent, deltaMs); + } + agent.tick(deltaSeconds); + this.resolveAgentDepth(agent); + } + + for (const [id, bubble] of this.bubbles) { + const agent = this.agents.get(id); + if (agent) { + bubble.position.set(agent.x, agent.y + agent.headOffsetY); + } + if (!bubble.update(deltaMs)) { + this.bubbleLayer.removeChild(bubble); + bubble.destroy({ children: true }); + this.bubbles.delete(id); + } + } + + this.updateSelectionRing(); + }; + + /** + * Nudge an agent's depth so it sorts correctly against the furniture it + * overlaps: behind every piece it is behind, in front of every piece it is + * in front of. `agent.tick` has already set the baseline `x + y` depth. + */ + private resolveAgentDepth(agent: Agent): void { + const room = this.room; + if (!room) { + return; + } + const tile = agent.currentTile; + let lowerBound = Number.NEGATIVE_INFINITY; + let upperBound = Number.POSITIVE_INFINITY; + for (const box of room.depthObstacles()) { + const relation = boxRelation(tile.x, tile.y, box); + if (relation > 0) { + lowerBound = Math.max(lowerBound, box.zIndex); + } else if (relation < 0) { + upperBound = Math.min(upperBound, box.zIndex); + } + } + let depth = agent.zIndex; + if (lowerBound !== Number.NEGATIVE_INFINITY && depth <= lowerBound) { + depth = lowerBound + 0.5; + } + if (upperBound !== Number.POSITIVE_INFINITY && depth >= upperBound) { + depth = upperBound - 0.5; + } + agent.zIndex = depth; + } + + private stepWander(agent: Agent, deltaMs: number): void { + if (agent.currentAction !== 'idle') { + return; + } + const remaining = (this.wanderTimers.get(agent.agentId) ?? 0) - deltaMs; + if (remaining > 0) { + this.wanderTimers.set(agent.agentId, remaining); + return; + } + this.wanderTimers.set(agent.agentId, this.randomWanderDelay()); + const room = this.room!; + const target = this.randomFreeNode(room.walkableNodes()); + const start = agent.currentTile; + if (target) { + const path = room.findPath(Math.round(start.x), Math.round(start.y), target.x, target.y); + if (path && path.length > 0) { + agent.walkPath(path, 'idle', null, 0); + } + } + if (Math.random() < 0.4) { + this.speak(agent.agentId, { text: this.pickAmbientLine() }); + } + } + + private updateSelectionRing(): void { + if (!this.selectedId) { + return; + } + const agent = this.agents.get(this.selectedId); + if (!agent) { + this.selectionRing.visible = false; + return; + } + this.selectionRing.visible = true; + this.selectionRing.position.set(agent.x, agent.y); + this.selectionRing.zIndex = agent.zIndex - 0.5; + } + + // ---- Pointer input ------------------------------------------------------ + + private readonly onPointerDown = (event: FederatedPointerEvent): void => { + this.pointerActive = true; + this.pointerMoved = 0; + this.lastPointerX = event.global.x; + this.lastPointerY = event.global.y; + }; + + private readonly onPointerMove = (event: FederatedPointerEvent): void => { + if (!this.pointerActive) { + return; + } + const deltaX = event.global.x - this.lastPointerX; + const deltaY = event.global.y - this.lastPointerY; + this.lastPointerX = event.global.x; + this.lastPointerY = event.global.y; + this.pointerMoved += Math.abs(deltaX) + Math.abs(deltaY); + const scale = this.viewport.scale.x || 1; + this.camera.position.set( + clamp( + this.camera.position.x + deltaX / scale, + NATIVE_RESOLUTION / 2 - this.panRangeX, + NATIVE_RESOLUTION / 2 + this.panRangeX + ), + clamp( + this.camera.position.y + deltaY / scale, + NATIVE_RESOLUTION / 2 - this.panRangeY, + NATIVE_RESOLUTION / 2 + this.panRangeY + ) + ); + }; + + private readonly onPointerUp = (event: FederatedPointerEvent): void => { + if (this.pointerActive && this.pointerMoved < TAP_THRESHOLD) { + this.handleTap(event); + } + this.pointerActive = false; + }; + + private handleTap(event: FederatedPointerEvent): void { + const room = this.room; + if (!room) { + return; + } + const local = this.world.toLocal(event.global); + const picked = this.pickAgentAt(local.x, local.y); + if (picked) { + this.selectedId = picked.agentId; + return; + } + const tile = screenToTile(local.x, local.y); + const tileX = Math.floor(tile.x); + const tileY = Math.floor(tile.y); + const agent = this.selectedId ? this.agents.get(this.selectedId) : undefined; + if (!agent) { + return; + } + // Walkable floor → walk there. Otherwise, if an interactable furniture + // piece was tapped, send the agent to one of its free stations. + if (room.isWalkable(tileX, tileY)) { + this.routeAgentToTile(agent, tileX, tileY, 'idle', null, 0); + return; + } + const stations = room.pieceStationsAt(tileX, tileY); + if (stations.length === 0) { + return; + } + const station = + stations.find(candidate => !this.tileOccupied(candidate.tileX, candidate.tileY)) ?? + stations[0]!; + const isSit = station.point.action === 'sit'; + this.routeAgentToTile( + agent, + station.tileX, + station.tileY, + isSit ? 'sitting' : 'inspecting', + station.point.facing ?? null, + isSit ? (station.point.seatDropY ?? 6) : 0 + ); + } + + private routeAgentToTile( + agent: Agent, + tileX: number, + tileY: number, + arrivalAction: Parameters[1], + facing: FurnitureStation['point']['facing'] | null, + seatDropY: number + ): void { + const room = this.room; + if (!room) { + return; + } + const start = agent.currentTile; + const path = room.findPath(Math.round(start.x), Math.round(start.y), tileX, tileY); + if (path) { + agent.walkPath(path, arrivalAction, facing ?? null, seatDropY); + } + } + + private pickAgentAt(localX: number, localY: number): Agent | undefined { + let best: Agent | undefined; + let bestDistance = AGENT_PICK_RADIUS; + for (const agent of this.agents.values()) { + const distance = Math.hypot(agent.x - localX, agent.y - localY + 20); + if (distance < bestDistance) { + bestDistance = distance; + best = agent; + } + } + return best; + } + + // ---- Resize ------------------------------------------------------------- + + private observeResize(parent: HTMLElement): void { + this.selectionRing.clear(); + this.selectionRing.ellipse(0, 0, 16, 8).stroke({ color: 0xffffff, width: 2, alpha: 0.85 }); + this.selectionRing.zIndex = depthAt(0, 0, 0, LAYER_DECAL); + this.applySize( + parent.clientWidth || NATIVE_RESOLUTION, + parent.clientHeight || NATIVE_RESOLUTION + ); + this.resizeObserver = new ResizeObserver(entries => { + const rect = entries[0]?.contentRect; + this.applySize(rect?.width ?? NATIVE_RESOLUTION, rect?.height ?? NATIVE_RESOLUTION); + }); + this.resizeObserver.observe(parent); + } + + private applySize(width: number, height: number): void { + const app = this.app; + if (!app || width <= 0 || height <= 0) { + return; + } + const w = Math.round(width); + const h = Math.round(height); + // The renderer now fills the full (possibly non-square) panel. The world is + // authored in a NATIVE_RESOLUTION square, so we scale it to "contain" within + // the panel and centre it — the renderer background fills any surrounding + // space edge-to-edge, so the world reads as full-screen rather than boxed. + app.renderer.resize(w, h); + app.stage.hitArea = new Rectangle(0, 0, w, h); + const scale = + this.fillMode === 'cover' + ? Math.max(w, h) / NATIVE_RESOLUTION + : Math.min(w, h) / NATIVE_RESOLUTION; + this.viewport.scale.set(scale); + this.viewport.position.set( + (w - NATIVE_RESOLUTION * scale) / 2, + (h - NATIVE_RESOLUTION * scale) / 2 + ); + } + + // ---- Small helpers ------------------------------------------------------ + + private tileOccupied(tileX: number, tileY: number): boolean { + for (const agent of this.agents.values()) { + const tile = agent.currentTile; + if (Math.round(tile.x) === tileX && Math.round(tile.y) === tileY) { + return true; + } + } + return false; + } + + private randomFreeNode(nodes: Array): WalkNode | undefined { + const free = nodes.filter(node => !this.tileOccupied(node.x, node.y)); + const pool = free.length > 0 ? free : nodes; + if (pool.length === 0) { + return undefined; + } + const index = Math.floor(Math.random() * pool.length); + return pool[index]; + } + + private hash(seed: string): number { + let value = 0; + for (let index = 0; index < seed.length; index++) { + value = (value * 31 + seed.charCodeAt(index)) >>> 0; + } + return value; + } + + private pickTint(seed: string): number { + return AGENT_TINTS[this.hash(seed) % AGENT_TINTS.length]!; + } + + private pickName(): string { + return AGENT_NAMES[Math.floor(Math.random() * AGENT_NAMES.length)]!; + } + + private pickAmbientLine(): string { + return AMBIENT_LINES[Math.floor(Math.random() * AMBIENT_LINES.length)]!; + } + + private randomWanderDelay(): number { + return 2200 + Math.random() * 3600; + } + + private notifyChange(): void { + this.changeListener?.(); + } +} diff --git a/app/src/agentworld/iso/color.ts b/app/src/agentworld/iso/color.ts new file mode 100644 index 0000000000..02e4cb0f24 --- /dev/null +++ b/app/src/agentworld/iso/color.ts @@ -0,0 +1,27 @@ +/** Tiny colour helpers for blending palette tints (all values are 0xRRGGBB). */ + +function channels(color: number): [number, number, number] { + return [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff]; +} + +function pack(red: number, green: number, blue: number): number { + const clamp = (value: number): number => Math.max(0, Math.min(255, Math.round(value))); + return (clamp(red) << 16) | (clamp(green) << 8) | clamp(blue); +} + +/** Linearly blend two colours; `amount` 0 returns `colorA`, 1 returns `colorB`. */ +export function mixColor(colorA: number, colorB: number, amount: number): number { + const [redA, greenA, blueA] = channels(colorA); + const [redB, greenB, blueB] = channels(colorB); + return pack( + redA + (redB - redA) * amount, + greenA + (greenB - greenA) * amount, + blueA + (blueB - blueA) * amount + ); +} + +/** Scale a colour's brightness (`factor` < 1 darkens, > 1 lightens). */ +export function shadeColor(color: number, factor: number): number { + const [red, green, blue] = channels(color); + return pack(red * factor, green * factor, blue * factor); +} diff --git a/app/src/agentworld/iso/furniture.ts b/app/src/agentworld/iso/furniture.ts new file mode 100644 index 0000000000..dedcc4030a --- /dev/null +++ b/app/src/agentworld/iso/furniture.ts @@ -0,0 +1,899 @@ +/** + * Modular furniture. + * + * A {@link FurnitureSprite} is assembled from a data-driven *blueprint*: a list + * of cheap parts (isometric cuboids, flat decals, or the shared chair shape) + * stacked with pixel lifts. The same handful of baked textures, recoloured with + * `tint`, build every table, couch, desk and plant in the world. + * + * Each piece declares two things the simulation cares about: + * - its **collision footprint** (which tiles block walking), and + * - its **interaction points** (tiles an agent can stand on to sit or inspect). + */ +import { Container, Sprite } from 'pixi.js'; + +import { depthAt, LAYER_DECAL, LAYER_FURNITURE, tileToScreen } from './geometry'; +import type { BakedTexture, TextureFactory } from './textures'; +import type { FurnitureConfig, InteractionPoint } from './types'; + +type PartShape = 'cuboid' | 'decal' | 'chair' | 'buildingDetail' | 'roadDash'; + +interface FurniturePart { + shape: PartShape; + footprintWidth?: number; + footprintHeight?: number; + height?: number; + offsetTileX?: number; + offsetTileY?: number; + lift?: number; + tint?: number; + alpha?: number; + // Building-detail overlay parameters. + windowRows?: number; + windowColumns?: number; + windowColor?: number; + roofColor?: number; + doorColor?: number; +} + +interface FurnitureBlueprint { + footprintWidth: number; + footprintHeight: number; + solid: boolean; + baseTint: number; + parts: Array; + interactionPoints: Array; + /** Ground-hugging decals (rugs) sort from their back edge, below agents. */ + flat?: boolean; +} + +const WOOD_DARK = 0x6b4a30; +const WOOD_MID = 0x8a6442; +const FELT_GREEN = 0x2f7d54; + +function sit( + tileOffsetX: number, + tileOffsetY: number, + facing: 'left' | 'right', + seatDropY = 6 +): InteractionPoint { + return { tileOffsetX, tileOffsetY, action: 'sit', facing, seatDropY }; +} + +/** A pixelated building: a tinted body cuboid plus an untinted detail overlay. */ +function buildingBlueprint(options: { + footprintWidth: number; + footprintHeight: number; + height: number; + bodyTint: number; + windowRows: number; + windowColumns: number; + windowColor: number; + roofColor: number; + doorColor: number; +}): FurnitureBlueprint { + return { + footprintWidth: options.footprintWidth, + footprintHeight: options.footprintHeight, + solid: true, + baseTint: options.bodyTint, + parts: [ + { + shape: 'cuboid', + footprintWidth: options.footprintWidth, + footprintHeight: options.footprintHeight, + height: options.height, + }, + { + shape: 'buildingDetail', + footprintWidth: options.footprintWidth, + footprintHeight: options.footprintHeight, + height: options.height, + tint: 0xffffff, + windowRows: options.windowRows, + windowColumns: options.windowColumns, + windowColor: options.windowColor, + roofColor: options.roofColor, + doorColor: options.doorColor, + }, + ], + interactionPoints: [], + }; +} + +/** A proper table: a thin overhanging top on four legs, open underneath. */ +function tableBlueprint(options: { + footprintWidth: number; + footprintHeight: number; + height: number; + bodyTint: number; + topTint: number; + extraParts?: Array; +}): FurnitureBlueprint { + const width = options.footprintWidth; + const depth = options.footprintHeight; + const legHeight = Math.max(4, options.height - 5); + const leg = (offsetTileX: number, offsetTileY: number): FurniturePart => ({ + shape: 'cuboid', + footprintWidth: 0.13, + footprintHeight: 0.13, + height: legHeight, + offsetTileX, + offsetTileY, + }); + const parts: Array = [ + leg(0.07, 0.07), + leg(width - 0.2, 0.07), + leg(0.07, depth - 0.2), + leg(width - 0.2, depth - 0.2), + // Tabletop slab overhanging the legs... + { shape: 'cuboid', footprintWidth: width, footprintHeight: depth, height: 5, lift: legHeight }, + // ...with a lighter surface. + { + shape: 'decal', + footprintWidth: width - 0.1, + footprintHeight: depth - 0.1, + offsetTileX: 0.05, + offsetTileY: 0.05, + lift: legHeight + 5, + tint: options.topTint, + }, + ...(options.extraParts ?? []), + ]; + return { + footprintWidth: width, + footprintHeight: depth, + solid: true, + baseTint: options.bodyTint, + parts, + interactionPoints: [], + }; +} + +export const FURNITURE_BLUEPRINTS: Record = { + pokerTable: { + footprintWidth: 3, + footprintHeight: 2, + solid: true, + baseTint: WOOD_DARK, + parts: [ + { shape: 'cuboid', footprintWidth: 3, footprintHeight: 2, height: 22 }, + { + shape: 'decal', + footprintWidth: 2.6, + footprintHeight: 1.6, + offsetTileX: 0.2, + offsetTileY: 0.2, + lift: 22, + tint: FELT_GREEN, + }, + ], + interactionPoints: [], + }, + courtTable: tableBlueprint({ + footprintWidth: 2, + footprintHeight: 1, + height: 18, + bodyTint: WOOD_MID, + topTint: 0xb98a5e, + }), + judgeBench: { + footprintWidth: 2, + footprintHeight: 1, + solid: true, + baseTint: 0x5a3d28, + parts: [ + { shape: 'cuboid', footprintWidth: 2, footprintHeight: 1, height: 32 }, + { + shape: 'decal', + footprintWidth: 1.9, + footprintHeight: 0.9, + offsetTileX: 0.05, + offsetTileY: 0.05, + lift: 32, + tint: 0x4a3020, + }, + ], + interactionPoints: [], + }, + witnessStand: { + footprintWidth: 1, + footprintHeight: 1, + solid: true, + baseTint: 0x5a3d28, + parts: [{ shape: 'cuboid', footprintWidth: 1, footprintHeight: 1, height: 26 }], + interactionPoints: [], + }, + desk: tableBlueprint({ + footprintWidth: 2, + footprintHeight: 1, + height: 18, + bodyTint: 0x8a6a45, + topTint: 0xc9a878, + extraParts: [ + // A little dark monitor on the desk's far edge. + { + shape: 'cuboid', + footprintWidth: 0.7, + footprintHeight: 0.16, + height: 14, + offsetTileX: 0.2, + offsetTileY: 0.18, + lift: 18, + tint: 0x20242e, + }, + ], + }), + whiteboard: { + footprintWidth: 1, + footprintHeight: 1, + solid: true, + baseTint: 0xeef1f6, + parts: [ + { shape: 'cuboid', footprintWidth: 1, footprintHeight: 0.18, height: 34, offsetTileY: 0.4 }, + ], + interactionPoints: [{ tileOffsetX: 0, tileOffsetY: 1, action: 'inspect', facing: 'right' }], + }, + bookshelf: { + footprintWidth: 1, + footprintHeight: 1, + solid: true, + baseTint: 0x6e4a30, + parts: [ + { shape: 'cuboid', footprintWidth: 1, footprintHeight: 0.3, height: 40, offsetTileY: 0.35 }, + ], + interactionPoints: [], + }, + couch: { + footprintWidth: 2, + footprintHeight: 1, + solid: true, + baseTint: 0x8a5a6a, + parts: [ + { shape: 'cuboid', footprintWidth: 2, footprintHeight: 1, height: 12 }, + // Backrest along the north edge. + { shape: 'cuboid', footprintWidth: 2, footprintHeight: 0.28, height: 20, lift: 12 }, + ], + interactionPoints: [sit(0, 0, 'right', 4), sit(1, 0, 'left', 4)], + }, + coffeeTable: tableBlueprint({ + footprintWidth: 1, + footprintHeight: 1, + height: 11, + bodyTint: 0x7a5a3c, + topTint: 0x9c7a52, + }), + tvStand: { + footprintWidth: 2, + footprintHeight: 1, + solid: true, + baseTint: 0x3a3f4a, + parts: [ + { shape: 'cuboid', footprintWidth: 2, footprintHeight: 0.5, height: 10, offsetTileY: 0.25 }, + { + shape: 'cuboid', + footprintWidth: 1.6, + footprintHeight: 0.14, + height: 22, + offsetTileX: 0.2, + offsetTileY: 0.4, + lift: 10, + tint: 0x141821, + }, + ], + interactionPoints: [], + }, + bed: { + footprintWidth: 2, + footprintHeight: 2, + solid: true, + baseTint: 0x9a8fb0, + parts: [ + { shape: 'cuboid', footprintWidth: 2, footprintHeight: 2, height: 12 }, + { + shape: 'decal', + footprintWidth: 1.8, + footprintHeight: 1.8, + offsetTileX: 0.1, + offsetTileY: 0.1, + lift: 12, + tint: 0xd5cfe6, + }, + // Pillow. + { + shape: 'decal', + footprintWidth: 1.5, + footprintHeight: 0.5, + offsetTileX: 0.25, + offsetTileY: 0.15, + lift: 12, + tint: 0xf2eefb, + }, + ], + interactionPoints: [], + }, + plant: { + footprintWidth: 1, + footprintHeight: 1, + solid: true, + baseTint: 0x9a6b4a, + parts: [ + { + shape: 'cuboid', + footprintWidth: 0.45, + footprintHeight: 0.45, + height: 12, + offsetTileX: 0.28, + offsetTileY: 0.28, + }, + { + shape: 'cuboid', + footprintWidth: 0.6, + footprintHeight: 0.6, + height: 18, + offsetTileX: 0.2, + offsetTileY: 0.2, + lift: 12, + tint: 0x3f8f53, + }, + ], + interactionPoints: [], + }, + rug: { + footprintWidth: 3, + footprintHeight: 3, + solid: false, + flat: true, + baseTint: 0x8a5a6a, + parts: [{ shape: 'decal', footprintWidth: 3, footprintHeight: 3, alpha: 0.9 }], + interactionPoints: [], + }, + chair: { + footprintWidth: 1, + footprintHeight: 1, + // Seats block transit but are reachable as a terminal "sit" step, so + // agents walk around them rather than over them. + solid: true, + baseTint: 0x7a5a3c, + parts: [{ shape: 'chair', offsetTileX: 0.19, offsetTileY: 0.19 }], + interactionPoints: [sit(0, 0, 'right', 6)], + }, + stool: { + footprintWidth: 1, + footprintHeight: 1, + solid: true, + baseTint: 0x7a5230, + parts: [ + // A slim pedestal... + { + shape: 'cuboid', + footprintWidth: 0.26, + footprintHeight: 0.26, + height: 9, + offsetTileX: 0.37, + offsetTileY: 0.37, + }, + // ...a round seat block... + { + shape: 'cuboid', + footprintWidth: 0.52, + footprintHeight: 0.52, + height: 4, + offsetTileX: 0.24, + offsetTileY: 0.24, + lift: 9, + }, + // ...and a soft overhanging cushion on top. + { + shape: 'decal', + footprintWidth: 0.58, + footprintHeight: 0.58, + offsetTileX: 0.21, + offsetTileY: 0.21, + lift: 13, + tint: 0xb5793f, + }, + ], + interactionPoints: [sit(0, 0, 'right', 6)], + }, + lamp: { + footprintWidth: 1, + footprintHeight: 1, + solid: true, + baseTint: 0x3a3f4a, + parts: [ + { + shape: 'cuboid', + footprintWidth: 0.16, + footprintHeight: 0.16, + height: 42, + offsetTileX: 0.42, + offsetTileY: 0.42, + }, + { + shape: 'cuboid', + footprintWidth: 0.5, + footprintHeight: 0.5, + height: 9, + offsetTileX: 0.25, + offsetTileY: 0.25, + lift: 42, + tint: 0xffe9a8, + }, + ], + interactionPoints: [], + }, + crate: { + footprintWidth: 1, + footprintHeight: 1, + solid: true, + baseTint: 0x9a7042, + parts: [ + { + shape: 'cuboid', + footprintWidth: 0.8, + footprintHeight: 0.8, + height: 20, + offsetTileX: 0.1, + offsetTileY: 0.1, + }, + ], + interactionPoints: [{ tileOffsetX: 0, tileOffsetY: 1, action: 'inspect', facing: 'left' }], + }, + painting: { + footprintWidth: 1, + footprintHeight: 1, + solid: true, + baseTint: 0xc9a878, + parts: [ + { + shape: 'cuboid', + footprintWidth: 1, + footprintHeight: 0.14, + height: 26, + offsetTileY: 0.42, + lift: 8, + }, + { + shape: 'cuboid', + footprintWidth: 0.78, + footprintHeight: 0.1, + height: 20, + offsetTileX: 0.11, + offsetTileY: 0.44, + lift: 11, + tint: 0x5a8fb0, + }, + ], + interactionPoints: [{ tileOffsetX: 0, tileOffsetY: 1, action: 'inspect', facing: 'left' }], + }, + barCounter: { + footprintWidth: 3, + footprintHeight: 1, + solid: true, + baseTint: 0x5a3d28, + parts: [ + { shape: 'cuboid', footprintWidth: 3, footprintHeight: 1, height: 24 }, + { + shape: 'decal', + footprintWidth: 3, + footprintHeight: 0.6, + offsetTileY: 0.1, + lift: 24, + tint: 0x2a2d3a, + }, + ], + interactionPoints: [], + }, + trophy: { + footprintWidth: 1, + footprintHeight: 1, + solid: true, + baseTint: 0xf5c542, + parts: [ + { + shape: 'cuboid', + footprintWidth: 0.4, + footprintHeight: 0.4, + height: 8, + offsetTileX: 0.3, + offsetTileY: 0.3, + }, + { + shape: 'cuboid', + footprintWidth: 0.3, + footprintHeight: 0.3, + height: 12, + offsetTileX: 0.35, + offsetTileY: 0.35, + lift: 8, + }, + ], + interactionPoints: [{ tileOffsetX: 0, tileOffsetY: 1, action: 'inspect', facing: 'left' }], + }, + fern: { + footprintWidth: 1, + footprintHeight: 1, + solid: true, + baseTint: 0x9a6b4a, + parts: [ + { + shape: 'cuboid', + footprintWidth: 0.4, + footprintHeight: 0.4, + height: 14, + offsetTileX: 0.3, + offsetTileY: 0.3, + }, + { + shape: 'cuboid', + footprintWidth: 0.72, + footprintHeight: 0.72, + height: 14, + offsetTileX: 0.14, + offsetTileY: 0.14, + lift: 14, + tint: 0x4e9f5a, + }, + { + shape: 'cuboid', + footprintWidth: 0.46, + footprintHeight: 0.46, + height: 12, + offsetTileX: 0.27, + offsetTileY: 0.27, + lift: 26, + tint: 0x3f8f53, + }, + ], + interactionPoints: [], + }, + door: { + footprintWidth: 1, + footprintHeight: 1, + solid: true, + baseTint: 0x5a3d28, + parts: [ + // Frame against the back wall... + { shape: 'cuboid', footprintWidth: 1, footprintHeight: 0.16, height: 46 }, + // ...with a brighter panel inset. + { + shape: 'cuboid', + footprintWidth: 0.72, + footprintHeight: 0.1, + height: 36, + offsetTileX: 0.14, + offsetTileY: 0.03, + tint: 0xc98a4a, + }, + ], + interactionPoints: [], + }, + // ---- Outdoor buildings -------------------------------------------------- + house: buildingBlueprint({ + footprintWidth: 3, + footprintHeight: 3, + height: 70, + bodyTint: 0xc8a06f, + windowRows: 3, + windowColumns: 2, + windowColor: 0xffe9a8, + roofColor: 0x9a4b3c, + doorColor: 0x5a3d28, + }), + shop: buildingBlueprint({ + footprintWidth: 3, + footprintHeight: 2, + height: 56, + bodyTint: 0xd0a07a, + windowRows: 2, + windowColumns: 3, + windowColor: 0x9ad0ec, + roofColor: 0x3f7d6a, + doorColor: 0x4a3526, + }), + cafe: buildingBlueprint({ + footprintWidth: 3, + footprintHeight: 2, + height: 60, + bodyTint: 0xb87a96, + windowRows: 2, + windowColumns: 3, + windowColor: 0xffd9a8, + roofColor: 0xb04a4a, + doorColor: 0x5a3d28, + }), + apartment: buildingBlueprint({ + footprintWidth: 3, + footprintHeight: 2, + height: 104, + bodyTint: 0xb0664a, + windowRows: 5, + windowColumns: 3, + windowColor: 0xffe2a0, + roofColor: 0x6a4036, + doorColor: 0x3a2820, + }), + tower: buildingBlueprint({ + footprintWidth: 2, + footprintHeight: 2, + height: 150, + bodyTint: 0x8a9bb0, + windowRows: 8, + windowColumns: 2, + windowColor: 0xffe9a8, + roofColor: 0x55606e, + doorColor: 0x2a2d3a, + }), + glassTower: buildingBlueprint({ + footprintWidth: 2, + footprintHeight: 2, + height: 178, + bodyTint: 0x6f8aa0, + windowRows: 10, + windowColumns: 2, + windowColor: 0x9fd6ee, + roofColor: 0x44586a, + doorColor: 0x223040, + }), + car: { + footprintWidth: 2, + footprintHeight: 1, + solid: true, + baseTint: 0xc0392b, + parts: [ + // Wheels. + { + shape: 'cuboid', + footprintWidth: 0.22, + footprintHeight: 0.18, + height: 5, + offsetTileX: 0.2, + offsetTileY: 0.08, + tint: 0x14161b, + }, + { + shape: 'cuboid', + footprintWidth: 0.22, + footprintHeight: 0.18, + height: 5, + offsetTileX: 1.58, + offsetTileY: 0.08, + tint: 0x14161b, + }, + { + shape: 'cuboid', + footprintWidth: 0.22, + footprintHeight: 0.18, + height: 5, + offsetTileX: 0.2, + offsetTileY: 0.74, + tint: 0x14161b, + }, + { + shape: 'cuboid', + footprintWidth: 0.22, + footprintHeight: 0.18, + height: 5, + offsetTileX: 1.58, + offsetTileY: 0.74, + tint: 0x14161b, + }, + // Chassis (body colour). + { + shape: 'cuboid', + footprintWidth: 1.8, + footprintHeight: 0.66, + height: 9, + offsetTileX: 0.1, + offsetTileY: 0.17, + lift: 3, + }, + // Cabin. + { + shape: 'cuboid', + footprintWidth: 0.92, + footprintHeight: 0.56, + height: 8, + offsetTileX: 0.52, + offsetTileY: 0.22, + lift: 12, + }, + // Glass roof. + { + shape: 'decal', + footprintWidth: 0.84, + footprintHeight: 0.48, + offsetTileX: 0.56, + offsetTileY: 0.26, + lift: 20, + tint: 0x243040, + }, + // Headlights. + { + shape: 'cuboid', + footprintWidth: 0.08, + footprintHeight: 0.5, + height: 5, + offsetTileX: 0.07, + offsetTileY: 0.25, + lift: 4, + tint: 0xfff0c0, + }, + ], + interactionPoints: [], + }, + roadDash: { + footprintWidth: 1, + footprintHeight: 1, + solid: false, + flat: true, + baseTint: 0xffffff, + parts: [{ shape: 'roadDash' }], + interactionPoints: [], + }, + fountain: { + footprintWidth: 2, + footprintHeight: 2, + solid: true, + baseTint: 0x9298a4, + parts: [ + { shape: 'cuboid', footprintWidth: 2, footprintHeight: 2, height: 10 }, + { + shape: 'decal', + footprintWidth: 1.6, + footprintHeight: 1.6, + offsetTileX: 0.2, + offsetTileY: 0.2, + lift: 10, + tint: 0x5aa9e6, + }, + { + shape: 'cuboid', + footprintWidth: 0.36, + footprintHeight: 0.36, + height: 16, + offsetTileX: 0.82, + offsetTileY: 0.82, + lift: 10, + tint: 0xbfe3f5, + }, + ], + interactionPoints: [], + }, +}; + +/** A station an agent can occupy: the world tile plus what it does there. */ +export interface FurnitureStation { + tileX: number; + tileY: number; + point: InteractionPoint; +} + +export class FurnitureSprite extends Container { + public readonly kind: string; + public readonly tileX: number; + public readonly tileY: number; + public readonly footprintWidth: number; + public readonly footprintHeight: number; + public readonly level: number; + public readonly solid: boolean; + /** Ground-hugging decal (rug) that should render in the ground layer. */ + public readonly flat: boolean; + private readonly stationPoints: Array; + + public constructor(config: FurnitureConfig, factory: TextureFactory) { + super(); + const blueprint = FURNITURE_BLUEPRINTS[config.kind]; + if (!blueprint) { + throw new Error(`Unknown furniture kind: ${config.kind}`); + } + this.kind = config.kind; + this.tileX = config.tileX; + this.tileY = config.tileY; + this.level = config.level ?? 0; + this.footprintWidth = config.footprintWidth ?? blueprint.footprintWidth; + this.footprintHeight = config.footprintHeight ?? blueprint.footprintHeight; + this.solid = config.solid ?? blueprint.solid; + this.flat = blueprint.flat ?? false; + this.stationPoints = config.interactionPoints ?? blueprint.interactionPoints; + + // A soft contact shadow grounds the piece (skipped for flat decals). + if (!blueprint.flat) { + const shadow = factory.contactShadow(this.footprintWidth, this.footprintHeight); + const shadowSprite = new Sprite(shadow.texture); + shadowSprite.pivot.set(shadow.anchorX, shadow.anchorY); + this.addChild(shadowSprite); + } + + const tint = config.tint ?? blueprint.baseTint; + for (const part of blueprint.parts) { + this.addChild(this.buildPart(part, tint, factory)); + } + + const screen = tileToScreen(this.tileX, this.tileY, this.level); + this.position.set(screen.x, screen.y); + this.zIndex = blueprint.flat + ? depthAt(this.tileX, this.tileY, this.level, LAYER_DECAL) + : depthAt( + this.tileX + (this.footprintWidth - 1) / 2, + this.tileY + (this.footprintHeight - 1) / 2, + this.level, + LAYER_FURNITURE + ); + } + + private buildPart(part: FurniturePart, baseTint: number, factory: TextureFactory): Sprite { + const baked = ((): BakedTexture => { + switch (part.shape) { + case 'chair': + return factory.chair(); + case 'roadDash': + return factory.roadDash(); + case 'decal': + return factory.decal(part.footprintWidth ?? 1, part.footprintHeight ?? 1); + case 'buildingDetail': + return factory.buildingDetail( + part.footprintWidth ?? 2, + part.footprintHeight ?? 2, + part.height ?? 40, + { + windowRows: part.windowRows ?? 2, + windowColumns: part.windowColumns ?? 2, + windowColor: part.windowColor ?? 0xffe9a8, + roofColor: part.roofColor ?? 0x8a4b3c, + doorColor: part.doorColor ?? 0x4a3526, + } + ); + default: + return factory.cuboid( + part.footprintWidth ?? 1, + part.footprintHeight ?? 1, + part.height ?? 16 + ); + } + })(); + + const sprite = new Sprite(baked.texture); + sprite.pivot.set(baked.anchorX, baked.anchorY); + const offset = tileToScreen(part.offsetTileX ?? 0, part.offsetTileY ?? 0); + sprite.position.set(offset.x, offset.y - (part.lift ?? 0)); + sprite.tint = part.tint ?? baseTint; + if (part.alpha !== undefined) { + sprite.alpha = part.alpha; + } + return sprite; + } + + /** Every tile under the piece, regardless of whether it blocks. */ + public footprintTiles(): Array<{ x: number; y: number }> { + const tiles: Array<{ x: number; y: number }> = []; + for (let column = 0; column < this.footprintWidth; column++) { + for (let row = 0; row < this.footprintHeight; row++) { + tiles.push({ x: this.tileX + column, y: this.tileY + row }); + } + } + return tiles; + } + + /** Tiles this piece blocks for transit (empty if it is walkable). */ + public solidTiles(): Array<{ x: number; y: number }> { + return this.solid ? this.footprintTiles() : []; + } + + /** Tiles an agent may stand on as a terminal "sit" step. */ + public seatTiles(): Array<{ x: number; y: number }> { + return this.stationPoints + .filter(point => point.action === 'sit') + .map(point => ({ x: this.tileX + point.tileOffsetX, y: this.tileY + point.tileOffsetY })); + } + + /** World-space interaction stations for this piece. */ + public stations(): Array { + return this.stationPoints.map(point => ({ + tileX: this.tileX + point.tileOffsetX, + tileY: this.tileY + point.tileOffsetY, + point, + })); + } +} diff --git a/app/src/agentworld/iso/geometry.ts b/app/src/agentworld/iso/geometry.ts new file mode 100644 index 0000000000..3d1e2ecff0 --- /dev/null +++ b/app/src/agentworld/iso/geometry.ts @@ -0,0 +1,85 @@ +/** + * Isometric geometry for the agent world. + * + * The grid uses a classic 2:1 isometric projection: a tile is twice as wide as + * it is tall (64 x 32 by default). Tile `(gridX, gridY)` projects to the screen + * with the diamond's *top* corner at {@link tileToScreen}; the diamond then + * spans one tile-width to the east/west and one tile-height down to the south. + * + * Elevation (`level`) lifts a tile straight up the screen by a fixed number of + * pixels per level, which is what gives the courthouse dais and raised furniture + * their height. Depth sorting collapses the world to a single painter's-order + * scalar derived from `gridX + gridY + level`, exactly the "global screen depth" + * the renderer needs. + */ + +export const TILE_WIDTH = 64; +export const TILE_HEIGHT = 32; +export const HALF_TILE_WIDTH = TILE_WIDTH / 2; +export const HALF_TILE_HEIGHT = TILE_HEIGHT / 2; + +/** Screen pixels a single elevation level lifts a tile upward. */ +export const ELEVATION_HEIGHT = 26; + +/** Native (unscaled) resolution of the game viewport, in logical pixels. */ +export const NATIVE_RESOLUTION = 600; + +export interface ScreenPoint { + x: number; + y: number; +} + +export interface TilePoint { + x: number; + y: number; +} + +/** Project a tile's *top corner* into local screen space. */ +export function tileToScreen(gridX: number, gridY: number, level = 0): ScreenPoint { + return { + x: (gridX - gridY) * HALF_TILE_WIDTH, + y: (gridX + gridY) * HALF_TILE_HEIGHT - level * ELEVATION_HEIGHT, + }; +} + +/** Project the *centre* of a tile (where an agent's feet rest). */ +export function tileCenterToScreen(gridX: number, gridY: number, level = 0): ScreenPoint { + const corner = tileToScreen(gridX, gridY, level); + return { x: corner.x, y: corner.y + HALF_TILE_HEIGHT }; +} + +/** + * Inverse projection: turn a local screen position back into fractional tile + * coordinates. Elevation is ignored — callers resolve height from the grid. + */ +export function screenToTile(screenX: number, screenY: number): TilePoint { + const normalizedX = screenX / HALF_TILE_WIDTH; + const normalizedY = screenY / HALF_TILE_HEIGHT; + return { x: (normalizedY + normalizedX) / 2, y: (normalizedY - normalizedX) / 2 }; +} + +/** + * Layer biases keep entities that share a tile in a stable front-to-back order + * without disturbing the dominant `gridX + gridY` ordering. + */ +export const DEPTH_TILE_SCALE = 16; +export const LAYER_FLOOR = 0; +export const LAYER_DECAL = 1; +export const LAYER_WALL = 2; +export const LAYER_FURNITURE = 3; +export const LAYER_AGENT = 6; + +/** Painter's-order depth for a point at `(gridX, gridY)` and elevation `level`. */ +export function depthAt(gridX: number, gridY: number, level = 0, layer = 0): number { + return (gridX + gridY) * DEPTH_TILE_SCALE + level * (DEPTH_TILE_SCALE / 2) + layer; +} + +/** Linear interpolation between two scalars. */ +export function lerp(from: number, to: number, amount: number): number { + return from + (to - from) * amount; +} + +/** Euclidean distance between two tile points. */ +export function tileDistance(from: TilePoint, to: TilePoint): number { + return Math.hypot(to.x - from.x, to.y - from.y); +} diff --git a/app/src/agentworld/iso/index.ts b/app/src/agentworld/iso/index.ts new file mode 100644 index 0000000000..b1909071f7 --- /dev/null +++ b/app/src/agentworld/iso/index.ts @@ -0,0 +1,30 @@ +/** Public surface of the isometric agent world engine. */ + +export { GameWorld } from './GameWorld'; +export type { AgentSummary } from './GameWorld'; +export { BaseRoom } from './BaseRoom'; +export { + ROOM_REGISTRY, + PokerTableRoom, + CourtHouseRoom, + OfficeRoom, + HomeRoom, + OutsideWorldRoom, +} from './rooms'; +export type { RoomEntry } from './rooms'; +export { ChatBubble } from './ChatBubble'; +export { Agent } from './Agent'; +export { FurnitureSprite, FURNITURE_BLUEPRINTS } from './furniture'; +export { TextureFactory } from './textures'; +export type { + AgentAction, + AgentState, + ChatMessage, + Facing, + FurnitureConfig, + InteractionPoint, + RoomDefinition, + RoomPalette, + WalkNode, +} from './types'; +export { TileCode } from './types'; diff --git a/app/src/agentworld/iso/rooms.ts b/app/src/agentworld/iso/rooms.ts new file mode 100644 index 0000000000..3370c0c049 --- /dev/null +++ b/app/src/agentworld/iso/rooms.ts @@ -0,0 +1,486 @@ +/** + * The four built-in room types. + * + * Every room is expressed as data — a tile matrix plus furniture placements — + * and wrapped in a thin {@link BaseRoom} subclass. Walls are authored only on + * the back (north + west) edges so the camera looks into an open interior, the + * classic isometric room read. + */ +import { BaseRoom } from './BaseRoom'; +import type { TextureFactory } from './textures'; +import { + type Facing, + type FurnitureConfig, + type InteractionPoint, + type RoomDefinition, + type RoomPalette, + TileCode, +} from './types'; + +// ---- Matrix authoring helpers ---------------------------------------------- + +function floorGrid(columns: number, rows: number): Array> { + const matrix: Array> = []; + for (let row = 0; row < rows; row++) { + matrix.push(new Array(columns).fill(TileCode.Floor)); + } + return matrix; +} + +function addBackWalls(matrix: Array>): void { + for (let column = 0; column < (matrix[0]?.length ?? 0); column++) { + matrix[0]![column] = TileCode.Wall; + } + for (let row = 0; row < matrix.length; row++) { + matrix[row]![0] = TileCode.Wall; + } +} + +function fillRectangle( + matrix: Array>, + tileX: number, + tileY: number, + width: number, + height: number, + code: TileCode +): void { + for (let row = tileY; row < tileY + height; row++) { + for (let column = tileX; column < tileX + width; column++) { + if (matrix[row]?.[column] !== undefined) { + matrix[row]![column] = code; + } + } + } +} + +function putTile( + matrix: Array>, + tileX: number, + tileY: number, + code: TileCode +): void { + const row = matrix[tileY]; + if (row && row[tileX] !== undefined) { + row[tileX] = code; + } +} + +function chair( + tileX: number, + tileY: number, + facing: Facing, + level = 0, + tint?: number +): FurnitureConfig { + const station: InteractionPoint = { + tileOffsetX: 0, + tileOffsetY: 0, + action: 'sit', + facing, + seatDropY: 6, + }; + return { kind: 'chair', tileX, tileY, level, tint, interactionPoints: [station] }; +} + +// ---- Palettes --------------------------------------------------------------- + +const POKER_PALETTE: RoomPalette = { + background: 0x0c1810, + floorTop: 0x356046, + floorSide: 0x244432, + wall: 0x4a3526, + dais: 0x3c5a44, + accent: 0x34d399, +}; + +const COURT_PALETTE: RoomPalette = { + background: 0x15121c, + floorTop: 0xcdc7ba, + floorSide: 0x9c9788, + wall: 0x4a3326, + dais: 0x7c5e46, + accent: 0xa78bfa, +}; + +const OFFICE_PALETTE: RoomPalette = { + background: 0x121620, + floorTop: 0x8f98a6, + floorSide: 0x6a727f, + wall: 0x5b6270, + dais: 0x768091, + accent: 0x60a5fa, +}; + +const HOME_PALETTE: RoomPalette = { + background: 0x1a1320, + floorTop: 0xb98a63, + floorSide: 0x8f6a49, + wall: 0x9a8f7a, + dais: 0xb98a63, + accent: 0xfbbf24, +}; + +const OUTSIDE_PALETTE: RoomPalette = { + background: 0x223047, + floorTop: 0x5f9450, + floorSide: 0x3f6e35, + wall: 0x6b7280, + dais: 0x7a8a6a, + accent: 0x7fc8a9, +}; + +// ---- Poker table room ------------------------------------------------------- + +function pokerDefinition(): RoomDefinition { + const matrix = floorGrid(12, 11); + addBackWalls(matrix); + const furniture: Array = [ + { kind: 'pokerTable', tileX: 5, tileY: 4 }, + // Eight seats facing inward toward the felt. + chair(5, 3, 'right'), + chair(7, 3, 'left'), + chair(5, 6, 'right'), + chair(7, 6, 'left'), + chair(4, 4, 'right'), + chair(4, 5, 'right'), + chair(8, 4, 'left'), + chair(8, 5, 'left'), + // A little bar with stools in front of the counter. + { kind: 'barCounter', tileX: 2, tileY: 8 }, + { kind: 'stool', tileX: 2, tileY: 9 }, + { kind: 'stool', tileX: 3, tileY: 9 }, + { kind: 'stool', tileX: 4, tileY: 9 }, + { kind: 'lamp', tileX: 1, tileY: 1 }, + { kind: 'fern', tileX: 10, tileY: 1 }, + { kind: 'trophy', tileX: 1, tileY: 9 }, + { kind: 'plant', tileX: 10, tileY: 9 }, + ]; + return { + key: 'poker', + name: 'Poker Table', + description: 'A felt table ringed with eight seats for a full table of agents.', + matrix, + palette: POKER_PALETTE, + furniture, + spawnTile: { x: 6, y: 9 }, + }; +} + +// ---- Court house ------------------------------------------------------------ + +function courtDefinition(): RoomDefinition { + const matrix = floorGrid(12, 14); + addBackWalls(matrix); + // Raised dais tier across the top-centre for the bench. + fillRectangle(matrix, 3, 1, 6, 2, TileCode.Dais); + const furniture: Array = [ + { kind: 'judgeBench', tileX: 5, tileY: 1, level: 1 }, + chair(4, 1, 'right', 1), + { kind: 'witnessStand', tileX: 8, tileY: 2, level: 1 }, + // Counsel tables facing the bench. + { kind: 'courtTable', tileX: 3, tileY: 9 }, + chair(3, 10, 'right'), + chair(4, 10, 'right'), + { kind: 'courtTable', tileX: 7, tileY: 9 }, + chair(7, 10, 'left'), + chair(8, 10, 'left'), + // Jury box along the west wall. + chair(1, 4, 'right'), + chair(1, 5, 'right'), + chair(1, 6, 'right'), + chair(2, 4, 'right'), + chair(2, 5, 'right'), + chair(2, 6, 'right'), + // Public gallery at the back. + chair(3, 12, 'right'), + chair(4, 12, 'right'), + chair(7, 12, 'left'), + chair(8, 12, 'left'), + { kind: 'painting', tileX: 10, tileY: 1 }, + { kind: 'crate', tileX: 9, tileY: 6 }, + { kind: 'lamp', tileX: 1, tileY: 12 }, + { kind: 'fern', tileX: 10, tileY: 12 }, + ]; + return { + key: 'court', + name: 'Court House', + description: "A raised judge's bench, a jury box, counsel tables and a public gallery.", + matrix, + palette: COURT_PALETTE, + furniture, + spawnTile: { x: 6, y: 12 }, + }; +} + +// ---- Office ----------------------------------------------------------------- + +function officeDefinition(): RoomDefinition { + const matrix = floorGrid(13, 12); + addBackWalls(matrix); + // Cubicle partition walls. + const partitions: Array<[number, number]> = [ + [4, 2], + [4, 3], + [8, 2], + [8, 3], + [4, 6], + [4, 7], + [8, 6], + [8, 7], + ]; + for (const [column, row] of partitions) { + putTile(matrix, column, row, TileCode.Partition); + } + const furniture: Array = [ + // Top row of cubicles. + { kind: 'desk', tileX: 1, tileY: 2 }, + chair(1, 3, 'right', 0, 0x556070), + { kind: 'desk', tileX: 5, tileY: 2 }, + chair(5, 3, 'right', 0, 0x556070), + { kind: 'desk', tileX: 9, tileY: 2 }, + chair(9, 3, 'right', 0, 0x556070), + // Bottom row of cubicles. + { kind: 'desk', tileX: 1, tileY: 6 }, + chair(1, 7, 'right', 0, 0x556070), + { kind: 'desk', tileX: 5, tileY: 6 }, + chair(5, 7, 'right', 0, 0x556070), + { kind: 'desk', tileX: 9, tileY: 6 }, + chair(9, 7, 'right', 0, 0x556070), + { + kind: 'whiteboard', + tileX: 11, + tileY: 1, + interactionPoints: [{ tileOffsetX: 0, tileOffsetY: 1, action: 'inspect', facing: 'left' }], + }, + { kind: 'bookshelf', tileX: 12, tileY: 4 }, + { kind: 'bookshelf', tileX: 12, tileY: 5 }, + { kind: 'plant', tileX: 3, tileY: 10 }, + { kind: 'plant', tileX: 10, tileY: 10 }, + { kind: 'lamp', tileX: 12, tileY: 9 }, + { kind: 'crate', tileX: 12, tileY: 10 }, + { kind: 'fern', tileX: 11, tileY: 10 }, + { kind: 'painting', tileX: 1, tileY: 1 }, + ]; + return { + key: 'office', + name: 'Office', + description: 'Cubicle desks, a whiteboard and bookshelves for heads-down agent work.', + matrix, + palette: OFFICE_PALETTE, + furniture, + spawnTile: { x: 6, y: 10 }, + }; +} + +// ---- Home ------------------------------------------------------------------- + +function homeDefinition(): RoomDefinition { + const matrix = floorGrid(12, 11); + addBackWalls(matrix); + const furniture: Array = [ + { kind: 'rug', tileX: 3, tileY: 4, footprintWidth: 4, footprintHeight: 3, tint: 0x7a4f5e }, + { kind: 'tvStand', tileX: 4, tileY: 2 }, + { kind: 'couch', tileX: 4, tileY: 6, tint: 0x4a6ea0 }, + { kind: 'coffeeTable', tileX: 5, tileY: 4 }, + chair(7, 4, 'left', 0, 0x8a5a6a), + { kind: 'bed', tileX: 9, tileY: 7 }, + { kind: 'bookshelf', tileX: 1, tileY: 2 }, + { kind: 'bookshelf', tileX: 1, tileY: 3 }, + { kind: 'door', tileX: 8, tileY: 1 }, + { kind: 'plant', tileX: 10, tileY: 1 }, + { kind: 'plant', tileX: 2, tileY: 9 }, + { kind: 'lamp', tileX: 2, tileY: 6 }, + { kind: 'fern', tileX: 10, tileY: 9 }, + { kind: 'painting', tileX: 6, tileY: 1 }, + { kind: 'stool', tileX: 7, tileY: 6 }, + ]; + return { + key: 'home', + name: 'Home', + description: 'A cosy lounge with couches, a rug, a bed and a custom door.', + matrix, + palette: HOME_PALETTE, + furniture, + spawnTile: { x: 6, y: 9 }, + }; +} + +// ---- Outside world ---------------------------------------------------------- + +const CORNER_BUILDINGS = ['tower', 'glassTower']; // 2x2 — fit the narrow corners +const TOP_BUILDINGS = ['house', 'shop', 'cafe', 'apartment']; // 3 wide +const BOTTOM_BUILDINGS = ['shop', 'cafe', 'apartment']; // 3x2 (no 3-tall house) + +/** + * Procedurally lay out a multi-block city: a grid of streets with sidewalks, + * each block packed with varied buildings, plus cars, lamps, trees, and a + * central park. Deterministic (fixed seed) so the city is stable. + */ +function outsideDefinition(): RoomDefinition { + const blocks = 5; + const pitch = 12; // tiles between successive road bands + const road = 2; + const size = blocks * pitch + road; // 62 + const matrix = floorGrid(size, size); + + // Lay the road grid. + for (let band = 0; band <= blocks; band++) { + const at = band * pitch; + fillRectangle(matrix, at, 0, road, size, TileCode.Road); + fillRectangle(matrix, 0, at, size, road, TileCode.Road); + } + // Any grass tile touching a road becomes a sidewalk. + const isRoad = (column: number, row: number): boolean => matrix[row]?.[column] === TileCode.Road; + for (let row = 0; row < size; row++) { + for (let column = 0; column < size; column++) { + if (matrix[row]![column] !== TileCode.Floor) { + continue; + } + if ( + isRoad(column - 1, row) || + isRoad(column + 1, row) || + isRoad(column, row - 1) || + isRoad(column, row + 1) + ) { + matrix[row]![column] = TileCode.Pavement; + } + } + } + + let seed = 0x1357acef; + const random = (): number => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0x100000000; + }; + const pick = (list: ReadonlyArray): string => list[Math.floor(random() * list.length)]!; + + const furniture: Array = []; + const parkBlock = Math.floor(blocks / 2); + + for (let blockY = 0; blockY < blocks; blockY++) { + for (let blockX = 0; blockX < blocks; blockX++) { + const ox = blockX * pitch + 3; // 8x8 block interior origin + const oy = blockY * pitch + 3; + // A street lamp on the block's north-west sidewalk corner. + furniture.push({ kind: 'lamp', tileX: ox - 1, tileY: oy - 1 }); + furniture.push({ kind: 'fern', tileX: ox - 1, tileY: oy + 4 }); + + if (blockX === parkBlock && blockY === parkBlock) { + // The central park. + furniture.push({ kind: 'fountain', tileX: ox + 3, tileY: oy + 3 }); + furniture.push(chair(ox + 3, oy + 5, 'right', 0, 0x6b7280)); + furniture.push(chair(ox + 4, oy + 5, 'left', 0, 0x6b7280)); + furniture.push({ kind: 'fern', tileX: ox + 1, tileY: oy + 1 }); + furniture.push({ kind: 'fern', tileX: ox + 6, tileY: oy + 1 }); + furniture.push({ kind: 'fern', tileX: ox + 1, tileY: oy + 6 }); + furniture.push({ kind: 'fern', tileX: ox + 6, tileY: oy + 6 }); + continue; + } + + // Four buildings around the block, leaving a yard in the middle. + furniture.push({ kind: pick(TOP_BUILDINGS), tileX: ox, tileY: oy }); + furniture.push({ kind: pick(CORNER_BUILDINGS), tileX: ox + 6, tileY: oy }); + furniture.push({ kind: pick(BOTTOM_BUILDINGS), tileX: ox, tileY: oy + 6 }); + furniture.push({ kind: pick(CORNER_BUILDINGS), tileX: ox + 6, tileY: oy + 6 }); + furniture.push({ kind: 'fern', tileX: ox + 4, tileY: oy + 4 }); + } + } + + // Moving traffic (live cars) is spawned by the world controller, not baked + // into the static furniture here. + + // Dashed lane markings down each road centre (skipping the intersections). + for (let band = 0; band <= blocks; band++) { + const at = band * pitch; + for (let index = 1; index < size; index += 2) { + if (index % pitch >= road) { + furniture.push({ kind: 'roadDash', tileX: at, tileY: index }); + furniture.push({ kind: 'roadDash', tileX: index, tileY: at }); + } + } + } + + return { + key: 'outside', + name: 'Outside World', + description: + 'A sprawling pixel city — a grid of streets, cars, towers and a central park. Drag to explore.', + matrix, + palette: OUTSIDE_PALETTE, + furniture, + spawnTile: { x: parkBlock * pitch + 6, y: parkBlock * pitch }, + topMargin: 230, + }; +} + +// ---- Concrete room subclasses ---------------------------------------------- + +export class PokerTableRoom extends BaseRoom { + public constructor(factory: TextureFactory) { + super(pokerDefinition(), factory); + } +} + +export class CourtHouseRoom extends BaseRoom { + public constructor(factory: TextureFactory) { + super(courtDefinition(), factory); + } +} + +export class OfficeRoom extends BaseRoom { + public constructor(factory: TextureFactory) { + super(officeDefinition(), factory); + } +} + +export class HomeRoom extends BaseRoom { + public constructor(factory: TextureFactory) { + super(homeDefinition(), factory); + } +} + +export class OutsideWorldRoom extends BaseRoom { + public constructor(factory: TextureFactory) { + super(outsideDefinition(), factory); + } +} + +export interface RoomEntry { + key: string; + name: string; + description: string; + create: (factory: TextureFactory) => BaseRoom; +} + +export const ROOM_REGISTRY: Array = [ + { + key: 'poker', + name: 'Poker', + description: 'Eight seats around a felt table.', + create: factory => new PokerTableRoom(factory), + }, + { + key: 'court', + name: 'Court', + description: 'Raised bench, jury box and gallery.', + create: factory => new CourtHouseRoom(factory), + }, + { + key: 'office', + name: 'Office', + description: 'Cubicles, desks and a whiteboard.', + create: factory => new OfficeRoom(factory), + }, + { + key: 'home', + name: 'Home', + description: 'A cosy lounge with couches and a rug.', + create: factory => new HomeRoom(factory), + }, + { + key: 'outside', + name: 'World', + description: 'A large open plaza ringed with buildings.', + create: factory => new OutsideWorldRoom(factory), + }, +]; diff --git a/app/src/agentworld/iso/textures.ts b/app/src/agentworld/iso/textures.ts new file mode 100644 index 0000000000..551587702e --- /dev/null +++ b/app/src/agentworld/iso/textures.ts @@ -0,0 +1,642 @@ +/** + * Procedural texture factory. + * + * Every visual in the world is baked once, in greyscale, into a small shared + * texture and then reused via `tint`. That keeps GPU memory tiny (a handful of + * textures regardless of how many agents or chairs exist) and is what lets us + * recolour entities for free. All textures are generated at resolution 1 with + * `nearest` filtering so they stay crisp and chunky when the world scales up. + */ +import { Graphics, type Renderer, type Texture } from 'pixi.js'; + +import { HALF_TILE_HEIGHT, HALF_TILE_WIDTH, TILE_HEIGHT, TILE_WIDTH } from './geometry'; + +/** A baked texture plus the pivot that re-aligns its origin to the tile anchor. */ +export interface BakedTexture { + texture: Texture; + anchorX: number; + anchorY: number; +} + +const SHADE_TOP = 0xffffff; +const SHADE_LEFT = 0xb6b6b6; +const SHADE_RIGHT = 0x8d8d8d; +const EDGE_COLOR = 0x05060a; + +function diamondPoints( + footprintWidth: number, + footprintHeight: number, + lift: number +): Array { + const north = [0, -lift]; + const east = [footprintWidth * HALF_TILE_WIDTH, footprintWidth * HALF_TILE_HEIGHT - lift]; + const south = [ + (footprintWidth - footprintHeight) * HALF_TILE_WIDTH, + (footprintWidth + footprintHeight) * HALF_TILE_HEIGHT - lift, + ]; + const west = [-footprintHeight * HALF_TILE_WIDTH, footprintHeight * HALF_TILE_HEIGHT - lift]; + return [...north, ...east, ...south, ...west]; +} + +export class TextureFactory { + private readonly renderer: Renderer; + private readonly cache = new Map(); + + public constructor(renderer: Renderer) { + this.renderer = renderer; + } + + /** Bake a `Graphics` whose local origin is the tile anchor point. */ + private bake(key: string, graphics: Graphics): BakedTexture { + const existing = this.cache.get(key); + if (existing) { + graphics.destroy(); + return existing; + } + const bounds = graphics.getLocalBounds(); + const texture = this.renderer.generateTexture({ + target: graphics, + resolution: 1, + antialias: false, + }); + texture.source.scaleMode = 'nearest'; + const baked: BakedTexture = { texture, anchorX: -bounds.minX, anchorY: -bounds.minY }; + graphics.destroy(); + this.cache.set(key, baked); + return baked; + } + + /** A flat floor diamond: a darker grout border, a bright surface and a sheen. */ + public floorTile(): BakedTexture { + const graphics = new Graphics(); + // Grout/border underneath (reads darker once tinted). + graphics.poly(diamondPoints(1, 1, 0)).fill({ color: 0xb2b2b2 }); + // Bright inset surface. + const inset = 0.1; + const surface = [ + 0, + inset * TILE_HEIGHT, + TILE_WIDTH / 2 - inset * TILE_WIDTH, + TILE_HEIGHT / 2, + 0, + TILE_HEIGHT - inset * TILE_HEIGHT, + -(TILE_WIDTH / 2 - inset * TILE_WIDTH), + TILE_HEIGHT / 2, + ]; + graphics.poly(surface).fill({ color: SHADE_TOP }); + // A soft specular sheen toward the north corner. + graphics.poly([0, 3, 12, 9, 0, 15, -12, 9]).fill({ color: 0xffffff, alpha: 0.16 }); + graphics + .poly(diamondPoints(1, 1, 0)) + .stroke({ color: EDGE_COLOR, width: 1, alpha: 0.14, alignment: 0 }); + return this.bake('floor', graphics); + } + + /** Asphalt road tile — a dark surface with a worn sheen and a curb seam. */ + public roadTile(): BakedTexture { + const graphics = new Graphics(); + graphics.poly(diamondPoints(1, 1, 0)).fill({ color: 0x2b2e36 }); + const inset = 0.07; + graphics + .poly([ + 0, + inset * TILE_HEIGHT, + TILE_WIDTH / 2 - inset * TILE_WIDTH, + TILE_HEIGHT / 2, + 0, + TILE_HEIGHT - inset * TILE_HEIGHT, + -(TILE_WIDTH / 2 - inset * TILE_WIDTH), + TILE_HEIGHT / 2, + ]) + .fill({ color: 0x3c4049 }); + graphics.poly([0, 5, 9, 9, 0, 13, -9, 9]).fill({ color: 0xffffff, alpha: 0.04 }); + return this.bake('road', graphics); + } + + /** A short dashed lane marking, laid down the centre of a road. */ + public roadDash(): BakedTexture { + const graphics = new Graphics(); + graphics.poly([0, 11, 6, 14.5, 0, 18, -6, 14.5]).fill({ color: 0xe6c34a, alpha: 0.85 }); + return this.bake('road-dash', graphics); + } + + /** A flat decorative diamond (rugs, mats) spanning a footprint. */ + public decal(footprintWidth: number, footprintHeight: number): BakedTexture { + const key = `decal:${footprintWidth}x${footprintHeight}`; + const graphics = new Graphics(); + graphics + .poly(diamondPoints(footprintWidth, footprintHeight, 0)) + .fill({ color: SHADE_TOP }) + .stroke({ color: EDGE_COLOR, width: 2, alpha: 0.25 }); + return this.bake(key, graphics); + } + + /** + * A three-tone isometric cuboid spanning `footprintWidth x footprintHeight` + * tiles and rising `height` pixels. The workhorse behind walls, tables, + * benches, desks and the courthouse dais. + */ + public cuboid( + footprintWidth: number, + footprintHeight: number, + height: number, + key = `cuboid:${footprintWidth}x${footprintHeight}x${height}` + ): BakedTexture { + const graphics = new Graphics(); + const top = diamondPoints(footprintWidth, footprintHeight, height); + // Top corners: [north, east, south, west]. + const eastX = top[2]!; + const eastY = top[3]!; + const southX = top[4]!; + const southY = top[5]!; + const westX = top[6]!; + const westY = top[7]!; + // Right face (east -> south) dropped to ground. + graphics + .poly([eastX, eastY, southX, southY, southX, southY + height, eastX, eastY + height]) + .fill({ color: SHADE_RIGHT }) + .stroke({ color: EDGE_COLOR, width: 1, alpha: 0.3 }); + // Left face (south -> west) dropped to ground. + graphics + .poly([southX, southY, westX, westY, westX, westY + height, southX, southY + height]) + .fill({ color: SHADE_LEFT }) + .stroke({ color: EDGE_COLOR, width: 1, alpha: 0.3 }); + // Soft bottom shading on the side faces for grounded depth. + const band = height * 0.62; + graphics + .poly([ + eastX, + eastY + band, + southX, + southY + band, + southX, + southY + height, + eastX, + eastY + height, + ]) + .fill({ color: 0x000000, alpha: 0.16 }); + graphics + .poly([ + southX, + southY + band, + westX, + westY + band, + westX, + westY + height, + southX, + southY + height, + ]) + .fill({ color: 0x000000, alpha: 0.12 }); + // Top face last so the seams sit cleanly on top. + graphics + .poly(top) + .fill({ color: SHADE_TOP }) + .stroke({ color: EDGE_COLOR, width: 1, alpha: 0.35 }); + // A faint highlight along the north top edge. + graphics + .poly([top[0]!, top[1]!, top[2]!, top[3]!, top[6]!, top[7]!]) + .fill({ color: 0xffffff, alpha: 0.08 }); + return this.bake(key, graphics); + } + + /** + * A tall, detailed wall block: shaded faces with a cornice cap, a chair-rail + * trim, a lighter wainscot panel with vertical grooves, and a dark baseboard. + * Everything is greyscale so the room palette's wall colour tints it whole. + */ + public wallBlock(height: number): BakedTexture { + const graphics = new Graphics(); + const top = diamondPoints(1, 1, height); + const northX = top[0]!; + const northY = top[1]!; + const eastX = top[2]!; + const eastY = top[3]!; + const southX = top[4]!; + const southY = top[5]!; + const westX = top[6]!; + const westY = top[7]!; + + // A horizontal band across a face, expressed as height fractions (0 = top). + const rightBand = (from: number, to: number, color: number, alpha = 1): void => { + graphics + .poly([ + eastX, + eastY + from * height, + southX, + southY + from * height, + southX, + southY + to * height, + eastX, + eastY + to * height, + ]) + .fill({ color, alpha }); + }; + const leftBand = (from: number, to: number, color: number, alpha = 1): void => { + graphics + .poly([ + southX, + southY + from * height, + westX, + westY + from * height, + westX, + westY + to * height, + southX, + southY + to * height, + ]) + .fill({ color, alpha }); + }; + // A vertical groove line down a face at horizontal fraction `t`. + const groove = ( + cornerAX: number, + cornerAY: number, + cornerBX: number, + cornerBY: number, + t: number + ): void => { + const x = cornerAX + (cornerBX - cornerAX) * t; + const y = cornerAY + (cornerBY - cornerAY) * t; + graphics + .moveTo(x, y + 0.18 * height) + .lineTo(x, y + 0.88 * height) + .stroke({ color: 0x000000, width: 1, alpha: 0.16 }); + }; + + // Right (east) face: three tiers split by mouldings, with a wainscot + // and baseboard at the bottom and a cornice on top. + rightBand(0, 1, 0x8d8d8d); + rightBand(0.62, 0.9, 0x9b9b9b); + rightBand(0.59, 0.62, 0x6c6c6c); + rightBand(0.31, 0.34, 0x6c6c6c); + rightBand(0.9, 1, 0x5d5d5d); + rightBand(0, 0.05, 0xa8a8a8); + groove(eastX, eastY, southX, southY, 0.34); + groove(eastX, eastY, southX, southY, 0.67); + graphics + .poly([eastX, eastY, southX, southY, southX, southY + height, eastX, eastY + height]) + .stroke({ color: EDGE_COLOR, width: 1, alpha: 0.3 }); + + // Left (west) face, a shade lighter overall. + leftBand(0, 1, 0xb6b6b6); + leftBand(0.62, 0.9, 0xc4c4c4); + leftBand(0.59, 0.62, 0x979797); + leftBand(0.31, 0.34, 0x979797); + leftBand(0.9, 1, 0x868686); + leftBand(0, 0.05, 0xd0d0d0); + groove(southX, southY, westX, westY, 0.34); + groove(southX, southY, westX, westY, 0.67); + graphics + .poly([southX, southY, westX, westY, westX, westY + height, southX, southY + height]) + .stroke({ color: EDGE_COLOR, width: 1, alpha: 0.3 }); + + // Top cap with a brighter inner cornice. + graphics + .poly(top) + .fill({ color: 0xd2d2d2 }) + .stroke({ color: EDGE_COLOR, width: 1, alpha: 0.35 }); + const capInset = 0.16; + graphics + .poly([ + northX, + northY + capInset * TILE_HEIGHT, + eastX - capInset * TILE_WIDTH, + eastY, + southX, + southY - capInset * TILE_HEIGHT, + westX + capInset * TILE_WIDTH, + westY, + ]) + .fill({ color: 0xe6e6e6 }); + return this.bake(`wall:${height}`, graphics); + } + + /** + * The detail overlay for a building: a coloured roof, a grid of lit windows + * on both visible faces, and a door. Baked untinted so it keeps its own + * colours over a tinted body cuboid (much like the agent face over a body). + */ + public buildingDetail( + footprintWidth: number, + footprintHeight: number, + height: number, + options: { + windowRows: number; + windowColumns: number; + windowColor: number; + roofColor: number; + doorColor: number; + } + ): BakedTexture { + const { windowRows, windowColumns, windowColor, roofColor, doorColor } = options; + const graphics = new Graphics(); + const top = diamondPoints(footprintWidth, footprintHeight, height); + const northX = top[0]!; + const northY = top[1]!; + const eastX = top[2]!; + const eastY = top[3]!; + const southX = top[4]!; + const southY = top[5]!; + const westX = top[6]!; + const westY = top[7]!; + + const facePoint = ( + aX: number, + aY: number, + bX: number, + bY: number, + u: number, + v: number + ): Array => [aX + (bX - aX) * u, aY + (bY - aY) * u + v * height]; + + const drawFace = (aX: number, aY: number, bX: number, bY: number): void => { + // Eave shadow band under the roof. + graphics + .poly([ + ...facePoint(aX, aY, bX, bY, 0, 0), + ...facePoint(aX, aY, bX, bY, 1, 0), + ...facePoint(aX, aY, bX, bY, 1, 0.06), + ...facePoint(aX, aY, bX, bY, 0, 0.06), + ]) + .fill({ color: 0x000000, alpha: 0.2 }); + // Foundation band along the base. + graphics + .poly([ + ...facePoint(aX, aY, bX, bY, 0, 0.9), + ...facePoint(aX, aY, bX, bY, 1, 0.9), + ...facePoint(aX, aY, bX, bY, 1, 1), + ...facePoint(aX, aY, bX, bY, 0, 1), + ]) + .fill({ color: 0x000000, alpha: 0.24 }); + // Window grid. + const bandTop = 0.14; + const bandBottom = windowRows >= 4 ? 0.86 : 0.6; + const gapV = 0.045; + const cellV = (bandBottom - bandTop - (windowRows - 1) * gapV) / windowRows; + const padU = 0.16; + const gapU = 0.07; + const cellU = (1 - 2 * padU - (windowColumns - 1) * gapU) / windowColumns; + for (let row = 0; row < windowRows; row++) { + const v1 = bandTop + row * (cellV + gapV); + const v2 = v1 + cellV; + for (let column = 0; column < windowColumns; column++) { + const u1 = padU + column * (cellU + gapU); + const u2 = u1 + cellU; + graphics + .poly([ + ...facePoint(aX, aY, bX, bY, u1, v1), + ...facePoint(aX, aY, bX, bY, u2, v1), + ...facePoint(aX, aY, bX, bY, u2, v2), + ...facePoint(aX, aY, bX, bY, u1, v2), + ]) + .fill({ color: 0x161a24 }); + const mu = 0.014; + const mv = cellV * 0.16; + graphics + .poly([ + ...facePoint(aX, aY, bX, bY, u1 + mu, v1 + mv), + ...facePoint(aX, aY, bX, bY, u2 - mu, v1 + mv), + ...facePoint(aX, aY, bX, bY, u2 - mu, v2 - mv), + ...facePoint(aX, aY, bX, bY, u1 + mu, v2 - mv), + ]) + .fill({ color: windowColor }); + } + } + }; + drawFace(eastX, eastY, southX, southY); + drawFace(southX, southY, westX, westY); + + // A door near the south corner of the right face. + graphics + .poly([ + ...facePoint(eastX, eastY, southX, southY, 0.44, 0.66), + ...facePoint(eastX, eastY, southX, southY, 0.6, 0.66), + ...facePoint(eastX, eastY, southX, southY, 0.6, 0.97), + ...facePoint(eastX, eastY, southX, southY, 0.44, 0.97), + ]) + .fill({ color: doorColor }); + + // Roof cap with a soft highlight. + graphics + .poly(top) + .fill({ color: roofColor }) + .stroke({ color: EDGE_COLOR, width: 1, alpha: 0.35 }); + const centerX = (northX + eastX + southX + westX) / 4; + const centerY = (northY + eastY + southY + westY) / 4; + const inset = (x: number, y: number): Array => [ + x + (centerX - x) * 0.2, + y + (centerY - y) * 0.2, + ]; + graphics + .poly([ + ...inset(northX, northY), + ...inset(eastX, eastY), + ...inset(southX, southY), + ...inset(westX, westY), + ]) + .fill({ color: 0xffffff, alpha: 0.12 }); + return this.bake( + `building:${footprintWidth}x${footprintHeight}x${height}:${windowRows}x${windowColumns}:${windowColor}:${roofColor}:${doorColor}`, + graphics + ); + } + + /** A small chair: a seat block with a low back, facing the camera. */ + public chair(): BakedTexture { + const graphics = new Graphics(); + const seat = diamondPoints(0.62, 0.62, 12); + const eastX = seat[2]!; + const eastY = seat[3]!; + const southX = seat[4]!; + const southY = seat[5]!; + const westX = seat[6]!; + const westY = seat[7]!; + const northX = seat[0]!; + const northY = seat[1]!; + // Seat sides. + graphics + .poly([eastX, eastY, southX, southY, southX, southY + 12, eastX, eastY + 12]) + .fill({ color: SHADE_RIGHT }); + graphics + .poly([southX, southY, westX, westY, westX, westY + 12, southX, southY + 12]) + .fill({ color: SHADE_LEFT }); + graphics + .poly(seat) + .fill({ color: SHADE_TOP }) + .stroke({ color: EDGE_COLOR, width: 1, alpha: 0.3 }); + // Back rest rising from the north edge. + graphics + .poly([northX, northY, eastX, eastY, eastX, eastY - 18, northX, northY - 18]) + .fill({ color: SHADE_LEFT }); + graphics + .poly([westX, westY, northX, northY, northX, northY - 18, westX, westY - 18]) + .fill({ color: SHADE_RIGHT }); + return this.bake('chair', graphics); + } + + /** + * A soft rounded character body — feet at local (0, 0), head above. Drawn in + * greyscale so the per-agent `tint` colours the whole figure cohesively. The + * face is a separate untinted overlay (see {@link TextureFactory.agentFace}). + */ + public agentBody(): BakedTexture { + const graphics = new Graphics(); + // Stubby arms tucked behind the torso. + graphics.ellipse(-12, -15, 3.6, 7).fill({ color: 0xe2e2e2 }); + graphics.ellipse(12, -15, 3.6, 7).fill({ color: 0xe2e2e2 }); + // Two little feet. + graphics.ellipse(-6, -2, 5, 3.4).fill({ color: 0xbcbcbc }); + graphics.ellipse(6, -2, 5, 3.4).fill({ color: 0xbcbcbc }); + // Torso capsule with a clean outline. + graphics + .roundRect(-13, -39, 26, 39, 12) + .fill({ color: SHADE_TOP }) + .stroke({ color: 0x1a1d29, width: 1.5, alpha: 0.22, alignment: 0 }); + // Rounded belly shade and a soft chest highlight for volume. + graphics.ellipse(0, -8, 11, 8).fill({ color: 0xd2d2d2, alpha: 0.55 }); + graphics.ellipse(-4, -29, 6, 7).fill({ color: 0xffffff, alpha: 0.5 }); + return this.bake('agent', graphics); + } + + /** + * A head accessory, attached at the top-centre of the head (local origin). + * Fabric kinds are drawn light so they can be tinted; identity kinds (crown, + * glasses, headphones, flower) carry their own colours and are left untinted. + */ + public accessory(kind: string): BakedTexture { + const graphics = new Graphics(); + switch (kind) { + case 'antenna': + graphics.rect(-0.8, -8, 1.6, 8).fill({ color: 0xc8c8c8 }); + graphics + .circle(0, -9, 2.6) + .fill({ color: 0xf2f2f2 }) + .stroke({ color: 0x1a1d29, width: 1, alpha: 0.2 }); + break; + case 'cap': + graphics.ellipse(0, -3, 8.5, 5).fill({ color: 0xffffff }); + graphics.ellipse(5, 1, 8, 2.6).fill({ color: 0xe2e2e2 }); + break; + case 'beanie': + graphics.ellipse(0, -4, 8.5, 6).fill({ color: 0xffffff }); + graphics.rect(-8, -2, 16, 3.4).fill({ color: 0xdddddd }); + graphics.circle(0, -11, 2.6).fill({ color: 0xffffff }); + break; + case 'party': + graphics.poly([0, -16, -6, 0, 6, 0]).fill({ color: 0xffffff }); + graphics.circle(0, -16, 2.4).fill({ color: 0xffe9a8 }); + break; + case 'bow': + graphics.poly([-8, -4, -1, 0, -8, 4]).fill({ color: 0xffffff }); + graphics.poly([8, -4, 1, 0, 8, 4]).fill({ color: 0xffffff }); + graphics.circle(0, 0, 2).fill({ color: 0xe2e2e2 }); + break; + case 'crown': + graphics.poly([-8, 0, -8, -5, -4, -1, 0, -7, 4, -1, 8, -5, 8, 0]).fill({ color: 0xf5c542 }); + graphics.rect(-8, -1, 16, 2.4).fill({ color: 0xe0ad2e }); + graphics.circle(0, -6, 1.5).fill({ color: 0xff5d6c }); + break; + case 'glasses': + graphics.circle(-5, 12, 3.2).stroke({ color: 0x1a1d29, width: 1.6 }); + graphics.circle(5, 12, 3.2).stroke({ color: 0x1a1d29, width: 1.6 }); + graphics.rect(-1.8, 11.4, 3.6, 1.1).fill({ color: 0x1a1d29 }); + break; + case 'headphones': + graphics + .moveTo(-9, 2) + .lineTo(-9, -6) + .lineTo(-5, -9) + .lineTo(5, -9) + .lineTo(9, -6) + .lineTo(9, 2) + .stroke({ color: 0x2a2d3a, width: 2.4 }); + graphics.roundRect(-11, -2, 4, 7, 2).fill({ color: 0x2a2d3a }); + graphics.roundRect(7, -2, 4, 7, 2).fill({ color: 0x2a2d3a }); + break; + case 'flower': + for (let petal = 0; petal < 5; petal++) { + const angle = (petal * Math.PI * 2) / 5 - Math.PI / 2; + graphics + .circle(Math.cos(angle) * 4, -3 + Math.sin(angle) * 4, 2.4) + .fill({ color: 0xff8fab }); + } + graphics.circle(0, -3, 2).fill({ color: 0xffe08a }); + break; + default: + break; + } + return this.bake(`accessory:${kind}`, graphics); + } + + /** Soft contact shadow sized to a footprint, grounding a furniture piece. */ + public contactShadow(footprintWidth: number, footprintHeight: number): BakedTexture { + const key = `contact:${footprintWidth}x${footprintHeight}`; + const graphics = new Graphics(); + const centerX = ((footprintWidth - footprintHeight) / 2) * TILE_WIDTH * 0.5; + const centerY = ((footprintWidth + footprintHeight) / 2) * TILE_HEIGHT * 0.5; + const radiusX = (footprintWidth + footprintHeight) * HALF_TILE_WIDTH * 0.46; + const radiusY = (footprintWidth + footprintHeight) * HALF_TILE_HEIGHT * 0.5; + graphics.ellipse(centerX, centerY, radiusX, radiusY).fill({ color: 0x000000, alpha: 0.1 }); + graphics + .ellipse(centerX, centerY, radiusX * 0.7, radiusY * 0.7) + .fill({ color: 0x000000, alpha: 0.12 }); + return this.bake(key, graphics); + } + + /** + * Facial features, baked once and left untinted so eyes, smile and blush + * stay legible on top of any body colour. Pupils and mouth sit slightly to + * one side so a horizontal flip reads as the agent changing which way it + * faces. + */ + public agentFace(): BakedTexture { + const graphics = new Graphics(); + // Rosy cheeks. + graphics.ellipse(-9, 3, 3.2, 2.1).fill({ color: 0xff9aa2, alpha: 0.5 }); + graphics.ellipse(9, 3, 3.2, 2.1).fill({ color: 0xff9aa2, alpha: 0.5 }); + // Eye whites. + graphics.ellipse(-5, 0, 4.2, 5.2).fill({ color: 0xffffff }); + graphics.ellipse(5, 0, 4.2, 5.2).fill({ color: 0xffffff }); + // Pupils, nudged toward the facing direction. + graphics.ellipse(-4.2, 1, 2.3, 3).fill({ color: 0x1a1d29 }); + graphics.ellipse(5.8, 1, 2.3, 3).fill({ color: 0x1a1d29 }); + // Catchlights. + graphics.circle(-5.2, -0.6, 1).fill({ color: 0xffffff }); + graphics.circle(4.8, -0.6, 1).fill({ color: 0xffffff }); + // A gentle smile. + graphics.ellipse(0.8, 7, 3.4, 1.7).fill({ color: 0x1a1d29, alpha: 0.85 }); + return this.bake('face', graphics); + } + + /** A soft elliptical ground shadow, decoupled so it can fade independently. */ + public agentShadow(): BakedTexture { + const graphics = new Graphics(); + graphics.ellipse(0, 0, 14, 7).fill({ color: 0x000000, alpha: 0.28 }); + return this.bake('shadow', graphics); + } + + /** Rounded nine-slice background for chat bubbles. */ + public bubbleBackground(): BakedTexture { + const graphics = new Graphics(); + graphics + .roundRect(0, 0, 48, 48, 14) + .fill({ color: 0xffffff }) + .stroke({ color: 0x10131c, width: 2, alpha: 0.12, alignment: 0 }); + return this.bake('bubble', graphics); + } + + /** Little downward tail that points the bubble at the agent's head. */ + public bubbleTail(): BakedTexture { + const graphics = new Graphics(); + graphics + .poly([0, 0, 14, 0, 7, 9]) + .fill({ color: 0xffffff }) + .stroke({ color: 0x10131c, width: 1, alpha: 0.1 }); + return this.bake('bubble-tail', graphics); + } + + public destroy(): void { + for (const baked of this.cache.values()) { + baked.texture.destroy(true); + } + this.cache.clear(); + } +} diff --git a/app/src/agentworld/iso/types.ts b/app/src/agentworld/iso/types.ts new file mode 100644 index 0000000000..24ec2da673 --- /dev/null +++ b/app/src/agentworld/iso/types.ts @@ -0,0 +1,121 @@ +/** + * Shared data types for the isometric agent world. + * + * The world is *state-driven*: agents are not puppeteered frame-by-frame, they + * are reconciled toward an authoritative {@link AgentState} that an external + * controller (an AI backend, a test harness, or the on-screen debug panel) + * pushes in. The renderer's job is to interpolate smoothly toward that state. + */ + +/** Tile codes used inside a room layout matrix. */ +export enum TileCode { + Void = 0, + Floor = 1, + Wall = 2, + Dais = 3, + /** A short interior divider (cubicle wall) — blocks, but waist-high. */ + Partition = 4, + /** Asphalt road surface (walkable, for streets and cars). */ + Road = 5, + /** Light paved sidewalk / plaza tile. */ + Pavement = 6, +} + +export type Facing = 'left' | 'right'; + +export type AgentAction = 'idle' | 'walking' | 'sitting' | 'inspecting'; + +/** One step of a resolved path: a tile plus the elevation level on that tile. */ +export interface WalkNode { + x: number; + y: number; + level: number; +} + +export interface TileCoord { + x: number; + y: number; +} + +export interface ChatMessage { + text: string; + /** Milliseconds the bubble stays before auto-fading. */ + durationMs?: number; +} + +/** + * Authoritative description of where an agent should be and what it is doing. + * Everything except `x`/`y` is optional so a controller can send sparse updates. + */ +export interface AgentState { + /** Target tile column. */ + x: number; + /** Target tile row. */ + y: number; + action?: AgentAction; + facing?: Facing; + /** Movement speed in tiles per second. */ + speed?: number; + /** Display name shown on the nameplate. */ + label?: string; + /** Body colour, used to give agents cheap visual diversity. */ + tint?: number; + /** Optional one-shot chat line to speak on this update. */ + say?: ChatMessage; +} + +/** A tile an agent can step onto to trigger a furniture interaction. */ +export interface InteractionPoint { + /** Tile offset from the furniture anchor where the agent stands. */ + tileOffsetX: number; + tileOffsetY: number; + action: 'sit' | 'inspect'; + facing?: Facing; + /** Extra pixels to lower the agent so it rests *on* the seat. */ + seatDropY?: number; +} + +/** Placement of one furniture piece inside a room. */ +export interface FurnitureConfig { + /** Builder key registered in the furniture kit. */ + kind: string; + /** Anchor tile (top corner of the footprint). */ + tileX: number; + tileY: number; + /** Footprint size in tiles. Defaults to 1 x 1. */ + footprintWidth?: number; + footprintHeight?: number; + /** Elevation level the piece sits on. */ + level?: number; + /** Whether the footprint blocks walking. Defaults to true. */ + solid?: boolean; + /** Multiply tint applied to the shared base texture. */ + tint?: number; + /** Stations agents can occupy. */ + interactionPoints?: Array; +} + +/** Five-shade palette that themes every room. */ +export interface RoomPalette { + background: number; + floorTop: number; + floorSide: number; + wall: number; + dais: number; + accent: number; +} + +/** Complete description of a room: its grid, palette, and furniture. */ +export interface RoomDefinition { + key: string; + name: string; + description: string; + /** Layout matrix addressed as `matrix[row][column]` = `matrix[y][x]`. */ + matrix: Array>; + palette: RoomPalette; + furniture: Array; + /** Where newly spawned agents enter. */ + spawnTile: TileCoord; + /** Extra vertical clearance above the floor for tall props (buildings). */ + topMargin?: number; +} diff --git a/app/src/agentworld/pages/AgentWorld.test.tsx b/app/src/agentworld/pages/AgentWorld.test.tsx new file mode 100644 index 0000000000..e1d4e19582 --- /dev/null +++ b/app/src/agentworld/pages/AgentWorld.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, test, vi } from 'vitest'; + +import AgentWorld from './AgentWorld'; + +vi.mock('../../lib/i18n/I18nContext', () => ({ + useT: () => ({ t: (key: string, fallback?: string) => fallback ?? key }), +})); + +vi.mock('../../components/layout/shell/SidebarSlot', () => ({ + SidebarContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../components/layout/TwoPaneNav', () => ({ + default: ({ + selected, + onSelect, + groups, + }: { + selected: string; + onSelect: (slug: string) => void; + groups: Array<{ items: Array<{ value: string; label: string }> }>; + }) => ( + + ), +})); + +vi.mock('../components/WalletAddressChip', () => ({ default: () => wallet-chip })); +vi.mock('./WorldSection', () => ({ default: () =>
world-section
})); +vi.mock('./FeedSection', () => ({ default: () =>
feed-section
})); +vi.mock('./LedgerSection', () => ({ default: () =>
ledger-section
})); +vi.mock('./JobsSection', () => ({ default: () =>
jobs-section
})); +vi.mock('./BountiesSection', () => ({ default: () =>
bounties-section
})); +vi.mock('./ExploreSection', () => ({ default: () =>
explore-section
})); +vi.mock('./DirectorySection', () => ({ default: () =>
directory-section
})); +vi.mock('./ProfilesSection', () => ({ default: () =>
profiles-section
})); +vi.mock('./IdentitiesSection', () => ({ default: () =>
identities-section
})); +vi.mock('./MarketplaceSection', () => ({ default: () =>
marketplace-section
})); +vi.mock('./MessagingSection', () => ({ default: () =>
messaging-section
})); + +function renderAgentWorld(path: string) { + return render( + + + } /> + + + ); +} + +describe('AgentWorld', () => { + test('defaults /agent-world to the TinyPlace world section', () => { + renderAgentWorld('/agent-world'); + + expect(screen.getByTestId('selected-section')).toHaveTextContent('world'); + expect(screen.getByText('world-section')).toBeInTheDocument(); + }); + + test('uses framed section chrome outside the world route and navigates from the sidebar', async () => { + const { container } = renderAgentWorld('/agent-world/feed'); + + expect(screen.getByTestId('selected-section')).toHaveTextContent('feed'); + expect(screen.getByText('feed-section')).toBeInTheDocument(); + expect(container.querySelector('.max-w-6xl')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'agentWorld.directory' })); + + expect(await screen.findByText('directory-section')).toBeInTheDocument(); + expect(screen.getByTestId('selected-section')).toHaveTextContent('directory'); + }); +}); diff --git a/app/src/agentworld/pages/AgentWorld.tsx b/app/src/agentworld/pages/AgentWorld.tsx index a8ffc1da79..9189338ddb 100644 --- a/app/src/agentworld/pages/AgentWorld.tsx +++ b/app/src/agentworld/pages/AgentWorld.tsx @@ -25,6 +25,7 @@ import LedgerSection from './LedgerSection'; import MarketplaceSection from './MarketplaceSection'; import MessagingSection from './MessagingSection'; import ProfilesSection from './ProfilesSection'; +import WorldSection from './WorldSection'; // Sub-nav section definition (one per section). interface AgentWorldSection { @@ -48,6 +49,12 @@ const navIcon = (d: string) => ( // (their routes still exist below so existing flows / deep links remain // reachable) — hidden, not removed. Jobs is superseded by Bounties. const SECTIONS: AgentWorldSection[] = [ + { + slug: 'world', + labelKey: 'agentWorld.world', + iconPath: + 'M3 9.75L12 4l9 5.75M5.25 10.75V19h13.5v-8.25M9 19v-4.5h6V19M8 12h.01M12 10h.01M16 12h.01', + }, { slug: 'feed', labelKey: 'agentWorld.feed', @@ -101,6 +108,8 @@ export default function AgentWorld() { // e.g. /agent-world/explore → 'explore' const pathParts = location.pathname.split('/'); const activeSlug = pathParts[pathParts.length - 1] || 'feed'; + const activeSection = activeSlug === 'agent-world' ? 'world' : activeSlug; + const isWorld = activeSection === 'world'; return (
@@ -110,7 +119,7 @@ export default function AgentWorld() {
navigate(`/agent-world/${slug}`)} groups={[ { @@ -135,10 +144,16 @@ export default function AgentWorld() { {/* Card surface around the active section so the section chrome and its inner cards sit on a framed panel (matching Brain) instead of floating flush on the bare shell background. */} -
-
+
+
- } /> + } /> + } /> } /> } /> } /> @@ -150,7 +165,7 @@ export default function AgentWorld() { } /> } /> } /> - } /> + } />
diff --git a/app/src/agentworld/pages/DirectorySection.test.tsx b/app/src/agentworld/pages/DirectorySection.test.tsx index a13ef99a10..0c588afee4 100644 --- a/app/src/agentworld/pages/DirectorySection.test.tsx +++ b/app/src/agentworld/pages/DirectorySection.test.tsx @@ -1,7 +1,7 @@ /** * Tests for DirectorySection — Agent World directory grid. * - * The page loads the agent directory via `apiClient.directory.listAgents()` and + * The page loads the agent directory via `apiClient.graphql.agents()` and * renders one of: loading skeleton / payment_required / error (generic + wallet * locked) / empty / populated grid of agent cards. Each card derives a handle, * initials, avatar colour and skills/tags from the raw `AgentCard`, and toggles @@ -21,7 +21,7 @@ import DirectorySection from './DirectorySection'; vi.mock('../AgentWorldShell', () => ({ apiClient: { - directory: { listAgents: vi.fn() }, + graphql: { agents: vi.fn() }, follows: { stats: vi.fn(), followers: vi.fn(), @@ -34,10 +34,8 @@ vi.mock('../AgentWorldShell', () => ({ vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() })); -const listAgents = vi.mocked(apiClient.directory.listAgents); +const listAgents = vi.mocked(apiClient.graphql.agents); const walletStatus = vi.mocked(fetchWalletStatus); -const followStats = vi.mocked(apiClient.follows.stats); -const followFollowing = vi.mocked(apiClient.follows.following); const followFollow = vi.mocked(apiClient.follows.follow); const followUnfollow = vi.mocked(apiClient.follows.unfollow); @@ -47,9 +45,11 @@ beforeEach(() => { walletStatus.mockResolvedValue({ accounts: [{ chain: 'solana', address: 'MyWaLLetAddr123' }], } as unknown as Awaited>); - // Default: stats return zero and we follow nobody (single batched lookup). - followStats.mockResolvedValue({ agentId: '', followerCount: 0, followingCount: 0 }); - followFollowing.mockResolvedValue({ following: [] }); + vi.mocked(apiClient.follows.stats).mockResolvedValue({ + agentId: 'fallback-agent', + followerCount: 0, + followingCount: 0, + }); }); // ── Loading state ────────────────────────────────────────────────────────────── @@ -333,13 +333,15 @@ describe('cancellation', () => { describe('follow button', () => { test('renders Follow button on agent cards when wallet is available', async () => { listAgents.mockResolvedValueOnce({ - agents: [{ agentId: 'other-agent-001', username: 'alice', name: 'Alice' }], - }); - followFollowing.mockResolvedValueOnce({ following: [] }); - followStats.mockResolvedValueOnce({ - agentId: 'other-agent-001', - followerCount: 5, - followingCount: 3, + agents: [ + { + agentId: 'other-agent-001', + username: 'alice', + name: 'Alice', + viewerIsFollowing: false, + followerCount: 5, + }, + ], }); render(); expect(await screen.findByText('@alice')).toBeInTheDocument(); @@ -351,15 +353,15 @@ describe('follow button', () => { test('renders Following button when already following', async () => { listAgents.mockResolvedValueOnce({ - agents: [{ agentId: 'other-agent-002', username: 'bob', name: 'Bob' }], - }); - followFollowing.mockResolvedValueOnce({ - following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-002', createdAt: '' }], - }); - followStats.mockResolvedValueOnce({ - agentId: 'other-agent-002', - followerCount: 10, - followingCount: 2, + agents: [ + { + agentId: 'other-agent-002', + username: 'bob', + name: 'Bob', + viewerIsFollowing: true, + followerCount: 10, + }, + ], }); render(); expect(await screen.findByText('Following')).toBeInTheDocument(); @@ -379,13 +381,15 @@ describe('follow button', () => { test('clicking Follow calls follows.follow and updates to Following', async () => { const user = userEvent.setup(); listAgents.mockResolvedValueOnce({ - agents: [{ agentId: 'other-agent-003', username: 'carol', name: 'Carol' }], - }); - followFollowing.mockResolvedValueOnce({ following: [] }); - followStats.mockResolvedValueOnce({ - agentId: 'other-agent-003', - followerCount: 0, - followingCount: 0, + agents: [ + { + agentId: 'other-agent-003', + username: 'carol', + name: 'Carol', + viewerIsFollowing: false, + followerCount: 0, + }, + ], }); followFollow.mockResolvedValueOnce({ follower: 'MyWaLLetAddr123', @@ -399,41 +403,42 @@ describe('follow button', () => { expect(await screen.findByText('Following')).toBeInTheDocument(); }); - test('fetches the following-set once for the whole directory (no per-card N+1)', async () => { + test('uses GraphQL follow edges and count-only stats fallback without follow-list fan-out', async () => { + vi.mocked(apiClient.follows.stats).mockImplementation(agentId => + Promise.resolve({ + agentId, + followerCount: agentId === 'other-agent-b' ? 7 : 0, + followingCount: 0, + }) + ); listAgents.mockResolvedValueOnce({ agents: [ - { agentId: 'other-agent-a', username: 'a', name: 'A' }, - { agentId: 'other-agent-b', username: 'b', name: 'B' }, - { agentId: 'other-agent-c', username: 'c', name: 'C' }, + { agentId: 'other-agent-a', username: 'a', name: 'A', viewerIsFollowing: false }, + { agentId: 'other-agent-b', username: 'b', name: 'B', viewerIsFollowing: true }, + { agentId: 'other-agent-c', username: 'c', name: 'C', viewerIsFollowing: false }, ], }); - followFollowing.mockResolvedValueOnce({ - following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-b', createdAt: '' }], - }); render(); - // Card B resolves to Following from the single batched lookup. expect(await screen.findByText('Following')).toBeInTheDocument(); - - // One batched following lookup regardless of card count; the old per-card - // followers lookup must not fire at all. - expect(followFollowing).toHaveBeenCalledTimes(1); - expect(followFollowing).toHaveBeenCalledWith('MyWaLLetAddr123', expect.anything()); + expect(await screen.findByText('7 followers')).toBeInTheDocument(); + expect(apiClient.follows.following).not.toHaveBeenCalled(); + expect(apiClient.follows.stats).toHaveBeenCalledTimes(3); expect(apiClient.follows.followers).not.toHaveBeenCalled(); }); test('clicking Following calls follows.unfollow and reverts to Follow', async () => { const user = userEvent.setup(); listAgents.mockResolvedValueOnce({ - agents: [{ agentId: 'other-agent-004', username: 'dave', name: 'Dave' }], - }); - followFollowing.mockResolvedValueOnce({ - following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-004', createdAt: '' }], - }); - followStats.mockResolvedValueOnce({ - agentId: 'other-agent-004', - followerCount: 1, - followingCount: 0, + agents: [ + { + agentId: 'other-agent-004', + username: 'dave', + name: 'Dave', + viewerIsFollowing: true, + followerCount: 1, + }, + ], }); followUnfollow.mockResolvedValueOnce(undefined); render(); diff --git a/app/src/agentworld/pages/DirectorySection.tsx b/app/src/agentworld/pages/DirectorySection.tsx index 99be0f477f..359f8dd2d4 100644 --- a/app/src/agentworld/pages/DirectorySection.tsx +++ b/app/src/agentworld/pages/DirectorySection.tsx @@ -4,8 +4,8 @@ * Ported from tiny.place `website/src/components/explore/Directory.tsx`. Renders * a browsable grid of agents registered in the tiny.place directory inside the * standard `PanelScaffold` chrome (section title comes from the sidebar). Each - * card shows the agent's handle, description, follower count, and skills/tags. - * Authenticated users can follow/unfollow agents directly from the card. + * card shows the agent's handle, description, and skills/tags. Authenticated + * users can follow/unfollow agents directly from the card. */ import debugFactory from 'debug'; import { useCallback, useEffect, useState } from 'react'; @@ -83,13 +83,13 @@ function useDirectoryAgents(): State { useEffect(() => { let cancelled = false; - debug('fetching directory agents'); + debug('fetching directory agents through GraphQL'); - void apiClient.directory - .listAgents() + void apiClient.graphql + .agents() .then(data => { if (cancelled) return; - debug('[tinyplace][ui] DirectorySection: loaded %d agents', data.agents.length); + debug('[tinyplace][ui] DirectorySection: loaded %d GraphQL agents', data.agents.length); setState({ status: 'ok', data }); }) .catch((err: unknown) => { @@ -124,46 +124,17 @@ function useMyAgentId(): string | null { return agentId; } -// Max followees pulled in the single batch lookup below. The directory rarely -// exceeds this; anything beyond it falls back to "not following" (the user can -// still follow, which corrects the state optimistically). -const FOLLOWING_FETCH_LIMIT = 500; +function getViewerIsFollowing(agent: AgentCard): boolean | null { + const value = agent['viewerIsFollowing']; + return typeof value === 'boolean' ? value : null; +} -/** - * Fetch the current agent's *following* list ONCE and expose it as a Set of - * followee ids. This replaces the previous per-card `follows.followers()` call - * (one request per directory card → N+1 / rate-limit pressure) with a single - * request, letting each card derive its follow-state locally. - * - * Returns `null` until myAgentId is known and the fetch resolves, so cards can - * distinguish "still loading" from "not following". - */ -function useMyFollowing(myAgentId: string | null): Set | null { - const [following, setFollowing] = useState | null>(null); - useEffect(() => { - if (!myAgentId) return; - let cancelled = false; - debug('[tinyplace][ui] DirectorySection: fetching following-set for %s', myAgentId); - void apiClient.follows - .following(myAgentId, { limit: FOLLOWING_FETCH_LIMIT }) - .then(res => { - if (cancelled) return; - const set = new Set(res.following.map(f => f.followee)); - debug('[tinyplace][ui] DirectorySection: following-set size=%d', set.size); - setFollowing(set); - }) - .catch((err: unknown) => { - if (cancelled) return; - // Treat a failed lookup as "follow nobody" so the UI still renders - // Follow buttons instead of getting stuck in the loading state. - debug('[tinyplace][ui] DirectorySection: following-set failed: %s', String(err)); - setFollowing(new Set()); - }); - return () => { - cancelled = true; - }; - }, [myAgentId]); - return following; +function getFollowerCount(agent: AgentCard): number | null { + for (const key of ['followerCount', 'followersCount']) { + const value = agent[key]; + if (typeof value === 'number') return value; + } + return null; } // ── Sub-components ──────────────────────────────────────────────────────────── @@ -193,57 +164,43 @@ function LoadingSkeleton() { ); } -function AgentCardItem({ - agent, - myAgentId, - followingSet, -}: { - agent: AgentCard; - myAgentId: string | null; - // Set of followee ids the current agent follows; null while still loading. - // Sourced once by the parent (see useMyFollowing) instead of per-card. - followingSet: Set | null; -}) { +function AgentCardItem({ agent, myAgentId }: { agent: AgentCard; myAgentId: string | null }) { const [selected, setSelected] = useState(false); - // Optimistic override applied after the user (un)follows from this card; null - // means "defer to followingSet". Avoids a re-fetch just to reflect our click. const [localFollow, setLocalFollow] = useState<'following' | 'not_following' | null>(null); - const [followerCount, setFollowerCount] = useState(null); + const [statsFollowerCount, setStatsFollowerCount] = useState(null); + const [followerDelta, setFollowerDelta] = useState(0); const [actionLoading, setActionLoading] = useState(false); const handle = getHandle(agent); const skills = getSkills(agent); const isSelf = myAgentId != null && agent.agentId === myAgentId; + const baseFollowerCount = getFollowerCount(agent); + const effectiveBaseFollowerCount = baseFollowerCount ?? statsFollowerCount; + const followerCount = + effectiveBaseFollowerCount == null + ? null + : Math.max(0, effectiveBaseFollowerCount + followerDelta); + const serverFollow = getViewerIsFollowing(agent); - // Follow-state derived from the batched following-set (or our optimistic - // override). 'unknown' = still loading, which hides the Follow button. const followState: 'unknown' | 'following' | 'not_following' = localFollow ?? - (followingSet == null - ? 'unknown' - : followingSet.has(agent.agentId) - ? 'following' - : 'not_following'); - - // Fetch follow stats on mount. - // NOTE: follower COUNT is still one request per card — `directory.listAgents` - // doesn't return counts. Eliminating these needs a backend/GraphQL directory - // query that embeds followerCount (tracked separately); the per-card follower - // *list* lookup has already been removed in favour of the batched set above. + (serverFollow == null ? 'unknown' : serverFollow ? 'following' : 'not_following'); + useEffect(() => { + if (baseFollowerCount != null) return; let cancelled = false; + debug('fetching fallback follow stats agent=%s', agent.agentId); void apiClient.follows .stats(agent.agentId) .then(stats => { - if (cancelled) return; - setFollowerCount(stats.followerCount); + if (!cancelled) setStatsFollowerCount(stats.followerCount); }) - .catch(() => { - // Stats unavailable -- leave null (hidden). + .catch(err => { + debug('fallback follow stats error agent=%s error=%s', agent.agentId, String(err)); }); return () => { cancelled = true; }; - }, [agent.agentId]); + }, [agent.agentId, baseFollowerCount]); const handleFollow = useCallback( async (e: React.MouseEvent) => { @@ -254,12 +211,12 @@ function AgentCardItem({ if (followState === 'following') { await apiClient.follows.unfollow(agent.agentId); setLocalFollow('not_following'); - setFollowerCount(c => (c != null ? Math.max(0, c - 1) : c)); + setFollowerDelta(delta => delta - 1); debug('unfollowed %s', agent.agentId); } else { await apiClient.follows.follow(agent.agentId); setLocalFollow('following'); - setFollowerCount(c => (c != null ? c + 1 : c)); + setFollowerDelta(delta => delta + 1); debug('followed %s', agent.agentId); } } catch (err) { @@ -356,8 +313,6 @@ function StatusBlock({ tone, title, body }: { tone: string; title: string; body? export default function DirectorySection() { const state = useDirectoryAgents(); const myAgentId = useMyAgentId(); - // One batched lookup of who we follow, shared by every card below. - const followingSet = useMyFollowing(myAgentId); let body: React.ReactNode; @@ -400,12 +355,7 @@ export default function DirectorySection() { ) : (
{agents.map(agent => ( - + ))}
); diff --git a/app/src/agentworld/pages/ExploreSection/ExploreSection.test.tsx b/app/src/agentworld/pages/ExploreSection/ExploreSection.test.tsx index 7fbde954d2..3df76f6899 100644 --- a/app/src/agentworld/pages/ExploreSection/ExploreSection.test.tsx +++ b/app/src/agentworld/pages/ExploreSection/ExploreSection.test.tsx @@ -7,8 +7,8 @@ * 2. Four independent live sections: * - Trending Communities → apiClient.groups.list() * - Active Jobs → apiClient.graphql.jobs() - * - Featured Bounties → apiClient.bounties.list() - * - New Agents → apiClient.directory.listAgents() + * - Featured Bounties → apiClient.graphql.bounties() + * - New Agents → apiClient.graphql.agents() * Each section independently handles loading (skeleton) / ok / empty / error * (silent degrade — section hidden). Mocks prevent real RPC calls. */ @@ -26,7 +26,7 @@ vi.mock('../../AgentWorldShell', () => ({ apiClient: { explorer: { overview: vi.fn() }, groups: { list: vi.fn() }, - graphql: { jobs: vi.fn() }, + graphql: { agents: vi.fn(), bounties: vi.fn(), jobs: vi.fn() }, bounties: { list: vi.fn() }, directory: { listAgents: vi.fn() }, }, @@ -45,8 +45,8 @@ vi.mock('react-router-dom', async () => { const mockOverview = vi.mocked(apiClient.explorer.overview); const mockGroupsList = vi.mocked(apiClient.groups.list); const mockGraphqlJobs = vi.mocked(apiClient.graphql.jobs); -const mockBountiesList = vi.mocked(apiClient.bounties.list); -const mockListAgents = vi.mocked(apiClient.directory.listAgents); +const mockBountiesList = vi.mocked(apiClient.graphql.bounties); +const mockListAgents = vi.mocked(apiClient.graphql.agents); // ── Sample fixtures ─────────────────────────────────────────────────────────── @@ -104,23 +104,22 @@ const JOBS_OK = { count: 1, }; -const BOUNTIES_OK = { - bounties: [ - { - bountyId: 'b-1', - creator: 'creator-addr', - title: 'Fix Critical Bug', - description: 'Critical bug in our system', - reward: { amount: '250', asset: 'SOL' }, - status: 'open', - submissionCount: 2, - commentCount: 0, - createdAt: '2024-01-10T00:00:00Z', - updatedAt: '2024-01-10T00:00:00Z', - deadline: '2024-03-01T00:00:00Z', - }, - ], -}; +const BOUNTIES_OK = [ + { + bountyId: 'b-1', + creator: 'creator-addr', + title: 'Fix Critical Bug', + description: 'Critical bug in our system', + reward: { amount: '250000000000', asset: 'SOL', network: 'solana' }, + status: 'open', + submissionCount: 2, + commentCount: 0, + createdAt: '2024-01-10T00:00:00Z', + updatedAt: '2024-01-10T00:00:00Z', + startAt: '2024-01-10T00:00:00Z', + deadline: '2024-03-01T00:00:00Z', + }, +]; const AGENTS_OK = { agents: [ @@ -215,7 +214,7 @@ describe('fully populated state', () => { expect(await screen.findByText('Featured Bounties')).toBeInTheDocument(); }); - test('renders bounty card with title, reward and submission count', async () => { + test('renders bounty card with title, normalized reward and submission count', async () => { renderExplore(); expect(await screen.findByText('Fix Critical Bug')).toBeInTheDocument(); expect(screen.getByText('250 SOL')).toBeInTheDocument(); @@ -267,9 +266,7 @@ describe('loading state', () => { mockOverview.mockReturnValue(new Promise(() => {})); mockGroupsList.mockResolvedValue([]); mockGraphqlJobs.mockResolvedValue({ jobs: [], count: 0 }); - mockBountiesList.mockResolvedValue({ bounties: [] } as unknown as Awaited< - ReturnType - >); + mockBountiesList.mockResolvedValue([]); mockListAgents.mockResolvedValue({ agents: [] }); renderExplore(); @@ -283,9 +280,7 @@ describe('empty sections', () => { beforeEach(() => { mockGroupsList.mockResolvedValue([]); mockGraphqlJobs.mockResolvedValue({ jobs: [], count: 0 }); - mockBountiesList.mockResolvedValue({ bounties: [] } as unknown as Awaited< - ReturnType - >); + mockBountiesList.mockResolvedValue([]); mockListAgents.mockResolvedValue({ agents: [] }); }); @@ -330,34 +325,36 @@ describe('empty sections', () => { describe('bounties client-side status filter', () => { test('filters out non-open bounties returned by the server', async () => { - mockBountiesList.mockResolvedValue({ - bounties: [ - { - bountyId: 'b-open', - creator: 'c', - title: 'Open Bounty', - description: 'desc', - reward: { amount: '100', asset: 'SOL' }, - status: 'open', - submissionCount: 0, - commentCount: 0, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - }, - { - bountyId: 'b-closed', - creator: 'c', - title: 'Closed Bounty', - description: 'desc', - reward: { amount: '50', asset: 'SOL' }, - status: 'closed', - submissionCount: 1, - commentCount: 0, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - }, - ], - } as unknown as Awaited>); + mockBountiesList.mockResolvedValue([ + { + bountyId: 'b-open', + creator: 'c', + title: 'Open Bounty', + description: 'desc', + reward: { amount: '100', asset: 'SOL', network: 'solana' }, + status: 'open', + submissionCount: 0, + commentCount: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + startAt: '2024-01-01T00:00:00Z', + deadline: '2024-02-01T00:00:00Z', + }, + { + bountyId: 'b-closed', + creator: 'c', + title: 'Closed Bounty', + description: 'desc', + reward: { amount: '50', asset: 'SOL', network: 'solana' }, + status: 'closed', + submissionCount: 1, + commentCount: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + startAt: '2024-01-01T00:00:00Z', + deadline: '2024-02-01T00:00:00Z', + }, + ]); renderExplore(); expect(await screen.findByText('Open Bounty')).toBeInTheDocument(); @@ -365,22 +362,22 @@ describe('bounties client-side status filter', () => { }); test('shows empty state when all returned bounties are non-open', async () => { - mockBountiesList.mockResolvedValue({ - bounties: [ - { - bountyId: 'b-closed', - creator: 'c', - title: 'Closed Only', - description: 'desc', - reward: { amount: '50', asset: 'SOL' }, - status: 'closed', - submissionCount: 0, - commentCount: 0, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - }, - ], - } as unknown as Awaited>); + mockBountiesList.mockResolvedValue([ + { + bountyId: 'b-closed', + creator: 'c', + title: 'Closed Only', + description: 'desc', + reward: { amount: '50', asset: 'SOL', network: 'solana' }, + status: 'closed', + submissionCount: 0, + commentCount: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + startAt: '2024-01-01T00:00:00Z', + deadline: '2024-02-01T00:00:00Z', + }, + ]); renderExplore(); expect(await screen.findByText('No open bounties')).toBeInTheDocument(); diff --git a/app/src/agentworld/pages/ExploreSection/index.tsx b/app/src/agentworld/pages/ExploreSection/index.tsx index a8d5596915..b0980f1444 100644 --- a/app/src/agentworld/pages/ExploreSection/index.tsx +++ b/app/src/agentworld/pages/ExploreSection/index.tsx @@ -5,8 +5,8 @@ * four live-data sections that each fetch independently: * - Trending Communities → apiClient.groups.list({ limit: 12 }) * - Active Jobs → apiClient.graphql.jobs({ status: 'OPEN', limit: 6 }) - * - Featured Bounties → apiClient.bounties.list({ status: 'open', limit: 6 }) - * - New Agents → apiClient.directory.listAgents({ limit: 8 }) + * - Featured Bounties → apiClient.graphql.bounties({ status: 'open', limit: 6 }) + * - New Agents → apiClient.graphql.agents({ limit: 8 }) * * Each live section handles loading / empty / error independently; a failure in * one section never crashes the page. The stats section uses a StatusBlock for @@ -19,14 +19,16 @@ import { useNavigate } from 'react-router-dom'; import PanelScaffold from '../../../components/layout/PanelScaffold'; import { type AgentCard, - type Bounty, type ExplorerOverview, + type GqlBounty, type GqlJobPosting, type GroupMetadata, PaymentRequiredError, } from '../../../lib/agentworld/invokeApiClient'; import { useT } from '../../../lib/i18n/I18nContext'; import { apiClient } from '../../AgentWorldShell'; +import { decimalsForAsset, resolveAssetSymbol } from '../../assets'; +import { formatUnits } from '../../components/X402ConfirmDialog'; const debug = debugFactory('agentworld:explore'); @@ -35,6 +37,22 @@ const debug = debugFactory('agentworld:explore'); const CARD_CLASS = 'rounded-lg border border-stone-200 bg-white dark:border-neutral-800 dark:bg-neutral-900'; +function formatAmount(amount: string): string { + if (!Number.isFinite(Number(amount))) return amount; + const negative = amount.startsWith('-'); + const body = negative ? amount.slice(1) : amount; + const [intPart, fracPart] = body.split('.'); + const grouped = Number(intPart).toLocaleString('en-US'); + const out = fracPart != null ? `${grouped}.${fracPart}` : grouped; + return negative ? `-${out}` : out; +} + +function formatReward(amount: string, asset: string): string { + const decimals = decimalsForAsset(asset); + const display = decimals > 0 ? formatUnits(amount, decimals) : amount; + return `${formatAmount(display)} ${resolveAssetSymbol(asset)}`; +} + // ── Stats section types & hook ──────────────────────────────────────────────── type StatsState = @@ -161,19 +179,19 @@ function useExploreJobs(): SectionState { // ── Bounties hook ───────────────────────────────────────────────────────────── -function useExploreBounties(): SectionState { - const [state, setState] = useState>({ status: 'loading' }); +function useExploreBounties(): SectionState { + const [state, setState] = useState>({ status: 'loading' }); useEffect(() => { let cancelled = false; debug('fetching explore bounties'); - void apiClient.bounties - .list({ status: 'open', limit: 6 }) + void apiClient.graphql + .bounties({ status: 'open', limit: 6 }) .then(result => { if (cancelled) return; // Client-side filter to open status in case the server ignores the param. - const open = (result.bounties ?? []).filter(b => b.status === 'open'); + const open = (result ?? []).filter(b => b.status === 'open'); if (open.length === 0) { debug('bounties section: empty, hiding'); setState({ status: 'empty' }); @@ -205,8 +223,8 @@ function useExploreAgents(): SectionState { let cancelled = false; debug('fetching explore agents'); - void apiClient.directory - .listAgents({ limit: 8 }) + void apiClient.graphql + .agents({ limit: 8 }) .then(result => { if (cancelled) return; const agents = result.agents ?? []; @@ -487,7 +505,7 @@ function BountySkeletonList() { ); } -function BountyRow({ bounty }: { bounty: Bounty }) { +function BountyRow({ bounty }: { bounty: GqlBounty }) { return (
@@ -500,7 +518,7 @@ function BountyRow({ bounty }: { bounty: Bounty }) {
- {bounty.reward.amount} {bounty.reward.asset} + {formatReward(bounty.reward.amount, bounty.reward.asset)}
@@ -515,7 +533,7 @@ function ExploreBountiesList({ emptyMessage, onViewAll, }: { - state: SectionState; + state: SectionState; title: string; viewAllLabel: string; emptyMessage: string; diff --git a/app/src/agentworld/pages/IdentitiesSection.test.tsx b/app/src/agentworld/pages/IdentitiesSection.test.tsx index e101a1f29e..f1584bfcc2 100644 --- a/app/src/agentworld/pages/IdentitiesSection.test.tsx +++ b/app/src/agentworld/pages/IdentitiesSection.test.tsx @@ -25,6 +25,7 @@ import IdentitiesSection from './IdentitiesSection'; vi.mock('../AgentWorldShell', () => ({ apiClient: { + graphql: { identityListings: vi.fn() }, registry: { get: vi.fn(), register: vi.fn() }, directoryIdentities: { list: vi.fn() }, marketplace: { @@ -53,6 +54,31 @@ beforeEach(() => { vi.mocked(apiClient.marketplace.buyIdentity).mockResolvedValue({ result: { saleId: 's1' } }); vi.mocked(apiClient.marketplace.bid).mockResolvedValue({ result: {}, committed: true }); vi.mocked(apiClient.marketplace.offer).mockResolvedValue({ result: {}, committed: true }); + vi.mocked(apiClient.graphql.identityListings).mockImplementation(params => { + if (typeof params?.length === 'number') { + return vi + .mocked(apiClient.marketplace.identityFloor)(params.length) + .then(floor => ({ + identities: floor.price + ? [ + { + listingId: `floor-${params.length}`, + name: `@floor-${params.length}`, + price: floor.price, + updatedAt: '', + }, + ] + : [], + })); + } + if (params?.limit === 20) { + return vi.mocked(apiClient.directoryIdentities.list)(params); + } + return vi.mocked(apiClient.marketplace.listIdentities)({ + limit: params?.limit, + status: 'active', + }); + }); }); afterEach(() => { @@ -637,6 +663,86 @@ describe('Trading tab — floor prices', () => { expect(await screen.findByText('250 USDC')).toBeInTheDocument(); // the other two cards resolve without a price → "No floor" expect(screen.getAllByText('No floor').length).toBeGreaterThanOrEqual(2); + expect(apiClient.graphql.identityListings).toHaveBeenCalledWith({ + length: 3, + limit: 20, + offset: 0, + sortBy: 'price_asc', + }); + }); + + test('ignores inactive cheaper listings when choosing a floor price', async () => { + vi.mocked(apiClient.graphql.identityListings).mockImplementation(params => { + if (params?.length === 3) { + return Promise.resolve({ + identities: [ + { + listingId: 'sold-floor', + name: '@soldfloor', + price: { amount: '50', asset: 'USDC' }, + status: 'sold', + updatedAt: '2026-02-03T00:00:00Z', + }, + { + listingId: 'active-floor', + name: '@activefloor', + price: { amount: '250', asset: 'USDC' }, + status: 'active', + updatedAt: '2026-02-03T00:00:00Z', + }, + ], + }); + } + if (typeof params?.length === 'number') return Promise.resolve({ identities: [] }); + return Promise.resolve({ identities: [] }); + }); + render(); + await gotoTab('Trading'); + expect(await screen.findByText('250 USDC')).toBeInTheDocument(); + expect(screen.queryByText('50 USDC')).not.toBeInTheDocument(); + }); + + test('paginates floor lookup past inactive rows before declaring no floor', async () => { + vi.mocked(apiClient.graphql.identityListings).mockImplementation(params => { + if (params?.length !== 3) return Promise.resolve({ identities: [] }); + if ((params.offset ?? 0) === 0) { + return Promise.resolve({ + identities: Array.from({ length: 20 }, (_, index) => ({ + listingId: `sold-floor-${index}`, + name: `@soldfloor${index}`, + price: { amount: '50', asset: 'USDC' }, + status: 'sold', + updatedAt: '2026-02-03T00:00:00Z', + })), + }); + } + return Promise.resolve({ + identities: [ + { + listingId: 'active-floor-page-2', + name: '@activefloorpage2', + price: { amount: '250', asset: 'USDC' }, + status: 'active', + updatedAt: '2026-02-03T00:00:00Z', + }, + ], + }); + }); + render(); + await gotoTab('Trading'); + expect(await screen.findByText('250 USDC')).toBeInTheDocument(); + expect(apiClient.graphql.identityListings).toHaveBeenCalledWith({ + length: 3, + limit: 20, + offset: 0, + sortBy: 'price_asc', + }); + expect(apiClient.graphql.identityListings).toHaveBeenCalledWith({ + length: 3, + limit: 20, + offset: 20, + sortBy: 'price_asc', + }); }); test('shows "Unavailable" when a floor card fetch rejects', async () => { @@ -672,24 +778,27 @@ describe('Trading tab — listed for sale', () => { }); test('renders listing cards including an auction badge and seller line', async () => { - vi.mocked(apiClient.marketplace.listIdentities).mockResolvedValue({ - identities: [ - { - listingId: 'sale-1', - name: '@forsale', - price: { amount: '42', asset: 'USDC' }, - listingType: 'auction', - seller: 'seller-x', - updatedAt: '2026-02-03T00:00:00Z', - }, - { - listingId: 'sale-2', - name: '@fixedone', - price: { amount: '7', asset: 'USDC' }, - listingType: 'fixed', - updatedAt: '2026-02-03T00:00:00Z', - }, - ], + vi.mocked(apiClient.graphql.identityListings).mockImplementation(params => { + if (typeof params?.length === 'number') return Promise.resolve({ identities: [] }); + return Promise.resolve({ + identities: [ + { + listingId: 'sale-1', + name: '@forsale', + price: { amount: '42', asset: 'USDC' }, + listingType: 'auction', + seller: 'seller-x', + updatedAt: '2026-02-03T00:00:00Z', + }, + { + listingId: 'sale-2', + name: '@fixedone', + price: { amount: '7', asset: 'USDC' }, + listingType: 'fixed', + updatedAt: '2026-02-03T00:00:00Z', + }, + ], + }); }); render(); await gotoTab('Trading'); @@ -704,19 +813,84 @@ describe('Trading tab — listed for sale', () => { expect(screen.getByText('7 USDC')).toBeInTheDocument(); }); + test('filters inactive GraphQL marketplace listings before rendering cards', async () => { + vi.mocked(apiClient.graphql.identityListings).mockImplementation(params => { + if (typeof params?.length === 'number') return Promise.resolve({ identities: [] }); + return Promise.resolve({ + identities: [ + { + listingId: 'active-1', + name: '@activeone', + price: { amount: '5', asset: 'USDC' }, + status: 'active', + updatedAt: '2026-02-03T00:00:00Z', + }, + { + listingId: 'sold-1', + name: '@soldone', + price: { amount: '9', asset: 'USDC' }, + status: 'sold', + updatedAt: '2026-02-03T00:00:00Z', + }, + ], + }); + }); + render(); + await gotoTab('Trading'); + + expect(await screen.findByText('@activeone')).toBeInTheDocument(); + expect(screen.queryByText('@soldone')).not.toBeInTheDocument(); + }); + + test('paginates GraphQL marketplace listings until active rows are found', async () => { + vi.mocked(apiClient.graphql.identityListings).mockImplementation(params => { + if (typeof params?.length === 'number') return Promise.resolve({ identities: [] }); + if ((params?.offset ?? 0) === 0) { + return Promise.resolve({ + identities: Array.from({ length: 50 }, (_, index) => ({ + listingId: `sold-${index}`, + name: `@sold${index}`, + price: { amount: '9', asset: 'USDC' }, + status: 'sold', + updatedAt: '2026-02-03T00:00:00Z', + })), + }); + } + return Promise.resolve({ + identities: [ + { + listingId: 'active-page-2', + name: '@activepage2', + price: { amount: '11', asset: 'USDC' }, + status: 'active', + updatedAt: '2026-02-03T00:00:00Z', + }, + ], + }); + }); + render(); + await gotoTab('Trading'); + + expect(await screen.findByText('@activepage2')).toBeInTheDocument(); + expect(apiClient.graphql.identityListings).toHaveBeenCalledWith({ limit: 50, offset: 0 }); + expect(apiClient.graphql.identityListings).toHaveBeenCalledWith({ limit: 50, offset: 50 }); + }); + test('shows the payment-required banner when listings are gated', async () => { - vi.mocked(apiClient.marketplace.listIdentities).mockRejectedValueOnce( - new PaymentRequiredError({ terms: 'x402' }) - ); + vi.mocked(apiClient.graphql.identityListings).mockImplementation(params => { + if (typeof params?.length === 'number') return Promise.resolve({ identities: [] }); + return Promise.reject(new PaymentRequiredError({ terms: 'x402' })); + }); render(); await gotoTab('Trading'); expect(await screen.findByText('Access requires payment')).toBeInTheDocument(); }); test('shows the error banner when listings fetch rejects', async () => { - vi.mocked(apiClient.marketplace.listIdentities).mockRejectedValueOnce( - new Error('listings down') - ); + vi.mocked(apiClient.graphql.identityListings).mockImplementation(params => { + if (typeof params?.length === 'number') return Promise.resolve({ identities: [] }); + return Promise.reject(new Error('listings down')); + }); render(); await gotoTab('Trading'); expect(await screen.findByText('Failed to load')).toBeInTheDocument(); diff --git a/app/src/agentworld/pages/IdentitiesSection.tsx b/app/src/agentworld/pages/IdentitiesSection.tsx index dada49d7a6..1182b481bb 100644 --- a/app/src/agentworld/pages/IdentitiesSection.tsx +++ b/app/src/agentworld/pages/IdentitiesSection.tsx @@ -45,6 +45,65 @@ type AsyncState = | { status: 'error'; message: string } | { status: 'ok'; data: T }; +const MARKETPLACE_PAGE_SIZE = 50; +const MARKETPLACE_TARGET_ACTIVE = 50; +const MARKETPLACE_MAX_PAGES = 5; +const FLOOR_PAGE_SIZE = 20; +const FLOOR_MAX_PAGES = 10; + +function isActiveListing(identity: IdentityListing): boolean { + return identity.status == null || identity.status === 'active'; +} + +async function fetchActiveMarketplaceIdentities(): Promise { + const active: Array = []; + let lastResponse: IdentitiesResponse | null = null; + + for ( + let page = 0; + page < MARKETPLACE_MAX_PAGES && active.length < MARKETPLACE_TARGET_ACTIVE; + page++ + ) { + const response = await apiClient.graphql.identityListings({ + limit: MARKETPLACE_PAGE_SIZE, + offset: page * MARKETPLACE_PAGE_SIZE, + }); + lastResponse = response; + const identities = response.identities ?? []; + active.push(...identities.filter(isActiveListing)); + if (identities.length < MARKETPLACE_PAGE_SIZE) { + break; + } + } + + return { + ...(lastResponse ?? { identities: [] }), + identities: active.slice(0, MARKETPLACE_TARGET_ACTIVE), + count: active.length, + }; +} + +async function fetchActiveFloorPrice(length: number): Promise { + for (let page = 0; page < FLOOR_MAX_PAGES; page++) { + const response = await apiClient.graphql.identityListings({ + length, + limit: FLOOR_PAGE_SIZE, + offset: page * FLOOR_PAGE_SIZE, + sortBy: 'price_asc', + }); + const identities = response.identities ?? []; + const listing = identities.find(isActiveListing); + if (listing) { + return { length, price: listing.price }; + } + if (identities.length < FLOOR_PAGE_SIZE) { + break; + } + } + + return { length, price: undefined }; +} + // ── Small hooks ─────────────────────────────────────────────────────────────── function useHandleAvailability( @@ -82,10 +141,11 @@ function useMarketplaceIdentities(): AsyncState { const [state, setState] = useState>({ status: 'loading' }); useEffect(() => { let cancelled = false; - void apiClient.marketplace - .listIdentities({ status: 'active' }) + void fetchActiveMarketplaceIdentities() .then(data => { - if (!cancelled) setState({ status: 'ok', data }); + if (!cancelled) { + setState({ status: 'ok', data }); + } }) .catch((err: unknown) => { if (cancelled) return; @@ -132,10 +192,10 @@ function useFloorPrice(length: number): AsyncState { const [state, setState] = useState>({ status: 'loading' }); useEffect(() => { let cancelled = false; - void apiClient.marketplace - .identityFloor(length) + void fetchActiveFloorPrice(length) .then(data => { - if (!cancelled) setState({ status: 'ok', data }); + if (cancelled) return; + setState({ status: 'ok', data }); }) .catch((err: unknown) => { if (cancelled) return; diff --git a/app/src/agentworld/pages/MarketplaceSection.test.tsx b/app/src/agentworld/pages/MarketplaceSection.test.tsx index 71ffd759ce..8c1bc6c631 100644 --- a/app/src/agentworld/pages/MarketplaceSection.test.tsx +++ b/app/src/agentworld/pages/MarketplaceSection.test.tsx @@ -23,8 +23,8 @@ import MarketplaceSection from './MarketplaceSection'; vi.mock('../AgentWorldShell', () => ({ apiClient: { - marketplace: { listProducts: vi.fn(), buyProduct: vi.fn() }, - jobs: { list: vi.fn() }, + graphql: { jobs: vi.fn(), products: vi.fn() }, + marketplace: { buyProduct: vi.fn() }, escrow: { list: vi.fn() }, artifacts: { list: vi.fn() }, }, @@ -64,9 +64,25 @@ const sampleJob = { client: 'client-alpha', title: 'Translate document', description: 'Translate from EN to FR', + budget: { amount: '50', asset: 'USDC' }, + proposalCount: 0, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + clientProfile: { + handle: 'client-alpha', + cryptoId: 'client-alpha', + displayName: 'Client Alpha', + verified: false, + }, }; -const sampleJobNoTitle = { jobId: 'job-2', status: 'open', client: 'client-beta' }; +const sampleJobNoTitle = { + ...sampleJob, + jobId: 'job-2', + status: 'open', + client: 'client-beta', + title: undefined, +}; const activeEscrow = { escrowId: 'esc-active-1', @@ -99,8 +115,8 @@ const sampleArtifactMinimal = { artifactId: 'art-2', owner: 'owner-2' }; beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.marketplace.listProducts).mockResolvedValue({ products: [] }); - vi.mocked(apiClient.jobs.list).mockResolvedValue({ jobs: [] }); + vi.mocked(apiClient.graphql.products).mockResolvedValue({ products: [] }); + vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [], count: 0 }); vi.mocked(apiClient.escrow.list).mockResolvedValue({ escrows: [] }); vi.mocked(apiClient.artifacts.list).mockResolvedValue({ artifacts: [] }); vi.mocked(apiClient.marketplace.buyProduct).mockResolvedValue({ result: { purchaseId: 'p1' } }); @@ -126,7 +142,7 @@ describe('tab navigation', () => { render(); await userEvent.click(screen.getByRole('tab', { name: 'Jobs' })); expect(screen.getByRole('tab', { name: 'Jobs' })).toHaveAttribute('aria-selected', 'true'); - expect(apiClient.jobs.list).toHaveBeenCalled(); + expect(apiClient.graphql.jobs).toHaveBeenCalledWith({ limit: 50 }); }); test('switching to Active fetches escrows', async () => { @@ -147,7 +163,7 @@ describe('tab navigation', () => { describe('Search tab', () => { test('shows the loading spinner before the fetch resolves', () => { // A never-resolving promise keeps the tab in its loading state. - vi.mocked(apiClient.marketplace.listProducts).mockReturnValue(new Promise(() => {})); + vi.mocked(apiClient.graphql.products).mockReturnValue(new Promise(() => {})); render(); expect(screen.getByText('Loading products…')).toBeInTheDocument(); }); @@ -158,7 +174,7 @@ describe('Search tab', () => { }); test('renders populated products with tags, price and category', async () => { - vi.mocked(apiClient.marketplace.listProducts).mockResolvedValue({ + vi.mocked(apiClient.graphql.products).mockResolvedValue({ products: [sampleProduct, sampleProductNoTags], }); render(); @@ -173,7 +189,7 @@ describe('Search tab', () => { }); test('search input filters products by name', async () => { - vi.mocked(apiClient.marketplace.listProducts).mockResolvedValue({ + vi.mocked(apiClient.graphql.products).mockResolvedValue({ products: [sampleProduct, sampleProductNoTags], }); render(); @@ -187,7 +203,7 @@ describe('Search tab', () => { }); test('search input shows "no match" empty state when nothing matches', async () => { - vi.mocked(apiClient.marketplace.listProducts).mockResolvedValue({ products: [sampleProduct] }); + vi.mocked(apiClient.graphql.products).mockResolvedValue({ products: [sampleProduct] }); render(); await screen.findByText('Widget Builder'); @@ -198,7 +214,7 @@ describe('Search tab', () => { }); test('search matches against seller and tags too', async () => { - vi.mocked(apiClient.marketplace.listProducts).mockResolvedValue({ + vi.mocked(apiClient.graphql.products).mockResolvedValue({ products: [sampleProduct, sampleProductNoTags], }); render(); @@ -212,20 +228,20 @@ describe('Search tab', () => { }); test('tolerates a response missing the products field', async () => { - vi.mocked(apiClient.marketplace.listProducts).mockResolvedValue({} as { products: never[] }); + vi.mocked(apiClient.graphql.products).mockResolvedValue({} as { products: never[] }); render(); expect(await screen.findByText('No products listed yet.')).toBeInTheDocument(); }); test('shows the generic error state on a plain rejection', async () => { - vi.mocked(apiClient.marketplace.listProducts).mockRejectedValueOnce(new Error('boom')); + vi.mocked(apiClient.graphql.products).mockRejectedValueOnce(new Error('boom')); render(); expect(await screen.findByText('Failed to load')).toBeInTheDocument(); expect(screen.getByText(/boom/)).toBeInTheDocument(); }); test('shows the wallet-locked error state when wallet is not configured', async () => { - vi.mocked(apiClient.marketplace.listProducts).mockRejectedValueOnce( + vi.mocked(apiClient.graphql.products).mockRejectedValueOnce( new Error('wallet is not configured') ); render(); @@ -233,7 +249,7 @@ describe('Search tab', () => { }); test('shows the wallet-locked error state when wallet secret material is missing', async () => { - vi.mocked(apiClient.marketplace.listProducts).mockRejectedValueOnce( + vi.mocked(apiClient.graphql.products).mockRejectedValueOnce( new Error('wallet secret material is missing') ); render(); @@ -241,7 +257,7 @@ describe('Search tab', () => { }); test('shows the payment-required state on a PaymentRequiredError', async () => { - vi.mocked(apiClient.marketplace.listProducts).mockRejectedValueOnce( + vi.mocked(apiClient.graphql.products).mockRejectedValueOnce( new PaymentRequiredError({ terms: 'x402' }) ); render(); @@ -263,7 +279,7 @@ describe('Jobs tab', () => { }); test('renders a job with title, description and status badge', async () => { - vi.mocked(apiClient.jobs.list).mockResolvedValue({ jobs: [sampleJob] }); + vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [sampleJob] }); await openJobs(); expect(await screen.findByText('Translate document')).toBeInTheDocument(); expect(screen.getByText('Translate from EN to FR')).toBeInTheDocument(); @@ -272,7 +288,7 @@ describe('Jobs tab', () => { }); test('falls back to jobId when title is not a string', async () => { - vi.mocked(apiClient.jobs.list).mockResolvedValue({ jobs: [sampleJobNoTitle] }); + vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [sampleJobNoTitle as never] }); await openJobs(); expect(await screen.findByText('job-2')).toBeInTheDocument(); // Unknown status uses the default badge styling but still renders the label. @@ -280,25 +296,25 @@ describe('Jobs tab', () => { }); test('tolerates a response missing the jobs field', async () => { - vi.mocked(apiClient.jobs.list).mockResolvedValue({} as { jobs: never[] }); + vi.mocked(apiClient.graphql.jobs).mockResolvedValue({} as { jobs: never[] }); await openJobs(); expect(await screen.findByText('No job postings yet.')).toBeInTheDocument(); }); test('shows the error state on rejection', async () => { - vi.mocked(apiClient.jobs.list).mockRejectedValueOnce(new Error('jobs down')); + vi.mocked(apiClient.graphql.jobs).mockRejectedValueOnce(new Error('jobs down')); await openJobs(); expect(await screen.findByText('Failed to load')).toBeInTheDocument(); }); test('shows payment-required on a PaymentRequiredError', async () => { - vi.mocked(apiClient.jobs.list).mockRejectedValueOnce(new PaymentRequiredError(null)); + vi.mocked(apiClient.graphql.jobs).mockRejectedValueOnce(new PaymentRequiredError(null)); await openJobs(); expect(await screen.findByText('Access requires payment')).toBeInTheDocument(); }); test('shows the loading spinner while jobs are pending', async () => { - vi.mocked(apiClient.jobs.list).mockReturnValue(new Promise(() => {})); + vi.mocked(apiClient.graphql.jobs).mockReturnValue(new Promise(() => {})); await openJobs(); expect(screen.getByText('Loading jobs…')).toBeInTheDocument(); }); @@ -488,7 +504,7 @@ describe('status badges', () => { describe('Search tab — buy product (x402)', () => { beforeEach(() => { - vi.mocked(apiClient.marketplace.listProducts).mockResolvedValue({ products: [sampleProduct] }); + vi.mocked(apiClient.graphql.products).mockResolvedValue({ products: [sampleProduct] }); }); test('Buy → confirm dialog shows the challenge amount + balance', async () => { diff --git a/app/src/agentworld/pages/MarketplaceSection.tsx b/app/src/agentworld/pages/MarketplaceSection.tsx index 6f48c040b6..121fb1ffdb 100644 --- a/app/src/agentworld/pages/MarketplaceSection.tsx +++ b/app/src/agentworld/pages/MarketplaceSection.tsx @@ -3,7 +3,8 @@ * * Renders a sub-tab bar (Search / Jobs / Post / Active / Delivered / Disputes / * Artifacts) and mounts the active tab component. Each tab calls into the - * invoke API client bridge (`openhuman.tinyplace_marketplace_*`, + * invoke API client bridge (`openhuman.tinyplace_graphql_*`, + * `openhuman.tinyplace_marketplace_*`, * `openhuman.tinyplace_artifacts_*`, `openhuman.tinyplace_escrow_*`, * `openhuman.tinyplace_jobs_*`) via `apiClient` from `AgentWorldShell`. * @@ -18,7 +19,7 @@ import PanelScaffold from '../../components/layout/PanelScaffold'; import { type ArtifactListResult, type EscrowListResponse, - type JobListResponse, + type GqlJobListResult, PaymentRequiredError, type Product, type ProductsResponse, @@ -70,8 +71,8 @@ function SearchTab() { useEffect(() => { let cancelled = false; - void apiClient.marketplace - .listProducts() + void apiClient.graphql + .products() .then(data => { if (!cancelled) setState({ status: 'ok', data }); }) @@ -243,19 +244,14 @@ function SearchTab() { // ── Sub-tab: Jobs ───────────────────────────────────────────────────────────── -// TODO(phase-3-follow-up): consider removing this Marketplace JobsTab once -// the top-level Jobs section (JobsSection.tsx, backed by GraphQL GqlJobPosting) -// is fully feature-complete (filters, pagination, proposals). The top-level -// section provides richer data (client_profile with avatar, dispute/escrow/ -// on-chain details) than this REST-backed tab. function JobsTab() { - const [state, setState] = useState>({ status: 'loading' }); + const [state, setState] = useState>({ status: 'loading' }); useEffect(() => { let cancelled = false; - void apiClient.jobs - .list() + void apiClient.graphql + .jobs({ limit: 50 }) .then(data => { if (!cancelled) setState({ status: 'ok', data }); }) diff --git a/app/src/agentworld/pages/WorldSection.tsx b/app/src/agentworld/pages/WorldSection.tsx new file mode 100644 index 0000000000..84786accde --- /dev/null +++ b/app/src/agentworld/pages/WorldSection.tsx @@ -0,0 +1,136 @@ +import debugFactory from 'debug'; +import { useEffect, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { GameWorld, ROOM_REGISTRY } from '../iso'; + +const debug = debugFactory('agentworld:world'); + +const WORLD_ROOM_KEY = 'outside'; +const WORLD_POPULATION = 100; +const ROOM_POPULATION = 8; + +const populationFor = (key: string): number => + key === WORLD_ROOM_KEY ? WORLD_POPULATION : ROOM_POPULATION; + +const toggleClass = (active: boolean): string => + `rounded-lg border px-3 py-2 text-sm transition ${ + active + ? 'border-primary-500 bg-primary-500 text-white dark:border-primary-500 dark:bg-primary-600' + : 'border-stone-200 bg-white/85 text-stone-800 hover:border-primary-400 dark:border-neutral-700 dark:bg-neutral-950/70 dark:text-neutral-100 dark:hover:border-primary-500' + }`; + +export default function WorldSection() { + const { t } = useT(); + const containerRef = useRef(null); + const worldRef = useRef(null); + const [ready, setReady] = useState(false); + const [roomKey, setRoomKey] = useState(WORLD_ROOM_KEY); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + debug('mount skipped: missing container'); + return; + } + + debug('mounting pixi world'); + const world = new GameWorld(); + worldRef.current = world; + let disposed = false; + + void world + .init(container) + .then(() => { + if (disposed) { + debug('renderer initialized after unmount; destroying stale world'); + world.destroy(); + return; + } + world.setChangeListener(() => { + setRoomKey(world.currentRoomKey); + }); + world.setRoom(WORLD_ROOM_KEY); + world.spawnAgents(populationFor(WORLD_ROOM_KEY)); + world.setAutonomous(true); + setReady(true); + debug('renderer ready room=%s population=%d', WORLD_ROOM_KEY, WORLD_POPULATION); + }) + .catch((error: unknown) => { + debug('renderer init failed: %s', String(error)); + }); + + return () => { + debug('unmounting pixi world'); + disposed = true; + world.setChangeListener(null); + world.destroy(); + worldRef.current = null; + }; + }, []); + + const handleRoom = (key: string): void => { + const world = worldRef.current; + if (!world) { + debug('room switch ignored before renderer ready room=%s', key); + return; + } + const population = populationFor(key); + debug('switching room room=%s population=%d', key, population); + world.setRoom(key); + world.spawnAgents(population); + world.setAutonomous(true); + setRoomKey(key); + }; + + const activeRoom = ROOM_REGISTRY.find(entry => entry.key === roomKey); + + return ( +
+
+ {ready ? null : ( +
+ {t('agentWorld.world.booting', 'Booting renderer...')} +
+ )} + +
+

+ {t('agentWorld.world.title', 'Agent World')} +

+

+ {t( + 'agentWorld.world.description', + 'Register your agent in tiny.place to get it to start moving around.' + )} +

+
+ + +
+ ); +} diff --git a/app/src/lib/agentworld/invokeApiClient.test.ts b/app/src/lib/agentworld/invokeApiClient.test.ts index 6fc5721c91..21cc0c7c5c 100644 --- a/app/src/lib/agentworld/invokeApiClient.test.ts +++ b/app/src/lib/agentworld/invokeApiClient.test.ts @@ -1528,3 +1528,113 @@ describe('graphql.agentCard', () => { expect(result).toBeNull(); }); }); + +describe('graphql marketplace reads', () => { + test('routes agents through GraphQL with nullable params', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ agents: [] }); + const client = createInvokeApiClient(); + await client.graphql.agents({ q: 'bot', limit: 5 }); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.tinyplace_graphql_agents', + params: { params: { q: 'bot', limit: 5 } }, + }); + }); + + test('normalizes product seller handles and crypto IDs', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ + products: [ + { + id: 'product-1', + seller: { handle: '@seller', cryptoId: 'seller-crypto' }, + sellerCryptoId: '', + }, + { id: 'product-2', seller: { cryptoId: 'fallback-crypto' } }, + ], + count: 2, + }); + const client = createInvokeApiClient(); + const result = await client.graphql.products({ limit: 2 }); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.tinyplace_graphql_products', + params: { params: { limit: 2 } }, + }); + expect(result.products[0]).toMatchObject({ + seller: '@seller', + sellerCryptoId: 'seller-crypto', + }); + expect(result.products[1]).toMatchObject({ + seller: 'fallback-crypto', + sellerCryptoId: 'fallback-crypto', + }); + }); + + test('normalizes a single GraphQL product and preserves null misses', async () => { + mockCallCoreRpc + .mockResolvedValueOnce({ id: 'product-3', sellerCryptoId: 'seller-from-field' }) + .mockResolvedValueOnce(null); + const client = createInvokeApiClient(); + + await expect(client.graphql.product('product-3')).resolves.toMatchObject({ + seller: 'seller-from-field', + sellerCryptoId: 'seller-from-field', + }); + await expect(client.graphql.product('missing')).resolves.toBeNull(); + }); + + test('normalizes identity listing sellers and nullable detail results', async () => { + mockCallCoreRpc + .mockResolvedValueOnce({ + listings: [ + { + listingId: 'listing-1', + seller: { handle: '@owner', cryptoId: 'owner-crypto' }, + sellerCryptoId: '', + }, + ], + count: 1, + }) + .mockResolvedValueOnce({ listingId: 'listing-2', sellerCryptoId: 'seller-field' }) + .mockResolvedValueOnce(null); + const client = createInvokeApiClient(); + + const list = await client.graphql.identityListings({ status: 'active' }); + expect(mockCallCoreRpc).toHaveBeenNthCalledWith(1, { + method: 'openhuman.tinyplace_graphql_identity_listings', + params: { params: { status: 'active' } }, + }); + expect(list.identities[0]).toMatchObject({ seller: '@owner', sellerCryptoId: 'owner-crypto' }); + + await expect(client.graphql.identityListing('listing-2')).resolves.toMatchObject({ + seller: 'seller-field', + sellerCryptoId: 'seller-field', + }); + await expect(client.graphql.identityListing('missing')).resolves.toBeNull(); + }); + + test('routes identity bids and normalizes offer and sale parties', async () => { + mockCallCoreRpc + .mockResolvedValueOnce({ bids: [] }) + .mockResolvedValueOnce({ offers: [{ offerId: 'offer-1', buyer: { handle: '@buyer' } }] }) + .mockResolvedValueOnce({ + sales: [ + { saleId: 'sale-1', buyer: { cryptoId: 'buyer-crypto' }, seller: { handle: '@seller' } }, + ], + }); + const client = createInvokeApiClient(); + + await client.graphql.identityBids('listing-1', { limit: 3 }); + expect(mockCallCoreRpc).toHaveBeenNthCalledWith(1, { + method: 'openhuman.tinyplace_graphql_identity_bids', + params: { listingId: 'listing-1', params: { limit: 3 } }, + }); + + await expect(client.graphql.identityOffers({ status: 'open' })).resolves.toMatchObject({ + offers: [{ buyer: '@buyer' }], + }); + await expect(client.graphql.identitySales('@seller', { limit: 1 })).resolves.toMatchObject({ + sales: [{ buyer: 'buyer-crypto', seller: '@seller' }], + }); + }); +}); diff --git a/app/src/lib/agentworld/invokeApiClient.ts b/app/src/lib/agentworld/invokeApiClient.ts index 3f0fe98806..cef36605f8 100644 --- a/app/src/lib/agentworld/invokeApiClient.ts +++ b/app/src/lib/agentworld/invokeApiClient.ts @@ -603,6 +603,62 @@ export interface ProductsResponse { [key: string]: unknown; } +export interface GqlAgentCardListResult { + agents: AgentCard[]; + count?: number; + [key: string]: unknown; +} + +export interface GqlProduct extends Omit { + seller: FeedAuthor; + sellerCryptoId?: string; +} + +export interface GqlProductListResult { + products: GqlProduct[]; + count?: number; + [key: string]: unknown; +} + +export interface GqlIdentityListing extends Omit { + seller?: FeedAuthor; + sellerCryptoId?: string; +} + +export interface GqlIdentityListingListResult { + identities?: GqlIdentityListing[]; + listings?: GqlIdentityListing[]; + count?: number; + [key: string]: unknown; +} + +export interface GqlIdentityBidListResult { + bids: IdentityBid[]; + count?: number; + [key: string]: unknown; +} + +export interface GqlIdentityOffer extends Omit { + buyer: FeedAuthor; +} + +export interface GqlIdentityOfferListResult { + offers: GqlIdentityOffer[]; + count?: number; + [key: string]: unknown; +} + +export interface GqlIdentitySale extends Omit { + buyer: FeedAuthor; + seller?: FeedAuthor; +} + +export interface GqlIdentitySaleListResult { + sales: GqlIdentitySale[]; + count?: number; + [key: string]: unknown; +} + export interface BroadcastChannel { broadcastId: string; name: string; @@ -1172,7 +1228,7 @@ export interface GqlJobPosting { export interface GqlJobListResult { jobs: GqlJobPosting[]; - count: number; + count?: number; } /** Reward block on a GraphQL bounty (amount in the asset's smallest base unit). */ @@ -1493,6 +1549,40 @@ export interface MessageEnvelope { [key: string]: unknown; } +function authorId(author: FeedAuthor | undefined, fallback = ''): string { + return author?.handle || author?.cryptoId || fallback; +} + +function normalizeGraphqlProduct(product: GqlProduct): Product { + const seller = authorId(product.seller, product.sellerCryptoId); + return { + ...(product as unknown as Product), + seller, + sellerCryptoId: product.sellerCryptoId || product.seller?.cryptoId || seller, + }; +} + +function normalizeGraphqlIdentityListing(listing: GqlIdentityListing): IdentityListing { + const seller = authorId(listing.seller, listing.sellerCryptoId); + return { + ...(listing as unknown as IdentityListing), + seller, + sellerCryptoId: listing.sellerCryptoId || listing.seller?.cryptoId || seller, + }; +} + +function normalizeGraphqlIdentityOffer(offer: GqlIdentityOffer): IdentityOffer { + return { ...(offer as unknown as IdentityOffer), buyer: authorId(offer.buyer) }; +} + +function normalizeGraphqlIdentitySale(sale: GqlIdentitySale): IdentitySale { + return { + ...(sale as unknown as IdentitySale), + buyer: authorId(sale.buyer), + seller: authorId(sale.seller), + }; +} + // ── Client factory ──────────────────────────────────────────────────────────── /** @@ -1958,6 +2048,11 @@ export function createInvokeApiClient() { offset: params?.offset ?? null, includeSelf: params?.includeSelf ?? null, }), + /** List directory agents through GraphQL, including server-resolved edges. */ + agents: (params?: AgentQueryParams) => + call('openhuman.tinyplace_graphql_agents', { + params: params ?? null, + }), /** List posts by a specific agent handle (public). */ posts: (handle: string, params?: { limit?: number; before?: number; viewer?: string }) => call('openhuman.tinyplace_graphql_posts', { @@ -2013,6 +2108,19 @@ export function createInvokeApiClient() { /** Fetch a single ledger transaction by ID (public, no auth). */ ledgerTransaction: (id: string) => call('openhuman.tinyplace_graphql_ledger_transaction', { id }), + products: async (params?: ProductQueryParams) => { + const result = await call('openhuman.tinyplace_graphql_products', { + params: params ?? null, + }); + return { + ...result, + products: result.products.map(normalizeGraphqlProduct), + } satisfies ProductsResponse & { count?: number }; + }, + product: async (id: string) => { + const result = await call('openhuman.tinyplace_graphql_product', { id }); + return result ? normalizeGraphqlProduct(result) : null; + }, /** List job postings with optional filters (public, no auth). */ jobs: (params?: GqlJobQueryParams) => call('openhuman.tinyplace_graphql_jobs', { params: params ?? null }), @@ -2036,6 +2144,64 @@ export function createInvokeApiClient() { /** Fetch an agent card by agent ID (public GraphQL). */ agentCard: (id: string) => call('openhuman.tinyplace_graphql_agent_card', { id }), + identityListings: async (params?: IdentityListingQueryParams) => { + const result = await call( + 'openhuman.tinyplace_graphql_identity_listings', + { params: params ?? null } + ); + const identities = result.identities ?? result.listings ?? []; + return { + ...result, + identities: identities.map(normalizeGraphqlIdentityListing), + } satisfies IdentitiesResponse & { count?: number }; + }, + identityListing: async ( + id: string, + params?: { + bidLimit?: number; + bidOffset?: number; + historyLimit?: number; + historyOffset?: number; + } + ) => { + const result = await call( + 'openhuman.tinyplace_graphql_identity_listing', + { id, params: params ?? null } + ); + return result ? normalizeGraphqlIdentityListing(result) : null; + }, + identityBids: (listingId: string, params?: { limit?: number; offset?: number }) => + call('openhuman.tinyplace_graphql_identity_bids', { + listingId, + params: params ?? null, + }), + identityOffers: async (params?: { + agent?: string; + buyer?: string; + name?: string; + status?: string; + limit?: number; + offset?: number; + }) => { + const result = await call( + 'openhuman.tinyplace_graphql_identity_offers', + { params: params ?? null } + ); + return { + ...result, + offers: result.offers.map(normalizeGraphqlIdentityOffer), + } satisfies OffersResponse & { count?: number }; + }, + identitySales: async (name: string, params?: { limit?: number; offset?: number }) => { + const result = await call( + 'openhuman.tinyplace_graphql_identity_sales', + { name, params: params ?? null } + ); + return { + ...result, + sales: result.sales.map(normalizeGraphqlIdentitySale), + } satisfies RecentSalesResponse & { count?: number }; + }, }, jobsWrite: { create: (params: JobCreateParams) => diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index d063798ed4..d869c56161 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -43,6 +43,21 @@ const messages: TranslationMap = { 'nav.wallet': 'المحفظة', 'agentWorld.description': 'Tiny.Place شبكة اجتماعية لوكلاء الذكاء الاصطناعي. استخدم OpenHuman للتفاعل والعثور على الوظائف ونشرها والتداول والنمو معًا.', + 'agentWorld.world': 'العالم', + 'agentWorld.world.booting': 'جارٍ تشغيل العارض...', + 'agentWorld.world.title': 'عالم الوكلاء', + 'agentWorld.world.description': 'سجّل وكيلك في tiny.place ليبدأ بالحركة داخل العالم.', + 'agentWorld.world.room': 'الغرفة', + 'agentWorld.world.rooms.poker.name': 'بوكر', + 'agentWorld.world.rooms.poker.description': 'ثمانية مقاعد حول طاولة مكسوة باللباد.', + 'agentWorld.world.rooms.court.name': 'محكمة', + 'agentWorld.world.rooms.court.description': 'منصة مرتفعة وصندوق محلفين وشرفة.', + 'agentWorld.world.rooms.office.name': 'مكتب', + 'agentWorld.world.rooms.office.description': 'مقصورات ومكاتب ولوح أبيض.', + 'agentWorld.world.rooms.home.name': 'منزل', + 'agentWorld.world.rooms.home.description': 'ردهة مريحة بأرائك وسجادة.', + 'agentWorld.world.rooms.outside.name': 'العالم', + 'agentWorld.world.rooms.outside.description': 'ساحة مفتوحة كبيرة تحيط بها المباني.', 'agentWorld.feed': 'التغذية', 'agentWorld.ledger': 'السجل', 'agentWorld.jobs': 'الوظائف', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 13fde335e3..f4f1bedd36 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -43,6 +43,22 @@ const messages: TranslationMap = { 'nav.wallet': 'ওয়ালেট', 'agentWorld.description': 'Tiny.Place হলো এআই এজেন্টদের জন্য একটি সোশ্যাল নেটওয়ার্ক। যোগাযোগ করতে, কাজ খুঁজতে ও পোস্ট করতে, লেনদেন করতে এবং একসাথে বেড়ে উঠতে OpenHuman ব্যবহার করুন।', + 'agentWorld.world': 'বিশ্ব', + 'agentWorld.world.booting': 'রেন্ডারার চালু হচ্ছে...', + 'agentWorld.world.title': 'এজেন্ট বিশ্ব', + 'agentWorld.world.description': + 'আপনার এজেন্টকে tiny.place-এ নিবন্ধন করুন যাতে সে চলাফেরা শুরু করে।', + 'agentWorld.world.room': 'রুম', + 'agentWorld.world.rooms.poker.name': 'পোকার', + 'agentWorld.world.rooms.poker.description': 'ফেল্ট টেবিলের চারপাশে আটটি আসন।', + 'agentWorld.world.rooms.court.name': 'আদালত', + 'agentWorld.world.rooms.court.description': 'উঁচু বেঞ্চ, জুরি বক্স এবং গ্যালারি।', + 'agentWorld.world.rooms.office.name': 'অফিস', + 'agentWorld.world.rooms.office.description': 'কিউবিকল, ডেস্ক এবং হোয়াইটবোর্ড।', + 'agentWorld.world.rooms.home.name': 'বাড়ি', + 'agentWorld.world.rooms.home.description': 'সোফা ও কার্পেটসহ আরামদায়ক লাউঞ্জ।', + 'agentWorld.world.rooms.outside.name': 'বিশ্ব', + 'agentWorld.world.rooms.outside.description': 'ভবনঘেরা বড় খোলা প্লাজা।', 'agentWorld.feed': 'ফিড', 'agentWorld.ledger': 'লেজার', 'agentWorld.jobs': 'চাকরি', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index f0ad51d91b..642dced3a9 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -43,6 +43,22 @@ const messages: TranslationMap = { 'nav.wallet': 'Wallet', 'agentWorld.description': 'Tiny.Place ist ein soziales Netzwerk für KI-Agenten. Nutze OpenHuman, um zu interagieren, Jobs zu finden und zu veröffentlichen, zu handeln und gemeinsam zu wachsen.', + 'agentWorld.world': 'Welt', + 'agentWorld.world.booting': 'Renderer wird gestartet...', + 'agentWorld.world.title': 'Agentenwelt', + 'agentWorld.world.description': + 'Registriere deinen Agenten bei tiny.place, damit er sich bewegen kann.', + 'agentWorld.world.room': 'Raum', + 'agentWorld.world.rooms.poker.name': 'Poker', + 'agentWorld.world.rooms.poker.description': 'Acht Sitze rund um einen Filztisch.', + 'agentWorld.world.rooms.court.name': 'Gericht', + 'agentWorld.world.rooms.court.description': 'Richterbank, Jurybox und Galerie.', + 'agentWorld.world.rooms.office.name': 'Büro', + 'agentWorld.world.rooms.office.description': 'Arbeitskabinen, Schreibtische und ein Whiteboard.', + 'agentWorld.world.rooms.home.name': 'Zuhause', + 'agentWorld.world.rooms.home.description': 'Eine gemütliche Lounge mit Sofas und Teppich.', + 'agentWorld.world.rooms.outside.name': 'Welt', + 'agentWorld.world.rooms.outside.description': 'Ein großer offener Platz mit Gebäuden ringsum.', 'agentWorld.feed': 'Feed', 'agentWorld.ledger': 'Kontobuch', 'agentWorld.jobs': 'Aufträge', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 33d4d6ee12..21bd884dc8 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -28,6 +28,22 @@ const en: TranslationMap = { // Agent World section sub-navigation labels 'agentWorld.description': 'Tiny.Place is a social network for AI agents. Use OpenHuman to interact, find and post jobs, trade, and grow together.', + 'agentWorld.world': 'World', + 'agentWorld.world.booting': 'Booting renderer...', + 'agentWorld.world.title': 'Agent World', + 'agentWorld.world.description': + 'Register your agent in tiny.place to get it to start moving around.', + 'agentWorld.world.room': 'Room', + 'agentWorld.world.rooms.poker.name': 'Poker', + 'agentWorld.world.rooms.poker.description': 'Eight seats around a felt table.', + 'agentWorld.world.rooms.court.name': 'Court', + 'agentWorld.world.rooms.court.description': 'Raised bench, jury box and gallery.', + 'agentWorld.world.rooms.office.name': 'Office', + 'agentWorld.world.rooms.office.description': 'Cubicles, desks and a whiteboard.', + 'agentWorld.world.rooms.home.name': 'Home', + 'agentWorld.world.rooms.home.description': 'A cosy lounge with couches and a rug.', + 'agentWorld.world.rooms.outside.name': 'World', + 'agentWorld.world.rooms.outside.description': 'A large open plaza ringed with buildings.', 'agentWorld.feed': 'Feed', 'agentWorld.ledger': 'Ledger', 'agentWorld.jobs': 'Jobs', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 6461831f10..87374594d0 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -43,6 +43,21 @@ const messages: TranslationMap = { 'nav.wallet': 'Cartera', 'agentWorld.description': 'Tiny.Place es una red social para agentes de IA. Usa OpenHuman para interactuar, encontrar y publicar trabajos, comerciar y crecer juntos.', + 'agentWorld.world': 'Mundo', + 'agentWorld.world.booting': 'Iniciando renderizador...', + 'agentWorld.world.title': 'Mundo de agentes', + 'agentWorld.world.description': 'Registra tu agente en tiny.place para que empiece a moverse.', + 'agentWorld.world.room': 'Sala', + 'agentWorld.world.rooms.poker.name': 'Póker', + 'agentWorld.world.rooms.poker.description': 'Ocho asientos alrededor de una mesa de fieltro.', + 'agentWorld.world.rooms.court.name': 'Tribunal', + 'agentWorld.world.rooms.court.description': 'Estrado, jurado y galería.', + 'agentWorld.world.rooms.office.name': 'Oficina', + 'agentWorld.world.rooms.office.description': 'Cubículos, escritorios y una pizarra.', + 'agentWorld.world.rooms.home.name': 'Hogar', + 'agentWorld.world.rooms.home.description': 'Una sala acogedora con sofás y una alfombra.', + 'agentWorld.world.rooms.outside.name': 'Mundo', + 'agentWorld.world.rooms.outside.description': 'Una gran plaza abierta rodeada de edificios.', 'agentWorld.feed': 'Noticias', 'agentWorld.ledger': 'Libro mayor', 'agentWorld.jobs': 'Trabajos', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e411d6f956..e21552ac24 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -43,6 +43,22 @@ const messages: TranslationMap = { 'nav.wallet': 'Portefeuille', 'agentWorld.description': 'Tiny.Place est un réseau social pour les agents IA. Utilisez OpenHuman pour interagir, trouver et publier des missions, échanger et grandir ensemble.', + 'agentWorld.world': 'Monde', + 'agentWorld.world.booting': 'Démarrage du moteur de rendu...', + 'agentWorld.world.title': 'Monde des agents', + 'agentWorld.world.description': + 'Enregistrez votre agent sur tiny.place pour le faire commencer à bouger.', + 'agentWorld.world.room': 'Salle', + 'agentWorld.world.rooms.poker.name': 'Poker', + 'agentWorld.world.rooms.poker.description': 'Huit sièges autour d’une table en feutre.', + 'agentWorld.world.rooms.court.name': 'Tribunal', + 'agentWorld.world.rooms.court.description': 'Banc surélevé, jury et galerie.', + 'agentWorld.world.rooms.office.name': 'Bureau', + 'agentWorld.world.rooms.office.description': 'Box, bureaux et tableau blanc.', + 'agentWorld.world.rooms.home.name': 'Maison', + 'agentWorld.world.rooms.home.description': 'Un salon chaleureux avec canapés et tapis.', + 'agentWorld.world.rooms.outside.name': 'Monde', + 'agentWorld.world.rooms.outside.description': 'Une grande place ouverte entourée de bâtiments.', 'agentWorld.feed': 'Fil', 'agentWorld.ledger': 'Grand livre', 'agentWorld.jobs': 'Missions', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index e96d14da62..b23a0c2c94 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -43,6 +43,21 @@ const messages: TranslationMap = { 'nav.wallet': 'वॉलेट', 'agentWorld.description': 'Tiny.Place एआई एजेंट्स के लिए एक सोशल नेटवर्क है। बातचीत करने, काम खोजने और पोस्ट करने, व्यापार करने और साथ मिलकर आगे बढ़ने के लिए OpenHuman का उपयोग करें।', + 'agentWorld.world': 'दुनिया', + 'agentWorld.world.booting': 'रेंडरर शुरू हो रहा है...', + 'agentWorld.world.title': 'एजेंट दुनिया', + 'agentWorld.world.description': 'अपने एजेंट को tiny.place पर रजिस्टर करें ताकि वह चलना शुरू करे।', + 'agentWorld.world.room': 'कमरा', + 'agentWorld.world.rooms.poker.name': 'पोकर', + 'agentWorld.world.rooms.poker.description': 'फेल्ट टेबल के चारों ओर आठ सीटें।', + 'agentWorld.world.rooms.court.name': 'अदालत', + 'agentWorld.world.rooms.court.description': 'ऊंचा बेंच, जूरी बॉक्स और गैलरी।', + 'agentWorld.world.rooms.office.name': 'ऑफिस', + 'agentWorld.world.rooms.office.description': 'क्यूबिकल, डेस्क और व्हाइटबोर्ड।', + 'agentWorld.world.rooms.home.name': 'घर', + 'agentWorld.world.rooms.home.description': 'सोफों और गलीचे वाला आरामदायक लाउंज।', + 'agentWorld.world.rooms.outside.name': 'दुनिया', + 'agentWorld.world.rooms.outside.description': 'इमारतों से घिरा बड़ा खुला प्लाजा।', 'agentWorld.feed': 'फ़ीड', 'agentWorld.ledger': 'खाता बही', 'agentWorld.jobs': 'कार्य', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index e6339ccb3a..8cc4f4f824 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -43,6 +43,21 @@ const messages: TranslationMap = { 'nav.wallet': 'Dompet', 'agentWorld.description': 'Tiny.Place adalah jejaring sosial untuk agen AI. Gunakan OpenHuman untuk berinteraksi, menemukan dan memasang pekerjaan, berdagang, serta tumbuh bersama.', + 'agentWorld.world': 'Dunia', + 'agentWorld.world.booting': 'Memulai perender...', + 'agentWorld.world.title': 'Dunia agen', + 'agentWorld.world.description': 'Daftarkan agen Anda di tiny.place agar mulai bergerak.', + 'agentWorld.world.room': 'Ruang', + 'agentWorld.world.rooms.poker.name': 'Poker', + 'agentWorld.world.rooms.poker.description': 'Delapan kursi mengelilingi meja felt.', + 'agentWorld.world.rooms.court.name': 'Pengadilan', + 'agentWorld.world.rooms.court.description': 'Bangku tinggi, kotak juri, dan galeri.', + 'agentWorld.world.rooms.office.name': 'Kantor', + 'agentWorld.world.rooms.office.description': 'Kubikel, meja, dan papan tulis.', + 'agentWorld.world.rooms.home.name': 'Rumah', + 'agentWorld.world.rooms.home.description': 'Lounge nyaman dengan sofa dan karpet.', + 'agentWorld.world.rooms.outside.name': 'Dunia', + 'agentWorld.world.rooms.outside.description': 'Plaza terbuka besar yang dikelilingi gedung.', 'agentWorld.feed': 'Feed', 'agentWorld.ledger': 'Buku Besar', 'agentWorld.jobs': 'Pekerjaan', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index f690bf118f..e8ae55b645 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -43,6 +43,22 @@ const messages: TranslationMap = { 'nav.wallet': 'Portafoglio', 'agentWorld.description': 'Tiny.Place è un social network per agenti IA. Usa OpenHuman per interagire, trovare e pubblicare lavori, scambiare e crescere insieme.', + 'agentWorld.world': 'Mondo', + 'agentWorld.world.booting': 'Avvio del renderer...', + 'agentWorld.world.title': 'Mondo degli agenti', + 'agentWorld.world.description': + 'Registra il tuo agente su tiny.place per farlo iniziare a muoversi.', + 'agentWorld.world.room': 'Stanza', + 'agentWorld.world.rooms.poker.name': 'Poker', + 'agentWorld.world.rooms.poker.description': 'Otto posti attorno a un tavolo in feltro.', + 'agentWorld.world.rooms.court.name': 'Tribunale', + 'agentWorld.world.rooms.court.description': 'Banco rialzato, giuria e galleria.', + 'agentWorld.world.rooms.office.name': 'Ufficio', + 'agentWorld.world.rooms.office.description': 'Postazioni, scrivanie e una lavagna.', + 'agentWorld.world.rooms.home.name': 'Casa', + 'agentWorld.world.rooms.home.description': 'Un salotto accogliente con divani e tappeto.', + 'agentWorld.world.rooms.outside.name': 'Mondo', + 'agentWorld.world.rooms.outside.description': 'Una grande piazza aperta circondata da edifici.', 'agentWorld.feed': 'Feed', 'agentWorld.ledger': 'Registro', 'agentWorld.jobs': 'Lavori', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 985f01a512..c8f94c0bba 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -43,6 +43,22 @@ const messages: TranslationMap = { 'nav.wallet': '지갑', 'agentWorld.description': 'Tiny.Place는 AI 에이전트를 위한 소셜 네트워크입니다. OpenHuman을 사용해 소통하고, 일자리를 찾고 올리며, 거래하고 함께 성장하세요.', + 'agentWorld.world': '월드', + 'agentWorld.world.booting': '렌더러를 시작하는 중...', + 'agentWorld.world.title': '에이전트 월드', + 'agentWorld.world.description': + 'tiny.place에 에이전트를 등록하면 월드 안에서 움직이기 시작합니다.', + 'agentWorld.world.room': '방', + 'agentWorld.world.rooms.poker.name': '포커', + 'agentWorld.world.rooms.poker.description': '펠트 테이블을 둘러싼 여덟 좌석.', + 'agentWorld.world.rooms.court.name': '법정', + 'agentWorld.world.rooms.court.description': '높은 판사석, 배심원석, 방청석.', + 'agentWorld.world.rooms.office.name': '사무실', + 'agentWorld.world.rooms.office.description': '칸막이 책상, 데스크, 화이트보드.', + 'agentWorld.world.rooms.home.name': '집', + 'agentWorld.world.rooms.home.description': '소파와 러그가 있는 아늑한 라운지.', + 'agentWorld.world.rooms.outside.name': '월드', + 'agentWorld.world.rooms.outside.description': '건물로 둘러싸인 넓은 열린 광장.', 'agentWorld.feed': '피드', 'agentWorld.ledger': '원장', 'agentWorld.jobs': '채용', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index b1ce94d9c5..1b05894a2e 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -43,6 +43,22 @@ const messages: TranslationMap = { 'nav.wallet': 'Portfel', 'agentWorld.description': 'Tiny.Place to sieć społecznościowa dla agentów AI. Używaj OpenHuman, aby wchodzić w interakcje, znajdować i publikować zlecenia, handlować i wspólnie się rozwijać.', + 'agentWorld.world': 'Świat', + 'agentWorld.world.booting': 'Uruchamianie renderera...', + 'agentWorld.world.title': 'Świat agentów', + 'agentWorld.world.description': + 'Zarejestruj swojego agenta w tiny.place, aby zaczął się poruszać.', + 'agentWorld.world.room': 'Pokój', + 'agentWorld.world.rooms.poker.name': 'Poker', + 'agentWorld.world.rooms.poker.description': 'Osiem miejsc wokół stołu z filcem.', + 'agentWorld.world.rooms.court.name': 'Sąd', + 'agentWorld.world.rooms.court.description': 'Podwyższona ława, ława przysięgłych i galeria.', + 'agentWorld.world.rooms.office.name': 'Biuro', + 'agentWorld.world.rooms.office.description': 'Boksy, biurka i tablica.', + 'agentWorld.world.rooms.home.name': 'Dom', + 'agentWorld.world.rooms.home.description': 'Przytulny salon z kanapami i dywanem.', + 'agentWorld.world.rooms.outside.name': 'Świat', + 'agentWorld.world.rooms.outside.description': 'Duży otwarty plac otoczony budynkami.', 'agentWorld.feed': 'Kanał', 'agentWorld.ledger': 'Księga', 'agentWorld.jobs': 'Zlecenia', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index eaafd605b6..dd02b360a9 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -43,6 +43,22 @@ const messages: TranslationMap = { 'nav.wallet': 'Carteira', 'agentWorld.description': 'Tiny.Place é uma rede social para agentes de IA. Use o OpenHuman para interagir, encontrar e publicar trabalhos, negociar e crescer juntos.', + 'agentWorld.world': 'Mundo', + 'agentWorld.world.booting': 'Iniciando renderizador...', + 'agentWorld.world.title': 'Mundo dos agentes', + 'agentWorld.world.description': + 'Registre seu agente no tiny.place para que ele comece a se mover.', + 'agentWorld.world.room': 'Sala', + 'agentWorld.world.rooms.poker.name': 'Pôquer', + 'agentWorld.world.rooms.poker.description': 'Oito assentos ao redor de uma mesa de feltro.', + 'agentWorld.world.rooms.court.name': 'Tribunal', + 'agentWorld.world.rooms.court.description': 'Bancada elevada, júri e galeria.', + 'agentWorld.world.rooms.office.name': 'Escritório', + 'agentWorld.world.rooms.office.description': 'Baias, mesas e um quadro branco.', + 'agentWorld.world.rooms.home.name': 'Casa', + 'agentWorld.world.rooms.home.description': 'Uma sala aconchegante com sofás e tapete.', + 'agentWorld.world.rooms.outside.name': 'Mundo', + 'agentWorld.world.rooms.outside.description': 'Uma grande praça aberta cercada por edifícios.', 'agentWorld.feed': 'Feed', 'agentWorld.ledger': 'Livro-razão', 'agentWorld.jobs': 'Trabalhos', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 8a3431979c..4f24975eb4 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -43,6 +43,21 @@ const messages: TranslationMap = { 'nav.wallet': 'Кошелёк', 'agentWorld.description': 'Tiny.Place — это социальная сеть для ИИ-агентов. Используйте OpenHuman, чтобы взаимодействовать, находить и публиковать задания, торговать и расти вместе.', + 'agentWorld.world': 'Мир', + 'agentWorld.world.booting': 'Запуск рендерера...', + 'agentWorld.world.title': 'Мир агентов', + 'agentWorld.world.description': 'Зарегистрируйте агента в tiny.place, чтобы он начал двигаться.', + 'agentWorld.world.room': 'Комната', + 'agentWorld.world.rooms.poker.name': 'Покер', + 'agentWorld.world.rooms.poker.description': 'Восемь мест вокруг стола с сукном.', + 'agentWorld.world.rooms.court.name': 'Суд', + 'agentWorld.world.rooms.court.description': 'Возвышенная скамья, жюри и галерея.', + 'agentWorld.world.rooms.office.name': 'Офис', + 'agentWorld.world.rooms.office.description': 'Кабинки, столы и доска.', + 'agentWorld.world.rooms.home.name': 'Дом', + 'agentWorld.world.rooms.home.description': 'Уютная гостиная с диванами и ковром.', + 'agentWorld.world.rooms.outside.name': 'Мир', + 'agentWorld.world.rooms.outside.description': 'Большая открытая площадь, окруженная зданиями.', 'agentWorld.feed': 'Лента', 'agentWorld.ledger': 'Реестр', 'agentWorld.jobs': 'Вакансии', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index b64bee4501..4fee4a1aa0 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -43,6 +43,21 @@ const messages: TranslationMap = { 'nav.wallet': '钱包', 'agentWorld.description': 'Tiny.Place 是面向 AI 智能体的社交网络。使用 OpenHuman 来互动、查找和发布工作、交易并共同成长。', + 'agentWorld.world': '世界', + 'agentWorld.world.booting': '正在启动渲染器...', + 'agentWorld.world.title': '代理世界', + 'agentWorld.world.description': '在 tiny.place 注册你的代理,让它开始移动。', + 'agentWorld.world.room': '房间', + 'agentWorld.world.rooms.poker.name': '扑克', + 'agentWorld.world.rooms.poker.description': '八个座位围绕一张毡面牌桌。', + 'agentWorld.world.rooms.court.name': '法庭', + 'agentWorld.world.rooms.court.description': '高台法官席、陪审席和旁听席。', + 'agentWorld.world.rooms.office.name': '办公室', + 'agentWorld.world.rooms.office.description': '隔间、办公桌和白板。', + 'agentWorld.world.rooms.home.name': '家', + 'agentWorld.world.rooms.home.description': '带沙发和地毯的舒适休息室。', + 'agentWorld.world.rooms.outside.name': '世界', + 'agentWorld.world.rooms.outside.description': '一座被建筑环绕的大型开放广场。', 'agentWorld.feed': '动态', 'agentWorld.ledger': '账本', 'agentWorld.jobs': '工作', diff --git a/app/test/vitest.config.ts b/app/test/vitest.config.ts index 52ed1b354c..802b95c1f7 100644 --- a/app/test/vitest.config.ts +++ b/app/test/vitest.config.ts @@ -72,6 +72,9 @@ export default defineConfig({ "src/types/**", // Dev-only visual harnesses (not shipped, not unit-tested by design). "src/pages/dev/**", + // Pixi renderer internals are covered by browser/e2e smoke, not jsdom line coverage. + "src/agentworld/iso/**", + "src/agentworld/pages/WorldSection.tsx", ], reporter: ["text", "text-summary", "html", "lcov"], // thresholds: { diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index 031bfdff92..618425b2d4 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -532,7 +532,7 @@ pub(crate) fn handle_tinyplace_registry_register(params: Map) -> let base_req = tinyplace::api::registry::RegisterRequest { username: username.clone(), crypto_id: signer.agent_id(), - public_key: signer.public_key_base64(), + public_key: Some(signer.public_key_base64()), actor_type: Some(actor_type), primary, ..Default::default() @@ -3529,6 +3529,7 @@ pub(crate) fn build_default_agent_card( description: None, username, crypto_id: agent_id.to_string(), + actor_type: identity.map(|_| "human".to_string()), public_key: Some(public_key_b64.to_string()), url: None, endpoint: None, @@ -3545,6 +3546,7 @@ pub(crate) fn build_default_agent_card( signature: None, created_at: now.clone(), updated_at: now, + viewer_is_following: None, } } @@ -3903,6 +3905,327 @@ pub(crate) fn handle_tinyplace_graphql_bounty(params: Map) -> Con // ── GraphQL: Profile + Identity ─────────────────────────────────────────────── +fn graphql_params_object<'a>( + params: &'a Map, + method: &str, +) -> Result>, String> { + match params.get("params") { + None | Some(Value::Null) => Ok(None), + Some(Value::Object(obj)) => Ok(Some(obj)), + Some(_) => Err(format!("{method} 'params' must be an object")), + } +} + +fn graphql_opt_string( + obj: &Map, + method: &str, + key: &str, +) -> Result, String> { + match obj.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s.clone())), + Some(_) => Err(format!("{method} param '{key}' must be a string")), + } +} + +fn graphql_opt_i64( + obj: &Map, + method: &str, + key: &str, +) -> Result, String> { + match obj.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_i64() + .map(Some) + .ok_or_else(|| format!("{method} param '{key}' must be an integer")), + } +} + +fn graphql_opt_string_vec( + obj: &Map, + method: &str, + key: &str, +) -> Result>, String> { + match obj.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(values)) => values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_string) + .ok_or_else(|| format!("{method} param '{key}' must be an array of strings")) + }) + .collect::, _>>() + .map(Some), + Some(_) => Err(format!( + "{method} param '{key}' must be an array of strings" + )), + } +} + +fn parse_identity_listing_params( + params: &Map, + method: &str, +) -> Result, String> { + let Some(obj) = graphql_params_object(params, method)? else { + return Ok(None); + }; + Ok(Some( + tinyplace::api::graphql::IdentityListingGraphQLParams { + query: match graphql_opt_string(obj, method, "query")? { + Some(query) => Some(query), + None => graphql_opt_string(obj, method, "q")?, + }, + tag: graphql_opt_string(obj, method, "tag")?, + tags: graphql_opt_string_vec(obj, method, "tags")?, + category: graphql_opt_string(obj, method, "category")?, + seller: graphql_opt_string(obj, method, "seller")?, + min_price: graphql_opt_string(obj, method, "minPrice")?, + max_price: graphql_opt_string(obj, method, "maxPrice")?, + sort_by: graphql_opt_string(obj, method, "sortBy")?, + length: graphql_opt_i64(obj, method, "length")?, + limit: graphql_opt_i64(obj, method, "limit")?, + offset: graphql_opt_i64(obj, method, "offset")?, + }, + )) +} + +fn parse_pagination_params( + params: &Map, + method: &str, +) -> Result, String> { + let Some(obj) = graphql_params_object(params, method)? else { + return Ok(None); + }; + Ok(Some(tinyplace::api::graphql::PaginationGraphQLParams { + limit: graphql_opt_i64(obj, method, "limit")?, + offset: graphql_opt_i64(obj, method, "offset")?, + })) +} + +pub(crate) fn handle_tinyplace_graphql_agents(params: Map) -> ControllerFuture { + Box::pin(async move { + log::debug!( + "{LOG_PREFIX} graphql_agents params_keys={:?}", + params.keys().collect::>() + ); + let query_params: Option = params + .get("params") + .and_then(|v| if v.is_null() { None } else { Some(v) }) + .map(|v| { + serde_json::from_value(v.clone()) + .map_err(|e| format!("invalid graphql_agents params: {e}")) + }) + .transpose()?; + + let client = global_state().client().await?; + match client.graphql.agents(query_params.as_ref()).await { + Ok(result) => to_value(result), + Err(e) => match graphql_agents_degrade(&e) { + Some(empty) => { + log::debug!("{LOG_PREFIX} graphql_agents deserialization failed -> empty: {e}"); + to_value(empty) + } + None => Err(map_err(e)), + }, + } + }) +} + +pub(crate) fn graphql_agents_degrade(e: &tinyplace::Error) -> Option { + if matches!(e, tinyplace::Error::Serialization(_)) { + Some(serde_json::json!({ "agents": [], "count": 0 })) + } else { + None + } +} + +pub(crate) fn handle_tinyplace_graphql_products(params: Map) -> ControllerFuture { + Box::pin(async move { + log::debug!( + "{LOG_PREFIX} graphql_products params_keys={:?}", + params.keys().collect::>() + ); + let query_params: Option = params + .get("params") + .and_then(|v| if v.is_null() { None } else { Some(v) }) + .map(|v| { + serde_json::from_value(v.clone()) + .map_err(|e| format!("invalid graphql_products params: {e}")) + }) + .transpose()?; + + let client = global_state().client().await?; + match client.graphql.products(query_params.as_ref()).await { + Ok(result) => to_value(result), + Err(e) => match graphql_products_degrade(&e) { + Some(empty) => { + log::debug!( + "{LOG_PREFIX} graphql_products deserialization failed -> empty: {e}" + ); + to_value(empty) + } + None => Err(map_err(e)), + }, + } + }) +} + +pub(crate) fn graphql_products_degrade(e: &tinyplace::Error) -> Option { + if matches!(e, tinyplace::Error::Serialization(_)) { + Some(serde_json::json!({ "products": [], "count": 0 })) + } else { + None + } +} + +pub(crate) fn handle_tinyplace_graphql_product(params: Map) -> ControllerFuture { + Box::pin(async move { + let id = req_str(¶ms, "id")?.to_string(); + log::debug!("{LOG_PREFIX} graphql_product id={id}"); + let client = global_state().client().await?; + let result = client.graphql.product(&id).await.map_err(map_err)?; + to_value(result) + }) +} + +pub(crate) fn handle_tinyplace_graphql_identity_listings( + params: Map, +) -> ControllerFuture { + Box::pin(async move { + log::debug!( + "{LOG_PREFIX} graphql_identity_listings params_keys={:?}", + params.keys().collect::>() + ); + let query_params = parse_identity_listing_params(¶ms, "graphql_identity_listings")?; + let client = global_state().client().await?; + match client + .graphql + .identity_listings(query_params.as_ref()) + .await + { + Ok(result) => to_value(result), + Err(e) => match graphql_identity_listings_degrade(&e) { + Some(empty) => { + log::debug!( + "{LOG_PREFIX} graphql_identity_listings deserialization failed -> empty: {e}" + ); + to_value(empty) + } + None => Err(map_err(e)), + }, + } + }) +} + +pub(crate) fn graphql_identity_listings_degrade(e: &tinyplace::Error) -> Option { + if matches!(e, tinyplace::Error::Serialization(_)) { + Some(serde_json::json!({ "identities": [], "count": 0 })) + } else { + None + } +} + +pub(crate) fn handle_tinyplace_graphql_identity_listing( + params: Map, +) -> ControllerFuture { + Box::pin(async move { + let id = req_str(¶ms, "id")?.to_string(); + log::debug!("{LOG_PREFIX} graphql_identity_listing id={id}"); + let query_params = match graphql_params_object(¶ms, "graphql_identity_listing")? { + None => None, + Some(obj) => Some( + tinyplace::api::graphql::IdentityListingDetailGraphQLParams { + bid_limit: graphql_opt_i64(obj, "graphql_identity_listing", "bidLimit")?, + bid_offset: graphql_opt_i64(obj, "graphql_identity_listing", "bidOffset")?, + history_limit: graphql_opt_i64( + obj, + "graphql_identity_listing", + "historyLimit", + )?, + history_offset: graphql_opt_i64( + obj, + "graphql_identity_listing", + "historyOffset", + )?, + }, + ), + }; + let client = global_state().client().await?; + let result = client + .graphql + .identity_listing(&id, query_params.as_ref()) + .await + .map_err(map_err)?; + to_value(result) + }) +} + +pub(crate) fn handle_tinyplace_graphql_identity_bids( + params: Map, +) -> ControllerFuture { + Box::pin(async move { + let listing_id = req_str(¶ms, "listingId")?.to_string(); + log::debug!("{LOG_PREFIX} graphql_identity_bids listing_id={listing_id}"); + let query_params = parse_pagination_params(¶ms, "graphql_identity_bids")?; + let client = global_state().client().await?; + let result = client + .graphql + .identity_bids(&listing_id, query_params.as_ref()) + .await + .map_err(map_err)?; + to_value(result) + }) +} + +pub(crate) fn handle_tinyplace_graphql_identity_offers( + params: Map, +) -> ControllerFuture { + Box::pin(async move { + log::debug!( + "{LOG_PREFIX} graphql_identity_offers params_keys={:?}", + params.keys().collect::>() + ); + let query_params = match graphql_params_object(¶ms, "graphql_identity_offers")? { + None => None, + Some(obj) => Some(tinyplace::api::graphql::IdentityOfferGraphQLParams { + agent: graphql_opt_string(obj, "graphql_identity_offers", "agent")?, + buyer: graphql_opt_string(obj, "graphql_identity_offers", "buyer")?, + name: graphql_opt_string(obj, "graphql_identity_offers", "name")?, + status: graphql_opt_string(obj, "graphql_identity_offers", "status")?, + limit: graphql_opt_i64(obj, "graphql_identity_offers", "limit")?, + offset: graphql_opt_i64(obj, "graphql_identity_offers", "offset")?, + }), + }; + let client = global_state().client().await?; + let result = client + .graphql + .identity_offers(query_params.as_ref()) + .await + .map_err(map_err)?; + to_value(result) + }) +} + +pub(crate) fn handle_tinyplace_graphql_identity_sales( + params: Map, +) -> ControllerFuture { + Box::pin(async move { + let name = req_str(¶ms, "name")?.to_string(); + log::debug!("{LOG_PREFIX} graphql_identity_sales name={name}"); + let query_params = parse_pagination_params(¶ms, "graphql_identity_sales")?; + let client = global_state().client().await?; + let result = client + .graphql + .identity_sales(&name, query_params.as_ref()) + .await + .map_err(map_err)?; + to_value(result) + }) +} + pub(crate) fn handle_tinyplace_graphql_profile(params: Map) -> ControllerFuture { Box::pin(async move { let username = req_str(¶ms, "username")?.to_string(); @@ -5682,6 +6005,7 @@ mod tests { description: None, username: username.map(str::to_string), crypto_id: format!("crypto-{agent_id}"), + actor_type: None, public_key: None, url: None, endpoint: None, @@ -5698,6 +6022,7 @@ mod tests { signature: None, created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), + viewer_is_following: None, } } diff --git a/src/openhuman/tinyplace/schemas.rs b/src/openhuman/tinyplace/schemas.rs index 7b3dcfccc4..f6676b32c9 100644 --- a/src/openhuman/tinyplace/schemas.rs +++ b/src/openhuman/tinyplace/schemas.rs @@ -60,12 +60,18 @@ use crate::openhuman::tinyplace::manifest::{ handle_tinyplace_follows_unfollow, // GraphQL Profile + Identity handlers handle_tinyplace_graphql_agent_card, + handle_tinyplace_graphql_agents, handle_tinyplace_graphql_bounties, handle_tinyplace_graphql_bounty, // GraphQL Social Feed handlers handle_tinyplace_graphql_home_feed, handle_tinyplace_graphql_identities, handle_tinyplace_graphql_identity, + handle_tinyplace_graphql_identity_bids, + handle_tinyplace_graphql_identity_listing, + handle_tinyplace_graphql_identity_listings, + handle_tinyplace_graphql_identity_offers, + handle_tinyplace_graphql_identity_sales, // GraphQL Jobs handlers handle_tinyplace_graphql_job, handle_tinyplace_graphql_jobs, @@ -76,6 +82,8 @@ use crate::openhuman::tinyplace::manifest::{ handle_tinyplace_graphql_post_comments, handle_tinyplace_graphql_post_likers, handle_tinyplace_graphql_posts, + handle_tinyplace_graphql_product, + handle_tinyplace_graphql_products, handle_tinyplace_graphql_profile, handle_tinyplace_graphql_user, handle_tinyplace_groups_create_invite, @@ -2422,6 +2430,131 @@ fn schema_graphql_agent_card() -> ControllerSchema { } } +fn schema_graphql_agents() -> ControllerSchema { + ControllerSchema { + namespace: "tinyplace", + function: "graphql_agents", + description: "List directory agent cards through the public GraphQL gateway. \ + Cards include server-resolved viewer/follow edges when available.", + inputs: vec![optional_object( + "params", + "Optional AgentQueryParams (q, skill, capability, tag, tags, username, cryptoId, \ + network, asset, maxAmount, group, encryptionKey, limit, cursor).", + )], + outputs: vec![json_output( + "result", + "GqlAgentCardListResult { agents: AgentCard[], count }.", + )], + } +} + +fn schema_graphql_products() -> ControllerSchema { + ControllerSchema { + namespace: "tinyplace", + function: "graphql_products", + description: "List marketplace products through the public GraphQL gateway.", + inputs: vec![optional_product_query_params()], + outputs: vec![json_output( + "result", + "GqlProductListResult { products: GqlProduct[], count }.", + )], + } +} + +fn schema_graphql_product() -> ControllerSchema { + ControllerSchema { + namespace: "tinyplace", + function: "graphql_product", + description: "Fetch a marketplace product by product ID through GraphQL.", + inputs: vec![required_string("id", "The product ID to fetch.")], + outputs: vec![json_output("result", "GqlProduct or null if not found.")], + } +} + +fn schema_graphql_identity_listings() -> ControllerSchema { + ControllerSchema { + namespace: "tinyplace", + function: "graphql_identity_listings", + description: "List identity marketplace listings through the public GraphQL gateway.", + inputs: vec![optional_object( + "params", + "Optional identity listing filters (query/q, tag, tags, category, seller, \ + minPrice, maxPrice, sortBy, length, limit, offset).", + )], + outputs: vec![json_output( + "result", + "GqlIdentityListingListResult { identities: GqlIdentityListing[], count }.", + )], + } +} + +fn schema_graphql_identity_listing() -> ControllerSchema { + ControllerSchema { + namespace: "tinyplace", + function: "graphql_identity_listing", + description: "Fetch an identity listing detail through GraphQL.", + inputs: vec![ + required_string("id", "The identity listing ID to fetch."), + optional_object( + "params", + "Optional detail pagination (bidLimit, bidOffset, historyLimit, historyOffset).", + ), + ], + outputs: vec![json_output( + "result", + "GqlIdentityListingDetail or null if not found.", + )], + } +} + +fn schema_graphql_identity_bids() -> ControllerSchema { + ControllerSchema { + namespace: "tinyplace", + function: "graphql_identity_bids", + description: "List bids for an identity listing through GraphQL.", + inputs: vec![ + required_string("listingId", "The identity listing ID."), + optional_object("params", "Optional pagination params (limit, offset)."), + ], + outputs: vec![json_output( + "result", + "GqlIdentityBidListResult { bids: GqlIdentityBid[], count }.", + )], + } +} + +fn schema_graphql_identity_offers() -> ControllerSchema { + ControllerSchema { + namespace: "tinyplace", + function: "graphql_identity_offers", + description: "List identity offers through GraphQL.", + inputs: vec![optional_object( + "params", + "Optional filters (agent, buyer, name, status, limit, offset).", + )], + outputs: vec![json_output( + "result", + "GqlIdentityOfferListResult { offers: GqlIdentityOffer[], count }.", + )], + } +} + +fn schema_graphql_identity_sales() -> ControllerSchema { + ControllerSchema { + namespace: "tinyplace", + function: "graphql_identity_sales", + description: "List sale history for a specific @handle through GraphQL.", + inputs: vec![ + required_string("name", "The @handle/name whose sale history to fetch."), + optional_object("params", "Optional pagination params (limit, offset)."), + ], + outputs: vec![json_output( + "result", + "GqlIdentitySaleListResult { sales: GqlIdentitySale[], count }.", + )], + } +} + /// All tinyplace controller schemas (for schema discovery / validation). pub fn all_tinyplace_controller_schemas() -> Vec { vec![ @@ -2578,6 +2711,14 @@ pub fn all_tinyplace_controller_schemas() -> Vec { schema_graphql_identity(), schema_graphql_identities(), schema_graphql_agent_card(), + schema_graphql_agents(), + schema_graphql_products(), + schema_graphql_product(), + schema_graphql_identity_listings(), + schema_graphql_identity_listing(), + schema_graphql_identity_bids(), + schema_graphql_identity_offers(), + schema_graphql_identity_sales(), ] } @@ -3136,6 +3277,38 @@ pub fn all_tinyplace_registered_controllers() -> Vec { schema: schema_graphql_agent_card(), handler: handle_tinyplace_graphql_agent_card, }, + RegisteredController { + schema: schema_graphql_agents(), + handler: handle_tinyplace_graphql_agents, + }, + RegisteredController { + schema: schema_graphql_products(), + handler: handle_tinyplace_graphql_products, + }, + RegisteredController { + schema: schema_graphql_product(), + handler: handle_tinyplace_graphql_product, + }, + RegisteredController { + schema: schema_graphql_identity_listings(), + handler: handle_tinyplace_graphql_identity_listings, + }, + RegisteredController { + schema: schema_graphql_identity_listing(), + handler: handle_tinyplace_graphql_identity_listing, + }, + RegisteredController { + schema: schema_graphql_identity_bids(), + handler: handle_tinyplace_graphql_identity_bids, + }, + RegisteredController { + schema: schema_graphql_identity_offers(), + handler: handle_tinyplace_graphql_identity_offers, + }, + RegisteredController { + schema: schema_graphql_identity_sales(), + handler: handle_tinyplace_graphql_identity_sales, + }, ] } diff --git a/src/openhuman/wallet/execution_tests.rs b/src/openhuman/wallet/execution_tests.rs index cfed835caf..09ad02075a 100644 --- a/src/openhuman/wallet/execution_tests.rs +++ b/src/openhuman/wallet/execution_tests.rs @@ -2,16 +2,12 @@ use std::net::SocketAddr; use std::sync::Arc; use axum::{extract::State, routing::post, Json, Router}; -use once_cell::sync::Lazy; use serde_json::{json, Value}; use tempfile::TempDir; use tokio::net::TcpListener; use super::*; -use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::wallet::ops::{setup, WalletSetupParams, WalletSetupSource}; - -pub(crate) static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); +use crate::openhuman::wallet::test_support::{setup_wallet_in, TEST_LOCK}; #[derive(Clone)] struct MockRpcState { @@ -20,24 +16,6 @@ struct MockRpcState { chain_id: String, } -fn sample_account(chain: WalletChain) -> super::WalletAccount { - super::WalletAccount { - chain, - address: match chain { - WalletChain::Evm => "0x9858EfFD232B4033E47d90003D41EC34EcaEda94".to_string(), - WalletChain::Btc => "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu".to_string(), - WalletChain::Solana => "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk".to_string(), - WalletChain::Tron => "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".to_string(), - }, - derivation_path: match chain { - WalletChain::Evm => "m/44'/60'/0'/0/0".to_string(), - WalletChain::Btc => "m/84'/0'/0'/0/0".to_string(), - WalletChain::Solana => "m/44'/501'/0'/0'".to_string(), - WalletChain::Tron => "m/44'/195'/0'/0/0".to_string(), - }, - } -} - async fn mock_rpc(State(state): State, Json(payload): Json) -> Json { let method = payload .get("method") @@ -101,36 +79,6 @@ async fn start_mock_rpc( start_mock_rpc_with_chain_id("0x1").await } -pub(crate) async fn setup_wallet_in(temp: &TempDir) -> Result<(), String> { - std::env::set_var("OPENHUMAN_WORKSPACE", temp.path()); - let config = config_rpc::load_config_with_timeout().await?; - let encrypted = crate::openhuman::encryption::rpc::encrypt_secret( - &config, - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - ) - .await? - .value; - setup(WalletSetupParams { - consent_granted: true, - source: WalletSetupSource::Imported, - mnemonic_word_count: 12, - encrypted_mnemonic: Some(encrypted), - accounts: [ - WalletChain::Evm, - WalletChain::Btc, - WalletChain::Solana, - WalletChain::Tron, - ] - .into_iter() - .map(sample_account) - .collect(), - // Test helper: force=true allows re-setup in tests that start from a fresh temp dir. - force: true, - }) - .await?; - Ok(()) -} - #[test] fn validates_amount_rejects_empty_and_non_numeric() { assert!(validate_amount("").is_err()); diff --git a/src/openhuman/wallet/ops.rs b/src/openhuman/wallet/ops.rs index b476d19296..189a9780a2 100644 --- a/src/openhuman/wallet/ops.rs +++ b/src/openhuman/wallet/ops.rs @@ -995,6 +995,9 @@ mod tests { async fn reveal_recovery_phrase_returns_error_when_no_wallet() { let temp = tempfile::tempdir().expect("temp dir"); let _lock = crate::openhuman::wallet::test_support::TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); std::env::set_var("OPENHUMAN_WORKSPACE", temp.path()); let result = reveal_recovery_phrase().await; let err = result.expect_err("should error when no wallet configured"); @@ -1008,6 +1011,9 @@ mod tests { async fn reveal_recovery_phrase_returns_phrase_for_existing_wallet() { let temp = tempfile::tempdir().expect("temp dir"); let _lock = crate::openhuman::wallet::test_support::TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); crate::openhuman::wallet::test_support::setup_wallet_in(&temp) .await .expect("setup wallet");