feat(agent-world): add TinyPlace world and GraphQL reads (#3867)

This commit is contained in:
Steven Enamakel
2026-06-21 07:59:21 -07:00
committed by GitHub
parent 35b7073d6a
commit db7f6d8b96
46 changed files with 5916 additions and 355 deletions
Generated
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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",
+261
View File
@@ -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<WalkNode> = [];
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<WalkNode>,
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);
}
}
+465
View File
@@ -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<readonly [number, number]> = [
[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<Array<number>> = [];
private readonly walkable = new Set<string>();
private readonly seats = new Set<string>();
private readonly stationsByTile = new Map<string, FurnitureStation>();
private readonly pieceByTile = new Map<string, FurnitureSprite>();
private readonly obstacles: Array<DepthObstacle> = [];
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<number>(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<DepthObstacle> {
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<FurnitureStation> {
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<FurnitureStation> {
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<WalkNode> {
const nodes: Array<WalkNode> = [];
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<string>
): Array<WalkNode> | 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<string>([startKey]);
const previousByKey = new Map<string, string>();
const nodeByKey = new Map<string, WalkNode>([[startKey, this.node(startX, startY)]]);
const queue: Array<PathQueueNode> = [{ 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<string, string>,
nodeByKey: ReadonlyMap<string, WalkNode>
): Array<WalkNode> {
const path: Array<WalkNode> = [];
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();
}
}
+127
View File
@@ -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;
}
}
}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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);
}
+899
View File
@@ -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<FurniturePart>;
interactionPoints: Array<InteractionPoint>;
/** 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<FurniturePart>;
}): 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<FurniturePart> = [
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<string, FurnitureBlueprint> = {
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<InteractionPoint>;
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<FurnitureStation> {
return this.stationPoints.map(point => ({
tileX: this.tileX + point.tileOffsetX,
tileY: this.tileY + point.tileOffsetY,
point,
}));
}
}
+85
View File
@@ -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);
}
+30
View File
@@ -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';
+486
View File
@@ -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<Array<TileCode>> {
const matrix: Array<Array<TileCode>> = [];
for (let row = 0; row < rows; row++) {
matrix.push(new Array<TileCode>(columns).fill(TileCode.Floor));
}
return matrix;
}
function addBackWalls(matrix: Array<Array<TileCode>>): 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<Array<TileCode>>,
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<Array<TileCode>>,
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<FurnitureConfig> = [
{ 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<FurnitureConfig> = [
{ 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<FurnitureConfig> = [
// 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<FurnitureConfig> = [
{ 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>): string => list[Math.floor(random() * list.length)]!;
const furniture: Array<FurnitureConfig> = [];
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<RoomEntry> = [
{
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),
},
];
+642
View File
@@ -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<number> {
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<string, BakedTexture>();
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<number> => [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<number> => [
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();
}
}
+121
View File
@@ -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<InteractionPoint>;
}
/** 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<Array<TileCode>>;
palette: RoomPalette;
furniture: Array<FurnitureConfig>;
/** Where newly spawned agents enter. */
spawnTile: TileCoord;
/** Extra vertical clearance above the floor for tall props (buildings). */
topMargin?: number;
}
@@ -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 }) => (
<div data-testid="sidebar-slot">{children}</div>
),
}));
vi.mock('../../components/layout/TwoPaneNav', () => ({
default: ({
selected,
onSelect,
groups,
}: {
selected: string;
onSelect: (slug: string) => void;
groups: Array<{ items: Array<{ value: string; label: string }> }>;
}) => (
<nav aria-label="agent-world-nav">
<div data-testid="selected-section">{selected}</div>
{groups.flatMap(group =>
group.items.map(item => (
<button key={item.value} type="button" onClick={() => onSelect(item.value)}>
{item.label}
</button>
))
)}
</nav>
),
}));
vi.mock('../components/WalletAddressChip', () => ({ default: () => <span>wallet-chip</span> }));
vi.mock('./WorldSection', () => ({ default: () => <div>world-section</div> }));
vi.mock('./FeedSection', () => ({ default: () => <div>feed-section</div> }));
vi.mock('./LedgerSection', () => ({ default: () => <div>ledger-section</div> }));
vi.mock('./JobsSection', () => ({ default: () => <div>jobs-section</div> }));
vi.mock('./BountiesSection', () => ({ default: () => <div>bounties-section</div> }));
vi.mock('./ExploreSection', () => ({ default: () => <div>explore-section</div> }));
vi.mock('./DirectorySection', () => ({ default: () => <div>directory-section</div> }));
vi.mock('./ProfilesSection', () => ({ default: () => <div>profiles-section</div> }));
vi.mock('./IdentitiesSection', () => ({ default: () => <div>identities-section</div> }));
vi.mock('./MarketplaceSection', () => ({ default: () => <div>marketplace-section</div> }));
vi.mock('./MessagingSection', () => ({ default: () => <div>messaging-section</div> }));
function renderAgentWorld(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/agent-world/*" element={<AgentWorld />} />
</Routes>
</MemoryRouter>
);
}
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');
});
});
+20 -5
View File
@@ -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 (
<div className="h-full">
@@ -110,7 +119,7 @@ export default function AgentWorld() {
<div className="h-full overflow-hidden">
<TwoPaneNav
ariaLabel={t('nav.agentWorld')}
selected={activeSlug}
selected={activeSection}
onSelect={slug => 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. */}
<div className="mx-auto h-full w-full max-w-6xl p-4">
<div className="h-full overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-soft dark:border-neutral-800 dark:bg-neutral-900">
<div className={isWorld ? 'h-full w-full p-0' : 'mx-auto h-full w-full max-w-6xl p-4'}>
<div
className={
isWorld
? 'h-full overflow-hidden bg-black'
: 'h-full overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-soft dark:border-neutral-800 dark:bg-neutral-900'
}>
<Routes>
<Route index element={<Navigate to="/agent-world/feed" replace />} />
<Route index element={<Navigate to="/agent-world/world" replace />} />
<Route path="world" element={<WorldSection />} />
<Route path="feed" element={<FeedSection />} />
<Route path="ledger" element={<LedgerSection />} />
<Route path="jobs" element={<JobsSection />} />
@@ -150,7 +165,7 @@ export default function AgentWorld() {
<Route path="identities" element={<IdentitiesSection />} />
<Route path="marketplace" element={<MarketplaceSection />} />
<Route path="messaging" element={<MessagingSection />} />
<Route path="*" element={<Navigate to="/agent-world/feed" replace />} />
<Route path="*" element={<Navigate to="/agent-world/world" replace />} />
</Routes>
</div>
</div>
@@ -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<ReturnType<typeof fetchWalletStatus>>);
// 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(<DirectorySection />);
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(<DirectorySection />);
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(<DirectorySection />);
// 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(<DirectorySection />);
+36 -86
View File
@@ -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<string> | null {
const [following, setFollowing] = useState<Set<string> | 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<string> | 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<number | null>(null);
const [statsFollowerCount, setStatsFollowerCount] = useState<number | null>(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');
(serverFollow == null ? 'unknown' : serverFollow ? '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.
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() {
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{agents.map(agent => (
<AgentCardItem
key={agent.agentId}
agent={agent}
myAgentId={myAgentId}
followingSet={followingSet}
/>
<AgentCardItem key={agent.agentId} agent={agent} myAgentId={myAgentId} />
))}
</div>
);
@@ -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<typeof mockBountiesList>
>);
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<typeof mockBountiesList>
>);
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<ReturnType<typeof mockBountiesList>>);
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<ReturnType<typeof mockBountiesList>>);
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();
@@ -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<GqlJobPosting> {
// ── Bounties hook ─────────────────────────────────────────────────────────────
function useExploreBounties(): SectionState<Bounty> {
const [state, setState] = useState<SectionState<Bounty>>({ status: 'loading' });
function useExploreBounties(): SectionState<GqlBounty> {
const [state, setState] = useState<SectionState<GqlBounty>>({ 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<AgentCard> {
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 (
<div className={`p-3 ${CARD_CLASS}`}>
<div className="flex items-start justify-between gap-2">
@@ -500,7 +518,7 @@ function BountyRow({ bounty }: { bounty: Bounty }) {
</div>
<div className="flex-shrink-0 text-right">
<div className="text-sm font-semibold text-stone-800 dark:text-neutral-200">
{bounty.reward.amount} {bounty.reward.asset}
{formatReward(bounty.reward.amount, bounty.reward.asset)}
</div>
</div>
</div>
@@ -515,7 +533,7 @@ function ExploreBountiesList({
emptyMessage,
onViewAll,
}: {
state: SectionState<Bounty>;
state: SectionState<GqlBounty>;
title: string;
viewAllLabel: string;
emptyMessage: string;
@@ -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(<IdentitiesSection />);
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(<IdentitiesSection />);
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(<IdentitiesSection />);
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(<IdentitiesSection />);
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(<IdentitiesSection />);
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(<IdentitiesSection />);
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(<IdentitiesSection />);
await gotoTab('Trading');
expect(await screen.findByText('Failed to load')).toBeInTheDocument();
+66 -6
View File
@@ -45,6 +45,65 @@ type AsyncState<T> =
| { 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<IdentitiesResponse> {
const active: Array<IdentityListing> = [];
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<IdentityFloor> {
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<IdentitiesResponse> {
const [state, setState] = useState<AsyncState<IdentitiesResponse>>({ 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<IdentityFloor> {
const [state, setState] = useState<AsyncState<IdentityFloor>>({ 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;
@@ -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(<MarketplaceSection />);
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(<MarketplaceSection />);
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(<MarketplaceSection />);
@@ -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(<MarketplaceSection />);
@@ -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(<MarketplaceSection />);
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(<MarketplaceSection />);
@@ -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(<MarketplaceSection />);
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(<MarketplaceSection />);
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(<MarketplaceSection />);
@@ -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(<MarketplaceSection />);
@@ -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(<MarketplaceSection />);
@@ -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 () => {
@@ -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<AsyncState<JobListResponse>>({ status: 'loading' });
const [state, setState] = useState<AsyncState<GqlJobListResult>>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
void apiClient.jobs
.list()
void apiClient.graphql
.jobs({ limit: 50 })
.then(data => {
if (!cancelled) setState({ status: 'ok', data });
})
+136
View File
@@ -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<HTMLDivElement>(null);
const worldRef = useRef<GameWorld | null>(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 (
<div className="relative h-full w-full overflow-hidden bg-black">
<div ref={containerRef} className="absolute inset-0" />
{ready ? null : (
<div className="absolute inset-0 flex items-center justify-center text-sm text-neutral-300">
{t('agentWorld.world.booting', 'Booting renderer...')}
</div>
)}
<div className="pointer-events-none absolute left-3 top-3 z-10 max-w-sm rounded-xl border border-white/15 bg-neutral-950/70 px-4 py-3 shadow-xl backdrop-blur-md">
<h1 className="text-lg font-semibold text-white">
{t('agentWorld.world.title', 'Agent World')}
</h1>
<p className="mt-1 text-xs leading-relaxed text-neutral-300">
{t(
'agentWorld.world.description',
'Register your agent in tiny.place to get it to start moving around.'
)}
</p>
</div>
<aside className="absolute right-3 top-3 z-10 flex w-72 max-w-[calc(100%-1.5rem)] flex-col gap-4 overflow-y-auto rounded-xl border border-white/15 bg-neutral-950/70 p-4 shadow-xl backdrop-blur-md">
<section className="flex flex-col gap-2 rounded-lg border border-white/10 bg-white/10 p-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-300">
{t('agentWorld.world.room', 'Room')}
</h2>
<div className="grid grid-cols-2 gap-2">
{ROOM_REGISTRY.map(entry => (
<button
key={entry.key}
className={toggleClass(entry.key === roomKey)}
type="button"
onClick={() => {
handleRoom(entry.key);
}}>
{t(`agentWorld.world.rooms.${entry.key}.name`, entry.name)}
</button>
))}
</div>
<p className="text-[11px] leading-relaxed text-neutral-300">
{activeRoom
? t(`agentWorld.world.rooms.${activeRoom.key}.description`, activeRoom.description)
: null}
</p>
</section>
</aside>
</div>
);
}
@@ -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' }],
});
});
});
+167 -1
View File
@@ -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<Product, 'seller' | 'sellerCryptoId'> {
seller: FeedAuthor;
sellerCryptoId?: string;
}
export interface GqlProductListResult {
products: GqlProduct[];
count?: number;
[key: string]: unknown;
}
export interface GqlIdentityListing extends Omit<IdentityListing, 'seller' | 'sellerCryptoId'> {
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<IdentityOffer, 'buyer'> {
buyer: FeedAuthor;
}
export interface GqlIdentityOfferListResult {
offers: GqlIdentityOffer[];
count?: number;
[key: string]: unknown;
}
export interface GqlIdentitySale extends Omit<IdentitySale, 'buyer' | 'seller'> {
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<GqlAgentCardListResult>('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<GqlPostListResult>('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<GqlLedgerTransaction | null>('openhuman.tinyplace_graphql_ledger_transaction', { id }),
products: async (params?: ProductQueryParams) => {
const result = await call<GqlProductListResult>('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<GqlProduct | null>('openhuman.tinyplace_graphql_product', { id });
return result ? normalizeGraphqlProduct(result) : null;
},
/** List job postings with optional filters (public, no auth). */
jobs: (params?: GqlJobQueryParams) =>
call<GqlJobListResult>('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<AgentCard | null>('openhuman.tinyplace_graphql_agent_card', { id }),
identityListings: async (params?: IdentityListingQueryParams) => {
const result = await call<GqlIdentityListingListResult>(
'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<GqlIdentityListing | null>(
'openhuman.tinyplace_graphql_identity_listing',
{ id, params: params ?? null }
);
return result ? normalizeGraphqlIdentityListing(result) : null;
},
identityBids: (listingId: string, params?: { limit?: number; offset?: number }) =>
call<GqlIdentityBidListResult>('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<GqlIdentityOfferListResult>(
'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<GqlIdentitySaleListResult>(
'openhuman.tinyplace_graphql_identity_sales',
{ name, params: params ?? null }
);
return {
...result,
sales: result.sales.map(normalizeGraphqlIdentitySale),
} satisfies RecentSalesResponse & { count?: number };
},
},
jobsWrite: {
create: (params: JobCreateParams) =>
+15
View File
@@ -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': 'الوظائف',
+16
View File
@@ -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': 'চাকরি',
+16
View File
@@ -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',
+16
View File
@@ -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',
+15
View File
@@ -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',
+16
View File
@@ -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 dune 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',
+15
View File
@@ -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': 'कार्य',
+15
View File
@@ -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',
+16
View File
@@ -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',
+16
View File
@@ -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': '채용',
+16
View File
@@ -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',
+16
View File
@@ -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',
+15
View File
@@ -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': 'Вакансии',
+15
View File
@@ -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': '工作',
+3
View File
@@ -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: {
+326 -1
View File
@@ -532,7 +532,7 @@ pub(crate) fn handle_tinyplace_registry_register(params: Map<String, Value>) ->
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<String, Value>) -> Con
// ── GraphQL: Profile + Identity ───────────────────────────────────────────────
fn graphql_params_object<'a>(
params: &'a Map<String, Value>,
method: &str,
) -> Result<Option<&'a Map<String, Value>>, 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<String, Value>,
method: &str,
key: &str,
) -> Result<Option<String>, 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<String, Value>,
method: &str,
key: &str,
) -> Result<Option<i64>, 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<String, Value>,
method: &str,
key: &str,
) -> Result<Option<Vec<String>>, 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::<Result<Vec<_>, _>>()
.map(Some),
Some(_) => Err(format!(
"{method} param '{key}' must be an array of strings"
)),
}
}
fn parse_identity_listing_params(
params: &Map<String, Value>,
method: &str,
) -> Result<Option<tinyplace::api::graphql::IdentityListingGraphQLParams>, 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<String, Value>,
method: &str,
) -> Result<Option<tinyplace::api::graphql::PaginationGraphQLParams>, 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<String, Value>) -> ControllerFuture {
Box::pin(async move {
log::debug!(
"{LOG_PREFIX} graphql_agents params_keys={:?}",
params.keys().collect::<Vec<_>>()
);
let query_params: Option<tinyplace::types::AgentQueryParams> = 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<Value> {
if matches!(e, tinyplace::Error::Serialization(_)) {
Some(serde_json::json!({ "agents": [], "count": 0 }))
} else {
None
}
}
pub(crate) fn handle_tinyplace_graphql_products(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
log::debug!(
"{LOG_PREFIX} graphql_products params_keys={:?}",
params.keys().collect::<Vec<_>>()
);
let query_params: Option<tinyplace::types::ProductQueryParams> = 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<Value> {
if matches!(e, tinyplace::Error::Serialization(_)) {
Some(serde_json::json!({ "products": [], "count": 0 }))
} else {
None
}
}
pub(crate) fn handle_tinyplace_graphql_product(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let id = req_str(&params, "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<String, Value>,
) -> ControllerFuture {
Box::pin(async move {
log::debug!(
"{LOG_PREFIX} graphql_identity_listings params_keys={:?}",
params.keys().collect::<Vec<_>>()
);
let query_params = parse_identity_listing_params(&params, "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<Value> {
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<String, Value>,
) -> ControllerFuture {
Box::pin(async move {
let id = req_str(&params, "id")?.to_string();
log::debug!("{LOG_PREFIX} graphql_identity_listing id={id}");
let query_params = match graphql_params_object(&params, "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<String, Value>,
) -> ControllerFuture {
Box::pin(async move {
let listing_id = req_str(&params, "listingId")?.to_string();
log::debug!("{LOG_PREFIX} graphql_identity_bids listing_id={listing_id}");
let query_params = parse_pagination_params(&params, "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<String, Value>,
) -> ControllerFuture {
Box::pin(async move {
log::debug!(
"{LOG_PREFIX} graphql_identity_offers params_keys={:?}",
params.keys().collect::<Vec<_>>()
);
let query_params = match graphql_params_object(&params, "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<String, Value>,
) -> ControllerFuture {
Box::pin(async move {
let name = req_str(&params, "name")?.to_string();
log::debug!("{LOG_PREFIX} graphql_identity_sales name={name}");
let query_params = parse_pagination_params(&params, "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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let username = req_str(&params, "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,
}
}
+173
View File
@@ -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<ControllerSchema> {
vec![
@@ -2578,6 +2711,14 @@ pub fn all_tinyplace_controller_schemas() -> Vec<ControllerSchema> {
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<RegisteredController> {
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,
},
]
}
+1 -53
View File
@@ -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<Mutex<()>> = 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<MockRpcState>, Json(payload): Json<Value>) -> Json<Value> {
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());
+6
View File
@@ -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");