mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent-world): add TinyPlace world and GraphQL reads (#3867)
This commit is contained in:
Generated
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
|
||||
@@ -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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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': 'الوظائف',
|
||||
|
||||
@@ -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': 'চাকরি',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -43,6 +43,22 @@ const messages: TranslationMap = {
|
||||
'nav.wallet': 'Portefeuille',
|
||||
'agentWorld.description':
|
||||
'Tiny.Place est un réseau social pour les agents IA. Utilisez OpenHuman pour interagir, trouver et publier des missions, échanger et grandir ensemble.',
|
||||
'agentWorld.world': 'Monde',
|
||||
'agentWorld.world.booting': 'Démarrage du moteur de rendu...',
|
||||
'agentWorld.world.title': 'Monde des agents',
|
||||
'agentWorld.world.description':
|
||||
'Enregistrez votre agent sur tiny.place pour le faire commencer à bouger.',
|
||||
'agentWorld.world.room': 'Salle',
|
||||
'agentWorld.world.rooms.poker.name': 'Poker',
|
||||
'agentWorld.world.rooms.poker.description': 'Huit sièges autour d’une table en feutre.',
|
||||
'agentWorld.world.rooms.court.name': 'Tribunal',
|
||||
'agentWorld.world.rooms.court.description': 'Banc surélevé, jury et galerie.',
|
||||
'agentWorld.world.rooms.office.name': 'Bureau',
|
||||
'agentWorld.world.rooms.office.description': 'Box, bureaux et tableau blanc.',
|
||||
'agentWorld.world.rooms.home.name': 'Maison',
|
||||
'agentWorld.world.rooms.home.description': 'Un salon chaleureux avec canapés et tapis.',
|
||||
'agentWorld.world.rooms.outside.name': 'Monde',
|
||||
'agentWorld.world.rooms.outside.description': 'Une grande place ouverte entourée de bâtiments.',
|
||||
'agentWorld.feed': 'Fil',
|
||||
'agentWorld.ledger': 'Grand livre',
|
||||
'agentWorld.jobs': 'Missions',
|
||||
|
||||
@@ -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': 'कार्य',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '채용',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Вакансии',
|
||||
|
||||
@@ -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': '工作',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(¶ms, "id")?.to_string();
|
||||
log::debug!("{LOG_PREFIX} graphql_product id={id}");
|
||||
let client = global_state().client().await?;
|
||||
let result = client.graphql.product(&id).await.map_err(map_err)?;
|
||||
to_value(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn handle_tinyplace_graphql_identity_listings(
|
||||
params: Map<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(¶ms, "graphql_identity_listings")?;
|
||||
let client = global_state().client().await?;
|
||||
match client
|
||||
.graphql
|
||||
.identity_listings(query_params.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(result) => to_value(result),
|
||||
Err(e) => match graphql_identity_listings_degrade(&e) {
|
||||
Some(empty) => {
|
||||
log::debug!(
|
||||
"{LOG_PREFIX} graphql_identity_listings deserialization failed -> empty: {e}"
|
||||
);
|
||||
to_value(empty)
|
||||
}
|
||||
None => Err(map_err(e)),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn graphql_identity_listings_degrade(e: &tinyplace::Error) -> Option<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(¶ms, "id")?.to_string();
|
||||
log::debug!("{LOG_PREFIX} graphql_identity_listing id={id}");
|
||||
let query_params = match graphql_params_object(¶ms, "graphql_identity_listing")? {
|
||||
None => None,
|
||||
Some(obj) => Some(
|
||||
tinyplace::api::graphql::IdentityListingDetailGraphQLParams {
|
||||
bid_limit: graphql_opt_i64(obj, "graphql_identity_listing", "bidLimit")?,
|
||||
bid_offset: graphql_opt_i64(obj, "graphql_identity_listing", "bidOffset")?,
|
||||
history_limit: graphql_opt_i64(
|
||||
obj,
|
||||
"graphql_identity_listing",
|
||||
"historyLimit",
|
||||
)?,
|
||||
history_offset: graphql_opt_i64(
|
||||
obj,
|
||||
"graphql_identity_listing",
|
||||
"historyOffset",
|
||||
)?,
|
||||
},
|
||||
),
|
||||
};
|
||||
let client = global_state().client().await?;
|
||||
let result = client
|
||||
.graphql
|
||||
.identity_listing(&id, query_params.as_ref())
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
to_value(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn handle_tinyplace_graphql_identity_bids(
|
||||
params: Map<String, Value>,
|
||||
) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let listing_id = req_str(¶ms, "listingId")?.to_string();
|
||||
log::debug!("{LOG_PREFIX} graphql_identity_bids listing_id={listing_id}");
|
||||
let query_params = parse_pagination_params(¶ms, "graphql_identity_bids")?;
|
||||
let client = global_state().client().await?;
|
||||
let result = client
|
||||
.graphql
|
||||
.identity_bids(&listing_id, query_params.as_ref())
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
to_value(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn handle_tinyplace_graphql_identity_offers(
|
||||
params: Map<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(¶ms, "graphql_identity_offers")? {
|
||||
None => None,
|
||||
Some(obj) => Some(tinyplace::api::graphql::IdentityOfferGraphQLParams {
|
||||
agent: graphql_opt_string(obj, "graphql_identity_offers", "agent")?,
|
||||
buyer: graphql_opt_string(obj, "graphql_identity_offers", "buyer")?,
|
||||
name: graphql_opt_string(obj, "graphql_identity_offers", "name")?,
|
||||
status: graphql_opt_string(obj, "graphql_identity_offers", "status")?,
|
||||
limit: graphql_opt_i64(obj, "graphql_identity_offers", "limit")?,
|
||||
offset: graphql_opt_i64(obj, "graphql_identity_offers", "offset")?,
|
||||
}),
|
||||
};
|
||||
let client = global_state().client().await?;
|
||||
let result = client
|
||||
.graphql
|
||||
.identity_offers(query_params.as_ref())
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
to_value(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn handle_tinyplace_graphql_identity_sales(
|
||||
params: Map<String, Value>,
|
||||
) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let name = req_str(¶ms, "name")?.to_string();
|
||||
log::debug!("{LOG_PREFIX} graphql_identity_sales name={name}");
|
||||
let query_params = parse_pagination_params(¶ms, "graphql_identity_sales")?;
|
||||
let client = global_state().client().await?;
|
||||
let result = client
|
||||
.graphql
|
||||
.identity_sales(&name, query_params.as_ref())
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
to_value(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn handle_tinyplace_graphql_profile(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let username = req_str(¶ms, "username")?.to_string();
|
||||
@@ -5682,6 +6005,7 @@ mod tests {
|
||||
description: None,
|
||||
username: username.map(str::to_string),
|
||||
crypto_id: format!("crypto-{agent_id}"),
|
||||
actor_type: None,
|
||||
public_key: None,
|
||||
url: None,
|
||||
endpoint: None,
|
||||
@@ -5698,6 +6022,7 @@ mod tests {
|
||||
signature: None,
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
viewer_is_following: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user