Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// rules/ (app owns lifecycle only; no display code here)
import { World } from "./lib/ecs-js/index.js"; // ECS World
import { configureWorld } from "../app/rules/scheduler.js";
import { playerEntity } from "./rules/utils/queries.js";
import { playerEntity, findNearestValidTileAround } from "./rules/utils/queries.js";

// display/ camera + director utilities (pure display resources)
import { createCamera, updateCamera, applyCamera } from "./display/camera/controller.js";
Expand Down Expand Up @@ -222,9 +222,13 @@ if (!playerEntity(world)) {
const pe = playerEntity(world);
if (pe) {
const ppos = world.get(pe.id, Position);
const spawnTile = findNearestValidTileAround(world, ppos, {
maxDistance: 1,
exclude: [{ x: ppos.x, y: ppos.y }],
});
const petId = world.create();
world.add(petId, Pet);
world.add(petId, Position, { x: ppos.x + 1, y: ppos.y });
world.add(petId, Position, spawnTile || { x: ppos.x, y: ppos.y });
world.add(petId, NamedIdentity, { name: "Kitty", identity: "kitty" });
world.add(petId, Faction, { key: "pet" });
world.add(petId, Owner, { ownerId: pe.id });
Expand Down
7 changes: 6 additions & 1 deletion src/rules/systems/petFollowSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Inventory } from "../components/Inventory.js";
import { ItemInfo } from "../components/ItemInfo.js";
import { NamedIdentity } from "../components/NamedIdentity.js";
import { MoveIntent } from "../components/Intents/MoveIntent.js";
import { findNearestValidTileAround } from "../utils/queries.js";

const FOLLOW_DISTANCE = 2; // start following when farther than this
const TELEPORT_DISTANCE = 10; // teleport if farther than this
Expand Down Expand Up @@ -46,7 +47,11 @@ export function petFollowSystem(world) {

// Teleport if too far (floor transition, etc.)
if (dist > TELEPORT_DISTANCE) {
world.set(id, Position, { x: playerPos.x + 1, y: playerPos.y });
const teleportTile = findNearestValidTileAround(world, playerPos, {
maxDistance: 1,
exclude: [{ x: playerPos.x, y: playerPos.y }],
});
if (teleportTile) world.set(id, Position, teleportTile);
continue;
}

Expand Down
50 changes: 50 additions & 0 deletions src/rules/utils/queries.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Position } from "../components/Position.js";
import { ItemInfo } from "../components/ItemInfo.js";
import { Player } from "../components/Player.js";
import { Collider } from "../components/Collider.js";
import { Vitality } from "../components/Vitality.js";
import { isWalkable } from "../environment/dungeon/tileMap.js";

export function itemsAt(world, x, y) {
const ids = [];
Expand All @@ -21,3 +24,50 @@ export function playerEntity(world) {
}
return null;
}

/**
* Find the nearest valid tile around a source point.
* Valid means walkable terrain and no solid/living occupant.
*
* @param {import('../../lib/ecs-js/index.js').World} world
* @param {{x:number, y:number}} source
* @param {{
* maxDistance?: number,
* exclude?: Array<{x:number, y:number}>
* }} [opts]
*/
export function findNearestValidTileAround(world, source, opts = {}) {
const maxDistance = Math.max(0, opts.maxDistance ?? 1);
const excluded = new Set((opts.exclude || []).map((p) => `${p.x},${p.y}`));
const blocked = new Set();

for (const [id, pos] of world.query(Position)) {
const col = world.get(id, Collider);
if (col?.solid) blocked.add(`${pos.x},${pos.y}`);

const vit = world.get(id, Vitality);
if (vit && (vit.hp ?? 0) > 0) blocked.add(`${pos.x},${pos.y}`);
}

const candidates = [];
for (let dy = -maxDistance; dy <= maxDistance; dy++) {
for (let dx = -maxDistance; dx <= maxDistance; dx++) {
const x = source.x + dx;
const y = source.y + dy;
const dist = Math.abs(dx) + Math.abs(dy);
Comment on lines +55 to +57
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxDistance is treated as a coordinate offset (square area), but the code computes Manhattan dist and will still consider/return tiles where |dx| + |dy| > maxDistance (e.g. dx=2,dy=2 when maxDistance=2). If maxDistance is intended to be a Manhattan radius, filter candidates to dist <= maxDistance (or iterate by distance rings) so the function never returns a tile beyond the requested distance.

Suggested change
const x = source.x + dx;
const y = source.y + dy;
const dist = Math.abs(dx) + Math.abs(dy);
const dist = Math.abs(dx) + Math.abs(dy);
if (dist > maxDistance) continue;
const x = source.x + dx;
const y = source.y + dy;

Copilot uses AI. Check for mistakes.
candidates.push({ x, y, dist, axisBias: (dx === 0 || dy === 0) ? 0 : 1 });
}
Comment on lines +53 to +59
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The candidate generation includes the source tile (dx=0, dy=0), so callers that forget to pass exclude: [source] can get back the same tile even though the docstring says "around a source point". Consider skipping dx=0/dy=0 by default (or clarifying in the JSDoc that the source tile is included unless excluded).

Copilot uses AI. Check for mistakes.
}

candidates.sort((a, b) => a.dist - b.dist || a.axisBias - b.axisBias);

for (const p of candidates) {
const key = `${p.x},${p.y}`;
if (excluded.has(key)) continue;
if (!isWalkable(p.x, p.y)) continue;
if (blocked.has(key)) continue;
return { x: p.x, y: p.y };
}

return null;
}
63 changes: 63 additions & 0 deletions tests/petPlacement.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { assert, assertEquals } from "jsr:@std/assert";
import { World } from '../src/lib/ecs-js/index.js';
import { findNearestValidTileAround } from '../src/rules/utils/queries.js';
import { petFollowSystem } from '../src/rules/systems/petFollowSystem.js';
import { loadChunk, clearAll } from '../src/rules/environment/dungeon/tileMap.js';
import { CHUNK_SIZE, TILE_FLOOR, TILE_WALL } from '../src/rules/environment/dungeon/constants.js';
import { Position } from '../src/rules/components/Position.js';
import { Player } from '../src/rules/components/Player.js';
import { Pet } from '../src/rules/components/Pet.js';
import { Collider } from '../src/rules/components/Collider.js';

function fillChunk(fill = TILE_FLOOR) {
const tiles = new Uint8Array(CHUNK_SIZE * CHUNK_SIZE);
tiles.fill(fill);
return tiles;
}

Deno.test('findNearestValidTileAround finds valid spawn near walls', () => {
clearAll();
const tiles = fillChunk(TILE_WALL);
tiles[1 * CHUNK_SIZE + 1] = TILE_FLOOR; // source tile
tiles[2 * CHUNK_SIZE + 1] = TILE_FLOOR; // only valid adjacent tile (1,2)
loadChunk(0, 0, tiles);

const world = new World({ seed: 1 });
const tile = findNearestValidTileAround(world, { x: 1, y: 1 }, {
maxDistance: 1,
exclude: [{ x: 1, y: 1 }],
});

assert(tile, 'expected a valid adjacent tile');
assertEquals(tile, { x: 1, y: 2 });
clearAll();
});

Deno.test('pet teleport keeps current position when nearby tiles are blocked', () => {
clearAll();
Comment on lines +18 to +37
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repository tests consistently use double quotes for Deno.test("...") names; this file uses single quotes. For consistency with the rest of the test suite, switch these test names to double quotes.

Copilot uses AI. Check for mistakes.
loadChunk(0, 0, fillChunk(TILE_FLOOR));

const world = new World({ seed: 2 });
const playerId = world.create();
world.add(playerId, Player);
world.add(playerId, Position, { x: 5, y: 5 });

const petId = world.create();
world.add(petId, Pet);
world.add(petId, Position, { x: 20, y: 20 });

for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const blocker = world.create();
world.add(blocker, Position, { x: 5 + dx, y: 5 + dy });
world.add(blocker, Collider, { solid: true, blocksSight: false });
}
}

petFollowSystem(world);

const petPos = world.get(petId, Position);
assertEquals(petPos, { x: 20, y: 20 });
clearAll();
});
Loading