From a1928f1da79eff621ecfd8be85596ad1280204bd Mon Sep 17 00:00:00 2001 From: markb5 Date: Tue, 12 May 2026 19:55:40 -0400 Subject: [PATCH 01/18] instances init --- src/engine/GameMap.ts | 8 ++ src/engine/InstanceController.ts | 161 +++++++++++++++++++++++++++++++ src/engine/World.ts | 23 +++-- src/engine/zone/InstanceZone.ts | 13 +++ src/engine/zone/ZoneMap.ts | 9 ++ 5 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 src/engine/InstanceController.ts create mode 100644 src/engine/zone/InstanceZone.ts diff --git a/src/engine/GameMap.ts b/src/engine/GameMap.ts index 5fc561aa6..c9f6f33f9 100644 --- a/src/engine/GameMap.ts +++ b/src/engine/GameMap.ts @@ -95,6 +95,14 @@ export default class GameMap { return this.zonemap.zoneByIndex(zoneIndex); } + addZone(zone: Zone): Zone { + return this.zonemap.addZone(zone); + } + + removeZone(index: number): boolean { + return this.zonemap.removeZone(index); + } + getZoneGrid(level: number): ZoneGrid { return this.zonemap.grid(level); } diff --git a/src/engine/InstanceController.ts b/src/engine/InstanceController.ts new file mode 100644 index 000000000..46b28d3eb --- /dev/null +++ b/src/engine/InstanceController.ts @@ -0,0 +1,161 @@ +import { CoordGrid } from '#/engine/CoordGrid.js'; +import { isZoneAllocated } from '#/engine/GameMap.js'; +import World from '#/engine/World.js'; +import InstanceZone from '#/engine/zone/InstanceZone.js'; + +type InstanceRecord = { + sw: CoordGrid; + floors: number; + zonesEast: number; + zonesNorth: number; +}; + +export default class InstanceController { + static readonly FIRST_INSTANCE_SW_MAPSQUARE: number = 25857; // m101_1 + + static readonly INSTANCES_PER_ROW: number = 32; + static readonly INSTANCE_ROWS: number = 64; + static readonly TOTAL_INSTANCES: number = InstanceController.INSTANCES_PER_ROW * InstanceController.INSTANCE_ROWS; + + static readonly INSTANCE_SIZE_TILES: number = 128; + static readonly INSTANCE_GAP_TILES: number = 64; + static readonly INSTANCE_SW_STRIDE_TILES: number = InstanceController.INSTANCE_SIZE_TILES + InstanceController.INSTANCE_GAP_TILES; + + nextInstancePointer: number = 0; + readonly instances: InstanceRecord[] = []; + + // --- + // Public methods + // --- + + createInstance(floors: number, zonesEast: number, zonesNorth: number): CoordGrid { + // Reclaim all stale instance slots, then find the next available slot pointer. + this.clearStaleInstances(); + this.findNextSlot(); + + // Instances are laid out in a fixed grid of 128x128 tiles, with a 64-tile buffer between them. + const slotX: number = this.nextInstancePointer % InstanceController.INSTANCES_PER_ROW; + const slotZ: number = Math.trunc(this.nextInstancePointer / InstanceController.INSTANCES_PER_ROW); + const baseX: number = 101 + slotX * 3; + const baseZ: number = 1 + slotZ * 3; + const sw: CoordGrid = { level: 0, x: baseX << 3, z: baseZ << 3 }; + + // Keep the instance metadata so we can later detect when it becomes empty again. + this.instances.push({ + sw, + floors, + zonesEast, + zonesNorth + }); + + // Materialize the instance zones directly into the game's zone map. + for (let level: number = 0; level < floors; level++) { + for (let east: number = 0; east < zonesEast; east++) { + for (let north: number = 0; north < zonesNorth; north++) { + const x: number = (baseX + east) << 3; + const z: number = (baseZ + north) << 3; + const zoneIndex: number = CoordGrid.packCoord(level, x, z); + const zone: InstanceZone = new InstanceZone(zoneIndex, { level, x, z }, 0); + World.gameMap.addZone(zone); + } + } + } + + this.incrementSlotPointer(); + return sw; + } + + isInstanceEmpty(instance: InstanceRecord): boolean { + // Any player in any zone covered by this instance means the instance is still in use. + for (let level: number = 0; level < instance.floors; level++) { + for (let east: number = 0; east < instance.zonesEast; east++) { + for (let north: number = 0; north < instance.zonesNorth; north++) { + const x: number = instance.sw.x + (east << 3); + const z: number = instance.sw.z + (north << 3); + const zone = World.gameMap.getZone(x, z, level); + for (const player of zone.getAllPlayersUnsafe()) { + return false; + } + } + } + } + + return true; + } + + // --- + // Private methods + // --- + + // Remove all stale instances before selecting the next slot. + private clearStaleInstances(): void { + // Walk backward so removals do not disturb the remaining indices. + for (let index: number = this.instances.length - 1; index >= 0; index--) { + const instance: InstanceRecord = this.instances[index]; + if (!this.isInstanceEmpty(instance)) { + continue; + } + + this.deleteInstance(instance); + this.instances.splice(index, 1); + } + } + + // Find the next pointer that is both unoccupied by records and unallocated on the map. + private findNextSlot(): void { + // Track occupied slots from active instance records to skip them during probing. + const occupiedSlots: Set = new Set(); + for (const instance of this.instances) { + occupiedSlots.add(this.getSlotPointer(instance.sw)); + } + + for (let attempts: number = 0; attempts < InstanceController.TOTAL_INSTANCES; attempts++) { + const pointer: number = (this.nextInstancePointer + attempts) % InstanceController.TOTAL_INSTANCES; + if (occupiedSlots.has(pointer)) { + continue; + } + + const slotX: number = pointer % InstanceController.INSTANCES_PER_ROW; + const slotZ: number = Math.trunc(pointer / InstanceController.INSTANCES_PER_ROW); + const swX: number = (101 + slotX * 3) << 3; + const swZ: number = (1 + slotZ * 3) << 3; + + if (isZoneAllocated(0, swX, swZ)) { + continue; + } + + this.nextInstancePointer = pointer; + return; + } + + throw new Error('[InstanceController] No available instance slots found.'); + } + + // Move pointer forward by one slot and wrap around at capacity. + private incrementSlotPointer(): void { + this.nextInstancePointer = (this.nextInstancePointer + 1) % InstanceController.TOTAL_INSTANCES; + } + + // Convert a recorded southwest coordinate back into its slot index. + private getSlotPointer(sw: CoordGrid): number { + const zoneX: number = sw.x >> 3; + const zoneZ: number = sw.z >> 3; + const slotX: number = Math.trunc((zoneX - 101) / 3); + const slotZ: number = Math.trunc((zoneZ - 1) / 3); + return slotZ * InstanceController.INSTANCES_PER_ROW + slotX; + } + + // Remove every zone belonging to this instance footprint from the world map. + private deleteInstance(instance: InstanceRecord): void { + // Remove each zone in the instance footprint from the live zone map. + for (let level: number = 0; level < instance.floors; level++) { + for (let east: number = 0; east < instance.zonesEast; east++) { + for (let north: number = 0; north < instance.zonesNorth; north++) { + const x: number = instance.sw.x + (east << 3); + const z: number = instance.sw.z + (north << 3); + World.gameMap.removeZone(CoordGrid.packCoord(level, x, z)); + } + } + } + } +} diff --git a/src/engine/World.ts b/src/engine/World.ts index 1a90cfb99..f61e9f136 100644 --- a/src/engine/World.ts +++ b/src/engine/World.ts @@ -52,6 +52,7 @@ import { PlayerStat } from '#/engine/entity/PlayerStat.js'; import { SessionLog } from '#/engine/entity/tracking/SessionLog.js'; import { WealthTransactionEvent, WealthEvent } from '#/engine/entity/tracking/WealthEvent.js'; import GameMap, { changeLocCollision, changeNpcCollision, changePlayerCollision } from '#/engine/GameMap.js'; +import InstanceController from '#/engine/InstanceController.js'; import { Inventory } from '#/engine/Inventory.js'; import ScriptPointer from '#/engine/script/ScriptPointer.js'; import ScriptProvider from '#/engine/script/ScriptProvider.js'; @@ -135,6 +136,9 @@ class World { // the game/zones map readonly gameMap: GameMap = new GameMap(Environment.NODE_MEMBERS); + // instance management + readonly instances: InstanceController = new InstanceController(); + // shared inventories (shops) readonly invs: Set = new Set(); @@ -908,11 +912,13 @@ class World { player.client.state = 1; - player.client.send(Uint8Array.from([ - 2, - Math.min(player.staffModLevel, 2), - 1 // mouse tracking can only be enabled on login - ])); + player.client.send( + Uint8Array.from([ + 2, + Math.min(player.staffModLevel, 2), + 1 // mouse tracking can only be enabled on login + ]) + ); const remote = player.client.remoteAddress; if (remote.indexOf('.') !== -1) { @@ -1877,10 +1883,7 @@ class World { } else if (reply === 10) { // hop timer const { remaining } = msg; - client.send(Uint8Array.from([ - 21, - Math.min(255, remaining! / 1000) - ])); + client.send(Uint8Array.from([21, Math.min(255, remaining! / 1000)])); client.close(); return; } @@ -2145,7 +2148,7 @@ class World { client.send(seed.data); } else if (client.opcode === 16 || client.opcode === 18) { let rev = World.loginBuf.g1(); - if (rev === 0xFF) { + if (rev === 0xff) { rev = World.loginBuf.g2(); } if (rev !== Environment.ENGINE_REVISION) { diff --git a/src/engine/zone/InstanceZone.ts b/src/engine/zone/InstanceZone.ts new file mode 100644 index 000000000..c93eb2f2a --- /dev/null +++ b/src/engine/zone/InstanceZone.ts @@ -0,0 +1,13 @@ +import { CoordGrid } from '#/engine/CoordGrid.js'; +import Zone from '#/engine/zone/Zone.js'; + +export default class InstanceZone extends Zone { + readonly source: CoordGrid; + readonly rotation: 0 | 1 | 2 | 3; + + constructor(index: number, source: CoordGrid, rotation: 0 | 1 | 2 | 3) { + super(index); + this.source = { ...source }; + this.rotation = rotation; + } +} diff --git a/src/engine/zone/ZoneMap.ts b/src/engine/zone/ZoneMap.ts index 17801b78b..b4eb8b481 100644 --- a/src/engine/zone/ZoneMap.ts +++ b/src/engine/zone/ZoneMap.ts @@ -41,6 +41,15 @@ export default class ZoneMap { return zone; } + addZone(zone: Zone): Zone { + this.zones.set(zone.index, zone); + return zone; + } + + removeZone(index: number): boolean { + return this.zones.delete(index); + } + grid(level: number): ZoneGrid { let grid: ZoneGrid | undefined = this.grids.get(level); if (typeof grid == 'undefined') { From be03f573e09045eb5278ebb9ca5fc8c5c8c39b6d Mon Sep 17 00:00:00 2001 From: Pazaz Date: Fri, 3 Apr 2026 05:20:14 -0400 Subject: [PATCH 02/18] feat: TS routefinder This should help Bun-Windows users with their startup/cycle times. WASM allocations are performing terribly. I tried to keep this API-compatible so that later we can let users pick between Rust or TS based on their performance concerns. Soon the same treatment for rsbuf. Will need to do some tests to make sure nothing is off by a hair. Funny history here... Kotlin -> JS/TS -> Rust -> TS (cherry picked from commit 55eccdcd2fda39f8b8df564c1c282b3bd8634c39) --- src/engine/GameMap.ts | 3 +- src/engine/entity/Loc.ts | 2 +- src/engine/entity/Npc.ts | 5 +- src/engine/entity/PathingEntity.ts | 35 +- src/engine/entity/Player.ts | 38 +- src/engine/routefinder/CollisionEngine.ts | 236 +++++++ src/engine/routefinder/CollisionStrategy.ts | 45 ++ src/engine/routefinder/Line.ts | 33 + src/engine/routefinder/LinePathFinder.ts | 155 +++++ src/engine/routefinder/LineValidator.ts | 145 ++++ src/engine/routefinder/NaivePathFinder.ts | 147 ++++ src/engine/routefinder/PackedCoord.ts | 25 + src/engine/routefinder/PathFinder.ts | 625 ++++++++++++++++++ src/engine/routefinder/ReachStrategy.ts | 324 +++++++++ src/engine/routefinder/RectangleBoundary.ts | 70 ++ src/engine/routefinder/Rotation.ts | 10 + src/engine/routefinder/StepValidator.ts | 239 +++++++ src/engine/routefinder/flags.ts | 169 +++++ src/engine/routefinder/index.ts | 134 ++++ src/engine/script/ScriptValidators.ts | 2 +- src/engine/script/handlers/LocOps.ts | 2 +- src/engine/script/handlers/ServerOps.ts | 2 +- .../game/client/handler/ClientCheatHandler.ts | 5 +- 23 files changed, 2405 insertions(+), 46 deletions(-) create mode 100644 src/engine/routefinder/CollisionEngine.ts create mode 100644 src/engine/routefinder/CollisionStrategy.ts create mode 100644 src/engine/routefinder/Line.ts create mode 100644 src/engine/routefinder/LinePathFinder.ts create mode 100644 src/engine/routefinder/LineValidator.ts create mode 100644 src/engine/routefinder/NaivePathFinder.ts create mode 100644 src/engine/routefinder/PackedCoord.ts create mode 100644 src/engine/routefinder/PathFinder.ts create mode 100644 src/engine/routefinder/ReachStrategy.ts create mode 100644 src/engine/routefinder/RectangleBoundary.ts create mode 100644 src/engine/routefinder/Rotation.ts create mode 100644 src/engine/routefinder/StepValidator.ts create mode 100644 src/engine/routefinder/flags.ts create mode 100644 src/engine/routefinder/index.ts diff --git a/src/engine/GameMap.ts b/src/engine/GameMap.ts index c9f6f33f9..776e1c7a0 100644 --- a/src/engine/GameMap.ts +++ b/src/engine/GameMap.ts @@ -1,7 +1,6 @@ import fs from 'fs'; -import { CollisionFlag, CollisionType, LocAngle, LocLayer } from '@2004scape/rsmod-pathfinder'; -import * as rsmod from '@2004scape/rsmod-pathfinder'; +import rsmod, { CollisionFlag, CollisionType, LocAngle, LocLayer } from '#/engine/routefinder/index.js'; import LocType from '#/cache/config/LocType.js'; import NpcType from '#/cache/config/NpcType.js'; diff --git a/src/engine/entity/Loc.ts b/src/engine/entity/Loc.ts index 6e2f0cb56..0e32daef2 100644 --- a/src/engine/entity/Loc.ts +++ b/src/engine/entity/Loc.ts @@ -1,4 +1,4 @@ -import { locShapeLayer } from '@2004scape/rsmod-pathfinder'; +import { locShapeLayer } from '#/engine/routefinder/index.js'; import { EntityLifeCycle } from '#/engine/entity/EntityLifeCycle.js'; import NonPathingEntity from '#/engine/entity/NonPathingEntity.js'; diff --git a/src/engine/entity/Npc.ts b/src/engine/entity/Npc.ts index 9b6b69270..5378d33dd 100644 --- a/src/engine/entity/Npc.ts +++ b/src/engine/entity/Npc.ts @@ -1,6 +1,6 @@ import { NpcInfoProt } from '@2004scape/rsbuf'; import * as rsbuf from '@2004scape/rsbuf'; -import { CollisionFlag, CollisionType } from '@2004scape/rsmod-pathfinder'; +import { CollisionFlag, CollisionType } from '#/engine/routefinder/index.js'; import HuntType from '#/cache/config/HuntType.js'; import NpcType from '#/cache/config/NpcType.js'; @@ -433,8 +433,9 @@ export default class Npc extends PathingEntity { this.uid = (type << 16) | this.nid; this.resetOnRevert = reset; + const npcType = NpcType.get(type); + if (reset) { - const npcType = NpcType.get(type); for (let index = 0; index < npcType.stats.length; index++) { const level = npcType.stats[index]; this.levels[index] = Math.max(level - (this.baseLevels[index] - this.levels[index]), 0); diff --git a/src/engine/entity/PathingEntity.ts b/src/engine/entity/PathingEntity.ts index 391ac4524..4c2d87d53 100644 --- a/src/engine/entity/PathingEntity.ts +++ b/src/engine/entity/PathingEntity.ts @@ -1,4 +1,4 @@ -import { CollisionFlag, CollisionType } from '@2004scape/rsmod-pathfinder'; +import { CollisionFlag, CollisionType } from '#/engine/routefinder/index.js'; import LocType from '#/cache/config/LocType.js'; import { CoordGrid } from '#/engine/CoordGrid.js'; @@ -559,22 +559,25 @@ export default abstract class PathingEntity extends Entity { } protected getCollisionStrategy(): CollisionType | null { - if (this.moveRestrict === MoveRestrict.NORMAL) { - return CollisionType.NORMAL; - } else if (this.moveRestrict === MoveRestrict.BLOCKED) { - return CollisionType.BLOCKED; - } else if (this.moveRestrict === MoveRestrict.BLOCKED_NORMAL) { - return CollisionType.LINE_OF_SIGHT; - } else if (this.moveRestrict === MoveRestrict.INDOORS) { - return CollisionType.INDOORS; - } else if (this.moveRestrict === MoveRestrict.OUTDOORS) { - return CollisionType.OUTDOORS; - } else if (this.moveRestrict === MoveRestrict.NOMOVE) { - return null; - } else if (this.moveRestrict === MoveRestrict.PASSTHRU) { - return CollisionType.NORMAL; + if (this instanceof Npc) { + const type: NpcType = NpcType.get(this.type); + if (type.moverestrict === MoveRestrict.NORMAL) { + return CollisionType.NORMAL; + } else if (type.moverestrict === MoveRestrict.BLOCKED) { + return CollisionType.BLOCKED; + } else if (type.moverestrict === MoveRestrict.BLOCKED_NORMAL) { + return CollisionType.LINE_OF_SIGHT; + } else if (type.moverestrict === MoveRestrict.INDOORS) { + return CollisionType.INDOORS; + } else if (type.moverestrict === MoveRestrict.OUTDOORS) { + return CollisionType.OUTDOORS; + } else if (type.moverestrict === MoveRestrict.NOMOVE) { + return null; + } else if (type.moverestrict === MoveRestrict.PASSTHRU) { + return CollisionType.NORMAL; + } } - return null; + return CollisionType.NORMAL; } protected resetPathingEntity(): void { diff --git a/src/engine/entity/Player.ts b/src/engine/entity/Player.ts index f84db52f6..e0256fed0 100644 --- a/src/engine/entity/Player.ts +++ b/src/engine/entity/Player.ts @@ -1,7 +1,7 @@ import 'dotenv/config'; import { PlayerInfoProt, Visibility } from '@2004scape/rsbuf'; -import { CollisionType, CollisionFlag } from '@2004scape/rsmod-pathfinder'; +import { CollisionFlag, CollisionType } from '#/engine/routefinder/index.js'; import Component from '#/cache/config/Component.js'; import FontType from '#/cache/config/FontType.js'; @@ -415,9 +415,17 @@ export default class Player extends PathingEntity { constructor(username: string, username37: bigint, hash64: bigint) { super( - 0, 3094, 3106, // tutorial island - 1, 1, - EntityLifeCycle.FOREVER, MoveRestrict.NORMAL, BlockWalk.NPC, MoveStrategy.SMART, PlayerInfoProt.FACE_COORD, PlayerInfoProt.FACE_ENTITY + 0, + 3094, + 3106, // tutorial island + 1, + 1, + EntityLifeCycle.FOREVER, + MoveRestrict.NORMAL, + BlockWalk.NPC, + MoveStrategy.SMART, + PlayerInfoProt.FACE_COORD, + PlayerInfoProt.FACE_ENTITY ); this.username = username; @@ -1324,8 +1332,8 @@ export default class Player extends PathingEntity { const stream = Packet.alloc(0); stream.p1(this.gender); - stream.p1(0xFF); // prayer icon? - stream.p1(0xFF); // skull icon? + stream.p1(0xff); // prayer icon? + stream.p1(0xff); // skull icon? const skippedSlots = []; @@ -1356,7 +1364,7 @@ export default class Player extends PathingEntity { } for (let slot = 0; slot < 12; slot++) { - if(this.npcId != -1) { + if (this.npcId != -1) { stream.p2(-1); stream.p2(this.npcId); break; @@ -1759,7 +1767,7 @@ export default class Player extends PathingEntity { const { basevar, startbit, endbit } = varbit; const mask = Packet.bitmask[endbit - startbit + 1]; - return this.vars[basevar] >> startbit & mask; + return (this.vars[basevar] >> startbit) & mask; } setVarBit(id: number, value: number) { @@ -1776,7 +1784,7 @@ export default class Player extends PathingEntity { } mask <<= startbit; - this.setVar(basevar, mask & value << startbit | this.vars[basevar] & ~mask); + this.setVar(basevar, (mask & (value << startbit)) | (this.vars[basevar] & ~mask)); } private writeVarp(id: number, value: number): void { @@ -2245,19 +2253,11 @@ export default class Player extends PathingEntity { const daysSinceLogin: number = (Number(lastDate) / (1000 * 60 * 60 * 24)) | 0; const daysSincePasswordChanged = 201; // hide :) const daysSinceRecoveriesChanged = 201; // hide :) - const currentDay: number = Number(nextDate) / (1000 * 60 * 60 * 24) | 0; + const currentDay: number = (Number(nextDate) / (1000 * 60 * 60 * 24)) | 0; const unreadMessageCount = 0; const membersCreditDays = 365; - this.write(new LastLoginInfo( - lastIp, - currentDay, - daysSinceLogin, - daysSincePasswordChanged, - daysSinceRecoveriesChanged, - unreadMessageCount, - membersCreditDays - )); + this.write(new LastLoginInfo(lastIp, currentDay, daysSinceLogin, daysSincePasswordChanged, daysSinceRecoveriesChanged, unreadMessageCount, membersCreditDays)); this.lastLoginTime = nextDate; } diff --git a/src/engine/routefinder/CollisionEngine.ts b/src/engine/routefinder/CollisionEngine.ts new file mode 100644 index 000000000..a7a7f1123 --- /dev/null +++ b/src/engine/routefinder/CollisionEngine.ts @@ -0,0 +1,236 @@ +import { CollisionFlag, LocAngle, LocShape } from '#/engine/routefinder/flags.js'; + +export default class CollisionEngine { + private static readonly ZONE_SIZE = 8; + private static readonly ZONE_TILE_COUNT = CollisionEngine.ZONE_SIZE * CollisionEngine.ZONE_SIZE; + + private readonly zones = new Map(); + + static zoneIndex(x: number, z: number, y: number): number { + return ((x >> 3) & 0x7ff) | (((z >> 3) & 0x7ff) << 11) | ((y & 0x3) << 22); + } + + private static tileIndex(x: number, z: number): number { + return (x & 0x7) | ((z & 0x7) << 3); + } + + private allocateIfAbsentByIndex(zoneIndex: number): Uint32Array { + let zone = this.zones.get(zoneIndex); + if (!zone) { + zone = new Uint32Array(CollisionEngine.ZONE_TILE_COUNT); + this.zones.set(zoneIndex, zone); + } + return zone; + } + + allocateIfAbsent(x: number, z: number, y: number): void { + this.allocateIfAbsentByIndex(CollisionEngine.zoneIndex(x, z, y)); + } + + deallocateIfPresent(x: number, z: number, y: number): void { + this.zones.delete(CollisionEngine.zoneIndex(x, z, y)); + } + + isZoneAllocated(x: number, z: number, y: number): boolean { + return this.zones.has(CollisionEngine.zoneIndex(x, z, y)); + } + + get(x: number, z: number, y: number): number { + const zone = this.zones.get(CollisionEngine.zoneIndex(x, z, y)); + return zone ? zone[CollisionEngine.tileIndex(x, z)] : CollisionFlag.NULL; + } + + isFlagged(x: number, z: number, y: number, masks: number): boolean { + const zone = this.zones.get(CollisionEngine.zoneIndex(x, z, y)); + return !!zone && (zone[CollisionEngine.tileIndex(x, z)] & masks) !== CollisionFlag.OPEN; + } + + set(x: number, z: number, y: number, mask: number): void { + const zone = this.allocateIfAbsentByIndex(CollisionEngine.zoneIndex(x, z, y)); + zone[CollisionEngine.tileIndex(x, z)] = mask >>> 0; + } + + add(x: number, z: number, y: number, mask: number): void { + const zone = this.allocateIfAbsentByIndex(CollisionEngine.zoneIndex(x, z, y)); + const tile = CollisionEngine.tileIndex(x, z); + zone[tile] = (zone[tile] | mask) >>> 0; + } + + remove(x: number, z: number, y: number, mask: number): void { + const zone = this.allocateIfAbsentByIndex(CollisionEngine.zoneIndex(x, z, y)); + const tile = CollisionEngine.tileIndex(x, z); + zone[tile] = (zone[tile] & ~mask) >>> 0; + } + + changeFloor(x: number, z: number, y: number, add: boolean): void { + if (add) { + this.add(x, z, y, CollisionFlag.FLOOR); + } else { + this.remove(x, z, y, CollisionFlag.FLOOR); + } + } + + changeRoof(x: number, z: number, y: number, add: boolean): void { + if (add) { + this.add(x, z, y, CollisionFlag.ROOF); + } else { + this.remove(x, z, y, CollisionFlag.ROOF); + } + } + + changeNpc(x: number, z: number, y: number, size: number, add: boolean): void { + this.changeSquare(x, z, y, size, CollisionFlag.NPC, add); + } + + changePlayer(x: number, z: number, y: number, size: number, add: boolean): void { + this.changeSquare(x, z, y, size, CollisionFlag.PLAYER, add); + } + + changeLoc(x: number, z: number, y: number, width: number, length: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + let mask = CollisionFlag.LOC; + if (blockrange) { + mask |= CollisionFlag.LOC_PROJ_BLOCKER; + } + if (breakroutefinding) { + mask |= CollisionFlag.LOC_ROUTE_BLOCKER; + } + + const area = width * length; + for (let index = 0; index < area; index++) { + const dx = x + (index % width); + const dz = z + ((index / width) | 0); + if (add) { + this.add(dx, dz, y, mask); + } else { + this.remove(dx, dz, y, mask); + } + } + } + + changeWall(x: number, z: number, y: number, angle: number, shape: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + if (shape === LocShape.WALL_STRAIGHT) { + this.changeWallStraight(x, z, y, angle, blockrange, breakroutefinding, add); + } else if (shape === LocShape.WALL_DIAGONAL_CORNER || shape === LocShape.WALL_SQUARE_CORNER) { + this.changeWallCorner(x, z, y, angle, blockrange, breakroutefinding, add); + } else if (shape === LocShape.WALL_L) { + this.changeWallL(x, z, y, angle, blockrange, breakroutefinding, add); + } + } + + zoneCount(): number { + return this.zones.size; + } + + private changeSquare(x: number, z: number, y: number, size: number, mask: number, add: boolean): void { + const area = size * size; + for (let index = 0; index < area; index++) { + const dx = x + (index % size); + const dz = z + ((index / size) | 0); + if (add) { + this.add(dx, dz, y, mask); + } else { + this.remove(dx, dz, y, mask); + } + } + } + + private changeWallStraight(x: number, z: number, y: number, angle: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + const west = this.wallMask(CollisionFlag.WALL_WEST, CollisionFlag.WALL_WEST_PROJ_BLOCKER, CollisionFlag.WALL_WEST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const east = this.wallMask(CollisionFlag.WALL_EAST, CollisionFlag.WALL_EAST_PROJ_BLOCKER, CollisionFlag.WALL_EAST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const north = this.wallMask(CollisionFlag.WALL_NORTH, CollisionFlag.WALL_NORTH_PROJ_BLOCKER, CollisionFlag.WALL_NORTH_ROUTE_BLOCKER, blockrange, breakroutefinding); + const south = this.wallMask(CollisionFlag.WALL_SOUTH, CollisionFlag.WALL_SOUTH_PROJ_BLOCKER, CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER, blockrange, breakroutefinding); + + if (angle === LocAngle.WEST) { + this.applyWallPair(x, z, y, west, x - 1, z, y, east, add); + } else if (angle === LocAngle.NORTH) { + this.applyWallPair(x, z, y, north, x, z + 1, y, south, add); + } else if (angle === LocAngle.EAST) { + this.applyWallPair(x, z, y, east, x + 1, z, y, west, add); + } else if (angle === LocAngle.SOUTH) { + this.applyWallPair(x, z, y, south, x, z - 1, y, north, add); + } + + if (breakroutefinding) { + this.changeWallStraight(x, z, y, angle, blockrange, false, add); + } else if (blockrange) { + this.changeWallStraight(x, z, y, angle, false, false, add); + } + } + + private changeWallCorner(x: number, z: number, y: number, angle: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + const northWest = this.wallMask(CollisionFlag.WALL_NORTH_WEST, CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER, CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const southEast = this.wallMask(CollisionFlag.WALL_SOUTH_EAST, CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER, CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const northEast = this.wallMask(CollisionFlag.WALL_NORTH_EAST, CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER, CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const southWest = this.wallMask(CollisionFlag.WALL_SOUTH_WEST, CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER, CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER, blockrange, breakroutefinding); + + if (angle === LocAngle.WEST) { + this.applyWallPair(x, z, y, northWest, x - 1, z + 1, y, southEast, add); + } else if (angle === LocAngle.NORTH) { + this.applyWallPair(x, z, y, northEast, x + 1, z + 1, y, southWest, add); + } else if (angle === LocAngle.EAST) { + this.applyWallPair(x, z, y, southEast, x + 1, z - 1, y, northWest, add); + } else if (angle === LocAngle.SOUTH) { + this.applyWallPair(x, z, y, southWest, x - 1, z - 1, y, northEast, add); + } + + if (breakroutefinding) { + this.changeWallCorner(x, z, y, angle, blockrange, false, add); + } else if (blockrange) { + this.changeWallCorner(x, z, y, angle, false, false, add); + } + } + + private changeWallL(x: number, z: number, y: number, angle: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + const west = this.wallMask(CollisionFlag.WALL_WEST, CollisionFlag.WALL_WEST_PROJ_BLOCKER, CollisionFlag.WALL_WEST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const east = this.wallMask(CollisionFlag.WALL_EAST, CollisionFlag.WALL_EAST_PROJ_BLOCKER, CollisionFlag.WALL_EAST_ROUTE_BLOCKER, blockrange, breakroutefinding); + const north = this.wallMask(CollisionFlag.WALL_NORTH, CollisionFlag.WALL_NORTH_PROJ_BLOCKER, CollisionFlag.WALL_NORTH_ROUTE_BLOCKER, blockrange, breakroutefinding); + const south = this.wallMask(CollisionFlag.WALL_SOUTH, CollisionFlag.WALL_SOUTH_PROJ_BLOCKER, CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER, blockrange, breakroutefinding); + + if (angle === LocAngle.WEST) { + this.applyWallSingle(x, z, y, north | west, add); + this.applyWallSingle(x - 1, z, y, east, add); + this.applyWallSingle(x, z + 1, y, south, add); + } else if (angle === LocAngle.NORTH) { + this.applyWallSingle(x, z, y, north | east, add); + this.applyWallSingle(x, z + 1, y, south, add); + this.applyWallSingle(x + 1, z, y, west, add); + } else if (angle === LocAngle.EAST) { + this.applyWallSingle(x, z, y, south | east, add); + this.applyWallSingle(x + 1, z, y, west, add); + this.applyWallSingle(x, z - 1, y, north, add); + } else if (angle === LocAngle.SOUTH) { + this.applyWallSingle(x, z, y, south | west, add); + this.applyWallSingle(x, z - 1, y, north, add); + this.applyWallSingle(x - 1, z, y, east, add); + } + + if (breakroutefinding) { + this.changeWallL(x, z, y, angle, blockrange, false, add); + } else if (blockrange) { + this.changeWallL(x, z, y, angle, false, false, add); + } + } + + private wallMask(normal: number, projectile: number, route: number, blockrange: boolean, breakroutefinding: boolean): number { + if (breakroutefinding) { + return route; + } + if (blockrange) { + return projectile; + } + return normal; + } + + private applyWallPair(srcX: number, srcZ: number, srcY: number, srcMask: number, dstX: number, dstZ: number, dstY: number, dstMask: number, add: boolean): void { + this.applyWallSingle(srcX, srcZ, srcY, srcMask, add); + this.applyWallSingle(dstX, dstZ, dstY, dstMask, add); + } + + private applyWallSingle(x: number, z: number, y: number, mask: number, add: boolean): void { + if (add) { + this.add(x, z, y, mask); + } else { + this.remove(x, z, y, mask); + } + } +} diff --git a/src/engine/routefinder/CollisionStrategy.ts b/src/engine/routefinder/CollisionStrategy.ts new file mode 100644 index 000000000..098f3c2fe --- /dev/null +++ b/src/engine/routefinder/CollisionStrategy.ts @@ -0,0 +1,45 @@ +import { CollisionFlag, CollisionType } from '#/engine/routefinder/flags.js'; + +const LINE_OF_SIGHT_MOVEMENT = + CollisionFlag.WALL_NORTH_WEST | + CollisionFlag.WALL_NORTH | + CollisionFlag.WALL_NORTH_EAST | + CollisionFlag.WALL_EAST | + CollisionFlag.WALL_SOUTH_EAST | + CollisionFlag.WALL_SOUTH | + CollisionFlag.WALL_SOUTH_WEST | + CollisionFlag.WALL_WEST | + CollisionFlag.LOC; + +const LINE_OF_SIGHT_ROUTE = + CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER | + CollisionFlag.WALL_NORTH_ROUTE_BLOCKER | + CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER | + CollisionFlag.WALL_EAST_ROUTE_BLOCKER | + CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER | + CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER | + CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER | + CollisionFlag.WALL_WEST_ROUTE_BLOCKER | + CollisionFlag.LOC_ROUTE_BLOCKER; + +export function canMove(collision: CollisionType, tileFlag: number, blockFlag: number): boolean { + switch (collision) { + case CollisionType.NORMAL: + return (tileFlag & blockFlag) === CollisionFlag.OPEN; + case CollisionType.BLOCKED: { + const flag = blockFlag & ~CollisionFlag.FLOOR; + return (tileFlag & flag) === CollisionFlag.OPEN && (tileFlag & CollisionFlag.FLOOR) !== CollisionFlag.OPEN; + } + case CollisionType.INDOORS: + return (tileFlag & blockFlag) === CollisionFlag.OPEN && (tileFlag & CollisionFlag.ROOF) !== CollisionFlag.OPEN; + case CollisionType.OUTDOORS: + return (tileFlag & (blockFlag | CollisionFlag.ROOF)) === CollisionFlag.OPEN; + case CollisionType.LINE_OF_SIGHT: { + const movementFlags = (blockFlag & LINE_OF_SIGHT_MOVEMENT) << 9; + const routeFlags = (blockFlag & LINE_OF_SIGHT_ROUTE) >>> 13; + return (tileFlag & (movementFlags | routeFlags)) === CollisionFlag.OPEN; + } + default: + return false; + } +} diff --git a/src/engine/routefinder/Line.ts b/src/engine/routefinder/Line.ts new file mode 100644 index 000000000..f92af9547 --- /dev/null +++ b/src/engine/routefinder/Line.ts @@ -0,0 +1,33 @@ +import { CollisionFlag } from '#/engine/routefinder/flags.js'; + +export default class Line { + static readonly SIGHT_BLOCKED_NORTH = CollisionFlag.LOC_PROJ_BLOCKER | CollisionFlag.WALL_NORTH_PROJ_BLOCKER; + static readonly SIGHT_BLOCKED_EAST = CollisionFlag.LOC_PROJ_BLOCKER | CollisionFlag.WALL_EAST_PROJ_BLOCKER; + static readonly SIGHT_BLOCKED_SOUTH = CollisionFlag.LOC_PROJ_BLOCKER | CollisionFlag.WALL_SOUTH_PROJ_BLOCKER; + static readonly SIGHT_BLOCKED_WEST = CollisionFlag.LOC_PROJ_BLOCKER | CollisionFlag.WALL_WEST_PROJ_BLOCKER; + + static readonly WALK_BLOCKED_NORTH = CollisionFlag.WALL_NORTH | CollisionFlag.WALK_BLOCKED; + static readonly WALK_BLOCKED_EAST = CollisionFlag.WALL_EAST | CollisionFlag.WALK_BLOCKED; + static readonly WALK_BLOCKED_SOUTH = CollisionFlag.WALL_SOUTH | CollisionFlag.WALK_BLOCKED; + static readonly WALK_BLOCKED_WEST = CollisionFlag.WALL_WEST | CollisionFlag.WALK_BLOCKED; + + static readonly HALF_TILE = (1 << 16) / 2; + + static scaleUp(tiles: number): number { + return tiles << 16; + } + + static scaleDown(tiles: number): number { + return tiles >> 16; + } + + static coordinate(a: number, b: number, size: number): number { + if (a >= b) { + return a; + } + if (a + size - 1 <= b) { + return a + size - 1; + } + return b; + } +} diff --git a/src/engine/routefinder/LinePathFinder.ts b/src/engine/routefinder/LinePathFinder.ts new file mode 100644 index 000000000..0218a4058 --- /dev/null +++ b/src/engine/routefinder/LinePathFinder.ts @@ -0,0 +1,155 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { CollisionFlag } from '#/engine/routefinder/flags.js'; +import Line from '#/engine/routefinder/Line.js'; +import PackedCoord from '#/engine/routefinder/PackedCoord.js'; + +export function lineOfSight(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): Uint32Array { + return rayCastPath( + flags, + y, + srcX, + srcZ, + destX, + destZ, + srcWidth, + srcHeight, + destWidth, + destHeight, + Line.SIGHT_BLOCKED_WEST | extraFlag, + Line.SIGHT_BLOCKED_EAST | extraFlag, + Line.SIGHT_BLOCKED_SOUTH | extraFlag, + Line.SIGHT_BLOCKED_NORTH | extraFlag, + CollisionFlag.LOC | extraFlag, + CollisionFlag.LOC_PROJ_BLOCKER | extraFlag, + true + ); +} + +export function lineOfWalk(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): Uint32Array { + return rayCastPath( + flags, + y, + srcX, + srcZ, + destX, + destZ, + srcWidth, + srcHeight, + destWidth, + destHeight, + Line.WALK_BLOCKED_WEST | extraFlag, + Line.WALK_BLOCKED_EAST | extraFlag, + Line.WALK_BLOCKED_SOUTH | extraFlag, + Line.WALK_BLOCKED_NORTH | extraFlag, + CollisionFlag.LOC | extraFlag, + CollisionFlag.LOC_PROJ_BLOCKER | extraFlag, + false + ); +} + +function rayCastPath( + flags: CollisionEngine, + y: number, + srcX: number, + srcZ: number, + destX: number, + destZ: number, + srcWidth: number, + srcHeight: number, + destWidth: number, + destHeight: number, + flagWest: number, + flagEast: number, + flagSouth: number, + flagNorth: number, + flagLoc: number, + flagProj: number, + los: boolean +): Uint32Array { + const startX = Line.coordinate(srcX, destX, srcWidth); + const startZ = Line.coordinate(srcZ, destZ, srcHeight); + const endX = Line.coordinate(destX, srcX, destWidth); + const endZ = Line.coordinate(destZ, srcZ, destHeight); + + if (startX === endX && startZ === endZ) { + return new Uint32Array(); + } + + if (los && flags.isFlagged(startX, startZ, y, flagLoc)) { + return new Uint32Array(); + } + + const deltaX = endX - startX; + const deltaZ = endZ - startZ; + const absoluteDeltaX = Math.abs(deltaX); + const absoluteDeltaZ = Math.abs(deltaZ); + const travelEast = deltaX >= 0; + const travelNorth = deltaZ >= 0; + let xFlags = travelEast ? flagWest : flagEast; + let zFlags = travelNorth ? flagSouth : flagNorth; + const coordinates: number[] = []; + + if (absoluteDeltaX > absoluteDeltaZ) { + const offsetX = travelEast ? 1 : -1; + const offsetZ = travelNorth ? 0 : -1; + let scaledZ = Line.scaleUp(startZ) + Line.HALF_TILE + offsetZ; + const tangent = (Line.scaleUp(deltaZ) / absoluteDeltaX) | 0; + let currX = startX; + + while (currX !== endX) { + currX += offsetX; + const currZ = Line.scaleDown(scaledZ); + if (los && currX === endX && currZ === endZ) { + xFlags &= ~flagProj; + } + if (flags.isFlagged(currX, currZ, y, xFlags)) { + return new Uint32Array(); + } + coordinates.push(new PackedCoord(y, currX, currZ).packed); + + scaledZ += tangent; + const nextZ = Line.scaleDown(scaledZ); + if (nextZ !== currZ) { + if (los && currX === endX && nextZ === endZ) { + zFlags &= ~flagProj; + } + if (flags.isFlagged(currX, nextZ, y, zFlags)) { + return new Uint32Array(); + } + coordinates.push(new PackedCoord(y, currX, nextZ).packed); + } + } + } else { + const offsetX = travelEast ? 0 : -1; + const offsetZ = travelNorth ? 1 : -1; + let scaledX = Line.scaleUp(startX) + Line.HALF_TILE + offsetX; + const tangent = (Line.scaleUp(deltaX) / absoluteDeltaZ) | 0; + let currZ = startZ; + + while (currZ !== endZ) { + currZ += offsetZ; + const currX = Line.scaleDown(scaledX); + if (los && currX === endX && currZ === endZ) { + zFlags &= ~flagProj; + } + if (flags.isFlagged(currX, currZ, y, zFlags)) { + return new Uint32Array(); + } + coordinates.push(new PackedCoord(y, currX, currZ).packed); + + scaledX += tangent; + const nextX = Line.scaleDown(scaledX); + if (nextX !== currX) { + if (los && nextX === endX && currZ === endZ) { + xFlags &= ~flagProj; + } + if (flags.isFlagged(nextX, currZ, y, xFlags)) { + return new Uint32Array(); + } + coordinates.push(new PackedCoord(y, nextX, currZ).packed); + } + } + } + + return Uint32Array.from(coordinates); +} diff --git a/src/engine/routefinder/LineValidator.ts b/src/engine/routefinder/LineValidator.ts new file mode 100644 index 000000000..0a0d98760 --- /dev/null +++ b/src/engine/routefinder/LineValidator.ts @@ -0,0 +1,145 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { CollisionFlag } from '#/engine/routefinder/flags.js'; +import Line from '#/engine/routefinder/Line.js'; + +export function hasLineOfSight(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): boolean { + return rayCastLine( + flags, + y, + srcX, + srcZ, + destX, + destZ, + srcWidth, + srcHeight, + destWidth, + destHeight, + Line.SIGHT_BLOCKED_WEST | extraFlag, + Line.SIGHT_BLOCKED_EAST | extraFlag, + Line.SIGHT_BLOCKED_SOUTH | extraFlag, + Line.SIGHT_BLOCKED_NORTH | extraFlag, + CollisionFlag.LOC | extraFlag, + CollisionFlag.LOC_PROJ_BLOCKER | extraFlag, + true + ); +} + +export function hasLineOfWalk(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): boolean { + return rayCastLine( + flags, + y, + srcX, + srcZ, + destX, + destZ, + srcWidth, + srcHeight, + destWidth, + destHeight, + Line.WALK_BLOCKED_WEST | extraFlag, + Line.WALK_BLOCKED_EAST | extraFlag, + Line.WALK_BLOCKED_SOUTH | extraFlag, + Line.WALK_BLOCKED_NORTH | extraFlag, + CollisionFlag.LOC | extraFlag, + CollisionFlag.LOC_PROJ_BLOCKER | extraFlag, + false + ); +} + +function rayCastLine( + flags: CollisionEngine, + y: number, + srcX: number, + srcZ: number, + destX: number, + destZ: number, + srcWidth: number, + srcHeight: number, + destWidth: number, + destHeight: number, + flagWest: number, + flagEast: number, + flagSouth: number, + flagNorth: number, + flagLoc: number, + flagProj: number, + los: boolean +): boolean { + const startX = Line.coordinate(srcX, destX, srcWidth); + const startZ = Line.coordinate(srcZ, destZ, srcHeight); + const endX = Line.coordinate(destX, srcX, destWidth); + const endZ = Line.coordinate(destZ, srcZ, destHeight); + + if (startX === endX && startZ === endZ) { + return true; + } + + if (los && flags.isFlagged(startX, startZ, y, flagLoc)) { + return false; + } + + const deltaX = endX - startX; + const deltaZ = endZ - startZ; + const absoluteDeltaX = Math.abs(deltaX); + const absoluteDeltaZ = Math.abs(deltaZ); + const travelEast = deltaX >= 0; + const travelNorth = deltaZ >= 0; + let xFlags = travelEast ? flagWest : flagEast; + let zFlags = travelNorth ? flagSouth : flagNorth; + + if (absoluteDeltaX > absoluteDeltaZ) { + const offsetX = travelEast ? 1 : -1; + const offsetZ = travelNorth ? 0 : -1; + let scaledZ = Line.scaleUp(startZ) + Line.HALF_TILE + offsetZ; + const tangent = (Line.scaleUp(deltaZ) / absoluteDeltaX) | 0; + let currX = startX; + + while (currX !== endX) { + currX += offsetX; + const currZ = Line.scaleDown(scaledZ); + if (los && currX === endX && currZ === endZ) { + xFlags &= ~flagProj; + } + if (flags.isFlagged(currX, currZ, y, xFlags)) { + return false; + } + + scaledZ += tangent; + const nextZ = Line.scaleDown(scaledZ); + if (los && currX === endX && nextZ === endZ) { + zFlags &= ~flagProj; + } + if (nextZ !== currZ && flags.isFlagged(currX, nextZ, y, zFlags)) { + return false; + } + } + } else { + const offsetX = travelEast ? 0 : -1; + const offsetZ = travelNorth ? 1 : -1; + let scaledX = Line.scaleUp(startX) + Line.HALF_TILE + offsetX; + const tangent = (Line.scaleUp(deltaX) / absoluteDeltaZ) | 0; + let currZ = startZ; + + while (currZ !== endZ) { + currZ += offsetZ; + const currX = Line.scaleDown(scaledX); + if (los && currX === endX && currZ === endZ) { + zFlags &= ~flagProj; + } + if (flags.isFlagged(currX, currZ, y, zFlags)) { + return false; + } + + scaledX += tangent; + const nextX = Line.scaleDown(scaledX); + if (los && nextX === endX && currZ === endZ) { + xFlags &= ~flagProj; + } + if (nextX !== currX && flags.isFlagged(nextX, currZ, y, xFlags)) { + return false; + } + } + } + + return true; +} diff --git a/src/engine/routefinder/NaivePathFinder.ts b/src/engine/routefinder/NaivePathFinder.ts new file mode 100644 index 000000000..f036ff915 --- /dev/null +++ b/src/engine/routefinder/NaivePathFinder.ts @@ -0,0 +1,147 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import PackedCoord from '#/engine/routefinder/PackedCoord.js'; +import { CollisionType } from '#/engine/routefinder/flags.js'; +import { canTravel } from '#/engine/routefinder/StepValidator.js'; + +const DIRECTIONS = [ + [-1, 0], + [1, 0], + [0, 1], + [0, -1] +] as const; + +export function findNaivePath( + flags: CollisionEngine, + y: number, + srcX: number, + srcZ: number, + destX: number, + destZ: number, + srcWidth: number, + srcHeight: number, + destWidth: number, + destHeight: number, + extraFlag: number, + collision: CollisionType +): Uint32Array { + if (intersects(srcX, srcZ, srcWidth, srcHeight, destX, destZ, destWidth, destHeight)) { + return cardinalDestination(y, srcX, srcZ); + } + + const dest = naiveDestination(y, srcX, srcZ, srcWidth, srcHeight, destX, destZ, 1, 1); + const coord = PackedCoord.from(dest[0]); + const dx = coord.x; + const dz = coord.z; + + if (isDiagonal(dx, dz, srcWidth, srcHeight, destX, destZ, destWidth, destHeight)) { + return dest; + } + + if (intersects(dx, dz, srcWidth, srcHeight, destX, destZ, destWidth, destHeight)) { + return dest; + } + + let currX = dx; + let currZ = dz; + while (currX !== destX && currZ !== destZ) { + const stepX = Math.sign(destX - currX); + const stepZ = Math.sign(destZ - currZ); + if (canTravel(flags, y, currX, currZ, stepX, stepZ, srcWidth, extraFlag, collision)) { + currX += stepX; + currZ += stepZ; + } else if (stepX !== 0 && canTravel(flags, y, currX, currZ, stepX, 0, srcWidth, extraFlag, collision)) { + currX += stepX; + } else if (stepZ !== 0 && canTravel(flags, y, currX, currZ, 0, stepZ, srcWidth, extraFlag, collision)) { + currZ += stepZ; + } else { + break; + } + } + + return new Uint32Array([new PackedCoord(y, currX, currZ).packed]); +} + +function intersects(srcX: number, srcZ: number, srcWidth: number, srcHeight: number, destX: number, destZ: number, destWidth: number, destHeight: number): boolean { + const srcHorizontal = srcX + srcWidth; + const srcVertical = srcZ + srcHeight; + const destHorizontal = destX + destWidth; + const destVertical = destZ + destHeight; + return !(destX >= srcHorizontal || destHorizontal <= srcX || destZ >= srcVertical || destVertical <= srcZ); +} + +function isDiagonal(srcX: number, srcZ: number, srcWidth: number, srcHeight: number, destX: number, destZ: number, destWidth: number, destHeight: number): boolean { + if (srcX + srcWidth === destX && srcZ + srcHeight === destZ) { + return true; + } + if (srcX - 1 === destX + destWidth - 1 && srcZ - 1 === destZ + destHeight - 1) { + return true; + } + if (srcX + srcWidth === destX && srcZ - 1 === destZ + destHeight - 1) { + return true; + } + return srcX - 1 === destX + destWidth - 1 && srcZ + srcHeight === destZ; +} + +function cardinalDestination(y: number, srcX: number, srcZ: number): Uint32Array { + const direction = DIRECTIONS[(Math.random() * DIRECTIONS.length) | 0]; + return new Uint32Array([new PackedCoord(y, srcX + direction[0], srcZ + direction[1]).packed]); +} + +function naiveDestination(y: number, srcX: number, srcZ: number, srcWidth: number, srcHeight: number, destX: number, destZ: number, destWidth: number, destHeight: number): Uint32Array { + const diagonal = srcX - destX + (srcZ - destZ); + const anti = srcX - destX - (srcZ - destZ); + const southWestClockwise = anti < 0; + const northWestClockwise = diagonal >= destHeight - 1 - (srcWidth - 1); + const northEastClockwise = anti > srcWidth - srcHeight; + const southEastClockwise = diagonal <= destWidth - 1 - (srcHeight - 1); + + if (southWestClockwise && !northWestClockwise) { + let offZ = 0; + if (diagonal >= -srcWidth) { + offZ = coerceAtMost(diagonal + srcWidth, destHeight - 1); + } else if (anti > -srcWidth) { + offZ = -(srcWidth + anti); + } + return new Uint32Array([new PackedCoord(y, -srcWidth + destX, offZ + destZ).packed]); + } + + if (northWestClockwise && !northEastClockwise) { + let offX = 0; + if (anti >= -destHeight) { + offX = coerceAtMost(anti + destHeight, destWidth - 1); + } else if (diagonal < destHeight) { + offX = coerceAtLeast(diagonal - destHeight, -(srcWidth - 1)); + } + return new Uint32Array([new PackedCoord(y, offX + destX, destHeight + destZ).packed]); + } + + if (northEastClockwise && !southEastClockwise) { + let offZ = 0; + if (anti <= destWidth) { + offZ = destHeight - anti; + } else if (diagonal < destWidth) { + offZ = coerceAtLeast(diagonal - destWidth, -(srcHeight - 1)); + } + return new Uint32Array([new PackedCoord(y, destWidth + destX, offZ + destZ).packed]); + } + + if (!(southEastClockwise && !southWestClockwise)) { + return new Uint32Array(); + } + + let offX = 0; + if (diagonal > -srcHeight) { + offX = coerceAtMost(diagonal + srcHeight, destWidth - 1); + } else if (anti < srcHeight) { + offX = coerceAtLeast(anti - srcHeight, -(srcHeight - 1)); + } + return new Uint32Array([new PackedCoord(y, offX + destX, -srcHeight + destZ).packed]); +} + +function coerceAtMost(value: number, max: number): number { + return value > max ? max : value; +} + +function coerceAtLeast(value: number, min: number): number { + return value < min ? min : value; +} diff --git a/src/engine/routefinder/PackedCoord.ts b/src/engine/routefinder/PackedCoord.ts new file mode 100644 index 000000000..edd945346 --- /dev/null +++ b/src/engine/routefinder/PackedCoord.ts @@ -0,0 +1,25 @@ +export default class PackedCoord { + packed: number; + + constructor(level: number, x: number, z: number) { + this.packed = ((z & 0x3fff) | ((x & 0x3fff) << 14) | ((level & 0x3) << 28)) >>> 0; + } + + static from(packed: number): PackedCoord { + const coord = Object.create(PackedCoord.prototype) as PackedCoord; + coord.packed = packed >>> 0; + return coord; + } + + get level(): number { + return (this.packed >>> 28) & 0x3; + } + + get x(): number { + return (this.packed >>> 14) & 0x3fff; + } + + get z(): number { + return this.packed & 0x3fff; + } +} diff --git a/src/engine/routefinder/PathFinder.ts b/src/engine/routefinder/PathFinder.ts new file mode 100644 index 000000000..1aeb931c0 --- /dev/null +++ b/src/engine/routefinder/PathFinder.ts @@ -0,0 +1,625 @@ +import { canMove } from '#/engine/routefinder/CollisionStrategy.js'; +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { CollisionFlag, CollisionType, DirectionFlag } from '#/engine/routefinder/flags.js'; +import PackedCoord from '#/engine/routefinder/PackedCoord.js'; +import ReachStrategy from '#/engine/routefinder/ReachStrategy.js'; +import { rotate } from '#/engine/routefinder/Rotation.js'; + +export default class PathFinder { + private static readonly DEFAULT_SEARCH_MAP_SIZE = 128; + private static readonly DEFAULT_RING_BUFFER_SIZE = 4096; + private static readonly DEFAULT_DISTANCE_VALUE = 99_999_999; + private static readonly DEFAULT_SRC_DIRECTION_VALUE = 99; + private static readonly MAX_ALTERNATIVE_ROUTE_LOWEST_COST = 1000; + private static readonly MAX_ALTERNATIVE_ROUTE_SEEK_RANGE = 100; + private static readonly MAX_ALTERNATIVE_ROUTE_DISTANCE_FROM_DESTINATION = 10; + + private readonly searchMapSize: number; + private readonly ringBufferSize: number; + private readonly searchHalfMapSize: number; + private readonly directions: Int8Array; + private readonly distances: Int32Array; + private readonly validLocalX: Int32Array; + private readonly validLocalZ: Int32Array; + + private currLocalX = 0; + private currLocalZ = 0; + private bufReaderIndex = 0; + private bufWriterIndex = 0; + + constructor() { + this.searchMapSize = PathFinder.DEFAULT_SEARCH_MAP_SIZE; + this.ringBufferSize = PathFinder.DEFAULT_RING_BUFFER_SIZE; + this.searchHalfMapSize = this.searchMapSize / 2; + this.directions = new Int8Array(this.searchMapSize * this.searchMapSize); + this.distances = new Int32Array(this.searchMapSize * this.searchMapSize); + this.validLocalX = new Int32Array(this.ringBufferSize); + this.validLocalZ = new Int32Array(this.ringBufferSize); + this.distances.fill(PathFinder.DEFAULT_DISTANCE_VALUE); + } + + findPath( + flags: CollisionEngine, + y: number, + srcX: number, + srcZ: number, + destX: number, + destZ: number, + srcSize: number, + destWidth: number, + destHeight: number, + angle: number, + shape: number, + moveNear: boolean, + blockAccessFlags: number, + maxWaypoints: number, + collision: CollisionType + ): Uint32Array { + this.reset(); + + const baseX = srcX - this.searchHalfMapSize; + const baseZ = srcZ - this.searchHalfMapSize; + const localSrcX = srcX - baseX; + const localSrcZ = srcZ - baseZ; + const localDestX = destX - baseX; + const localDestZ = destZ - baseZ; + + this.appendDirection(localSrcX, localSrcZ, PathFinder.DEFAULT_SRC_DIRECTION_VALUE, 0); + + let pathFound: boolean; + switch (srcSize) { + case 1: + pathFound = this.findPath1(flags, baseX, baseZ, y, localDestX, localDestZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags, collision); + break; + case 2: + pathFound = this.findPath2(flags, baseX, baseZ, y, localDestX, localDestZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags, collision); + break; + default: + pathFound = this.findPathN(flags, baseX, baseZ, y, localDestX, localDestZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags, collision); + break; + } + + if (!pathFound) { + if (!moveNear) { + return new Uint32Array(); + } + + const foundApproachPoint = this.findClosestApproachPoint(localDestX, localDestZ, rotate(angle, destWidth, destHeight), rotate(angle, destHeight, destWidth)); + if (!foundApproachPoint) { + return new Uint32Array(); + } + } + + const limit = maxWaypoints; + const waypoints: number[] = []; + let next = this.directions[this.localIndex(this.currLocalX, this.currLocalZ)]; + let curr = -1; + + for (let index = 0; index < this.directions.length; index++) { + if (this.currLocalX === localSrcX && this.currLocalZ === localSrcZ) { + break; + } + + if (curr !== next) { + curr = next; + if (waypoints.length >= limit) { + waypoints.pop(); + } + waypoints.unshift(new PackedCoord(y, baseX + this.currLocalX, baseZ + this.currLocalZ).packed); + } + + if ((curr & DirectionFlag.East) !== 0) { + this.currLocalX += 1; + } else if ((curr & DirectionFlag.West) !== 0) { + this.currLocalX -= 1; + } + + if ((curr & DirectionFlag.North) !== 0) { + this.currLocalZ += 1; + } else if ((curr & DirectionFlag.South) !== 0) { + this.currLocalZ -= 1; + } + + next = this.directions[this.localIndex(this.currLocalX, this.currLocalZ)]; + } + + return Uint32Array.from(waypoints); + } + + private findPath1( + flags: CollisionEngine, + baseX: number, + baseZ: number, + y: number, + localDestX: number, + localDestZ: number, + destWidth: number, + destHeight: number, + srcSize: number, + angle: number, + shape: number, + blockAccessFlags: number, + collision: CollisionType + ): boolean { + const relativeSearchSize = this.searchMapSize - 1; + + while (this.bufWriterIndex !== this.bufReaderIndex) { + this.currLocalX = this.validLocalX[this.bufReaderIndex]; + this.currLocalZ = this.validLocalZ[this.bufReaderIndex]; + this.bufReaderIndex = (this.bufReaderIndex + 1) & (this.ringBufferSize - 1); + + if (ReachStrategy.reached(flags, y, this.currLocalX + baseX, this.currLocalZ + baseZ, localDestX + baseX, localDestZ + baseZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags)) { + return true; + } + + const nextDistance = this.distances[this.localIndex(this.currLocalX, this.currLocalZ)] + 1; + + let x = this.currLocalX - 1; + let z = this.currLocalZ; + if (this.currLocalX > 0 && this.directions[this.localIndex(x, z)] === 0 && canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_WEST)) { + this.appendDirection(x, z, DirectionFlag.East, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ; + if (this.currLocalX < relativeSearchSize && this.directions[this.localIndex(x, z)] === 0 && canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_EAST)) { + this.appendDirection(x, z, DirectionFlag.West, nextDistance); + } + + x = this.currLocalX; + z = this.currLocalZ - 1; + if (this.currLocalZ > 0 && this.directions[this.localIndex(x, z)] === 0 && canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH)) { + this.appendDirection(x, z, DirectionFlag.North, nextDistance); + } + + x = this.currLocalX; + z = this.currLocalZ + 1; + if (this.currLocalZ < relativeSearchSize && this.directions[this.localIndex(x, z)] === 0 && canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_NORTH)) { + this.appendDirection(x, z, DirectionFlag.South, nextDistance); + } + + x = this.currLocalX - 1; + z = this.currLocalZ - 1; + if ( + this.currLocalX > 0 && + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ, y), CollisionFlag.BLOCK_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, z, y), CollisionFlag.BLOCK_SOUTH) + ) { + this.appendDirection(x, z, DirectionFlag.NorthEast, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ - 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ, y), CollisionFlag.BLOCK_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, z, y), CollisionFlag.BLOCK_SOUTH) + ) { + this.appendDirection(x, z, DirectionFlag.NorthWest, nextDistance); + } + + x = this.currLocalX - 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX > 0 && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_NORTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ, y), CollisionFlag.BLOCK_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, z, y), CollisionFlag.BLOCK_NORTH) + ) { + this.appendDirection(x, z, DirectionFlag.SouthEast, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_NORTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ, y), CollisionFlag.BLOCK_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, z, y), CollisionFlag.BLOCK_NORTH) + ) { + this.appendDirection(x, z, DirectionFlag.SouthWest, nextDistance); + } + } + + return false; + } + + private findPath2( + flags: CollisionEngine, + baseX: number, + baseZ: number, + y: number, + localDestX: number, + localDestZ: number, + destWidth: number, + destHeight: number, + srcSize: number, + angle: number, + shape: number, + blockAccessFlags: number, + collision: CollisionType + ): boolean { + const relativeSearchSize = this.searchMapSize - 2; + + while (this.bufWriterIndex !== this.bufReaderIndex) { + this.currLocalX = this.validLocalX[this.bufReaderIndex]; + this.currLocalZ = this.validLocalZ[this.bufReaderIndex]; + this.bufReaderIndex = (this.bufReaderIndex + 1) & (this.ringBufferSize - 1); + + if (ReachStrategy.reached(flags, y, this.currLocalX + baseX, this.currLocalZ + baseZ, localDestX + baseX, localDestZ + baseZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags)) { + return true; + } + + const nextDistance = this.distances[this.localIndex(this.currLocalX, this.currLocalZ)] + 1; + + let x = this.currLocalX - 1; + let z = this.currLocalZ; + if ( + this.currLocalX > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + 1, y), CollisionFlag.BLOCK_NORTH_WEST) + ) { + this.appendDirection(x, z, DirectionFlag.East, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ; + if ( + this.currLocalX < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, z, y), CollisionFlag.BLOCK_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, this.currLocalZ + 1, y), CollisionFlag.BLOCK_NORTH_EAST) + ) { + this.appendDirection(x, z, DirectionFlag.West, nextDistance); + } + + x = this.currLocalX; + z = this.currLocalZ - 1; + if ( + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 1, z, y), CollisionFlag.BLOCK_SOUTH_EAST) + ) { + this.appendDirection(x, z, DirectionFlag.North, nextDistance); + } + + x = this.currLocalX; + z = this.currLocalZ + 1; + if ( + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + 2, y), CollisionFlag.BLOCK_NORTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 1, this.currLocalZ + 2, y), CollisionFlag.BLOCK_NORTH_EAST) + ) { + this.appendDirection(x, z, DirectionFlag.South, nextDistance); + } + + x = this.currLocalX - 1; + z = this.currLocalZ - 1; + if ( + this.currLocalX > 0 && + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, z, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST) + ) { + this.appendDirection(x, z, DirectionFlag.NorthEast, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ - 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, z, y), CollisionFlag.BLOCK_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, this.currLocalZ, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST) + ) { + this.appendDirection(x, z, DirectionFlag.NorthWest, nextDistance); + } + + x = this.currLocalX - 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX > 0 && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + 2, y), CollisionFlag.BLOCK_NORTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX, this.currLocalZ + 2, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST) + ) { + this.appendDirection(x, z, DirectionFlag.SouthEast, nextDistance); + } + + x = this.currLocalX + 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + 2, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, this.currLocalZ + 2, y), CollisionFlag.BLOCK_NORTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + 2, z, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST) + ) { + this.appendDirection(x, z, DirectionFlag.SouthWest, nextDistance); + } + } + + return false; + } + + private findPathN( + flags: CollisionEngine, + baseX: number, + baseZ: number, + y: number, + localDestX: number, + localDestZ: number, + destWidth: number, + destHeight: number, + srcSize: number, + angle: number, + shape: number, + blockAccessFlags: number, + collision: CollisionType + ): boolean { + const relativeSearchSize = this.searchMapSize - srcSize; + + while (this.bufWriterIndex !== this.bufReaderIndex) { + this.currLocalX = this.validLocalX[this.bufReaderIndex]; + this.currLocalZ = this.validLocalZ[this.bufReaderIndex]; + this.bufReaderIndex = (this.bufReaderIndex + 1) & (this.ringBufferSize - 1); + + if (ReachStrategy.reached(flags, y, this.currLocalX + baseX, this.currLocalZ + baseZ, localDestX + baseX, localDestZ + baseZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags)) { + return true; + } + + const nextDistance = this.distances[this.localIndex(this.currLocalX, this.currLocalZ)] + 1; + + let x = this.currLocalX - 1; + let z = this.currLocalZ; + if ( + this.currLocalX > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + srcSize - 1, y), CollisionFlag.BLOCK_NORTH_WEST) + ) { + let blocked = false; + for (let index = 1; index < srcSize - 1; index++) { + if (!canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + index, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST)) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.East, nextDistance); + } + } + + x = this.currLocalX + 1; + z = this.currLocalZ; + if ( + this.currLocalX < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, z, y), CollisionFlag.BLOCK_SOUTH_EAST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, this.currLocalZ + srcSize - 1, y), CollisionFlag.BLOCK_NORTH_EAST) + ) { + let blocked = false; + for (let index = 1; index < srcSize - 1; index++) { + if (!canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, this.currLocalZ + index, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST)) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.West, nextDistance); + } + } + + x = this.currLocalX; + z = this.currLocalZ - 1; + if ( + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize - 1, z, y), CollisionFlag.BLOCK_SOUTH_EAST) + ) { + let blocked = false; + for (let index = 1; index < srcSize - 1; index++) { + if (!canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + index, z, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST)) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.North, nextDistance); + } + } + + x = this.currLocalX; + z = this.currLocalZ + 1; + if ( + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_NORTH_WEST) && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize - 1, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_NORTH_EAST) + ) { + let blocked = false; + for (let index = 1; index < srcSize - 1; index++) { + if (!canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x + index, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST)) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.South, nextDistance); + } + } + + x = this.currLocalX - 1; + z = this.currLocalZ - 1; + if (this.currLocalX > 0 && this.currLocalZ > 0 && this.directions[this.localIndex(x, z)] === 0 && canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, z, y), CollisionFlag.BLOCK_SOUTH_WEST)) { + let blocked = false; + for (let index = 1; index < srcSize; index++) { + if ( + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + index - 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST) || + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + index - 1, z, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST) + ) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.NorthEast, nextDistance); + } + } + + x = this.currLocalX + 1; + z = this.currLocalZ - 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ > 0 && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, z, y), CollisionFlag.BLOCK_SOUTH_EAST) + ) { + let blocked = false; + for (let index = 1; index < srcSize; index++) { + if ( + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, this.currLocalZ + index - 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST) || + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + index, z, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST) + ) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.NorthWest, nextDistance); + } + } + + x = this.currLocalX - 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX > 0 && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_NORTH_WEST) + ) { + let blocked = false; + for (let index = 1; index < srcSize; index++) { + if ( + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, x, this.currLocalZ + index, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST) || + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + index - 1, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST) + ) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.SouthEast, nextDistance); + } + } + + x = this.currLocalX + 1; + z = this.currLocalZ + 1; + if ( + this.currLocalX < relativeSearchSize && + this.currLocalZ < relativeSearchSize && + this.directions[this.localIndex(x, z)] === 0 && + canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_NORTH_EAST) + ) { + let blocked = false; + for (let index = 1; index < srcSize; index++) { + if ( + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + index, this.currLocalZ + srcSize, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST) || + !canMove(collision, PathFinder.collisionFlag(flags, baseX, baseZ, this.currLocalX + srcSize, this.currLocalZ + index, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST) + ) { + blocked = true; + break; + } + } + if (!blocked) { + this.appendDirection(x, z, DirectionFlag.SouthWest, nextDistance); + } + } + } + + return false; + } + + private findClosestApproachPoint(localDestX: number, localDestZ: number, width: number, height: number): boolean { + let lowestCost = PathFinder.MAX_ALTERNATIVE_ROUTE_LOWEST_COST; + let maxAlternativePath = PathFinder.MAX_ALTERNATIVE_ROUTE_SEEK_RANGE; + const alternativeRouteRange = PathFinder.MAX_ALTERNATIVE_ROUTE_DISTANCE_FROM_DESTINATION; + + for (let x = localDestX - alternativeRouteRange; x <= localDestX + alternativeRouteRange; x++) { + for (let z = localDestZ - alternativeRouteRange; z <= localDestZ + alternativeRouteRange; z++) { + if (!(x >= 0 && x < this.searchMapSize) || !(z >= 0 && z < this.searchMapSize) || this.distances[this.localIndex(x, z)] >= PathFinder.MAX_ALTERNATIVE_ROUTE_SEEK_RANGE) { + continue; + } + + let dx = 0; + if (x < localDestX) { + dx = localDestX - x; + } else if (x > localDestX + width - 1) { + dx = x - (width + localDestX - 1); + } + + let dz = 0; + if (z < localDestZ) { + dz = localDestZ - z; + } else if (z > localDestZ + height - 1) { + dz = z - (height + localDestZ - 1); + } + + const cost = dx * dx + dz * dz; + if (cost < lowestCost || (cost === lowestCost && maxAlternativePath > this.distances[this.localIndex(x, z)])) { + this.currLocalX = x; + this.currLocalZ = z; + lowestCost = cost; + maxAlternativePath = this.distances[this.localIndex(x, z)]; + } + } + } + + return lowestCost !== PathFinder.MAX_ALTERNATIVE_ROUTE_LOWEST_COST; + } + + private localIndex(x: number, z: number): number { + return x * this.searchMapSize + z; + } + + private static collisionFlag(flags: CollisionEngine, baseX: number, baseZ: number, localX: number, localZ: number, y: number): number { + return flags.get(baseX + localX, baseZ + localZ, y); + } + + private appendDirection(x: number, z: number, direction: number, distance: number): void { + const index = this.localIndex(x, z); + this.directions[index] = direction; + this.distances[index] = distance; + this.validLocalX[this.bufWriterIndex] = x; + this.validLocalZ[this.bufWriterIndex] = z; + this.bufWriterIndex = (this.bufWriterIndex + 1) & (this.ringBufferSize - 1); + } + + private reset(): void { + this.directions.fill(0); + this.distances.fill(PathFinder.DEFAULT_DISTANCE_VALUE); + this.bufReaderIndex = 0; + this.bufWriterIndex = 0; + } +} diff --git a/src/engine/routefinder/ReachStrategy.ts b/src/engine/routefinder/ReachStrategy.ts new file mode 100644 index 000000000..bb3a51099 --- /dev/null +++ b/src/engine/routefinder/ReachStrategy.ts @@ -0,0 +1,324 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { CollisionFlag, LocAngle, LocShape } from '#/engine/routefinder/flags.js'; +import { collides, reachRectangle1, reachRectangleN } from '#/engine/routefinder/RectangleBoundary.js'; +import { rotate, rotateFlags } from '#/engine/routefinder/Rotation.js'; + +export default class ReachStrategy { + private static readonly WALL_STRATEGY = 0; + private static readonly WALL_DECOR_STRATEGY = 1; + private static readonly RECTANGLE_STRATEGY = 2; + private static readonly NO_STRATEGY = 3; + private static readonly RECTANGLE_EXCLUSIVE_STRATEGY = 4; + + static alteredRotation(angle: number, shape: number): number { + return shape === 7 ? (angle + 2) & 0x3 : angle; + } + + static reached(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, destWidth: number, destHeight: number, srcSize: number, angle: number, shape: number, blockAccessFlags: number): boolean { + const exitStrategy = ReachStrategy.exitStrategy(shape); + if (exitStrategy !== ReachStrategy.RECTANGLE_EXCLUSIVE_STRATEGY && srcX === destX && srcZ === destZ) { + return true; + } + + switch (exitStrategy) { + case ReachStrategy.WALL_STRATEGY: + return ReachStrategy.reachWall(flags, y, srcX, srcZ, destX, destZ, srcSize, shape, angle); + case ReachStrategy.WALL_DECOR_STRATEGY: + return ReachStrategy.reachWallDecor(flags, y, srcX, srcZ, destX, destZ, srcSize, shape, angle); + case ReachStrategy.RECTANGLE_STRATEGY: + return ReachStrategy.reachRectangle(flags, y, srcX, srcZ, destX, destZ, srcSize, destWidth, destHeight, angle, blockAccessFlags); + case ReachStrategy.RECTANGLE_EXCLUSIVE_STRATEGY: + return ReachStrategy.reachExclusiveRectangle(flags, y, srcX, srcZ, destX, destZ, srcSize, destWidth, destHeight, angle, blockAccessFlags); + default: + return false; + } + } + + static reachRectangle(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, destWidth: number, destHeight: number, angle: number, blockAccessFlags: number): boolean { + const rotatedWidth = rotate(angle, destWidth, destHeight); + const rotatedHeight = rotate(angle, destHeight, destWidth); + const rotatedBlockAccess = rotateFlags(angle, blockAccessFlags); + const intersects = collides(srcX, srcZ, destX, destZ, srcSize, srcSize, rotatedWidth, rotatedHeight); + + return srcSize === 1 + ? intersects || reachRectangle1(flags, y, srcX, srcZ, destX, destZ, rotatedWidth, rotatedHeight, rotatedBlockAccess) + : intersects || reachRectangleN(flags, y, srcX, srcZ, destX, destZ, srcSize, srcSize, rotatedWidth, rotatedHeight, rotatedBlockAccess); + } + + static reachExclusiveRectangle(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, destWidth: number, destHeight: number, angle: number, blockAccessFlags: number): boolean { + const rotatedWidth = rotate(angle, destWidth, destHeight); + const rotatedHeight = rotate(angle, destHeight, destWidth); + const rotatedBlockAccess = rotateFlags(angle, blockAccessFlags); + const intersects = collides(srcX, srcZ, destX, destZ, srcSize, srcSize, rotatedWidth, rotatedHeight); + + return srcSize === 1 + ? !intersects && reachRectangle1(flags, y, srcX, srcZ, destX, destZ, rotatedWidth, rotatedHeight, rotatedBlockAccess) + : !intersects && reachRectangleN(flags, y, srcX, srcZ, destX, destZ, srcSize, srcSize, rotatedWidth, rotatedHeight, rotatedBlockAccess); + } + + private static exitStrategy(shape: number): number { + if (shape === -2) { + return ReachStrategy.RECTANGLE_EXCLUSIVE_STRATEGY; + } + if (shape === -1) { + return ReachStrategy.NO_STRATEGY; + } + if ((shape >= 0 && shape <= 3) || shape === 9) { + return ReachStrategy.WALL_STRATEGY; + } + if (shape < 9) { + return ReachStrategy.WALL_DECOR_STRATEGY; + } + if ((shape >= 10 && shape <= 11) || shape === 22) { + return ReachStrategy.RECTANGLE_STRATEGY; + } + return ReachStrategy.NO_STRATEGY; + } + + private static reachWall(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, shape: number, angle: number): boolean { + if (srcSize === 1 && srcX === destX && srcZ === destZ) { + return true; + } + if (srcSize !== 1 && destX >= srcX && srcX + srcSize - 1 >= destX && destZ >= srcZ && srcZ + srcSize - 1 >= destZ) { + return true; + } + return srcSize === 1 ? ReachStrategy.reachWall1(flags, y, srcX, srcZ, destX, destZ, shape, angle) : ReachStrategy.reachWallN(flags, y, srcX, srcZ, destX, destZ, srcSize, shape, angle); + } + + private static reachWallDecor(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, shape: number, angle: number): boolean { + if (srcSize === 1 && srcX === destX && srcZ === destZ) { + return true; + } + if (srcSize !== 1 && destX >= srcX && srcX + srcSize - 1 >= destX && destZ >= srcZ && srcZ + srcSize - 1 >= destZ) { + return true; + } + return srcSize === 1 ? ReachStrategy.reachWallDecor1(flags, y, srcX, srcZ, destX, destZ, shape, angle) : ReachStrategy.reachWallDecorN(flags, y, srcX, srcZ, destX, destZ, srcSize, shape, angle); + } + + private static reachWall1(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, shape: number, angle: number): boolean { + const collisionFlags = flags.get(srcX, srcZ, y); + + if (shape === LocShape.WALL_STRAIGHT) { + if (angle === LocAngle.WEST) { + return ( + (srcX === destX - 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.NORTH) { + return ( + (srcX === destX && srcZ === destZ + 1) || + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.EAST) { + return ( + (srcX === destX + 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + return ( + (srcX === destX && srcZ === destZ - 1) || + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) + ); + } + + if (shape === LocShape.WALL_L) { + if (angle === LocAngle.WEST) { + return ( + (srcX === destX - 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ + 1) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.NORTH) { + return ( + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ + 1) || + (srcX === destX + 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.EAST) { + return ( + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ - 1) + ); + } + return ( + (srcX === destX - 1 && srcZ === destZ) || + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1) + ); + } + + if (shape === LocShape.WALL_DIAGONAL) { + return ( + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) || + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) + ); + } + + return false; + } + + private static reachWallN(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, shape: number, angle: number): boolean { + const collisionFlags = flags.get(srcX, srcZ, y); + const east = srcX + srcSize - 1; + const north = srcZ + srcSize - 1; + + if (shape === LocShape.WALL_STRAIGHT) { + if (angle === LocAngle.WEST) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.NORTH) { + return ( + (destX >= srcX && destX <= east && srcZ === destZ + 1) || + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.EAST) { + return ( + (srcX === destX + 1 && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + return ( + (destX >= srcX && destX <= east && srcZ === destZ - srcSize) || + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) + ); + } + + if (shape === LocShape.WALL_L) { + if (angle === LocAngle.WEST) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ + 1) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.NORTH) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ + 1) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) + ); + } + if (angle === LocAngle.EAST) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize) + ); + } + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ) || + (destX >= srcX && destX <= east && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize) + ); + } + + if (shape === LocShape.WALL_DIAGONAL) { + return ( + (destX >= srcX && destX <= east && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.BLOCK_NORTH) === CollisionFlag.OPEN) || + (destX >= srcX && destX <= east && srcZ === destZ - srcSize && (collisionFlags & CollisionFlag.BLOCK_SOUTH) === CollisionFlag.OPEN) || + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_WEST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.BLOCK_EAST) === CollisionFlag.OPEN) + ); + } + + return false; + } + + private static reachWallDecor1(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, shape: number, angle: number): boolean { + const collisionFlags = flags.get(srcX, srcZ, y); + if (shape === LocShape.WALLDECOR_DIAGONAL_OFFSET || shape === LocShape.WALLDECOR_DIAGONAL_NOOFFSET) { + const rotation = ReachStrategy.alteredRotation(angle, shape); + if (rotation === LocAngle.WEST) { + return (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) || (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN); + } + if (rotation === LocAngle.NORTH) { + return (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN); + } + if (rotation === LocAngle.EAST) { + return (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN); + } + return (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) || (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN); + } + + if (shape === LocShape.WALLDECOR_DIAGONAL_BOTH) { + return ( + (srcX === destX && srcZ === destZ + 1 && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) || + (srcX === destX && srcZ === destZ - 1 && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) || + (srcX === destX - 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ === destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) + ); + } + + return false; + } + + private static reachWallDecorN(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcSize: number, shape: number, angle: number): boolean { + const collisionFlags = flags.get(srcX, srcZ, y); + const east = srcX + srcSize - 1; + const north = srcZ + srcSize - 1; + + if (shape === LocShape.WALLDECOR_DIAGONAL_OFFSET || shape === LocShape.WALLDECOR_DIAGONAL_NOOFFSET) { + const rotation = ReachStrategy.alteredRotation(angle, shape); + if (rotation === LocAngle.WEST) { + return ( + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) || + (srcX <= destX && srcZ === destZ - srcSize && east >= destX && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) + ); + } + if (rotation === LocAngle.NORTH) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || + (srcX <= destX && srcZ === destZ - srcSize && east >= destX && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) + ); + } + if (rotation === LocAngle.EAST) { + return ( + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || + (srcX <= destX && srcZ === destZ + 1 && east >= destX && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) + ); + } + return ( + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) || + (srcX <= destX && srcZ === destZ + 1 && east >= destX && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) + ); + } + + if (shape === LocShape.WALLDECOR_DIAGONAL_BOTH) { + return ( + (srcX <= destX && srcZ === destZ + 1 && east >= destX && (collisionFlags & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) || + (srcX <= destX && srcZ === destZ - srcSize && east >= destX && (collisionFlags & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) || + (srcX === destX - srcSize && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) || + (srcX === destX + 1 && srcZ <= destZ && north >= destZ && (collisionFlags & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) + ); + } + + return false; + } +} diff --git a/src/engine/routefinder/RectangleBoundary.ts b/src/engine/routefinder/RectangleBoundary.ts new file mode 100644 index 000000000..69e5c20ce --- /dev/null +++ b/src/engine/routefinder/RectangleBoundary.ts @@ -0,0 +1,70 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { BlockAccessFlag, CollisionFlag } from '#/engine/routefinder/flags.js'; + +export function collides(srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number): boolean { + if (srcX >= destX + destWidth || srcX + srcWidth <= destX) { + return false; + } + return srcZ < destZ + destHeight && destZ < srcHeight + srcZ; +} + +export function reachRectangle1(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, destWidth: number, destHeight: number, blockAccessFlags: number): boolean { + const east = destX + destWidth - 1; + const north = destZ + destHeight - 1; + + if (srcX === destX - 1 && srcZ >= destZ && srcZ <= north && (flags.get(srcX, srcZ, y) & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN && (blockAccessFlags & BlockAccessFlag.BLOCK_WEST) === 0) { + return true; + } + + if (srcX === east + 1 && srcZ >= destZ && srcZ <= north && (flags.get(srcX, srcZ, y) & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN && (blockAccessFlags & BlockAccessFlag.BLOCK_EAST) === 0) { + return true; + } + + if (srcZ + 1 === destZ && srcX >= destX && srcX <= east && (flags.get(srcX, srcZ, y) & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN && (blockAccessFlags & BlockAccessFlag.BLOCK_SOUTH) === 0) { + return true; + } + + return srcZ === north + 1 && srcX >= destX && srcX <= east && (flags.get(srcX, srcZ, y) & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN && (blockAccessFlags & BlockAccessFlag.BLOCK_NORTH) === 0; +} + +export function reachRectangleN(flags: CollisionEngine, y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, blockAccessFlags: number): boolean { + const srcEast = srcX + srcWidth; + const srcNorth = srcZ + srcHeight; + const destEast = destX + destWidth; + const destNorth = destZ + destHeight; + + if (destEast === srcX && (blockAccessFlags & BlockAccessFlag.BLOCK_EAST) === 0) { + const fromZ = Math.max(srcZ, destZ); + const toZ = Math.min(srcNorth, destNorth); + for (let sideZ = fromZ; sideZ < toZ; sideZ++) { + if ((flags.get(destEast - 1, sideZ, y) & CollisionFlag.WALL_EAST) === CollisionFlag.OPEN) { + return true; + } + } + } else if (srcEast === destX && (blockAccessFlags & BlockAccessFlag.BLOCK_WEST) === 0) { + const fromZ = Math.max(srcZ, destZ); + const toZ = Math.min(srcNorth, destNorth); + for (let sideZ = fromZ; sideZ < toZ; sideZ++) { + if ((flags.get(destX, sideZ, y) & CollisionFlag.WALL_WEST) === CollisionFlag.OPEN) { + return true; + } + } + } else if (srcZ === destNorth && (blockAccessFlags & BlockAccessFlag.BLOCK_NORTH) === 0) { + const fromX = Math.max(srcX, destX); + const toX = Math.min(srcEast, destEast); + for (let sideX = fromX; sideX < toX; sideX++) { + if ((flags.get(sideX, destNorth - 1, y) & CollisionFlag.WALL_NORTH) === CollisionFlag.OPEN) { + return true; + } + } + } else if (destZ === srcNorth && (blockAccessFlags & BlockAccessFlag.BLOCK_SOUTH) === 0) { + const fromX = Math.max(srcX, destX); + const toX = Math.min(srcEast, destEast); + for (let sideX = fromX; sideX < toX; sideX++) { + if ((flags.get(sideX, destZ, y) & CollisionFlag.WALL_SOUTH) === CollisionFlag.OPEN) { + return true; + } + } + } + return false; +} diff --git a/src/engine/routefinder/Rotation.ts b/src/engine/routefinder/Rotation.ts new file mode 100644 index 000000000..58c7c1cc5 --- /dev/null +++ b/src/engine/routefinder/Rotation.ts @@ -0,0 +1,10 @@ +export function rotate(angle: number, a: number, b: number): number { + return (angle & 0x1) !== 0 ? b : a; +} + +export function rotateFlags(angle: number, blockAccessFlags: number): number { + if (angle === 0) { + return blockAccessFlags; + } + return (((blockAccessFlags << angle) & 0xf) | (blockAccessFlags >>> (4 - angle))) >>> 0; +} diff --git a/src/engine/routefinder/StepValidator.ts b/src/engine/routefinder/StepValidator.ts new file mode 100644 index 000000000..439b5bbb6 --- /dev/null +++ b/src/engine/routefinder/StepValidator.ts @@ -0,0 +1,239 @@ +import { canMove } from '#/engine/routefinder/CollisionStrategy.js'; +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { CollisionFlag, CollisionType } from '#/engine/routefinder/flags.js'; + +export function canTravel(flags: CollisionEngine, y: number, x: number, z: number, offsetX: number, offsetZ: number, size: number, extraFlag: number, collision: CollisionType): boolean { + if (offsetX === 0 && offsetZ === -1) { + return !isBlockedSouth(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === 0 && offsetZ === 1) { + return !isBlockedNorth(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === -1 && offsetZ === 0) { + return !isBlockedWest(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === 1 && offsetZ === 0) { + return !isBlockedEast(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === -1 && offsetZ === -1) { + return !isBlockedSouthWest(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === -1 && offsetZ === 1) { + return !isBlockedNorthWest(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === 1 && offsetZ === -1) { + return !isBlockedSouthEast(flags, y, x, z, size, extraFlag, collision); + } + if (offsetX === 1 && offsetZ === 1) { + return !isBlockedNorthEast(flags, y, x, z, size, extraFlag, collision); + } + return false; +} + +function isBlockedSouth(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return !canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_SOUTH | extraFlag); + case 2: + return !canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag) || !canMove(collision, flags.get(x + 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag); + default: + if (!canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + size - 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag)) { + return true; + } + for (let midX = x + 1; midX < x + size - 1; midX++) { + if (!canMove(collision, flags.get(midX, z - 1, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedNorth(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return !canMove(collision, flags.get(x, z + 1, y), CollisionFlag.BLOCK_NORTH | extraFlag); + case 2: + return !canMove(collision, flags.get(x, z + 2, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag) || !canMove(collision, flags.get(x + 1, z + 2, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag); + default: + if (!canMove(collision, flags.get(x, z + size, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + size - 1, z + size, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag)) { + return true; + } + for (let midX = x + 1; midX < x + size - 1; midX++) { + if (!canMove(collision, flags.get(midX, z + size, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedWest(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return !canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_WEST | extraFlag); + case 2: + return !canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag) || !canMove(collision, flags.get(x - 1, z + 1, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag); + default: + if (!canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x - 1, z + size - 1, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag)) { + return true; + } + for (let midZ = z + 1; midZ < z + size - 1; midZ++) { + if (!canMove(collision, flags.get(x - 1, midZ, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedEast(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return !canMove(collision, flags.get(x + 1, z, y), CollisionFlag.BLOCK_EAST | extraFlag); + case 2: + return !canMove(collision, flags.get(x + 2, z, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag) || !canMove(collision, flags.get(x + 2, z + 1, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag); + default: + if (!canMove(collision, flags.get(x + size, z, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + size, z + size - 1, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag)) { + return true; + } + for (let midZ = z + 1; midZ < z + size - 1; midZ++) { + if (!canMove(collision, flags.get(x + size, midZ, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedSouthWest(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return ( + !canMove(collision, flags.get(x - 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag) || + !canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_WEST | extraFlag) || + !canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_SOUTH | extraFlag) + ); + case 2: + return ( + !canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST | extraFlag) || + !canMove(collision, flags.get(x - 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag) || + !canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST | extraFlag) + ); + default: + if (!canMove(collision, flags.get(x - 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_WEST | extraFlag)) { + return true; + } + for (let mid = 1; mid < size; mid++) { + if (!canMove(collision, flags.get(x - 1, z + mid - 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + mid - 1, z - 1, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedNorthWest(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return ( + !canMove(collision, flags.get(x - 1, z + 1, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag) || + !canMove(collision, flags.get(x - 1, z, y), CollisionFlag.BLOCK_WEST | extraFlag) || + !canMove(collision, flags.get(x, z + 1, y), CollisionFlag.BLOCK_NORTH | extraFlag) + ); + case 2: + return ( + !canMove(collision, flags.get(x - 1, z + 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST | extraFlag) || + !canMove(collision, flags.get(x - 1, z + 2, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag) || + !canMove(collision, flags.get(x, z + 2, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST | extraFlag) + ); + default: + if (!canMove(collision, flags.get(x - 1, z + size, y), CollisionFlag.BLOCK_NORTH_WEST | extraFlag)) { + return true; + } + for (let mid = 1; mid < size; mid++) { + if (!canMove(collision, flags.get(x - 1, z + mid, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_EAST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + mid - 1, z + size, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedSouthEast(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return ( + !canMove(collision, flags.get(x + 1, z - 1, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag) || + !canMove(collision, flags.get(x + 1, z, y), CollisionFlag.BLOCK_EAST | extraFlag) || + !canMove(collision, flags.get(x, z - 1, y), CollisionFlag.BLOCK_SOUTH | extraFlag) + ); + case 2: + return ( + !canMove(collision, flags.get(x + 1, z - 1, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST | extraFlag) || + !canMove(collision, flags.get(x + 2, z - 1, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag) || + !canMove(collision, flags.get(x + 2, z, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST | extraFlag) + ); + default: + if (!canMove(collision, flags.get(x + size, z - 1, y), CollisionFlag.BLOCK_SOUTH_EAST | extraFlag)) { + return true; + } + for (let mid = 1; mid < size; mid++) { + if (!canMove(collision, flags.get(x + size, z + mid - 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + mid, z - 1, y), CollisionFlag.BLOCK_NORTH_EAST_AND_WEST | extraFlag)) { + return true; + } + } + return false; + } +} + +function isBlockedNorthEast(flags: CollisionEngine, y: number, x: number, z: number, size: number, extraFlag: number, collision: CollisionType): boolean { + switch (size) { + case 1: + return ( + !canMove(collision, flags.get(x + 1, z + 1, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag) || + !canMove(collision, flags.get(x + 1, z, y), CollisionFlag.BLOCK_EAST | extraFlag) || + !canMove(collision, flags.get(x, z + 1, y), CollisionFlag.BLOCK_NORTH | extraFlag) + ); + case 2: + return ( + !canMove(collision, flags.get(x + 1, z + 2, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST | extraFlag) || + !canMove(collision, flags.get(x + 2, z + 2, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag) || + !canMove(collision, flags.get(x + 2, z + 1, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST | extraFlag) + ); + default: + if (!canMove(collision, flags.get(x + size, z + size, y), CollisionFlag.BLOCK_NORTH_EAST | extraFlag)) { + return true; + } + for (let mid = 1; mid < size; mid++) { + if (!canMove(collision, flags.get(x + mid, z + size, y), CollisionFlag.BLOCK_SOUTH_EAST_AND_WEST | extraFlag)) { + return true; + } + if (!canMove(collision, flags.get(x + size, z + mid, y), CollisionFlag.BLOCK_NORTH_AND_SOUTH_WEST | extraFlag)) { + return true; + } + } + return false; + } +} diff --git a/src/engine/routefinder/flags.ts b/src/engine/routefinder/flags.ts new file mode 100644 index 000000000..2a2eea2ed --- /dev/null +++ b/src/engine/routefinder/flags.ts @@ -0,0 +1,169 @@ +export const CollisionFlag = { + OPEN: 0x0, + WALL_NORTH_WEST: 0x1, + WALL_NORTH: 0x2, + WALL_NORTH_EAST: 0x4, + WALL_EAST: 0x8, + WALL_SOUTH_EAST: 0x10, + WALL_SOUTH: 0x20, + WALL_SOUTH_WEST: 0x40, + WALL_WEST: 0x80, + LOC: 0x100, + WALL_NORTH_WEST_PROJ_BLOCKER: 0x200, + WALL_NORTH_PROJ_BLOCKER: 0x400, + WALL_NORTH_EAST_PROJ_BLOCKER: 0x800, + WALL_EAST_PROJ_BLOCKER: 0x1000, + WALL_SOUTH_EAST_PROJ_BLOCKER: 0x2000, + WALL_SOUTH_PROJ_BLOCKER: 0x4000, + WALL_SOUTH_WEST_PROJ_BLOCKER: 0x8000, + WALL_WEST_PROJ_BLOCKER: 0x10000, + LOC_PROJ_BLOCKER: 0x20000, + FLOOR_DECORATION: 0x40000, + NPC: 0x80000, + PLAYER: 0x100000, + FLOOR: 0x200000, + WALL_NORTH_WEST_ROUTE_BLOCKER: 0x400000, + WALL_NORTH_ROUTE_BLOCKER: 0x800000, + WALL_NORTH_EAST_ROUTE_BLOCKER: 0x1000000, + WALL_EAST_ROUTE_BLOCKER: 0x2000000, + WALL_SOUTH_EAST_ROUTE_BLOCKER: 0x4000000, + WALL_SOUTH_ROUTE_BLOCKER: 0x8000000, + WALL_SOUTH_WEST_ROUTE_BLOCKER: 0x10000000, + WALL_WEST_ROUTE_BLOCKER: 0x20000000, + LOC_ROUTE_BLOCKER: 0x40000000, + ROOF: 0x80000000, + FLOOR_BLOCKED: 0x240000, + WALK_BLOCKED: 0x240100, + BLOCK_WEST: 0x240108, + BLOCK_EAST: 0x240180, + BLOCK_SOUTH: 0x240102, + BLOCK_NORTH: 0x240120, + BLOCK_SOUTH_WEST: 0x24010e, + BLOCK_SOUTH_EAST: 0x240183, + BLOCK_NORTH_WEST: 0x240138, + BLOCK_NORTH_EAST: 0x2401e0, + BLOCK_NORTH_AND_SOUTH_EAST: 0x24013e, + BLOCK_NORTH_AND_SOUTH_WEST: 0x2401e3, + BLOCK_NORTH_EAST_AND_WEST: 0x24018f, + BLOCK_SOUTH_EAST_AND_WEST: 0x2401f8, + BLOCK_WEST_ROUTE_BLOCKER: 0x2260000, + BLOCK_EAST_ROUTE_BLOCKER: 0x20260000, + BLOCK_SOUTH_ROUTE_BLOCKER: 0x10878976, + BLOCK_NORTH_ROUTE_BLOCKER: 0x8260000, + BLOCK_SOUTH_WEST_ROUTE_BLOCKER: 0x43a40000, + BLOCK_SOUTH_EAST_ROUTE_BLOCKER: 0x60e40000, + BLOCK_NORTH_WEST_ROUTE_BLOCKER: 0x4e240000, + BLOCK_NORTH_EAST_ROUTE_BLOCKER: 0x78240000, + BLOCK_NORTH_AND_SOUTH_EAST_ROUTE_BLOCKER: 0x4fa40000, + BLOCK_NORTH_AND_SOUTH_WEST_ROUTE_BLOCKER: 0x78e40000, + BLOCK_NORTH_EAST_AND_WEST_ROUTE_BLOCKER: 0x63e40000, + BLOCK_SOUTH_EAST_AND_WEST_ROUTE_BLOCKER: 0x7e240000, + NULL: 0x7fffffff +} as const; + +export const BlockAccessFlag = { + BLOCK_NORTH: 0x1, + BLOCK_EAST: 0x2, + BLOCK_SOUTH: 0x4, + BLOCK_WEST: 0x8 +} as const; + +export const DirectionFlag = { + North: 0x1, + East: 0x2, + South: 0x4, + West: 0x8, + SouthWest: 0xc, + NorthWest: 0x9, + SouthEast: 0x6, + NorthEast: 0x3 +} as const; + +export const CollisionType = { + NORMAL: 0, + BLOCKED: 1, + INDOORS: 2, + OUTDOORS: 3, + LINE_OF_SIGHT: 4 +} as const; + +export const LocAngle = { + WEST: 0, + NORTH: 1, + EAST: 2, + SOUTH: 3 +} as const; + +export const LocLayer = { + WALL: 0, + WALL_DECOR: 1, + GROUND: 2, + GROUND_DECOR: 3 +} as const; + +export const LocShape = { + WALL_STRAIGHT: 0, + WALL_DIAGONAL_CORNER: 1, + WALL_L: 2, + WALL_SQUARE_CORNER: 3, + WALLDECOR_STRAIGHT_NOOFFSET: 4, + WALLDECOR_STRAIGHT_OFFSET: 5, + WALLDECOR_DIAGONAL_OFFSET: 6, + WALLDECOR_DIAGONAL_NOOFFSET: 7, + WALLDECOR_DIAGONAL_BOTH: 8, + WALL_DIAGONAL: 9, + CENTREPIECE_STRAIGHT: 10, + CENTREPIECE_DIAGONAL: 11, + ROOF_STRAIGHT: 12, + ROOF_DIAGONAL_WITH_ROOFEDGE: 13, + ROOF_DIAGONAL: 14, + ROOF_L_CONCAVE: 15, + ROOF_L_CONVEX: 16, + ROOF_FLAT: 17, + ROOFEDGE_STRAIGHT: 18, + ROOFEDGE_DIAGONAL_CORNER: 19, + ROOFEDGE_L: 20, + ROOFEDGE_SQUARE_CORNER: 21, + GROUND_DECOR: 22 +} as const; + +export type CollisionType = (typeof CollisionType)[keyof typeof CollisionType]; +export type CollisionFlag = (typeof CollisionFlag)[keyof typeof CollisionFlag]; +export type BlockAccessFlag = (typeof BlockAccessFlag)[keyof typeof BlockAccessFlag]; +export type LocAngle = (typeof LocAngle)[keyof typeof LocAngle]; +export type LocLayer = (typeof LocLayer)[keyof typeof LocLayer]; +export type LocShape = (typeof LocShape)[keyof typeof LocShape]; + +export function locShapeLayer(shape: number): LocLayer { + switch (shape) { + case LocShape.WALL_STRAIGHT: + case LocShape.WALL_DIAGONAL_CORNER: + case LocShape.WALL_L: + case LocShape.WALL_SQUARE_CORNER: + return LocLayer.WALL; + case LocShape.WALLDECOR_STRAIGHT_NOOFFSET: + case LocShape.WALLDECOR_STRAIGHT_OFFSET: + case LocShape.WALLDECOR_DIAGONAL_OFFSET: + case LocShape.WALLDECOR_DIAGONAL_NOOFFSET: + case LocShape.WALLDECOR_DIAGONAL_BOTH: + return LocLayer.WALL_DECOR; + case LocShape.WALL_DIAGONAL: + case LocShape.CENTREPIECE_STRAIGHT: + case LocShape.CENTREPIECE_DIAGONAL: + case LocShape.ROOF_STRAIGHT: + case LocShape.ROOF_DIAGONAL_WITH_ROOFEDGE: + case LocShape.ROOF_DIAGONAL: + case LocShape.ROOF_L_CONCAVE: + case LocShape.ROOF_L_CONVEX: + case LocShape.ROOF_FLAT: + case LocShape.ROOFEDGE_STRAIGHT: + case LocShape.ROOFEDGE_DIAGONAL_CORNER: + case LocShape.ROOFEDGE_L: + case LocShape.ROOFEDGE_SQUARE_CORNER: + return LocLayer.GROUND; + case LocShape.GROUND_DECOR: + return LocLayer.GROUND_DECOR; + default: + throw new Error(`Unknown loc shape ${shape}`); + } +} diff --git a/src/engine/routefinder/index.ts b/src/engine/routefinder/index.ts new file mode 100644 index 000000000..d55f6738a --- /dev/null +++ b/src/engine/routefinder/index.ts @@ -0,0 +1,134 @@ +import CollisionEngine from '#/engine/routefinder/CollisionEngine.js'; +import { canTravel as canTravelStep } from '#/engine/routefinder/StepValidator.js'; +import { findNaivePath as findNaivePathImpl } from '#/engine/routefinder/NaivePathFinder.js'; +import { hasLineOfSight as hasLineOfSightImpl, hasLineOfWalk as hasLineOfWalkImpl } from '#/engine/routefinder/LineValidator.js'; +import { lineOfSight as lineOfSightImpl, lineOfWalk as lineOfWalkImpl } from '#/engine/routefinder/LinePathFinder.js'; +import PathFinder from '#/engine/routefinder/PathFinder.js'; +import ReachStrategy from '#/engine/routefinder/ReachStrategy.js'; +import { CollisionFlag, CollisionType, LocAngle, LocLayer, LocShape, locShapeLayer } from '#/engine/routefinder/flags.js'; + +export { CollisionFlag, CollisionType, LocAngle, LocLayer, LocShape, locShapeLayer }; + +export class RouteFinder { + readonly collisionFlags = new CollisionEngine(); + readonly pathfinder = new PathFinder(); + + findPath( + y: number, + srcX: number, + srcZ: number, + destX: number, + destZ: number, + srcSize: number, + destWidth: number, + destHeight: number, + angle: number, + shape: number, + moveNear: boolean, + blockAccessFlags: number, + maxWaypoints: number, + collision: CollisionType + ): Uint32Array { + return this.pathfinder.findPath(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcSize, destWidth, destHeight, angle, shape, moveNear, blockAccessFlags, maxWaypoints, collision); + } + + findNaivePath(y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number, collision: CollisionType): Uint32Array { + return findNaivePathImpl(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcWidth, srcHeight, destWidth, destHeight, extraFlag, collision); + } + + changeFloor(x: number, z: number, y: number, add: boolean): void { + this.collisionFlags.changeFloor(x, z, y, add); + } + + changeLoc(x: number, z: number, y: number, width: number, length: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + this.collisionFlags.changeLoc(x, z, y, width, length, blockrange, breakroutefinding, add); + } + + changeNpc(x: number, z: number, y: number, size: number, add: boolean): void { + this.collisionFlags.changeNpc(x, z, y, size, add); + } + + changePlayer(x: number, z: number, y: number, size: number, add: boolean): void { + this.collisionFlags.changePlayer(x, z, y, size, add); + } + + changeRoof(x: number, z: number, y: number, add: boolean): void { + this.collisionFlags.changeRoof(x, z, y, add); + } + + changeWall(x: number, z: number, y: number, angle: number, shape: number, blockrange: boolean, breakroutefinding: boolean, add: boolean): void { + this.collisionFlags.changeWall(x, z, y, angle, shape, blockrange, breakroutefinding, add); + } + + allocateIfAbsent(x: number, z: number, y: number): void { + this.collisionFlags.allocateIfAbsent(x, z, y); + } + + deallocateIfPresent(x: number, z: number, y: number): void { + this.collisionFlags.deallocateIfPresent(x, z, y); + } + + isZoneAllocated(x: number, z: number, y: number): boolean { + return this.collisionFlags.isZoneAllocated(x, z, y); + } + + isFlagged(x: number, z: number, y: number, masks: number): boolean { + return this.collisionFlags.isFlagged(x, z, y, masks); + } + + canTravel(y: number, x: number, z: number, offsetX: number, offsetZ: number, size: number, extraFlag: number, collision: CollisionType): boolean { + return canTravelStep(this.collisionFlags, y, x, z, offsetX, offsetZ, size, extraFlag, collision); + } + + hasLineOfSight(y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): boolean { + return hasLineOfSightImpl(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcWidth, srcHeight, destWidth, destHeight, extraFlag); + } + + hasLineOfWalk(y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): boolean { + return hasLineOfWalkImpl(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcWidth, srcHeight, destWidth, destHeight, extraFlag); + } + + lineOfSight(y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): Uint32Array { + return lineOfSightImpl(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcWidth, srcHeight, destWidth, destHeight, extraFlag); + } + + lineOfWalk(y: number, srcX: number, srcZ: number, destX: number, destZ: number, srcWidth: number, srcHeight: number, destWidth: number, destHeight: number, extraFlag: number): Uint32Array { + return lineOfWalkImpl(this.collisionFlags, y, srcX, srcZ, destX, destZ, srcWidth, srcHeight, destWidth, destHeight, extraFlag); + } + + reached(y: number, srcX: number, srcZ: number, destX: number, destZ: number, destWidth: number, destHeight: number, srcSize: number, angle: number, shape: number, blockAccessFlags: number): boolean { + return ReachStrategy.reached(this.collisionFlags, y, srcX, srcZ, destX, destZ, destWidth, destHeight, srcSize, angle, shape, blockAccessFlags); + } + + locShapeLayer(shape: number): LocLayer { + return locShapeLayer(shape); + } + + __set(x: number, z: number, y: number, mask: number): void { + this.collisionFlags.set(x, z, y, mask); + } +} + +const routefinder = new RouteFinder(); + +export default routefinder; + +export const findPath = routefinder.findPath.bind(routefinder); +export const findNaivePath = routefinder.findNaivePath.bind(routefinder); +export const changeFloor = routefinder.changeFloor.bind(routefinder); +export const changeLoc = routefinder.changeLoc.bind(routefinder); +export const changeNpc = routefinder.changeNpc.bind(routefinder); +export const changePlayer = routefinder.changePlayer.bind(routefinder); +export const changeRoof = routefinder.changeRoof.bind(routefinder); +export const changeWall = routefinder.changeWall.bind(routefinder); +export const allocateIfAbsent = routefinder.allocateIfAbsent.bind(routefinder); +export const deallocateIfPresent = routefinder.deallocateIfPresent.bind(routefinder); +export const isZoneAllocated = routefinder.isZoneAllocated.bind(routefinder); +export const isFlagged = routefinder.isFlagged.bind(routefinder); +export const canTravel = routefinder.canTravel.bind(routefinder); +export const hasLineOfSight = routefinder.hasLineOfSight.bind(routefinder); +export const hasLineOfWalk = routefinder.hasLineOfWalk.bind(routefinder); +export const lineOfSight = routefinder.lineOfSight.bind(routefinder); +export const lineOfWalk = routefinder.lineOfWalk.bind(routefinder); +export const reached = routefinder.reached.bind(routefinder); +export const __set = routefinder.__set.bind(routefinder); diff --git a/src/engine/script/ScriptValidators.ts b/src/engine/script/ScriptValidators.ts index eeb6a0f50..05986e19b 100644 --- a/src/engine/script/ScriptValidators.ts +++ b/src/engine/script/ScriptValidators.ts @@ -1,4 +1,4 @@ -import { LocAngle, LocShape } from '@2004scape/rsmod-pathfinder'; +import { LocAngle, LocShape } from '#/engine/routefinder/index.js'; import CategoryType from '#/cache/config/CategoryType.js'; import { ConfigType } from '#/cache/config/ConfigType.js'; diff --git a/src/engine/script/handlers/LocOps.ts b/src/engine/script/handlers/LocOps.ts index efac8acab..4c78e2282 100644 --- a/src/engine/script/handlers/LocOps.ts +++ b/src/engine/script/handlers/LocOps.ts @@ -1,4 +1,4 @@ -import { LocAngle, LocShape, locShapeLayer } from '@2004scape/rsmod-pathfinder'; +import { LocAngle, LocShape, locShapeLayer } from '#/engine/routefinder/index.js'; import LocType from '#/cache/config/LocType.js'; import { ParamHelper } from '#/cache/config/ParamHelper.js'; diff --git a/src/engine/script/handlers/ServerOps.ts b/src/engine/script/handlers/ServerOps.ts index 665daf9a2..fabc7da94 100644 --- a/src/engine/script/handlers/ServerOps.ts +++ b/src/engine/script/handlers/ServerOps.ts @@ -1,4 +1,4 @@ -import { LocLayer, LocAngle } from '@2004scape/rsmod-pathfinder'; +import { LocLayer, LocAngle } from '#/engine/routefinder/index.js'; import SpotanimType from '#/cache/config/SpotanimType.js'; import { CoordGrid } from '#/engine/CoordGrid.js'; diff --git a/src/network/game/client/handler/ClientCheatHandler.ts b/src/network/game/client/handler/ClientCheatHandler.ts index a5f14969e..8d1f89a61 100644 --- a/src/network/game/client/handler/ClientCheatHandler.ts +++ b/src/network/game/client/handler/ClientCheatHandler.ts @@ -1,7 +1,7 @@ import v8 from 'node:v8'; import { Visibility } from '@2004scape/rsbuf'; -import { LocAngle, LocShape } from '@2004scape/rsmod-pathfinder'; +import { LocAngle, LocShape } from '#/engine/routefinder/index.js'; import Component from '#/cache/config/Component.js'; import IdkType from '#/cache/config/IdkType.js'; @@ -140,7 +140,6 @@ export default class ClientCheatHandler extends ClientGameMessageHandler Date: Tue, 12 May 2026 21:05:38 -0400 Subject: [PATCH 03/18] lint errors --- src/datastruct/DoublyLinkList.ts | 4 + src/engine/GameMap.ts | 4 + src/engine/InstanceController.ts | 24 +- src/engine/entity/Loc.ts | 12 + src/engine/routefinder/CollisionEngine.ts | 25 ++ src/engine/zone/InstanceZone.ts | 270 +++++++++++++++++++++- src/engine/zone/Zone.ts | 5 +- src/engine/zone/ZoneMap.ts | 14 +- 8 files changed, 344 insertions(+), 14 deletions(-) diff --git a/src/datastruct/DoublyLinkList.ts b/src/datastruct/DoublyLinkList.ts index 4987245ac..9aabed5bb 100644 --- a/src/datastruct/DoublyLinkList.ts +++ b/src/datastruct/DoublyLinkList.ts @@ -9,6 +9,10 @@ export default class DoublyLinkList { this.sentinel.prev2 = this.sentinel; } + isEmpty(): boolean { + return this.sentinel.next2 === this.sentinel; + } + addTail(node: T): void { if (node.prev2) { node.unlink2(); diff --git a/src/engine/GameMap.ts b/src/engine/GameMap.ts index 776e1c7a0..3ae5812f7 100644 --- a/src/engine/GameMap.ts +++ b/src/engine/GameMap.ts @@ -94,6 +94,10 @@ export default class GameMap { return this.zonemap.zoneByIndex(zoneIndex); } + hasZone(x: number, z: number, level: number): boolean { + return this.zonemap.hasZone(x, z, level); + } + addZone(zone: Zone): Zone { return this.zonemap.addZone(zone); } diff --git a/src/engine/InstanceController.ts b/src/engine/InstanceController.ts index 46b28d3eb..d7d7d9fb9 100644 --- a/src/engine/InstanceController.ts +++ b/src/engine/InstanceController.ts @@ -1,5 +1,6 @@ import { CoordGrid } from '#/engine/CoordGrid.js'; import { isZoneAllocated } from '#/engine/GameMap.js'; +import routeFinder from '#/engine/routefinder/index.js'; import World from '#/engine/World.js'; import InstanceZone from '#/engine/zone/InstanceZone.js'; @@ -55,7 +56,7 @@ export default class InstanceController { const x: number = (baseX + east) << 3; const z: number = (baseZ + north) << 3; const zoneIndex: number = CoordGrid.packCoord(level, x, z); - const zone: InstanceZone = new InstanceZone(zoneIndex, { level, x, z }, 0); + const zone: InstanceZone = new InstanceZone(zoneIndex); World.gameMap.addZone(zone); } } @@ -65,6 +66,21 @@ export default class InstanceController { return sw; } + copyZone(instanceSw: CoordGrid, instanceOffset: CoordGrid, source: CoordGrid, rotation: 0 | 1 | 2 | 3): void { + const target: CoordGrid = { + level: instanceSw.level + instanceOffset.level, + x: instanceSw.x + (instanceOffset.x << 3), + z: instanceSw.z + (instanceOffset.z << 3) + }; + + const targetZone = World.gameMap.getZone(target.x, target.z, target.level); + const sourceZone = World.gameMap.getZone(source.x, source.z, source.level); + + if (targetZone instanceof InstanceZone) { + targetZone.copyFromZone(sourceZone, rotation); + } + } + isInstanceEmpty(instance: InstanceRecord): boolean { // Any player in any zone covered by this instance means the instance is still in use. for (let level: number = 0; level < instance.floors; level++) { @@ -73,7 +89,7 @@ export default class InstanceController { const x: number = instance.sw.x + (east << 3); const z: number = instance.sw.z + (north << 3); const zone = World.gameMap.getZone(x, z, level); - for (const player of zone.getAllPlayersUnsafe()) { + if (zone.hasPlayers()) { return false; } } @@ -147,13 +163,15 @@ export default class InstanceController { // Remove every zone belonging to this instance footprint from the world map. private deleteInstance(instance: InstanceRecord): void { - // Remove each zone in the instance footprint from the live zone map. + // Remove each zone in the instance footprint from the live zone map and collision data. for (let level: number = 0; level < instance.floors; level++) { for (let east: number = 0; east < instance.zonesEast; east++) { for (let north: number = 0; north < instance.zonesNorth; north++) { const x: number = instance.sw.x + (east << 3); const z: number = instance.sw.z + (north << 3); World.gameMap.removeZone(CoordGrid.packCoord(level, x, z)); + // Clean up collision data for this zone + routeFinder.collisionFlags.deallocateIfPresent(x, z, level); } } } diff --git a/src/engine/entity/Loc.ts b/src/engine/entity/Loc.ts index 0e32daef2..8c1a44f00 100644 --- a/src/engine/entity/Loc.ts +++ b/src/engine/entity/Loc.ts @@ -31,14 +31,26 @@ export default class Loc extends NonPathingEntity { return this.currentInfo & 0x3fff; } + get baseType(): number { + return this.baseInfo & 0x3fff; + } + get shape(): number { return (this.currentInfo >> 14) & 0x1f; } + get baseShape(): number { + return (this.baseInfo >> 14) & 0x1f; + } + get angle(): number { return (this.currentInfo >> 19) & 0x3; } + get baseAngle(): number { + return (this.baseInfo >> 19) & 0x3; + } + get layer(): number { return (this.baseInfo >> 21) & 0x3; } diff --git a/src/engine/routefinder/CollisionEngine.ts b/src/engine/routefinder/CollisionEngine.ts index a7a7f1123..4b8b3db95 100644 --- a/src/engine/routefinder/CollisionEngine.ts +++ b/src/engine/routefinder/CollisionEngine.ts @@ -233,4 +233,29 @@ export default class CollisionEngine { this.remove(x, z, y, mask); } } + + /** + * Get the collision flags for an entire zone. + * Returns null if the zone is not allocated. + * @param x The x coordinate (absolute world position). + * @param z The z coordinate (absolute world position). + * @param y The level (0-3). + * @returns A copy of the zone's collision flags, or null if not allocated. + */ + getZone(x: number, z: number, y: number): Uint32Array | null { + const zone = this.zones.get(CollisionEngine.zoneIndex(x, z, y)); + return zone ? new Uint32Array(zone) : null; + } + + /** + * Set collision flags for an entire zone. + * @param x The x coordinate (absolute world position). + * @param z The z coordinate (absolute world position). + * @param y The level (0-3). + * @param flags The 64-element collision flag array (8x8 zone). + */ + setZone(x: number, z: number, y: number, flags: Uint32Array): void { + const zone = this.allocateIfAbsentByIndex(CollisionEngine.zoneIndex(x, z, y)); + zone.set(flags); + } } diff --git a/src/engine/zone/InstanceZone.ts b/src/engine/zone/InstanceZone.ts index c93eb2f2a..09c12a2d9 100644 --- a/src/engine/zone/InstanceZone.ts +++ b/src/engine/zone/InstanceZone.ts @@ -1,13 +1,275 @@ import { CoordGrid } from '#/engine/CoordGrid.js'; +import { CollisionFlag } from '#/engine/routefinder/flags.js'; +import routeFinder from '#/engine/routefinder/index.js'; +import { EntityLifeCycle } from '#/engine/entity/EntityLifeCycle.js'; +import Loc from '#/engine/entity/Loc.js'; +import World from '#/engine/World.js'; import Zone from '#/engine/zone/Zone.js'; export default class InstanceZone extends Zone { - readonly source: CoordGrid; - readonly rotation: 0 | 1 | 2 | 3; + source: CoordGrid; + rotation: 0 | 1 | 2 | 3; + private copiedFrom: boolean = false; - constructor(index: number, source: CoordGrid, rotation: 0 | 1 | 2 | 3) { + constructor(index: number) { super(index); - this.source = { ...source }; + this.source = { level: 0, x: 0, z: 0 }; + this.rotation = 0; + } + + /** + * Copy entities (locations, objects, collision) from the source zone into this instance zone, + * applying the specified rotation transformation. + * + * @param sourceZone The overworld zone to copy from. + * @param rotation The rotation to apply (0, 1, 2, or 3). + */ + copyFromZone(sourceZone: Zone, rotation: 0 | 1 | 2 | 3): void { + if (this.copiedFrom) { + throw new Error('InstanceZone has already been copied from a source'); + } + + // Update source and rotation metadata + this.source = { level: sourceZone.level, x: sourceZone.x, z: sourceZone.z }; this.rotation = rotation; + this.copiedFrom = true; + + // Copy collision data with rotation applied + this.copyCollisionWithRotation(sourceZone, rotation); + + // Copy locs with rotation applied + this.copyLocsWithRotation(sourceZone, rotation); + } + + private copyLocsWithRotation(sourceZone: Zone, rotation: 0 | 1 | 2 | 3): void { + // Iterate through permanent locs only (DESPAWN lifecycle) + for (const sourceLoc of sourceZone.getAllLocsSafe()) { + if (sourceLoc.lifecycle !== EntityLifeCycle.DESPAWN) { + continue; + } + + // Extract base properties (before any runtime changes) + const baseType = sourceLoc.baseType; + const baseShape = sourceLoc.baseShape; + const baseAngle = sourceLoc.baseAngle; + let width = sourceLoc.width; + let length = sourceLoc.length; + + // Get zone-relative coordinates (within 0-7 range) + const zoneX = sourceZone.x >> 3; + const zoneZ = sourceZone.z >> 3; + const locX = (sourceLoc.x >> 3) - zoneX; + const locZ = (sourceLoc.z >> 3) - zoneZ; + + // Rotate position and dimensions + let rotatedX = locX; + let rotatedZ = locZ; + if (rotation === 1) { + // 90° CW + rotatedX = 7 - locZ; + rotatedZ = locX; + [width, length] = [length, width]; + } else if (rotation === 2) { + // 180° + rotatedX = 7 - locX; + rotatedZ = 7 - locZ; + } else if (rotation === 3) { + // 270° CW + rotatedX = locZ; + rotatedZ = 7 - locX; + [width, length] = [length, width]; + } + + // Rotate angle + const rotatedAngle = ((baseAngle + rotation) & 0x3) as 0 | 1 | 2 | 3; + + // Compute absolute coordinates in instance zone + const instanceZoneX = this.x >> 3; + const instanceZoneZ = this.z >> 3; + const absoluteX = ((instanceZoneX + rotatedX) << 3) | (sourceLoc.x & 0x7); + const absoluteZ = ((instanceZoneZ + rotatedZ) << 3) | (sourceLoc.z & 0x7); + + // Create new Loc with rotated properties + const newLoc = new Loc(this.level, absoluteX, absoluteZ, width, length, EntityLifeCycle.DESPAWN, baseType, baseShape, rotatedAngle); + + // Try to add the loc; catch errors if zone is uninitialized + try { + World.addLoc(newLoc, 0); + } catch (_: any) { + // Silently skip locs that land in uninitialized zones + // This can happen if a loc extends beyond the allocated instance grid + } + } + } + + private copyCollisionWithRotation(sourceZone: Zone, rotation: 0 | 1 | 2 | 3): void { + const sourceCollision = routeFinder.collisionFlags.getZone(sourceZone.index & 0x7ff, (sourceZone.index >> 11) & 0x7ff, (sourceZone.index >> 22) & 0x3); + if (!sourceCollision) { + return; // No collision to copy + } + + const destCollision = new Uint32Array(64); + + // Iterate through the 8x8 zone and apply rotation transformations + for (let srcIdx = 0; srcIdx < 64; srcIdx++) { + const srcFlags = sourceCollision[srcIdx]; + if (srcFlags === CollisionFlag.OPEN) { + continue; // Skip empty tiles + } + + // Unpack source tile coordinates from linear index + const srcX = srcIdx & 0x7; + const srcZ = (srcIdx >> 3) & 0x7; + + // Rotate coordinates + let dstX = srcX; + let dstZ = srcZ; + if (rotation === 1) { + // 90° CW + dstX = 7 - srcZ; + dstZ = srcX; + } else if (rotation === 2) { + // 180° + dstX = 7 - srcX; + dstZ = 7 - srcZ; + } else if (rotation === 3) { + // 270° CW + dstX = srcZ; + dstZ = 7 - srcX; + } + + const dstIdx = dstX | (dstZ << 3); + const rotatedFlags = this.rotateCollisionFlags(srcFlags, rotation); + destCollision[dstIdx] = rotatedFlags; + } + + // Write rotated collision data to destination zone + routeFinder.collisionFlags.setZone(this.index & 0x7ff, (this.index >> 11) & 0x7ff, (this.index >> 22) & 0x3, destCollision); + } + + private rotateCollisionFlags(flags: number, rotation: 0 | 1 | 2 | 3): number { + if (rotation === 0) { + return flags; + } + + let result = flags; + + // Extract directional wall flags + const hasWallNorth = !!(flags & CollisionFlag.WALL_NORTH); + const hasWallEast = !!(flags & CollisionFlag.WALL_EAST); + const hasWallSouth = !!(flags & CollisionFlag.WALL_SOUTH); + const hasWallWest = !!(flags & CollisionFlag.WALL_WEST); + const hasWallNW = !!(flags & CollisionFlag.WALL_NORTH_WEST); + const hasWallNE = !!(flags & CollisionFlag.WALL_NORTH_EAST); + const hasWallSE = !!(flags & CollisionFlag.WALL_SOUTH_EAST); + const hasWallSW = !!(flags & CollisionFlag.WALL_SOUTH_WEST); + + const hasWallNorthProj = !!(flags & CollisionFlag.WALL_NORTH_PROJ_BLOCKER); + const hasWallEastProj = !!(flags & CollisionFlag.WALL_EAST_PROJ_BLOCKER); + const hasWallSouthProj = !!(flags & CollisionFlag.WALL_SOUTH_PROJ_BLOCKER); + const hasWallWestProj = !!(flags & CollisionFlag.WALL_WEST_PROJ_BLOCKER); + const hasWallNWProj = !!(flags & CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER); + const hasWallNEProj = !!(flags & CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER); + const hasWallSEProj = !!(flags & CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER); + const hasWallSWProj = !!(flags & CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER); + + const hasWallNorthRoute = !!(flags & CollisionFlag.WALL_NORTH_ROUTE_BLOCKER); + const hasWallEastRoute = !!(flags & CollisionFlag.WALL_EAST_ROUTE_BLOCKER); + const hasWallSouthRoute = !!(flags & CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER); + const hasWallWestRoute = !!(flags & CollisionFlag.WALL_WEST_ROUTE_BLOCKER); + const hasWallNWRoute = !!(flags & CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER); + const hasWallNERoute = !!(flags & CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER); + const hasWallSERoute = !!(flags & CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER); + const hasWallSWRoute = !!(flags & CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER); + + // Clear all directional flags + result &= ~(CollisionFlag.WALL_NORTH | CollisionFlag.WALL_EAST | CollisionFlag.WALL_SOUTH | CollisionFlag.WALL_WEST); + result &= ~(CollisionFlag.WALL_NORTH_WEST | CollisionFlag.WALL_NORTH_EAST | CollisionFlag.WALL_SOUTH_EAST | CollisionFlag.WALL_SOUTH_WEST); + result &= ~(CollisionFlag.WALL_NORTH_PROJ_BLOCKER | CollisionFlag.WALL_EAST_PROJ_BLOCKER | CollisionFlag.WALL_SOUTH_PROJ_BLOCKER | CollisionFlag.WALL_WEST_PROJ_BLOCKER); + result &= ~(CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER | CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER | CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER | CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER); + result &= ~(CollisionFlag.WALL_NORTH_ROUTE_BLOCKER | CollisionFlag.WALL_EAST_ROUTE_BLOCKER | CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER | CollisionFlag.WALL_WEST_ROUTE_BLOCKER); + result &= ~(CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER | CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER | CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER | CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER); + + if (rotation === 1) { + // 90° CW: N→E, E→S, S→W, W→N + if (hasWallNorth) result |= CollisionFlag.WALL_EAST; + if (hasWallEast) result |= CollisionFlag.WALL_SOUTH; + if (hasWallSouth) result |= CollisionFlag.WALL_WEST; + if (hasWallWest) result |= CollisionFlag.WALL_NORTH; + if (hasWallNW) result |= CollisionFlag.WALL_NORTH_EAST; + if (hasWallNE) result |= CollisionFlag.WALL_SOUTH_EAST; + if (hasWallSE) result |= CollisionFlag.WALL_SOUTH_WEST; + if (hasWallSW) result |= CollisionFlag.WALL_NORTH_WEST; + if (hasWallNorthProj) result |= CollisionFlag.WALL_EAST_PROJ_BLOCKER; + if (hasWallEastProj) result |= CollisionFlag.WALL_SOUTH_PROJ_BLOCKER; + if (hasWallSouthProj) result |= CollisionFlag.WALL_WEST_PROJ_BLOCKER; + if (hasWallWestProj) result |= CollisionFlag.WALL_NORTH_PROJ_BLOCKER; + if (hasWallNWProj) result |= CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER; + if (hasWallNEProj) result |= CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER; + if (hasWallSEProj) result |= CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER; + if (hasWallSWProj) result |= CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER; + if (hasWallNorthRoute) result |= CollisionFlag.WALL_EAST_ROUTE_BLOCKER; + if (hasWallEastRoute) result |= CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER; + if (hasWallSouthRoute) result |= CollisionFlag.WALL_WEST_ROUTE_BLOCKER; + if (hasWallWestRoute) result |= CollisionFlag.WALL_NORTH_ROUTE_BLOCKER; + if (hasWallNWRoute) result |= CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER; + if (hasWallNERoute) result |= CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER; + if (hasWallSERoute) result |= CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER; + if (hasWallSWRoute) result |= CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER; + } else if (rotation === 2) { + // 180°: N↔S, E↔W, NW↔SE, NE↔SW + if (hasWallNorth) result |= CollisionFlag.WALL_SOUTH; + if (hasWallEast) result |= CollisionFlag.WALL_WEST; + if (hasWallSouth) result |= CollisionFlag.WALL_NORTH; + if (hasWallWest) result |= CollisionFlag.WALL_EAST; + if (hasWallNW) result |= CollisionFlag.WALL_SOUTH_EAST; + if (hasWallNE) result |= CollisionFlag.WALL_SOUTH_WEST; + if (hasWallSE) result |= CollisionFlag.WALL_NORTH_WEST; + if (hasWallSW) result |= CollisionFlag.WALL_NORTH_EAST; + if (hasWallNorthProj) result |= CollisionFlag.WALL_SOUTH_PROJ_BLOCKER; + if (hasWallEastProj) result |= CollisionFlag.WALL_WEST_PROJ_BLOCKER; + if (hasWallSouthProj) result |= CollisionFlag.WALL_NORTH_PROJ_BLOCKER; + if (hasWallWestProj) result |= CollisionFlag.WALL_EAST_PROJ_BLOCKER; + if (hasWallNWProj) result |= CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER; + if (hasWallNEProj) result |= CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER; + if (hasWallSEProj) result |= CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER; + if (hasWallSWProj) result |= CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER; + if (hasWallNorthRoute) result |= CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER; + if (hasWallEastRoute) result |= CollisionFlag.WALL_WEST_ROUTE_BLOCKER; + if (hasWallSouthRoute) result |= CollisionFlag.WALL_NORTH_ROUTE_BLOCKER; + if (hasWallWestRoute) result |= CollisionFlag.WALL_EAST_ROUTE_BLOCKER; + if (hasWallNWRoute) result |= CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER; + if (hasWallNERoute) result |= CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER; + if (hasWallSERoute) result |= CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER; + if (hasWallSWRoute) result |= CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER; + } else if (rotation === 3) { + // 270° CW: N→W, W→S, S→E, E→N + if (hasWallNorth) result |= CollisionFlag.WALL_WEST; + if (hasWallEast) result |= CollisionFlag.WALL_NORTH; + if (hasWallSouth) result |= CollisionFlag.WALL_EAST; + if (hasWallWest) result |= CollisionFlag.WALL_SOUTH; + if (hasWallNW) result |= CollisionFlag.WALL_SOUTH_WEST; + if (hasWallNE) result |= CollisionFlag.WALL_NORTH_WEST; + if (hasWallSE) result |= CollisionFlag.WALL_NORTH_EAST; + if (hasWallSW) result |= CollisionFlag.WALL_SOUTH_EAST; + if (hasWallNorthProj) result |= CollisionFlag.WALL_WEST_PROJ_BLOCKER; + if (hasWallEastProj) result |= CollisionFlag.WALL_NORTH_PROJ_BLOCKER; + if (hasWallSouthProj) result |= CollisionFlag.WALL_EAST_PROJ_BLOCKER; + if (hasWallWestProj) result |= CollisionFlag.WALL_SOUTH_PROJ_BLOCKER; + if (hasWallNWProj) result |= CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER; + if (hasWallNEProj) result |= CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER; + if (hasWallSEProj) result |= CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER; + if (hasWallSWProj) result |= CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER; + if (hasWallNorthRoute) result |= CollisionFlag.WALL_WEST_ROUTE_BLOCKER; + if (hasWallEastRoute) result |= CollisionFlag.WALL_NORTH_ROUTE_BLOCKER; + if (hasWallSouthRoute) result |= CollisionFlag.WALL_EAST_ROUTE_BLOCKER; + if (hasWallWestRoute) result |= CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER; + if (hasWallNWRoute) result |= CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER; + if (hasWallNERoute) result |= CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER; + if (hasWallSERoute) result |= CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER; + if (hasWallSWRoute) result |= CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER; + } + + return result; } } diff --git a/src/engine/zone/Zone.ts b/src/engine/zone/Zone.ts index fc9822301..86540944f 100644 --- a/src/engine/zone/Zone.ts +++ b/src/engine/zone/Zone.ts @@ -32,7 +32,6 @@ import LinkList from '#/datastruct/LinkList.js'; import ServerGameProtRepository from '#/network/game/server/ServerGameProtRepository.js'; import DoublyLinkList from '#/datastruct/DoublyLinkList.js'; - export default class Zone { private static readonly SIZE: number = 8 * 8; private static readonly LOCS: number = this.SIZE << 2; @@ -76,6 +75,10 @@ export default class Zone { return this.objsCount; } + hasPlayers(): boolean { + return !this.players.isEmpty(); + } + enter(entity: PathingEntity): void { if (entity instanceof Player) { this.players.addTail(entity); diff --git a/src/engine/zone/ZoneMap.ts b/src/engine/zone/ZoneMap.ts index b4eb8b481..78bcda0cd 100644 --- a/src/engine/zone/ZoneMap.ts +++ b/src/engine/zone/ZoneMap.ts @@ -24,23 +24,25 @@ export default class ZoneMap { zone(x: number, z: number, level: number): Zone { const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); - let zone: Zone | undefined = this.zones.get(zoneIndex); + const zone: Zone | undefined = this.zones.get(zoneIndex); if (typeof zone == 'undefined') { - zone = new Zone(zoneIndex); - this.zones.set(zoneIndex, zone); + throw new Error(`Zone not initialized at x=${x}, z=${z}, level=${level}`); } return zone; } zoneByIndex(index: number): Zone { - let zone: Zone | undefined = this.zones.get(index); + const zone: Zone | undefined = this.zones.get(index); if (typeof zone == 'undefined') { - zone = new Zone(index); - this.zones.set(index, zone); + throw new Error(`Zone not initialized at index=${index}`); } return zone; } + hasZone(x: number, z: number, level: number): boolean { + return this.zones.has(ZoneMap.zoneIndex(x, z, level)); + } + addZone(zone: Zone): Zone { this.zones.set(zone.index, zone); return zone; From 401abf281f584f30db2993a1c4c5cdd4c7398aa2 Mon Sep 17 00:00:00 2001 From: markb5 Date: Tue, 12 May 2026 21:06:18 -0400 Subject: [PATCH 04/18] fix any lint --- src/engine/zone/InstanceZone.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engine/zone/InstanceZone.ts b/src/engine/zone/InstanceZone.ts index 09c12a2d9..da985c2c7 100644 --- a/src/engine/zone/InstanceZone.ts +++ b/src/engine/zone/InstanceZone.ts @@ -95,7 +95,7 @@ export default class InstanceZone extends Zone { // Try to add the loc; catch errors if zone is uninitialized try { World.addLoc(newLoc, 0); - } catch (_: any) { + } catch (_: unknown) { // Silently skip locs that land in uninitialized zones // This can happen if a loc extends beyond the allocated instance grid } From 3c8af4381c1bb99821b11c2e6c9bb5f8f39ed116 Mon Sep 17 00:00:00 2001 From: markb5 Date: Tue, 12 May 2026 21:27:45 -0400 Subject: [PATCH 05/18] networking --- src/engine/entity/BuildArea.ts | 58 ++++++++++++++++++- .../game/server/ServerGameProtRepository.ts | 3 + .../game/server/codec/RebuildRegionEncoder.ts | 44 ++++++++++++++ .../game/server/model/RebuildRegion.ts | 26 +++++++++ 4 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/network/game/server/codec/RebuildRegionEncoder.ts create mode 100644 src/network/game/server/model/RebuildRegion.ts diff --git a/src/engine/entity/BuildArea.ts b/src/engine/entity/BuildArea.ts index 9ee1afd30..75cf08da2 100644 --- a/src/engine/entity/BuildArea.ts +++ b/src/engine/entity/BuildArea.ts @@ -1,16 +1,21 @@ import { CoordGrid } from '#/engine/CoordGrid.js'; import Player from '#/engine/entity/Player.js'; import World from '#/engine/World.js'; +import InstanceZone from '#/engine/zone/InstanceZone.js'; import ZoneMap from '#/engine/zone/ZoneMap.js'; import RebuildNormal from '#/network/game/server/model/RebuildNormal.js'; +import RebuildRegion, { type RegionTemplate } from '#/network/game/server/model/RebuildRegion.js'; export default class BuildArea { + // TODO: Enable once REBUILD_REGION payload has been verified against the 377 client. + private static readonly ENABLE_REGION_REBUILD_SKELETON: boolean = true; + // constructor readonly player: Player; readonly loadedZones: Set; readonly activeZones: Set; readonly mapsquares: Set; - + lastBuild: number = -1; constructor(player: Player) { @@ -83,7 +88,11 @@ export default class BuildArea { } } - this.player.write(new RebuildNormal(zoneX, zoneZ, this.mapsquares)); + if (BuildArea.ENABLE_REGION_REBUILD_SKELETON && this.isInstanceBuildArea()) { + this.player.write(this.buildRegionSkeletonMessage(zoneX, zoneZ)); + } else { + this.player.write(new RebuildNormal(zoneX, zoneZ, this.mapsquares)); + } this.player.originX = this.player.x; this.player.originZ = this.player.z; @@ -91,4 +100,49 @@ export default class BuildArea { this.lastBuild = World.currentTick; // DO NOT DELETE THIS NO MATTER WHAT ?? } } + + private isInstanceBuildArea(): boolean { + const zoneX: number = CoordGrid.zone(this.player.x) << 3; + const zoneZ: number = CoordGrid.zone(this.player.z) << 3; + const zone = World.gameMap.getZone(zoneX, zoneZ, this.player.level); + return zone instanceof InstanceZone; + } + + private buildRegionSkeletonMessage(zoneX: number, zoneZ: number): RebuildRegion { + const templates: RegionTemplate[] = []; + + const minZoneX: number = zoneX - 6; + const maxZoneX: number = zoneX + 6; + const minZoneZ: number = zoneZ - 6; + const maxZoneZ: number = zoneZ + 6; + + for (let level = 0; level < 4; level++) { + for (let currentZoneX: number = minZoneX; currentZoneX <= maxZoneX; currentZoneX++) { + for (let currentZoneZ: number = minZoneZ; currentZoneZ <= maxZoneZ; currentZoneZ++) { + const currentX: number = currentZoneX << 3; + const currentZ: number = currentZoneZ << 3; + if (!World.gameMap.hasZone(currentX, currentZ, level)) { + continue; + } + + const zone = World.gameMap.getZone(currentX, currentZ, level); + if (!(zone instanceof InstanceZone)) { + continue; + } + + templates.push({ + level: zone.level, + zoneX: zone.x >> 3, + zoneZ: zone.z >> 3, + sourceLevel: zone.source.level, + sourceZoneX: zone.source.x >> 3, + sourceZoneZ: zone.source.z >> 3, + rotation: zone.rotation + }); + } + } + } + + return new RebuildRegion(zoneX, zoneZ, templates); + } } diff --git a/src/network/game/server/ServerGameProtRepository.ts b/src/network/game/server/ServerGameProtRepository.ts index 406f40f67..09bcc03fe 100644 --- a/src/network/game/server/ServerGameProtRepository.ts +++ b/src/network/game/server/ServerGameProtRepository.ts @@ -50,6 +50,7 @@ import ObjRevealEncoder from '#/network/game/server/codec/ObjRevealEncoder.js'; import PCountDialogEncoder from '#/network/game/server/codec/PCountDialogEncoder.js'; import PlayerInfoEncoder from '#/network/game/server/codec/PlayerInfoEncoder.js'; import RebuildNormalEncoder from '#/network/game/server/codec/RebuildNormalEncoder.js'; +import RebuildRegionEncoder from '#/network/game/server/codec/RebuildRegionEncoder.js'; import ResetAnimsEncoder from '#/network/game/server/codec/ResetAnimsEncoder.js'; import ResetClientVarCacheEncoder from '#/network/game/server/codec/ResetClientVarCacheEncoder.js'; import SetMultiwayEncoder from '#/network/game/server/codec/SetMultiwayEncoder.js'; @@ -119,6 +120,7 @@ import ObjReveal from '#/network/game/server/model/ObjReveal.js'; import PCountDialog from '#/network/game/server/model/PCountDialog.js'; import PlayerInfo from '#/network/game/server/model/PlayerInfo.js'; import RebuildNormal from '#/network/game/server/model/RebuildNormal.js'; +import RebuildRegion from '#/network/game/server/model/RebuildRegion.js'; import ResetAnims from '#/network/game/server/model/ResetAnims.js'; import ResetClientVarCache from '#/network/game/server/model/ResetClientVarCache.js'; import SetMultiway from '#/network/game/server/model/SetMultiway.js'; @@ -218,6 +220,7 @@ class ServerGameProtRepository { this.bind(PCountDialog, new PCountDialogEncoder()); this.bind(PlayerInfo, new PlayerInfoEncoder()); this.bind(RebuildNormal, new RebuildNormalEncoder()); + this.bind(RebuildRegion, new RebuildRegionEncoder()); this.bind(ResetAnims, new ResetAnimsEncoder()); this.bind(ResetClientVarCache, new ResetClientVarCacheEncoder()); this.bind(SetMultiway, new SetMultiwayEncoder()); diff --git a/src/network/game/server/codec/RebuildRegionEncoder.ts b/src/network/game/server/codec/RebuildRegionEncoder.ts new file mode 100644 index 000000000..cb50448c6 --- /dev/null +++ b/src/network/game/server/codec/RebuildRegionEncoder.ts @@ -0,0 +1,44 @@ +import Packet from '#/io/Packet.js'; +import ServerGameMessageEncoder from '#/network/game/server/ServerGameMessageEncoder.js'; +import ServerGameProt from '#/network/game/server/ServerGameProt.js'; +import RebuildRegion, { packRegionTemplate } from '#/network/game/server/model/RebuildRegion.js'; + +export default class RebuildRegionEncoder extends ServerGameMessageEncoder { + prot = ServerGameProt.REBUILD_REGION; + + encode(buf: Packet, message: RebuildRegion): void { + // 377 decode order for packet 53: + // 1) zoneX (g2_alt2) + // 2) 4*13*13 template flags + optional 26-bit templates in bit access + // 3) zoneZ (g2_alt2) + buf.p2_alt2(message.zoneX); + + const templateByZone = new Map(); + for (const template of message.templates) { + const key = (template.level << 22) | ((template.zoneX & 0x7ff) << 11) | (template.zoneZ & 0x7ff); + templateByZone.set(key, packRegionTemplate(template)); + } + + buf.bitStart(); + + // Emit 4*13*13 zone templates centered around the player's build-area center zone. + for (let level = 0; level < 4; level++) { + for (let zoneX = message.zoneX - 6; zoneX <= message.zoneX + 6; zoneX++) { + for (let zoneZ = message.zoneZ - 6; zoneZ <= message.zoneZ + 6; zoneZ++) { + const key = (level << 22) | ((zoneX & 0x7ff) << 11) | (zoneZ & 0x7ff); + const packed = templateByZone.get(key); + + if (packed === undefined) { + buf.pBit(1, 0); + } else { + buf.pBit(1, 1); + buf.pBit(26, packed); + } + } + } + } + + buf.bitEnd(); + buf.p2_alt2(message.zoneZ); + } +} diff --git a/src/network/game/server/model/RebuildRegion.ts b/src/network/game/server/model/RebuildRegion.ts new file mode 100644 index 000000000..16bf98642 --- /dev/null +++ b/src/network/game/server/model/RebuildRegion.ts @@ -0,0 +1,26 @@ +import ServerGameMessage from '#/network/game/server/ServerGameMessage.js'; + +export type RegionTemplate = { + level: number; + zoneX: number; + zoneZ: number; + sourceLevel: number; + sourceZoneX: number; + sourceZoneZ: number; + rotation: 0 | 1 | 2 | 3; +}; + +export function packRegionTemplate(template: RegionTemplate): number { + // Matches client sceneMapRegion entry read via gBit(26). + return ((template.sourceLevel & 0x3) << 24) | ((template.sourceZoneX & 0x3ff) << 14) | ((template.sourceZoneZ & 0x7ff) << 3) | ((template.rotation & 0x3) << 1); +} + +export default class RebuildRegion extends ServerGameMessage { + constructor( + readonly zoneX: number, + readonly zoneZ: number, + readonly templates: RegionTemplate[] + ) { + super(); + } +} From 28f08917d21200b082803d831235c56b0bd1ed8e Mon Sep 17 00:00:00 2001 From: markb5 Date: Tue, 12 May 2026 22:25:29 -0400 Subject: [PATCH 06/18] runescript --- src/engine/InstanceController.ts | 22 ++++++ src/engine/script/ScriptOpcode.ts | 16 ++-- src/engine/script/ScriptPointer.ts | 7 +- src/engine/script/ScriptRunner.ts | 2 + src/engine/script/ScriptState.ts | 42 ++++++++++ src/engine/script/handlers/RegionOps.ts | 101 ++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 src/engine/script/handlers/RegionOps.ts diff --git a/src/engine/InstanceController.ts b/src/engine/InstanceController.ts index d7d7d9fb9..cca5f5898 100644 --- a/src/engine/InstanceController.ts +++ b/src/engine/InstanceController.ts @@ -99,6 +99,28 @@ export default class InstanceController { return true; } + /** + * Find an instance that contains the given coordinate. + * @param coord The coordinate to search for. + * @returns The instance record if found, null otherwise. + */ + findInstanceByCoord(coord: CoordGrid): InstanceRecord | null { + for (const instance of this.instances) { + // Check if coord falls within this instance's footprint + if ( + coord.level >= instance.sw.level && + coord.level < instance.sw.level + instance.floors && + coord.x >= instance.sw.x && + coord.x < instance.sw.x + (instance.zonesEast << 3) && + coord.z >= instance.sw.z && + coord.z < instance.sw.z + (instance.zonesNorth << 3) + ) { + return instance; + } + } + return null; + } + // --- // Private methods // --- diff --git a/src/engine/script/ScriptOpcode.ts b/src/engine/script/ScriptOpcode.ts index c490d756d..2c2a91064 100644 --- a/src/engine/script/ScriptOpcode.ts +++ b/src/engine/script/ScriptOpcode.ts @@ -61,6 +61,10 @@ export const enum ScriptOpcode { SEQLENGTH, // official SPOTANIM_MAP, WORLD_DELAY, // official + REGION_CREATE = 1022, + REGION_SET, + REGION_GETCOORD, + REGION_FINDBYCOORD, // Player ops (2000-2499) AFK_EVENT = 2000, @@ -448,7 +452,7 @@ export const enum ScriptOpcode { CONSOLE = 10000, ERROR, GETTIMESPENT, // custom: used to profile script execution (current duration) - TIMESPENT, // custom: used to profile script execution (record start time) + TIMESPENT // custom: used to profile script execution (record start time) } export const ScriptOpcodeMap: Map = new Map([ @@ -512,6 +516,10 @@ export const ScriptOpcodeMap: Map = new Map([ ['SEQLENGTH', ScriptOpcode.SEQLENGTH], ['SPOTANIM_MAP', ScriptOpcode.SPOTANIM_MAP], ['WORLD_DELAY', ScriptOpcode.WORLD_DELAY], + ['REGION_CREATE', ScriptOpcode.REGION_CREATE], + ['REGION_SET', ScriptOpcode.REGION_SET], + ['REGION_GETCOORD', ScriptOpcode.REGION_GETCOORD], + ['REGION_FINDBYCOORD', ScriptOpcode.REGION_FINDBYCOORD], ['AFK_EVENT', ScriptOpcode.AFK_EVENT], ['ALLOWDESIGN', ScriptOpcode.ALLOWDESIGN], @@ -871,9 +879,7 @@ export const ScriptOpcodeMap: Map = new Map([ ['CONSOLE', ScriptOpcode.CONSOLE], ['ERROR', ScriptOpcode.ERROR], ['GETTIMESPENT', ScriptOpcode.GETTIMESPENT], - ['TIMESPENT', ScriptOpcode.TIMESPENT], + ['TIMESPENT', ScriptOpcode.TIMESPENT] ]); -export const ScriptOpcodeNameMap: Map = new Map( - Array.from(ScriptOpcodeMap.entries()).map(([key, value]) => [value, key]) -); +export const ScriptOpcodeNameMap: Map = new Map(Array.from(ScriptOpcodeMap.entries()).map(([key, value]) => [value, key])); diff --git a/src/engine/script/ScriptPointer.ts b/src/engine/script/ScriptPointer.ts index dfc7c3971..be8a8287d 100644 --- a/src/engine/script/ScriptPointer.ts +++ b/src/engine/script/ScriptPointer.ts @@ -15,6 +15,8 @@ const enum ScriptPointer { ActiveLoc2, ActiveObj, ActiveObj2, + ActiveRegion, + ActiveRegion2, _LAST } @@ -29,13 +31,16 @@ export const ScriptPointerNameMap: Map = new Map([ [ScriptPointer.ActiveLoc2, 'ActiveLoc2'], [ScriptPointer.ActiveObj, 'ActiveObj'], [ScriptPointer.ActiveObj2, 'ActiveObj2'], - [ScriptPointer._LAST, '_LAST'], + [ScriptPointer.ActiveRegion, 'ActiveRegion'], + [ScriptPointer.ActiveRegion2, 'ActiveRegion2'], + [ScriptPointer._LAST, '_LAST'] ]); export const ActiveNpc: ScriptPointer[] = [ScriptPointer.ActiveNpc, ScriptPointer.ActiveNpc2]; export const ActiveLoc: ScriptPointer[] = [ScriptPointer.ActiveLoc, ScriptPointer.ActiveLoc2]; export const ActiveObj: ScriptPointer[] = [ScriptPointer.ActiveObj, ScriptPointer.ActiveObj2]; export const ActivePlayer: ScriptPointer[] = [ScriptPointer.ActivePlayer, ScriptPointer.ActivePlayer2]; +export const ActiveRegion: ScriptPointer[] = [ScriptPointer.ActiveRegion, ScriptPointer.ActiveRegion2]; export const ProtectedActivePlayer: ScriptPointer[] = [ScriptPointer.ProtectedActivePlayer, ScriptPointer.ProtectedActivePlayer2]; /** diff --git a/src/engine/script/ScriptRunner.ts b/src/engine/script/ScriptRunner.ts index 3de757d20..be97f0b39 100644 --- a/src/engine/script/ScriptRunner.ts +++ b/src/engine/script/ScriptRunner.ts @@ -17,6 +17,7 @@ import NumberOps from '#/engine/script/handlers/NumberOps.js'; import ObjConfigOps from '#/engine/script/handlers/ObjConfigOps.js'; import ObjOps from '#/engine/script/handlers/ObjOps.js'; import PlayerOps from '#/engine/script/handlers/PlayerOps.js'; +import RegionOps from '#/engine/script/handlers/RegionOps.js'; import ServerOps from '#/engine/script/handlers/ServerOps.js'; import StringOps from '#/engine/script/handlers/StringOps.js'; import StructOps from '#/engine/script/handlers/StructOps.js'; @@ -39,6 +40,7 @@ export default class ScriptRunner { // Language required opcodes ...CoreOps, ...ServerOps, + ...RegionOps, ...PlayerOps, ...NpcOps, ...LocOps, diff --git a/src/engine/script/ScriptState.ts b/src/engine/script/ScriptState.ts index 6f333b417..2f2bcb5c1 100644 --- a/src/engine/script/ScriptState.ts +++ b/src/engine/script/ScriptState.ts @@ -102,6 +102,16 @@ export default class ScriptState { _activeObj: Obj | null = null; _activeObj2: Obj | null = null; + /** + * The primary active region, represented by the instance southwest coord. + */ + _activeRegion: CoordGrid | null = null; + + /** + * The secondary active region, represented by the instance southwest coord. + */ + _activeRegion2: CoordGrid | null = null; + /** * Used for string splitting operations with split_init and related commands. */ @@ -306,6 +316,38 @@ export default class ScriptState { } } + /** + * Gets the active region. Automatically checks the operand to determine primary and secondary. + */ + get activeRegion() { + const region = this.intOperand === 0 ? this._activeRegion : this._activeRegion2; + if (region === null) { + throw new Error('Attempt to access null active_region'); + } + return region; + } + + // gets the secondary region from the perspective of the command (.command returns region1) + get activeRegion2() { + const region = this.intOperand === 0 ? this._activeRegion2 : this._activeRegion; + if (region === null) { + throw new Error('Attempt to access null active_region'); + } + return region; + } + + /** + * Sets the active region. Automatically checks the operand to determine primary and secondary. + * @param region The region southwest coord to set. + */ + set activeRegion(region: CoordGrid) { + if (this.intOperand === 0) { + this._activeRegion = region; + } else { + this._activeRegion2 = region; + } + } + get intOperand(): number { return this.script.intOperands[this.pc]; } diff --git a/src/engine/script/handlers/RegionOps.ts b/src/engine/script/handlers/RegionOps.ts new file mode 100644 index 000000000..1df391cd3 --- /dev/null +++ b/src/engine/script/handlers/RegionOps.ts @@ -0,0 +1,101 @@ +import { CoordGrid } from '#/engine/CoordGrid.js'; +import { ScriptOpcode } from '#/engine/script/ScriptOpcode.js'; +import { ActiveRegion, checkedHandler } from '#/engine/script/ScriptPointer.js'; +import { CommandHandlers } from '#/engine/script/ScriptRunner.js'; +import { check, CoordValid, NumberPositive } from '#/engine/script/ScriptValidators.js'; +import World from '#/engine/World.js'; + +const RegionOps: CommandHandlers = { + [ScriptOpcode.REGION_CREATE]: state => { + const secondary: number = state.intOperand; + const [levels, zonesEast, zonesNorth] = state.popInts(3); + + check(levels, NumberPositive); + check(zonesEast, NumberPositive); + check(zonesNorth, NumberPositive); + + if (levels < 1 || levels > 4) { + throw new Error(`region_create levels out of range: ${levels}. Expected 1..4.`); + } + + // Instance slots are 16x16 zones inside a 128x128 footprint. + if (zonesEast < 1 || zonesEast > 16) { + throw new Error(`region_create zonesEast out of range: ${zonesEast}. Expected 1..16.`); + } + + if (zonesNorth < 1 || zonesNorth > 16) { + throw new Error(`region_create zonesNorth out of range: ${zonesNorth}. Expected 1..16.`); + } + + const sw = World.instances.createInstance(levels, zonesEast, zonesNorth); + state.activeRegion = sw; + state.pointerAdd(ActiveRegion[secondary]); + state.pushInt(CoordGrid.packCoord(sw.level, sw.x, sw.z)); + }, + + [ScriptOpcode.REGION_SET]: checkedHandler(ActiveRegion, state => { + const [destLevel, destEast, destNorth, sourceCoord, rotation] = state.popInts(5); + + check(destLevel, NumberPositive); + check(destEast, NumberPositive); + check(destNorth, NumberPositive); + const source: CoordGrid = check(sourceCoord, CoordValid); + check(rotation, NumberPositive); + + if (destLevel < 0 || destLevel > 3) { + throw new Error(`region_set destLevel out of range: ${destLevel}. Expected 0..3.`); + } + + if (destEast < 0 || destEast > 15) { + throw new Error(`region_set destEast out of range: ${destEast}. Expected 0..15.`); + } + + if (destNorth < 0 || destNorth > 15) { + throw new Error(`region_set destNorth out of range: ${destNorth}. Expected 0..15.`); + } + + if (rotation < 0 || rotation > 3) { + throw new Error(`region_set rotation out of range: ${rotation}. Expected 0..3.`); + } + + World.instances.copyZone(state.activeRegion, { level: destLevel, x: destEast, z: destNorth }, source, rotation as 0 | 1 | 2 | 3); + }), + + [ScriptOpcode.REGION_GETCOORD]: checkedHandler(ActiveRegion, state => { + const [levelOffset, xOffset, zOffset] = state.popInts(3); + + check(levelOffset, NumberPositive); + check(xOffset, NumberPositive); + check(zOffset, NumberPositive); + + if (levelOffset < 0 || levelOffset > 3) { + throw new Error(`region_getcoord levelOffset out of range: ${levelOffset}. Expected 0..3.`); + } + + if (xOffset < 0 || xOffset > 127) { + throw new Error(`region_getcoord xOffset out of range: ${xOffset}. Expected 0..127.`); + } + + if (zOffset < 0 || zOffset > 127) { + throw new Error(`region_getcoord zOffset out of range: ${zOffset}. Expected 0..127.`); + } + + state.pushInt(CoordGrid.packCoord(state.activeRegion.level + levelOffset, state.activeRegion.x + xOffset, state.activeRegion.z + zOffset)); + }), + + [ScriptOpcode.REGION_FINDBYCOORD]: state => { + const coord: CoordGrid = check(state.popInt(), CoordValid); + const secondary: number = state.intOperand; + const instance = World.instances.findInstanceByCoord(coord); + + if (instance) { + state.activeRegion = instance.sw; + state.pointerAdd(ActiveRegion[secondary]); + state.pushInt(1); + } else { + state.pushInt(0); + } + } +}; + +export default RegionOps; From adf0ddebe96e81ae833f00c7ac911a2c6129c946 Mon Sep 17 00:00:00 2001 From: markb5 Date: Wed, 13 May 2026 11:56:59 -0400 Subject: [PATCH 07/18] debug fixes --- src/engine/GameMap.ts | 4 +- src/engine/InstanceController.ts | 15 +++++-- src/engine/entity/BuildArea.ts | 12 ++--- src/engine/entity/PathingEntity.ts | 16 +++++-- src/engine/script/handlers/RegionOps.ts | 3 +- src/engine/zone/InstanceZone.ts | 44 ++++++++++--------- src/engine/zone/ZoneMap.ts | 10 +++-- src/network/game/server/ServerGameProt.ts | 2 +- .../game/server/codec/RebuildRegionEncoder.ts | 8 ++-- .../game/server/model/RebuildRegion.ts | 5 ++- 10 files changed, 74 insertions(+), 45 deletions(-) diff --git a/src/engine/GameMap.ts b/src/engine/GameMap.ts index 3ae5812f7..f2eaf5a7e 100644 --- a/src/engine/GameMap.ts +++ b/src/engine/GameMap.ts @@ -271,7 +271,9 @@ export default class GameMap { changeLocCollision(shape, angle, type.blockrange, length, width, type.active, absoluteX, absoluteZ, actualLevel, true); } - this.getZone(absoluteX, absoluteZ, actualLevel).addStaticLoc(new Loc(actualLevel, absoluteX, absoluteZ, width, length, EntityLifeCycle.RESPAWN, locId, shape, angle)); + if (type.active === 1) { + this.getZone(absoluteX, absoluteZ, actualLevel).addStaticLoc(new Loc(actualLevel, absoluteX, absoluteZ, width, length, EntityLifeCycle.RESPAWN, locId, shape, angle)); + } } locIdOffset = packet.gsmarts(); } diff --git a/src/engine/InstanceController.ts b/src/engine/InstanceController.ts index cca5f5898..bdaf6ae9a 100644 --- a/src/engine/InstanceController.ts +++ b/src/engine/InstanceController.ts @@ -3,6 +3,8 @@ import { isZoneAllocated } from '#/engine/GameMap.js'; import routeFinder from '#/engine/routefinder/index.js'; import World from '#/engine/World.js'; import InstanceZone from '#/engine/zone/InstanceZone.js'; +import ZoneMap from '#/engine/zone/ZoneMap.js'; +import { printDebug } from '#/util/Logger.js'; type InstanceRecord = { sw: CoordGrid; @@ -55,9 +57,10 @@ export default class InstanceController { for (let north: number = 0; north < zonesNorth; north++) { const x: number = (baseX + east) << 3; const z: number = (baseZ + north) << 3; - const zoneIndex: number = CoordGrid.packCoord(level, x, z); + const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); const zone: InstanceZone = new InstanceZone(zoneIndex); World.gameMap.addZone(zone); + routeFinder.allocateIfAbsent(x, z, level); } } } @@ -78,6 +81,12 @@ export default class InstanceController { if (targetZone instanceof InstanceZone) { targetZone.copyFromZone(sourceZone, rotation); + printDebug(`[Instance] Zone copy complete: src=(${source.x},${source.z},L${source.level}) -> dst=(${target.x},${target.z},L${target.level}) rot=${rotation} locs=${targetZone.totalLocs}`); + for (const loc of targetZone.getAllLocsSafe()) { + printDebug(` [Loc] type=${loc.type} at (${loc.x},${loc.z},L${loc.level}) shape=${loc.shape} angle=${loc.angle} active=${loc.isActive}`); + } + } else { + printDebug(`[Instance] ERROR: Target zone is not InstanceZone, it's ${targetZone.constructor.name}`); } } @@ -191,9 +200,9 @@ export default class InstanceController { for (let north: number = 0; north < instance.zonesNorth; north++) { const x: number = instance.sw.x + (east << 3); const z: number = instance.sw.z + (north << 3); - World.gameMap.removeZone(CoordGrid.packCoord(level, x, z)); + World.gameMap.removeZone(ZoneMap.zoneIndex(x, z, level)); // Clean up collision data for this zone - routeFinder.collisionFlags.deallocateIfPresent(x, z, level); + routeFinder.deallocateIfPresent(x, z, level); } } } diff --git a/src/engine/entity/BuildArea.ts b/src/engine/entity/BuildArea.ts index 75cf08da2..c8abc2676 100644 --- a/src/engine/entity/BuildArea.ts +++ b/src/engine/entity/BuildArea.ts @@ -7,7 +7,7 @@ import RebuildNormal from '#/network/game/server/model/RebuildNormal.js'; import RebuildRegion, { type RegionTemplate } from '#/network/game/server/model/RebuildRegion.js'; export default class BuildArea { - // TODO: Enable once REBUILD_REGION payload has been verified against the 377 client. + // Dynamic rebuild currently enabled for instance zones. private static readonly ENABLE_REGION_REBUILD_SKELETON: boolean = true; // constructor @@ -132,17 +132,17 @@ export default class BuildArea { templates.push({ level: zone.level, - zoneX: zone.x >> 3, - zoneZ: zone.z >> 3, + zoneX: zone.x, + zoneZ: zone.z, sourceLevel: zone.source.level, - sourceZoneX: zone.source.x >> 3, - sourceZoneZ: zone.source.z >> 3, + sourceZoneX: zone.source.x, + sourceZoneZ: zone.source.z, rotation: zone.rotation }); } } } - return new RebuildRegion(zoneX, zoneZ, templates); + return new RebuildRegion(zoneX, zoneZ, templates, this.player.x, this.player.z, this.player.level); } } diff --git a/src/engine/entity/PathingEntity.ts b/src/engine/entity/PathingEntity.ts index 4c2d87d53..2d82973db 100644 --- a/src/engine/entity/PathingEntity.ts +++ b/src/engine/entity/PathingEntity.ts @@ -1,6 +1,7 @@ import { CollisionFlag, CollisionType } from '#/engine/routefinder/index.js'; import LocType from '#/cache/config/LocType.js'; +import NpcType from '#/cache/config/NpcType.js'; import { CoordGrid } from '#/engine/CoordGrid.js'; import { BlockWalk } from '#/engine/entity/BlockWalk.js'; import Entity from '#/engine/entity/Entity.js'; @@ -262,22 +263,27 @@ export default abstract class PathingEntity extends Entity { } teleJump(x: number, z: number, level: number): void { - this.teleport(x, z, level); + if (!this.teleport(x, z, level)) { + return; + } this.moveSpeed = MoveSpeed.INSTANT; this.jump = true; } - teleport(x: number, z: number, level: number): void { + teleport(x: number, z: number, level: number): boolean { if (isNaN(level)) { level = 0; } level = Math.max(0, Math.min(level, 3)); - if (!isZoneAllocated(level, x, z) && (!(this instanceof Player) || this.staffModLevel < 3)) { + const allocated: boolean = isZoneAllocated(level, x, z); + const initialized: boolean = World.gameMap.hasZone(x, z, level); + if (!allocated || !initialized) { + console.error(`[Teleport] Invalid teleport for ${this.constructor.name} from (${this.x}, ${this.z}, L${this.level}) to (${x}, ${z}, L${level}) allocated=${allocated} initialized=${initialized}`); if (this instanceof Player) { this.messageGame('Invalid teleport!'); } - return; + return false; } const previousX: number = this.x; @@ -299,6 +305,8 @@ export default abstract class PathingEntity extends Entity { this.moveSpeed = MoveSpeed.INSTANT; this.jump = true; } + + return true; } /** diff --git a/src/engine/script/handlers/RegionOps.ts b/src/engine/script/handlers/RegionOps.ts index 1df391cd3..64d1f8739 100644 --- a/src/engine/script/handlers/RegionOps.ts +++ b/src/engine/script/handlers/RegionOps.ts @@ -80,7 +80,8 @@ const RegionOps: CommandHandlers = { throw new Error(`region_getcoord zOffset out of range: ${zOffset}. Expected 0..127.`); } - state.pushInt(CoordGrid.packCoord(state.activeRegion.level + levelOffset, state.activeRegion.x + xOffset, state.activeRegion.z + zOffset)); + const coord = CoordGrid.packCoord(state.activeRegion.level + levelOffset, state.activeRegion.x + xOffset, state.activeRegion.z + zOffset); + state.pushInt(coord); }), [ScriptOpcode.REGION_FINDBYCOORD]: state => { diff --git a/src/engine/zone/InstanceZone.ts b/src/engine/zone/InstanceZone.ts index da985c2c7..486485b8a 100644 --- a/src/engine/zone/InstanceZone.ts +++ b/src/engine/zone/InstanceZone.ts @@ -42,12 +42,7 @@ export default class InstanceZone extends Zone { } private copyLocsWithRotation(sourceZone: Zone, rotation: 0 | 1 | 2 | 3): void { - // Iterate through permanent locs only (DESPAWN lifecycle) for (const sourceLoc of sourceZone.getAllLocsSafe()) { - if (sourceLoc.lifecycle !== EntityLifeCycle.DESPAWN) { - continue; - } - // Extract base properties (before any runtime changes) const baseType = sourceLoc.baseType; const baseShape = sourceLoc.baseShape; @@ -55,11 +50,11 @@ export default class InstanceZone extends Zone { let width = sourceLoc.width; let length = sourceLoc.length; - // Get zone-relative coordinates (within 0-7 range) - const zoneX = sourceZone.x >> 3; - const zoneZ = sourceZone.z >> 3; - const locX = (sourceLoc.x >> 3) - zoneX; - const locZ = (sourceLoc.z >> 3) - zoneZ; + // Get zone-relative tile coordinates (0-7 range). + // sourceZone.x is already in zone-index units (tile >> 3), so shift left to + // get the absolute tile base of the zone's SW corner. + const locX = sourceLoc.x - (sourceZone.x << 3); + const locZ = sourceLoc.z - (sourceZone.z << 3); // Rotate position and dimensions let rotatedX = locX; @@ -80,24 +75,31 @@ export default class InstanceZone extends Zone { [width, length] = [length, width]; } + // Skip any loc whose rotated base tile falls outside the 8×8 zone footprint. + // Without this, World.addLoc would reach ZoneMap.zone() which auto-creates a + // plain Zone outside the instance boundary. + if (rotatedX < 0 || rotatedX > 7 || rotatedZ < 0 || rotatedZ > 7) { + continue; + } + // Rotate angle const rotatedAngle = ((baseAngle + rotation) & 0x3) as 0 | 1 | 2 | 3; - // Compute absolute coordinates in instance zone - const instanceZoneX = this.x >> 3; - const instanceZoneZ = this.z >> 3; - const absoluteX = ((instanceZoneX + rotatedX) << 3) | (sourceLoc.x & 0x7); - const absoluteZ = ((instanceZoneZ + rotatedZ) << 3) | (sourceLoc.z & 0x7); + // Compute absolute coordinates in instance zone. + // this.x is zone-index (tile >> 3), so shift left to get the tile base. + const absoluteX = (this.x << 3) + rotatedX; + const absoluteZ = (this.z << 3) + rotatedZ; // Create new Loc with rotated properties - const newLoc = new Loc(this.level, absoluteX, absoluteZ, width, length, EntityLifeCycle.DESPAWN, baseType, baseShape, rotatedAngle); + const newLoc = new Loc(this.level, absoluteX, absoluteZ, width, length, sourceLoc.lifecycle, baseType, baseShape, rotatedAngle); - // Try to add the loc; catch errors if zone is uninitialized - try { + if (sourceLoc.lifecycle === EntityLifeCycle.DESPAWN) { + // Preserve dynamic loc semantics when the source loc is runtime-spawned. World.addLoc(newLoc, 0); - } catch (_: unknown) { - // Silently skip locs that land in uninitialized zones - // This can happen if a loc extends beyond the allocated instance grid + } else { + // Instance rebuilds already make the client infer unchanged static locs from cache. + // Copy them directly into the instance zone so server-side interaction lookup works. + this.addStaticLoc(newLoc); } } } diff --git a/src/engine/zone/ZoneMap.ts b/src/engine/zone/ZoneMap.ts index 78bcda0cd..81b98140c 100644 --- a/src/engine/zone/ZoneMap.ts +++ b/src/engine/zone/ZoneMap.ts @@ -24,17 +24,19 @@ export default class ZoneMap { zone(x: number, z: number, level: number): Zone { const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); - const zone: Zone | undefined = this.zones.get(zoneIndex); + let zone: Zone | undefined = this.zones.get(zoneIndex); if (typeof zone == 'undefined') { - throw new Error(`Zone not initialized at x=${x}, z=${z}, level=${level}`); + zone = new Zone(zoneIndex); + this.zones.set(zoneIndex, zone); } return zone; } zoneByIndex(index: number): Zone { - const zone: Zone | undefined = this.zones.get(index); + let zone: Zone | undefined = this.zones.get(index); if (typeof zone == 'undefined') { - throw new Error(`Zone not initialized at index=${index}`); + zone = new Zone(index); + this.zones.set(index, zone); } return zone; } diff --git a/src/network/game/server/ServerGameProt.ts b/src/network/game/server/ServerGameProt.ts index 9ee18c67d..0eceaaf8b 100644 --- a/src/network/game/server/ServerGameProt.ts +++ b/src/network/game/server/ServerGameProt.ts @@ -70,7 +70,7 @@ export default class ServerGameProt { // maps static readonly REBUILD_NORMAL = new ServerGameProt(222, 4); - static readonly REBUILD_REGION = new ServerGameProt(53, -1); + static readonly REBUILD_REGION = new ServerGameProt(53, -2); // vars static readonly VARP_SMALL = new ServerGameProt(182, 3); diff --git a/src/network/game/server/codec/RebuildRegionEncoder.ts b/src/network/game/server/codec/RebuildRegionEncoder.ts index cb50448c6..88ce4f7b8 100644 --- a/src/network/game/server/codec/RebuildRegionEncoder.ts +++ b/src/network/game/server/codec/RebuildRegionEncoder.ts @@ -7,10 +7,11 @@ export default class RebuildRegionEncoder extends ServerGameMessageEncoder(); @@ -39,6 +40,7 @@ export default class RebuildRegionEncoder extends ServerGameMessageEncoder Date: Wed, 13 May 2026 12:56:49 -0400 Subject: [PATCH 08/18] zone guards --- src/engine/GameMap.ts | 77 ++++++++++++++++++++++---------- src/engine/InstanceController.ts | 3 +- src/engine/zone/InstanceZone.ts | 18 ++++---- src/engine/zone/ZoneMap.ts | 72 +++++++++++++++++++++++++++-- 4 files changed, 130 insertions(+), 40 deletions(-) diff --git a/src/engine/GameMap.ts b/src/engine/GameMap.ts index f2eaf5a7e..883ad99e5 100644 --- a/src/engine/GameMap.ts +++ b/src/engine/GameMap.ts @@ -49,32 +49,40 @@ export default class GameMap { return; } - printDebug('Loading game map'); + // Allow zones to be auto-created during map loading + this.zonemap.beginInitialization(); - if (fs.existsSync(`${Environment.BUILD_SRC_DIR}/maps/multiway.csv`)) { - this.loadCsvMap(this.multimap, fs.readFileSync(`${Environment.BUILD_SRC_DIR}/maps/multiway.csv`, 'ascii').split(/\r?\n/)); - } + try { + printDebug('Loading game map'); - if (fs.existsSync(`${Environment.BUILD_SRC_DIR}/maps/free2play.csv`)) { - this.loadCsvMap(this.freemap, fs.readFileSync(`${Environment.BUILD_SRC_DIR}/maps/free2play.csv`, 'ascii').split(/\r?\n/)); - } + if (fs.existsSync(`${Environment.BUILD_SRC_DIR}/maps/multiway.csv`)) { + this.loadCsvMap(this.multimap, fs.readFileSync(`${Environment.BUILD_SRC_DIR}/maps/multiway.csv`, 'ascii').split(/\r?\n/)); + } - const path: string = 'data/pack/server/maps/'; - const maps: string[] = fs.readdirSync(path).filter(x => x[0] === 'm'); - for (let index: number = 0; index < maps.length; index++) { - const [mx, mz] = maps[index].substring(1).split('_').map(Number); - const mapsquareX: number = mx << 6; - const mapsquareZ: number = mz << 6; - - this.loadNpcs(Packet.load(`${path}n${mx}_${mz}`), mapsquareX, mapsquareZ); - this.loadObjs(Packet.load(`${path}o${mx}_${mz}`), mapsquareX, mapsquareZ); - // collision - const lands: Int8Array = new Int8Array(GameMap.MAPSQUARE); // 4 * 64 * 64 size is guaranteed for lands - this.loadGround(lands, Packet.load(`${path}m${mx}_${mz}`), mapsquareX, mapsquareZ); - this.loadLocations(lands, Packet.load(`${path}l${mx}_${mz}`), mapsquareX, mapsquareZ); - } + if (fs.existsSync(`${Environment.BUILD_SRC_DIR}/maps/free2play.csv`)) { + this.loadCsvMap(this.freemap, fs.readFileSync(`${Environment.BUILD_SRC_DIR}/maps/free2play.csv`, 'ascii').split(/\r?\n/)); + } + + const path: string = 'data/pack/server/maps/'; + const maps: string[] = fs.readdirSync(path).filter(x => x[0] === 'm'); + for (let index: number = 0; index < maps.length; index++) { + const [mx, mz] = maps[index].substring(1).split('_').map(Number); + const mapsquareX: number = mx << 6; + const mapsquareZ: number = mz << 6; + + this.loadNpcs(Packet.load(`${path}n${mx}_${mz}`), mapsquareX, mapsquareZ); + this.loadObjs(Packet.load(`${path}o${mx}_${mz}`), mapsquareX, mapsquareZ); + // collision + const lands: Int8Array = new Int8Array(GameMap.MAPSQUARE); // 4 * 64 * 64 size is guaranteed for lands + this.loadGround(lands, Packet.load(`${path}m${mx}_${mz}`), mapsquareX, mapsquareZ); + this.loadLocations(lands, Packet.load(`${path}l${mx}_${mz}`), mapsquareX, mapsquareZ); + } - printDebug(`${World.getTotalNpcs()}/${Environment.NODE_MAX_NPCS} static NPCs added`); + printDebug(`${World.getTotalNpcs()}/${Environment.NODE_MAX_NPCS} static NPCs added`); + } finally { + // Lock down zone creation after map is fully loaded + this.zonemap.endInitialization(); + } } isMulti(coord: number): boolean { @@ -87,11 +95,32 @@ export default class GameMap { } getZone(x: number, z: number, level: number): Zone { - return this.zonemap.zone(x, z, level); + return this.zonemap.getZone(x, z, level); } getZoneIndex(zoneIndex: number): Zone { - return this.zonemap.zoneByIndex(zoneIndex); + return this.zonemap.getZoneByIndex(zoneIndex); + } + + /** + * Get a zone if it exists, or null if it hasn't been created. + */ + getZoneIfExists(x: number, z: number, level: number): Zone | null { + return this.zonemap.getZoneIfExists(x, z, level); + } + + /** + * Create a new zone during world startup. Should only be called from init(). + */ + createZone(x: number, z: number, level: number): Zone { + return this.zonemap.createZone(x, z, level); + } + + /** + * Create a new InstanceZone. Should only be called during instance creation. + */ + createInstanceZone(zoneIndex: number): Zone { + return this.zonemap.createInstanceZone(zoneIndex); } hasZone(x: number, z: number, level: number): boolean { diff --git a/src/engine/InstanceController.ts b/src/engine/InstanceController.ts index bdaf6ae9a..858ea0f12 100644 --- a/src/engine/InstanceController.ts +++ b/src/engine/InstanceController.ts @@ -58,8 +58,7 @@ export default class InstanceController { const x: number = (baseX + east) << 3; const z: number = (baseZ + north) << 3; const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); - const zone: InstanceZone = new InstanceZone(zoneIndex); - World.gameMap.addZone(zone); + World.gameMap.createInstanceZone(zoneIndex); routeFinder.allocateIfAbsent(x, z, level); } } diff --git a/src/engine/zone/InstanceZone.ts b/src/engine/zone/InstanceZone.ts index 486485b8a..b67d4b69b 100644 --- a/src/engine/zone/InstanceZone.ts +++ b/src/engine/zone/InstanceZone.ts @@ -75,13 +75,6 @@ export default class InstanceZone extends Zone { [width, length] = [length, width]; } - // Skip any loc whose rotated base tile falls outside the 8×8 zone footprint. - // Without this, World.addLoc would reach ZoneMap.zone() which auto-creates a - // plain Zone outside the instance boundary. - if (rotatedX < 0 || rotatedX > 7 || rotatedZ < 0 || rotatedZ > 7) { - continue; - } - // Rotate angle const rotatedAngle = ((baseAngle + rotation) & 0x3) as 0 | 1 | 2 | 3; @@ -90,6 +83,12 @@ export default class InstanceZone extends Zone { const absoluteX = (this.x << 3) + rotatedX; const absoluteZ = (this.z << 3) + rotatedZ; + if (!World.gameMap.hasZone(absoluteX, absoluteZ, this.level)) { + throw new Error(`Instance loc out of bounds: source=(${sourceLoc.x},${sourceLoc.z},L${sourceLoc.level}) rotated=(${absoluteX},${absoluteZ},L${this.level}) has no destination zone`); + } + + const destinationZone = World.gameMap.getZone(absoluteX, absoluteZ, this.level); + // Create new Loc with rotated properties const newLoc = new Loc(this.level, absoluteX, absoluteZ, width, length, sourceLoc.lifecycle, baseType, baseShape, rotatedAngle); @@ -97,9 +96,8 @@ export default class InstanceZone extends Zone { // Preserve dynamic loc semantics when the source loc is runtime-spawned. World.addLoc(newLoc, 0); } else { - // Instance rebuilds already make the client infer unchanged static locs from cache. - // Copy them directly into the instance zone so server-side interaction lookup works. - this.addStaticLoc(newLoc); + // Static locs stay attached to the zone that owns their rotated base tile. + destinationZone.addStaticLoc(newLoc); } } } diff --git a/src/engine/zone/ZoneMap.ts b/src/engine/zone/ZoneMap.ts index 81b98140c..15bdcd881 100644 --- a/src/engine/zone/ZoneMap.ts +++ b/src/engine/zone/ZoneMap.ts @@ -1,6 +1,7 @@ import { CoordGrid } from '#/engine/CoordGrid.js'; import Zone from '#/engine/zone/Zone.js'; import ZoneGrid from '#/engine/zone/ZoneGrid.js'; +import InstanceZone from '#/engine/zone/InstanceZone.js'; export default class ZoneMap { static zoneIndex(x: number, z: number, level: number): number { @@ -16,31 +17,94 @@ export default class ZoneMap { private readonly zones: Map; private readonly grids: Map; + private isInitializing: boolean = false; constructor() { this.zones = new Map(); this.grids = new Map(); } - zone(x: number, z: number, level: number): Zone { + /** + * Call before loading map data to allow zone auto-creation. + */ + beginInitialization(): void { + this.isInitializing = true; + } + + /** + * Call after loading map data to lock down zone creation. + */ + endInitialization(): void { + this.isInitializing = false; + } + + /** + * Get an existing zone, or auto-create it if needed. + * TODO: Once all code paths are audited, enforce zone pre-creation when not initializing. + * Currently zones auto-create to maintain compatibility during refactor. + */ + getZone(x: number, z: number, level: number): Zone { const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); let zone: Zone | undefined = this.zones.get(zoneIndex); - if (typeof zone == 'undefined') { + + if (typeof zone === 'undefined') { + // Auto-create zone on demand (TODO: enforce pre-creation after refactor) zone = new Zone(zoneIndex); this.zones.set(zoneIndex, zone); } return zone; } - zoneByIndex(index: number): Zone { + /** + * Get an existing zone by index, or auto-create it if needed. + * TODO: Once all code paths are audited, enforce zone pre-creation when not initializing. + * Currently zones auto-create to maintain compatibility during refactor. + */ + getZoneByIndex(index: number): Zone { let zone: Zone | undefined = this.zones.get(index); - if (typeof zone == 'undefined') { + + if (typeof zone === 'undefined') { + // Auto-create zone on demand (TODO: enforce pre-creation after refactor) zone = new Zone(index); this.zones.set(index, zone); } return zone; } + /** + * Get an existing zone, or null if it doesn't exist. + * Use this only for optional zone lookups where missing zones are expected. + */ + getZoneIfExists(x: number, z: number, level: number): Zone | null { + const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); + return this.zones.get(zoneIndex) ?? null; + } + + /** + * Create a new Zone and register it. Only called during world startup or instance creation. + */ + createZone(x: number, z: number, level: number): Zone { + const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); + if (this.zones.has(zoneIndex)) { + throw new Error(`Zone already exists at (${x}, ${z}, L${level})`); + } + const zone = new Zone(zoneIndex); + this.zones.set(zoneIndex, zone); + return zone; + } + + /** + * Create a new InstanceZone and register it. Only called during instance creation. + */ + createInstanceZone(zoneIndex: number): Zone { + if (this.zones.has(zoneIndex)) { + throw new Error(`Zone already exists at index ${zoneIndex}`); + } + const zone = new InstanceZone(zoneIndex); + this.zones.set(zoneIndex, zone); + return zone; + } + hasZone(x: number, z: number, level: number): boolean { return this.zones.has(ZoneMap.zoneIndex(x, z, level)); } From d65b972651cc8f6b305a165fe1a13ba3cf504576 Mon Sep 17 00:00:00 2001 From: markb5 Date: Wed, 13 May 2026 14:18:40 -0400 Subject: [PATCH 09/18] first full instance! --- src/engine/InstanceController.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/engine/InstanceController.ts b/src/engine/InstanceController.ts index 858ea0f12..767b89c5a 100644 --- a/src/engine/InstanceController.ts +++ b/src/engine/InstanceController.ts @@ -33,6 +33,7 @@ export default class InstanceController { createInstance(floors: number, zonesEast: number, zonesNorth: number): CoordGrid { // Reclaim all stale instance slots, then find the next available slot pointer. + printDebug(`[Instance] createInstance request floors=${floors}, zonesEast=${zonesEast}, zonesNorth=${zonesNorth}, nextPointer=${this.nextInstancePointer}`); this.clearStaleInstances(); this.findNextSlot(); @@ -42,6 +43,7 @@ export default class InstanceController { const baseX: number = 101 + slotX * 3; const baseZ: number = 1 + slotZ * 3; const sw: CoordGrid = { level: 0, x: baseX << 3, z: baseZ << 3 }; + printDebug(`[Instance] selected slot pointer=${this.nextInstancePointer}, slot=(${slotX},${slotZ}), sw=(${sw.x},${sw.z},L0)`); // Keep the instance metadata so we can later detect when it becomes empty again. this.instances.push({ @@ -58,6 +60,13 @@ export default class InstanceController { const x: number = (baseX + east) << 3; const z: number = (baseZ + north) << 3; const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); + + const existingZone: boolean = World.gameMap.hasZone(x, z, level); + const allocatedCollision: boolean = isZoneAllocated(level, x, z); + if (existingZone || allocatedCollision) { + printDebug(`[Instance] WARNING: creating instance zone where state already exists index=${zoneIndex} coord=(${x},${z},L${level}) existingZone=${existingZone} allocatedCollision=${allocatedCollision}`); + } + World.gameMap.createInstanceZone(zoneIndex); routeFinder.allocateIfAbsent(x, z, level); } @@ -147,7 +156,7 @@ export default class InstanceController { } } - // Find the next pointer that is both unoccupied by records and unallocated on the map. + // Find the next pointer using SW-zone occupancy as the canonical slot marker. private findNextSlot(): void { // Track occupied slots from active instance records to skip them during probing. const occupiedSlots: Set = new Set(); @@ -155,6 +164,8 @@ export default class InstanceController { occupiedSlots.add(this.getSlotPointer(instance.sw)); } + printDebug(`[Instance] findNextSlot start: nextPointer=${this.nextInstancePointer}, activeInstances=${this.instances.length}, occupiedSlots=${occupiedSlots.size}`); + for (let attempts: number = 0; attempts < InstanceController.TOTAL_INSTANCES; attempts++) { const pointer: number = (this.nextInstancePointer + attempts) % InstanceController.TOTAL_INSTANCES; if (occupiedSlots.has(pointer)) { @@ -165,11 +176,18 @@ export default class InstanceController { const slotZ: number = Math.trunc(pointer / InstanceController.INSTANCES_PER_ROW); const swX: number = (101 + slotX * 3) << 3; const swZ: number = (1 + slotZ * 3) << 3; + const allocated: boolean = isZoneAllocated(0, swX, swZ); + const hasZone: boolean = World.gameMap.hasZone(swX, swZ, 0); - if (isZoneAllocated(0, swX, swZ)) { + // If this slot's SW zone is occupied, the slot is considered in use. + if (allocated || hasZone) { + if (attempts < 10) { + printDebug(`[Instance] slot ${pointer} blocked: sw=(${swX},${swZ},L0) allocated=${allocated} hasZone=${hasZone}`); + } continue; } + printDebug(`[Instance] slot ${pointer} available: sw=(${swX},${swZ},L0)`); this.nextInstancePointer = pointer; return; } From f3be0bda0f90e60207908a9ed381972aade99045 Mon Sep 17 00:00:00 2001 From: markb5 Date: Wed, 13 May 2026 23:48:58 -0400 Subject: [PATCH 10/18] exit coord --- src/engine/InstanceController.ts | 44 +++++++++++++------------ src/engine/entity/PathingEntity.ts | 24 ++++++++++++-- src/engine/entity/Player.ts | 26 +++++++++++++++ src/engine/entity/PlayerLoading.ts | 28 +++++++++++++++- src/engine/script/ScriptOpcode.ts | 6 ++++ src/engine/script/ScriptState.ts | 37 +++++++++++++++++++-- src/engine/script/handlers/RegionOps.ts | 38 ++++++++++++++++++++- 7 files changed, 175 insertions(+), 28 deletions(-) diff --git a/src/engine/InstanceController.ts b/src/engine/InstanceController.ts index 767b89c5a..56f7c6f1f 100644 --- a/src/engine/InstanceController.ts +++ b/src/engine/InstanceController.ts @@ -7,10 +7,12 @@ import ZoneMap from '#/engine/zone/ZoneMap.js'; import { printDebug } from '#/util/Logger.js'; type InstanceRecord = { + uid: number; sw: CoordGrid; floors: number; zonesEast: number; zonesNorth: number; + exitCoord: CoordGrid | null; }; export default class InstanceController { @@ -42,15 +44,18 @@ export default class InstanceController { const slotZ: number = Math.trunc(this.nextInstancePointer / InstanceController.INSTANCES_PER_ROW); const baseX: number = 101 + slotX * 3; const baseZ: number = 1 + slotZ * 3; + const uid: number = this.nextInstancePointer; const sw: CoordGrid = { level: 0, x: baseX << 3, z: baseZ << 3 }; - printDebug(`[Instance] selected slot pointer=${this.nextInstancePointer}, slot=(${slotX},${slotZ}), sw=(${sw.x},${sw.z},L0)`); + printDebug(`[Instance] selected slot pointer=${uid}, slot=(${slotX},${slotZ}), sw=(${sw.x},${sw.z},L0)`); // Keep the instance metadata so we can later detect when it becomes empty again. this.instances.push({ + uid, sw, floors, zonesEast, - zonesNorth + zonesNorth, + exitCoord: null }); // Materialize the instance zones directly into the game's zone map. @@ -122,16 +127,22 @@ export default class InstanceController { * @returns The instance record if found, null otherwise. */ findInstanceByCoord(coord: CoordGrid): InstanceRecord | null { + return this.findInstanceByTile(coord.level, coord.x, coord.z); + } + + findInstanceByTile(level: number, x: number, z: number): InstanceRecord | null { for (const instance of this.instances) { - // Check if coord falls within this instance's footprint - if ( - coord.level >= instance.sw.level && - coord.level < instance.sw.level + instance.floors && - coord.x >= instance.sw.x && - coord.x < instance.sw.x + (instance.zonesEast << 3) && - coord.z >= instance.sw.z && - coord.z < instance.sw.z + (instance.zonesNorth << 3) - ) { + // Check if tile falls within this instance's footprint + if (level >= instance.sw.level && level < instance.sw.level + instance.floors && x >= instance.sw.x && x < instance.sw.x + (instance.zonesEast << 3) && z >= instance.sw.z && z < instance.sw.z + (instance.zonesNorth << 3)) { + return instance; + } + } + return null; + } + + findInstanceByUid(uid: number): InstanceRecord | null { + for (const instance of this.instances) { + if (instance.uid === uid) { return instance; } } @@ -161,7 +172,7 @@ export default class InstanceController { // Track occupied slots from active instance records to skip them during probing. const occupiedSlots: Set = new Set(); for (const instance of this.instances) { - occupiedSlots.add(this.getSlotPointer(instance.sw)); + occupiedSlots.add(instance.uid); } printDebug(`[Instance] findNextSlot start: nextPointer=${this.nextInstancePointer}, activeInstances=${this.instances.length}, occupiedSlots=${occupiedSlots.size}`); @@ -200,15 +211,6 @@ export default class InstanceController { this.nextInstancePointer = (this.nextInstancePointer + 1) % InstanceController.TOTAL_INSTANCES; } - // Convert a recorded southwest coordinate back into its slot index. - private getSlotPointer(sw: CoordGrid): number { - const zoneX: number = sw.x >> 3; - const zoneZ: number = sw.z >> 3; - const slotX: number = Math.trunc((zoneX - 101) / 3); - const slotZ: number = Math.trunc((zoneZ - 1) / 3); - return slotZ * InstanceController.INSTANCES_PER_ROW + slotX; - } - // Remove every zone belonging to this instance footprint from the world map. private deleteInstance(instance: InstanceRecord): void { // Remove each zone in the instance footprint from the live zone map and collision data. diff --git a/src/engine/entity/PathingEntity.ts b/src/engine/entity/PathingEntity.ts index 2d82973db..5be48c206 100644 --- a/src/engine/entity/PathingEntity.ts +++ b/src/engine/entity/PathingEntity.ts @@ -276,6 +276,27 @@ export default abstract class PathingEntity extends Entity { } level = Math.max(0, Math.min(level, 3)); + const previousX: number = this.x; + const previousZ: number = this.z; + const previousLevel: number = this.level; + + if (this instanceof Player) { + const movingToInstance: boolean = Player.isInstanceX(x); + if (movingToInstance) { + const targetInstance = World.instances.findInstanceByTile(level, x, z); + if (targetInstance?.exitCoord) { + this.previousOverworldX = targetInstance.exitCoord.x; + this.previousOverworldZ = targetInstance.exitCoord.z; + this.previousOverworldLevel = targetInstance.exitCoord.level; + } else { + this.previousOverworldX = previousX; + this.previousOverworldZ = previousZ; + this.previousOverworldLevel = previousLevel; + } + this.hasPreviousOverworldTile = true; + } + } + const allocated: boolean = isZoneAllocated(level, x, z); const initialized: boolean = World.gameMap.hasZone(x, z, level); if (!allocated || !initialized) { @@ -286,9 +307,6 @@ export default abstract class PathingEntity extends Entity { return false; } - const previousX: number = this.x; - const previousZ: number = this.z; - const previousLevel: number = this.level; this.x = x; this.z = z; this.level = level; diff --git a/src/engine/entity/Player.ts b/src/engine/entity/Player.ts index e0256fed0..addd9a22e 100644 --- a/src/engine/entity/Player.ts +++ b/src/engine/entity/Player.ts @@ -100,6 +100,13 @@ export function getExpByLevel(level: number) { } export default class Player extends PathingEntity { + // Instance tiles are hosted in a low-x reserved band; overworld tiles are outside this threshold. + static readonly INSTANCE_X_THRESHOLD: number = 2048; + + static isInstanceX(x: number): boolean { + return x < Player.INSTANCE_X_THRESHOLD; + } + static readonly DESIGN_BODY_COLORS: number[][] = [ [6798, 107, 10283, 16, 4797, 7744, 5799, 4634, 33697, 22433, 2983, 54193], [8741, 12, 64030, 43162, 7735, 8404, 1701, 38430, 24094, 10153, 56621, 4783, 1341, 16578, 35003, 25239], @@ -271,6 +278,12 @@ export default class Player extends PathingEntity { // last login info sav.p8(this.lastLoginTime); + // persistent overworld fallback tile used when logging back in from instance coords + sav.p2(this.previousOverworldX); + sav.p2(this.previousOverworldZ); + sav.p1(this.previousOverworldLevel); + sav.p1(this.hasPreviousOverworldTile ? 1 : 0); + sav.p4(Packet.getcrc(sav.data, 0, sav.pos)); return sav.data.subarray(0, sav.pos); } @@ -323,6 +336,13 @@ export default class Player extends PathingEntity { lastLevels: Uint8Array = new Uint8Array(21); // we track this so we know to flush stats only once a tick on changes originX: number = -1; originZ: number = -1; + + // Last known overworld tile; updated when transitioning from overworld -> instance. + previousOverworldX: number = 0; + previousOverworldZ: number = 0; + previousOverworldLevel: number = 0; + hasPreviousOverworldTile: boolean = false; + buildArea: BuildArea = new BuildArea(this); animProtect: number = 0; invListeners: InventoryListener[] = []; @@ -500,6 +520,12 @@ export default class Player extends PathingEntity { // - runenergy // - reset anims // - social + if (!Player.isInstanceX(this.x)) { + this.previousOverworldX = this.x; + this.previousOverworldZ = this.z; + this.previousOverworldLevel = this.level; + this.hasPreviousOverworldTile = true; + } this.buildArea.rebuildNormal(); this.write(new ChatFilterSettings(this.publicChat, this.privateChat, this.tradeDuel)); diff --git a/src/engine/entity/PlayerLoading.ts b/src/engine/entity/PlayerLoading.ts index 5f3252ca2..da6aec76b 100644 --- a/src/engine/entity/PlayerLoading.ts +++ b/src/engine/entity/PlayerLoading.ts @@ -11,7 +11,7 @@ import { fromBase37, toBase37 } from '#/util/JString.js'; export class PlayerLoading { public static readonly SAV_MAGIC: number = 0x2004; - public static readonly SAV_VERSION: number = 7; + public static readonly SAV_VERSION: number = 9; static verify(sav: Packet) { if (sav.g2() !== PlayerLoading.SAV_MAGIC) { @@ -160,6 +160,32 @@ export class PlayerLoading { player.lastLoginTime = sav.g8(); } + // persistent overworld fallback tile for instance logout/login recovery + if (version >= 9) { + player.previousOverworldX = sav.g2(); + player.previousOverworldZ = sav.g2(); + player.previousOverworldLevel = sav.g1(); + player.hasPreviousOverworldTile = sav.g1() === 1; + } else if (version >= 8) { + player.previousOverworldX = sav.g2(); + player.previousOverworldZ = sav.g2(); + player.previousOverworldLevel = sav.g1(); + const legacyDefaultFallback = player.previousOverworldX === 3094 && player.previousOverworldZ === 3106 && player.previousOverworldLevel === 0; + player.hasPreviousOverworldTile = !legacyDefaultFallback && !Player.isInstanceX(player.previousOverworldX); + } else if (!Player.isInstanceX(player.x)) { + player.previousOverworldX = player.x; + player.previousOverworldZ = player.z; + player.previousOverworldLevel = player.level; + player.hasPreviousOverworldTile = true; + } + + // Only relocate on login: if saved in an instance, return to last known overworld tile. + if (Player.isInstanceX(player.x) && player.hasPreviousOverworldTile && !Player.isInstanceX(player.previousOverworldX)) { + player.x = player.previousOverworldX; + player.z = player.previousOverworldZ; + player.level = player.previousOverworldLevel; + } + player.combatLevel = player.getCombatLevel(); return player; diff --git a/src/engine/script/ScriptOpcode.ts b/src/engine/script/ScriptOpcode.ts index 2c2a91064..553026d6b 100644 --- a/src/engine/script/ScriptOpcode.ts +++ b/src/engine/script/ScriptOpcode.ts @@ -65,6 +65,9 @@ export const enum ScriptOpcode { REGION_SET, REGION_GETCOORD, REGION_FINDBYCOORD, + REGION_UID, + REGION_FINDBYUID, + REGION_SETEXITCOORD, // Player ops (2000-2499) AFK_EVENT = 2000, @@ -520,6 +523,9 @@ export const ScriptOpcodeMap: Map = new Map([ ['REGION_SET', ScriptOpcode.REGION_SET], ['REGION_GETCOORD', ScriptOpcode.REGION_GETCOORD], ['REGION_FINDBYCOORD', ScriptOpcode.REGION_FINDBYCOORD], + ['REGION_UID', ScriptOpcode.REGION_UID], + ['REGION_FINDBYUID', ScriptOpcode.REGION_FINDBYUID], + ['REGION_SETEXITCOORD', ScriptOpcode.REGION_SETEXITCOORD], ['AFK_EVENT', ScriptOpcode.AFK_EVENT], ['ALLOWDESIGN', ScriptOpcode.ALLOWDESIGN], diff --git a/src/engine/script/ScriptState.ts b/src/engine/script/ScriptState.ts index 2f2bcb5c1..c61506323 100644 --- a/src/engine/script/ScriptState.ts +++ b/src/engine/script/ScriptState.ts @@ -103,12 +103,23 @@ export default class ScriptState { _activeObj2: Obj | null = null; /** - * The primary active region, represented by the instance southwest coord. + * The primary active region, represented by instance uid. + */ + _activeRegionUid: number = -1; + + /** + * The secondary active region, represented by instance uid. + */ + _activeRegionUid2: number = -1; + + /** + * Cached southwest coord for the primary active region. + * Kept for compatibility with region operations that use tile offsets. */ _activeRegion: CoordGrid | null = null; /** - * The secondary active region, represented by the instance southwest coord. + * Cached southwest coord for the secondary active region. */ _activeRegion2: CoordGrid | null = null; @@ -348,6 +359,28 @@ export default class ScriptState { } } + /** + * Gets the active region uid. Automatically checks the operand to determine primary and secondary. + */ + get activeRegionUid() { + const uid = this.intOperand === 0 ? this._activeRegionUid : this._activeRegionUid2; + if (uid < 0) { + throw new Error('Attempt to access null active_region_uid'); + } + return uid; + } + + /** + * Sets the active region uid. Automatically checks the operand to determine primary and secondary. + */ + set activeRegionUid(uid: number) { + if (this.intOperand === 0) { + this._activeRegionUid = uid; + } else { + this._activeRegionUid2 = uid; + } + } + get intOperand(): number { return this.script.intOperands[this.pc]; } diff --git a/src/engine/script/handlers/RegionOps.ts b/src/engine/script/handlers/RegionOps.ts index 64d1f8739..ec069d839 100644 --- a/src/engine/script/handlers/RegionOps.ts +++ b/src/engine/script/handlers/RegionOps.ts @@ -28,7 +28,13 @@ const RegionOps: CommandHandlers = { } const sw = World.instances.createInstance(levels, zonesEast, zonesNorth); + const instance = World.instances.findInstanceByCoord(sw); + if (!instance) { + throw new Error('region_create failed to resolve created instance uid'); + } + state.activeRegion = sw; + state.activeRegionUid = instance.uid; state.pointerAdd(ActiveRegion[secondary]); state.pushInt(CoordGrid.packCoord(sw.level, sw.x, sw.z)); }, @@ -91,12 +97,42 @@ const RegionOps: CommandHandlers = { if (instance) { state.activeRegion = instance.sw; + state.activeRegionUid = instance.uid; state.pointerAdd(ActiveRegion[secondary]); state.pushInt(1); } else { state.pushInt(0); } - } + }, + + [ScriptOpcode.REGION_UID]: checkedHandler(ActiveRegion, state => { + state.pushInt(state.activeRegionUid); + }), + + [ScriptOpcode.REGION_FINDBYUID]: state => { + const uid: number = state.popInt(); + const secondary: number = state.intOperand; + const instance = World.instances.findInstanceByUid(uid); + + if (instance) { + state.activeRegion = instance.sw; + state.activeRegionUid = instance.uid; + state.pointerAdd(ActiveRegion[secondary]); + state.pushInt(1); + } else { + state.pushInt(0); + } + }, + + [ScriptOpcode.REGION_SETEXITCOORD]: checkedHandler(ActiveRegion, state => { + const exitCoord: CoordGrid = check(state.popInt(), CoordValid); + const instance = World.instances.findInstanceByCoord(state.activeRegion); + if (!instance) { + throw new Error('region_setexitcoord requires active_region to reference a valid instance'); + } + + instance.exitCoord = { level: exitCoord.level, x: exitCoord.x, z: exitCoord.z }; + }) }; export default RegionOps; From fb549d5e81c013d33282f9febb36857a37132a52 Mon Sep 17 00:00:00 2001 From: markb5 Date: Thu, 14 May 2026 10:21:42 -0400 Subject: [PATCH 11/18] check zones/mapzones on movement --- src/engine/entity/NetworkPlayer.ts | 43 ++------------------------- src/engine/entity/PathingEntity.ts | 11 ++++++- src/engine/entity/Player.ts | 47 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 41 deletions(-) diff --git a/src/engine/entity/NetworkPlayer.ts b/src/engine/entity/NetworkPlayer.ts index 5d82f1709..07f99392b 100644 --- a/src/engine/entity/NetworkPlayer.ts +++ b/src/engine/entity/NetworkPlayer.ts @@ -23,7 +23,6 @@ import IfOpenSide from '#/network/game/server/model/IfOpenSide.js'; import Logout from '#/network/game/server/model/Logout.js'; import NpcInfo from '#/network/game/server/model/NpcInfo.js'; import PlayerInfo from '#/network/game/server/model/PlayerInfo.js'; -import SetMultiway from '#/network/game/server/model/SetMultiway.js'; import UpdateInvFull from '#/network/game/server/model/UpdateInvFull.js'; import UpdateRunEnergy from '#/network/game/server/model/UpdateRunEnergy.js'; import UpdateRunWeight from '#/network/game/server/model/UpdateRunWeight.js'; @@ -67,12 +66,7 @@ export class NetworkPlayer extends Player { this.restrictedLimit = 0; const bytesStart = this.client.in.pos; - while ( - this.userLimit < ClientGameProtCategory.USER_EVENT.limit && - this.clientLimit < ClientGameProtCategory.CLIENT_EVENT.limit && - this.restrictedLimit < ClientGameProtCategory.RESTRICTED_EVENT.limit && - this.read() - ) { + while (this.userLimit < ClientGameProtCategory.USER_EVENT.limit && this.clientLimit < ClientGameProtCategory.CLIENT_EVENT.limit && this.restrictedLimit < ClientGameProtCategory.RESTRICTED_EVENT.limit && this.read()) { // empty } const bytesRead = bytesStart - this.client.in.pos; @@ -258,39 +252,8 @@ export class NetworkPlayer extends Player { info.unlink(); } - // map zone changed - const mapZone = CoordGrid.packCoord(0, (this.x >> 6) << 6, (this.z >> 6) << 6); - if (this.lastMapZone !== mapZone) { - // map zone triggers - if (this.lastMapZone !== -1) { - const { x, z } = CoordGrid.unpackCoord(this.lastMapZone); - this.triggerMapzoneExit(x, z); - } - - this.triggerMapzone((this.x >> 6) << 6, (this.z >> 6) << 6); - this.lastMapZone = mapZone; - } - - // zone changed - const zone = CoordGrid.packCoord(this.level, (this.x >> 3) << 3, (this.z >> 3) << 3); - if (this.lastZone !== zone) { - this.buildArea.rebuildZones(); - - // zone triggers - const lastWasMulti = World.gameMap.isMulti(this.lastZone); - const nowIsMulti = World.gameMap.isMulti(zone); - if (lastWasMulti != nowIsMulti) { - this.write(new SetMultiway(nowIsMulti)); - } - - if (this.lastZone !== -1) { - const { level, x, z } = CoordGrid.unpackCoord(this.lastZone); - this.triggerZoneExit(level, x, z); - } - - this.triggerZone(this.level, (this.x >> 3) << 3, (this.z >> 3) << 3); - this.lastZone = zone; - } + // map/zone transition triggers are now queued from tile updates + // (movement/teleport/login path), not from client-out update. } updatePlayers() { diff --git a/src/engine/entity/PathingEntity.ts b/src/engine/entity/PathingEntity.ts index 5be48c206..cf15f6bda 100644 --- a/src/engine/entity/PathingEntity.ts +++ b/src/engine/entity/PathingEntity.ts @@ -124,6 +124,11 @@ export default abstract class PathingEntity extends Entity { abstract blockWalkFlag(): CollisionFlag; abstract defaultMoveSpeed(): MoveSpeed; + /** + * Hook for entity-specific logic when tile/level changes are applied. + */ + protected onTileUpdated(_previousX: number, _previousZ: number, _previousLevel: number): void {} + /** * Process movement function for a PathingEntity to use. * Checks for if this PathingEntity has any waypoints to move towards. @@ -162,8 +167,10 @@ export default abstract class PathingEntity extends Entity { * @param previousLevel Their previous recorded level position before movement. This one is important for teleport. */ private refreshZonePresence(previousX: number, previousZ: number, previousLevel: number): void { + const moved: boolean = this.x != previousX || this.z !== previousZ || this.level !== previousLevel; + // only update collision map when the entity moves. - if (this.x != previousX || this.z !== previousZ || this.level !== previousLevel) { + if (moved) { // update collision map // players and npcs both can change this collision switch (this.blockWalk) { @@ -224,6 +231,7 @@ export default abstract class PathingEntity extends Entity { this.focus(CoordGrid.fine(moveX, this.width), CoordGrid.fine(moveZ, this.length), false); this.stepsTaken++; this.refreshZonePresence(previousX, previousZ, this.level); + this.onTileUpdated(previousX, previousZ, this.level); if (this.waypointIndex !== -1) { const coord: CoordGrid = CoordGrid.unpackCoord(this.waypoints[this.waypointIndex]); @@ -315,6 +323,7 @@ export default abstract class PathingEntity extends Entity { const moveZ: number = CoordGrid.moveZ(this.z, dir); this.focus(CoordGrid.fine(moveX, this.width), CoordGrid.fine(moveZ, this.length), false); this.refreshZonePresence(previousX, previousZ, previousLevel); + this.onTileUpdated(previousX, previousZ, previousLevel); this.lastStepX = this.x - 1; this.lastStepZ = this.z; this.tele = true; diff --git a/src/engine/entity/Player.ts b/src/engine/entity/Player.ts index addd9a22e..684b87ef0 100644 --- a/src/engine/entity/Player.ts +++ b/src/engine/entity/Player.ts @@ -56,6 +56,7 @@ import MidiJingle from '#/network/game/server/model/MidiJingle.js'; import MidiSong from '#/network/game/server/model/MidiSong.js'; import ResetAnims from '#/network/game/server/model/ResetAnims.js'; import ResetClientVarCache from '#/network/game/server/model/ResetClientVarCache.js'; +import SetMultiway from '#/network/game/server/model/SetMultiway.js'; import TutOpen from '#/network/game/server/model/TutOpen.js'; import UnsetMapFlag from '#/network/game/server/model/UnsetMapFlag.js'; import UpdateInvStopTransmit from '#/network/game/server/model/UpdateInvStopTransmit.js'; @@ -528,6 +529,7 @@ export default class Player extends PathingEntity { } this.buildArea.rebuildNormal(); + this.queueZoneTransitionTriggers(this.x, this.z, this.level, true); this.write(new ChatFilterSettings(this.publicChat, this.privateChat, this.tradeDuel)); // todo: exact order @@ -560,6 +562,51 @@ export default class Player extends PathingEntity { this.isActive = true; } + protected override onTileUpdated(previousX: number, previousZ: number, previousLevel: number): void { + this.queueZoneTransitionTriggers(previousX, previousZ, previousLevel, false); + } + + private queueZoneTransitionTriggers(previousX: number, previousZ: number, previousLevel: number, initialLogin: boolean): void { + const previousMapZoneX = (previousX >> 6) << 6; + const previousMapZoneZ = (previousZ >> 6) << 6; + const currentMapZoneX = (this.x >> 6) << 6; + const currentMapZoneZ = (this.z >> 6) << 6; + + const previousZoneX = (previousX >> 3) << 3; + const previousZoneZ = (previousZ >> 3) << 3; + const currentZoneX = (this.x >> 3) << 3; + const currentZoneZ = (this.z >> 3) << 3; + + const mapZoneChanged: boolean = initialLogin || previousMapZoneX !== currentMapZoneX || previousMapZoneZ !== currentMapZoneZ; + if (mapZoneChanged) { + if (!initialLogin) { + this.triggerMapzoneExit(previousMapZoneX, previousMapZoneZ); + } + this.triggerMapzone(currentMapZoneX, currentMapZoneZ); + this.lastMapZone = CoordGrid.packCoord(0, currentMapZoneX, currentMapZoneZ); + } + + const zoneChanged: boolean = initialLogin || previousLevel !== this.level || previousZoneX !== currentZoneX || previousZoneZ !== currentZoneZ; + if (zoneChanged) { + this.buildArea.rebuildZones(); + + if (!initialLogin) { + const previousZoneCoord = CoordGrid.packCoord(previousLevel, previousZoneX, previousZoneZ); + const currentZoneCoord = CoordGrid.packCoord(this.level, currentZoneX, currentZoneZ); + const lastWasMulti = World.gameMap.isMulti(previousZoneCoord); + const nowIsMulti = World.gameMap.isMulti(currentZoneCoord); + if (lastWasMulti !== nowIsMulti) { + this.write(new SetMultiway(nowIsMulti)); + } + + this.triggerZoneExit(previousLevel, previousZoneX, previousZoneZ); + } + + this.triggerZone(this.level, currentZoneX, currentZoneZ); + this.lastZone = CoordGrid.packCoord(this.level, currentZoneX, currentZoneZ); + } + } + onReconnect() { // - varp_reset // - varps From 74fe79274e38a1926d0c37ca171ae70367ace3a3 Mon Sep 17 00:00:00 2001 From: markb5 Date: Thu, 14 May 2026 16:06:26 -0400 Subject: [PATCH 12/18] zone triggers copied to instances --- src/engine/entity/Player.ts | 40 ++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/engine/entity/Player.ts b/src/engine/entity/Player.ts index 684b87ef0..bc74ce8f6 100644 --- a/src/engine/entity/Player.ts +++ b/src/engine/entity/Player.ts @@ -45,6 +45,7 @@ import ScriptRunner from '#/engine/script/ScriptRunner.js'; import ScriptState from '#/engine/script/ScriptState.js'; import ServerTriggerType from '#/engine/script/ServerTriggerType.js'; import World from '#/engine/World.js'; +import InstanceZone from '#/engine/zone/InstanceZone.js'; import Packet from '#/io/Packet.js'; import ChatFilterSettings from '#/network/game/server/model/ChatFilterSettings.js'; import HintArrow from '#/network/game/server/model/HintArrow.js'; @@ -567,17 +568,21 @@ export default class Player extends PathingEntity { } private queueZoneTransitionTriggers(previousX: number, previousZ: number, previousLevel: number, initialLogin: boolean): void { - const previousMapZoneX = (previousX >> 6) << 6; - const previousMapZoneZ = (previousZ >> 6) << 6; - const currentMapZoneX = (this.x >> 6) << 6; - const currentMapZoneZ = (this.z >> 6) << 6; - const previousZoneX = (previousX >> 3) << 3; const previousZoneZ = (previousZ >> 3) << 3; const currentZoneX = (this.x >> 3) << 3; const currentZoneZ = (this.z >> 3) << 3; - const mapZoneChanged: boolean = initialLogin || previousMapZoneX !== currentMapZoneX || previousMapZoneZ !== currentMapZoneZ; + const previousTriggerZone = this.resolveTriggerZoneBase(previousZoneX, previousZoneZ, previousLevel); + const currentTriggerZone = this.resolveTriggerZoneBase(currentZoneX, currentZoneZ, this.level); + + const previousMapZoneX = (previousTriggerZone.x >> 6) << 6; + const previousMapZoneZ = (previousTriggerZone.z >> 6) << 6; + const currentMapZoneX = (currentTriggerZone.x >> 6) << 6; + const currentMapZoneZ = (currentTriggerZone.z >> 6) << 6; + + const mapZoneBoundaryChanged: boolean = initialLogin || (previousX >> 6) << 6 !== (this.x >> 6) << 6 || (previousZ >> 6) << 6 !== (this.z >> 6) << 6; + const mapZoneChanged: boolean = initialLogin || (mapZoneBoundaryChanged && (previousMapZoneX !== currentMapZoneX || previousMapZoneZ !== currentMapZoneZ)); if (mapZoneChanged) { if (!initialLogin) { this.triggerMapzoneExit(previousMapZoneX, previousMapZoneZ); @@ -599,14 +604,31 @@ export default class Player extends PathingEntity { this.write(new SetMultiway(nowIsMulti)); } - this.triggerZoneExit(previousLevel, previousZoneX, previousZoneZ); + this.triggerZoneExit(previousTriggerZone.level, previousTriggerZone.x, previousTriggerZone.z); } - this.triggerZone(this.level, currentZoneX, currentZoneZ); - this.lastZone = CoordGrid.packCoord(this.level, currentZoneX, currentZoneZ); + this.triggerZone(currentTriggerZone.level, currentTriggerZone.x, currentTriggerZone.z); + this.lastZone = CoordGrid.packCoord(currentTriggerZone.level, currentTriggerZone.x, currentTriggerZone.z); } } + private resolveTriggerZoneBase(zoneX: number, zoneZ: number, level: number): CoordGrid { + const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, level); + if (zone instanceof InstanceZone) { + return { + level: zone.source.level, + x: zone.source.x << 3, + z: zone.source.z << 3 + }; + } + + return { + level, + x: zoneX, + z: zoneZ + }; + } + onReconnect() { // - varp_reset // - varps From c734becbd092ad3c266e9d1ce45447a23822416e Mon Sep 17 00:00:00 2001 From: markb5 Date: Fri, 15 May 2026 17:14:53 -0400 Subject: [PATCH 13/18] remove collision from spawned npcs --- src/engine/World.ts | 10 ----- src/engine/zone/InstanceZone.ts | 65 ++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/engine/World.ts b/src/engine/World.ts index f61e9f136..93ac2d261 100644 --- a/src/engine/World.ts +++ b/src/engine/World.ts @@ -1279,16 +1279,6 @@ class World { const zone = this.gameMap.getZone(npc.x, npc.z, npc.level); zone.enter(npc); - switch (npc.blockWalk) { - case BlockWalk.NPC: - changeNpcCollision(npc.width, npc.x, npc.z, npc.level, true); - break; - case BlockWalk.ALL: - changeNpcCollision(npc.width, npc.x, npc.z, npc.level, true); - changePlayerCollision(npc.width, npc.x, npc.z, npc.level, true); - break; - } - npc.resetEntity(true); npc.playAnimation(-1, 0); diff --git a/src/engine/zone/InstanceZone.ts b/src/engine/zone/InstanceZone.ts index b67d4b69b..cab4af0f8 100644 --- a/src/engine/zone/InstanceZone.ts +++ b/src/engine/zone/InstanceZone.ts @@ -103,12 +103,16 @@ export default class InstanceZone extends Zone { } private copyCollisionWithRotation(sourceZone: Zone, rotation: 0 | 1 | 2 | 3): void { - const sourceCollision = routeFinder.collisionFlags.getZone(sourceZone.index & 0x7ff, (sourceZone.index >> 11) & 0x7ff, (sourceZone.index >> 22) & 0x3); + const sourceBaseX = sourceZone.x << 3; + const sourceBaseZ = sourceZone.z << 3; + const sourceCollision = routeFinder.collisionFlags.getZone(sourceBaseX, sourceBaseZ, sourceZone.level); if (!sourceCollision) { return; // No collision to copy } const destCollision = new Uint32Array(64); + const destBaseX = this.x << 3; + const destBaseZ = this.z << 3; // Iterate through the 8x8 zone and apply rotation transformations for (let srcIdx = 0; srcIdx < 64; srcIdx++) { @@ -144,7 +148,64 @@ export default class InstanceZone extends Zone { } // Write rotated collision data to destination zone - routeFinder.collisionFlags.setZone(this.index & 0x7ff, (this.index >> 11) & 0x7ff, (this.index >> 22) & 0x3, destCollision); + routeFinder.collisionFlags.setZone(destBaseX, destBaseZ, this.level, destCollision); + + // Copying a single 8x8 can miss the mirrored half of edge walls. + // Emit outward-facing mirrors onto adjacent tiles so crossing the zone boundary + // is blocked consistently even when neighboring zones are not copied. + this.mirrorOutboundEdgeWalls(destCollision, destBaseX, destBaseZ, this.level); + } + + private mirrorOutboundEdgeWalls(destCollision: Uint32Array, destBaseX: number, destBaseZ: number, level: number): void { + const mirror = (flags: number, sourceMask: number, dstX: number, dstZ: number, mirrorMask: number): void => { + if (flags & sourceMask && World.gameMap.hasZone(dstX, dstZ, level)) { + routeFinder.collisionFlags.add(dstX, dstZ, level, mirrorMask); + } + }; + + for (let localZ = 0; localZ < 8; localZ++) { + const westFlags = destCollision[localZ << 3]; + mirror(westFlags, CollisionFlag.WALL_WEST, destBaseX - 1, destBaseZ + localZ, CollisionFlag.WALL_EAST); + mirror(westFlags, CollisionFlag.WALL_WEST_PROJ_BLOCKER, destBaseX - 1, destBaseZ + localZ, CollisionFlag.WALL_EAST_PROJ_BLOCKER); + mirror(westFlags, CollisionFlag.WALL_WEST_ROUTE_BLOCKER, destBaseX - 1, destBaseZ + localZ, CollisionFlag.WALL_EAST_ROUTE_BLOCKER); + + const eastFlags = destCollision[7 | (localZ << 3)]; + mirror(eastFlags, CollisionFlag.WALL_EAST, destBaseX + 8, destBaseZ + localZ, CollisionFlag.WALL_WEST); + mirror(eastFlags, CollisionFlag.WALL_EAST_PROJ_BLOCKER, destBaseX + 8, destBaseZ + localZ, CollisionFlag.WALL_WEST_PROJ_BLOCKER); + mirror(eastFlags, CollisionFlag.WALL_EAST_ROUTE_BLOCKER, destBaseX + 8, destBaseZ + localZ, CollisionFlag.WALL_WEST_ROUTE_BLOCKER); + } + + for (let localX = 0; localX < 8; localX++) { + const southFlags = destCollision[localX]; + mirror(southFlags, CollisionFlag.WALL_SOUTH, destBaseX + localX, destBaseZ - 1, CollisionFlag.WALL_NORTH); + mirror(southFlags, CollisionFlag.WALL_SOUTH_PROJ_BLOCKER, destBaseX + localX, destBaseZ - 1, CollisionFlag.WALL_NORTH_PROJ_BLOCKER); + mirror(southFlags, CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER, destBaseX + localX, destBaseZ - 1, CollisionFlag.WALL_NORTH_ROUTE_BLOCKER); + + const northFlags = destCollision[localX | (7 << 3)]; + mirror(northFlags, CollisionFlag.WALL_NORTH, destBaseX + localX, destBaseZ + 8, CollisionFlag.WALL_SOUTH); + mirror(northFlags, CollisionFlag.WALL_NORTH_PROJ_BLOCKER, destBaseX + localX, destBaseZ + 8, CollisionFlag.WALL_SOUTH_PROJ_BLOCKER); + mirror(northFlags, CollisionFlag.WALL_NORTH_ROUTE_BLOCKER, destBaseX + localX, destBaseZ + 8, CollisionFlag.WALL_SOUTH_ROUTE_BLOCKER); + } + + const southWestFlags = destCollision[0]; + mirror(southWestFlags, CollisionFlag.WALL_SOUTH_WEST, destBaseX - 1, destBaseZ - 1, CollisionFlag.WALL_NORTH_EAST); + mirror(southWestFlags, CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER, destBaseX - 1, destBaseZ - 1, CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER); + mirror(southWestFlags, CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER, destBaseX - 1, destBaseZ - 1, CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER); + + const southEastFlags = destCollision[7]; + mirror(southEastFlags, CollisionFlag.WALL_SOUTH_EAST, destBaseX + 8, destBaseZ - 1, CollisionFlag.WALL_NORTH_WEST); + mirror(southEastFlags, CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER, destBaseX + 8, destBaseZ - 1, CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER); + mirror(southEastFlags, CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER, destBaseX + 8, destBaseZ - 1, CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER); + + const northWestFlags = destCollision[7 << 3]; + mirror(northWestFlags, CollisionFlag.WALL_NORTH_WEST, destBaseX - 1, destBaseZ + 8, CollisionFlag.WALL_SOUTH_EAST); + mirror(northWestFlags, CollisionFlag.WALL_NORTH_WEST_PROJ_BLOCKER, destBaseX - 1, destBaseZ + 8, CollisionFlag.WALL_SOUTH_EAST_PROJ_BLOCKER); + mirror(northWestFlags, CollisionFlag.WALL_NORTH_WEST_ROUTE_BLOCKER, destBaseX - 1, destBaseZ + 8, CollisionFlag.WALL_SOUTH_EAST_ROUTE_BLOCKER); + + const northEastFlags = destCollision[7 | (7 << 3)]; + mirror(northEastFlags, CollisionFlag.WALL_NORTH_EAST, destBaseX + 8, destBaseZ + 8, CollisionFlag.WALL_SOUTH_WEST); + mirror(northEastFlags, CollisionFlag.WALL_NORTH_EAST_PROJ_BLOCKER, destBaseX + 8, destBaseZ + 8, CollisionFlag.WALL_SOUTH_WEST_PROJ_BLOCKER); + mirror(northEastFlags, CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER, destBaseX + 8, destBaseZ + 8, CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER); } private rotateCollisionFlags(flags: number, rotation: 0 | 1 | 2 | 3): number { From 13594c37173db0ea3d43146d66ea4d06999a7c48 Mon Sep 17 00:00:00 2001 From: markb5 Date: Sat, 16 May 2026 15:08:56 -0400 Subject: [PATCH 14/18] zone instantiation --- src/engine/GameMap.ts | 8 ++ src/engine/InstanceController.ts | 180 ++++++++++++++++++------ src/engine/World.ts | 133 ++++++++++++++--- src/engine/entity/BuildArea.ts | 2 +- src/engine/entity/NetworkPlayer.ts | 5 +- src/engine/entity/PathingEntity.ts | 23 ++- src/engine/script/ScriptIterators.ts | 55 +++++--- src/engine/script/handlers/ServerOps.ts | 11 +- src/engine/zone/InstanceZone.ts | 18 ++- src/engine/zone/ZoneMap.ts | 50 +++++-- 10 files changed, 378 insertions(+), 107 deletions(-) diff --git a/src/engine/GameMap.ts b/src/engine/GameMap.ts index 883ad99e5..faa846699 100644 --- a/src/engine/GameMap.ts +++ b/src/engine/GameMap.ts @@ -102,6 +102,10 @@ export default class GameMap { return this.zonemap.getZoneByIndex(zoneIndex); } + getZoneIndexIfExists(zoneIndex: number): Zone | null { + return this.zonemap.getZoneByIndexIfExists(zoneIndex); + } + /** * Get a zone if it exists, or null if it hasn't been created. */ @@ -127,6 +131,10 @@ export default class GameMap { return this.zonemap.hasZone(x, z, level); } + isInitializing(): boolean { + return this.zonemap.isInitializingMap(); + } + addZone(zone: Zone): Zone { return this.zonemap.addZone(zone); } diff --git a/src/engine/InstanceController.ts b/src/engine/InstanceController.ts index 56f7c6f1f..93d14840e 100644 --- a/src/engine/InstanceController.ts +++ b/src/engine/InstanceController.ts @@ -25,30 +25,46 @@ export default class InstanceController { static readonly INSTANCE_SIZE_TILES: number = 128; static readonly INSTANCE_GAP_TILES: number = 64; static readonly INSTANCE_SW_STRIDE_TILES: number = InstanceController.INSTANCE_SIZE_TILES + InstanceController.INSTANCE_GAP_TILES; + static readonly MAX_MISSING_SOURCE_ZONE_LOGS: number = 5; + static readonly DEBUG_INSTANCE_COPY_VERBOSE: boolean = false; nextInstancePointer: number = 0; readonly instances: InstanceRecord[] = []; + private missingSourceZoneLogCount: number = 0; // --- // Public methods // --- + /** + * Create a new instance record and reserve a slot in the global instance grid. + * This exists to give scripts a stable instance identity/footprint first, + * while zones are materialized lazily as each chunk is copied in. + */ createInstance(floors: number, zonesEast: number, zonesNorth: number): CoordGrid { + this.missingSourceZoneLogCount = 0; + // Reclaim all stale instance slots, then find the next available slot pointer. - printDebug(`[Instance] createInstance request floors=${floors}, zonesEast=${zonesEast}, zonesNorth=${zonesNorth}, nextPointer=${this.nextInstancePointer}`); + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { + printDebug(`[Instance] createInstance request floors=${floors}, zonesEast=${zonesEast}, zonesNorth=${zonesNorth}, nextPointer=${this.nextInstancePointer}`); + } this.clearStaleInstances(); this.findNextSlot(); // Instances are laid out in a fixed grid of 128x128 tiles, with a 64-tile buffer between them. const slotX: number = this.nextInstancePointer % InstanceController.INSTANCES_PER_ROW; const slotZ: number = Math.trunc(this.nextInstancePointer / InstanceController.INSTANCES_PER_ROW); - const baseX: number = 101 + slotX * 3; - const baseZ: number = 1 + slotZ * 3; + const firstMapsquareX: number = InstanceController.FIRST_INSTANCE_SW_MAPSQUARE >> 8; + const firstMapsquareZ: number = InstanceController.FIRST_INSTANCE_SW_MAPSQUARE & 0xff; + const baseTileX: number = (firstMapsquareX << 6) + slotX * InstanceController.INSTANCE_SW_STRIDE_TILES; + const baseTileZ: number = (firstMapsquareZ << 6) + slotZ * InstanceController.INSTANCE_SW_STRIDE_TILES; const uid: number = this.nextInstancePointer; - const sw: CoordGrid = { level: 0, x: baseX << 3, z: baseZ << 3 }; - printDebug(`[Instance] selected slot pointer=${uid}, slot=(${slotX},${slotZ}), sw=(${sw.x},${sw.z},L0)`); + const sw: CoordGrid = { level: 0, x: baseTileX, z: baseTileZ }; + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { + printDebug(`[Instance] selected slot pointer=${uid}, slot=(${slotX},${slotZ}), sw=(${sw.x},${sw.z},L0)`); + } - // Keep the instance metadata so we can later detect when it becomes empty again. + // Keep only the instance metadata here; zones are materialized lazily when copied from the overworld. this.instances.push({ uid, sw, @@ -58,51 +74,67 @@ export default class InstanceController { exitCoord: null }); - // Materialize the instance zones directly into the game's zone map. - for (let level: number = 0; level < floors; level++) { - for (let east: number = 0; east < zonesEast; east++) { - for (let north: number = 0; north < zonesNorth; north++) { - const x: number = (baseX + east) << 3; - const z: number = (baseZ + north) << 3; - const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); - - const existingZone: boolean = World.gameMap.hasZone(x, z, level); - const allocatedCollision: boolean = isZoneAllocated(level, x, z); - if (existingZone || allocatedCollision) { - printDebug(`[Instance] WARNING: creating instance zone where state already exists index=${zoneIndex} coord=(${x},${z},L${level}) existingZone=${existingZone} allocatedCollision=${allocatedCollision}`); - } - - World.gameMap.createInstanceZone(zoneIndex); - routeFinder.allocateIfAbsent(x, z, level); - } - } - } - this.incrementSlotPointer(); return sw; } + /** + * Copy one source zone into a destination chunk of an existing instance. + * This is purposed to be the single controlled path for lazy instance chunk + * creation, bounds enforcement, and source->instance template assignment. + */ copyZone(instanceSw: CoordGrid, instanceOffset: CoordGrid, source: CoordGrid, rotation: 0 | 1 | 2 | 3): void { + const instance = this.instances.find(candidate => candidate.sw.level === instanceSw.level && candidate.sw.x === instanceSw.x && candidate.sw.z === instanceSw.z); + if (!instance) { + throw new Error(`copyZone failed: instance not found at sw=(${instanceSw.x}, ${instanceSw.z}, L${instanceSw.level})`); + } + + if (instanceOffset.level < 0 || instanceOffset.level >= instance.floors || instanceOffset.x < 0 || instanceOffset.x >= instance.zonesEast || instanceOffset.z < 0 || instanceOffset.z >= instance.zonesNorth) { + throw new Error( + `copyZone out of bounds: offset=(${instanceOffset.x}, ${instanceOffset.z}, L${instanceOffset.level}) size=(${instance.zonesEast}, ${instance.zonesNorth}, floors=${instance.floors}) sw=(${instanceSw.x}, ${instanceSw.z}, L${instanceSw.level})` + ); + } + const target: CoordGrid = { level: instanceSw.level + instanceOffset.level, x: instanceSw.x + (instanceOffset.x << 3), z: instanceSw.z + (instanceOffset.z << 3) }; - const targetZone = World.gameMap.getZone(target.x, target.z, target.level); - const sourceZone = World.gameMap.getZone(source.x, source.z, source.level); + const targetZone = this.ensureInstanceZone(target.x, target.z, target.level); + const sourceZone = World.gameMap.getZoneIfExists(source.x, source.z, source.level); + + if (!sourceZone) { + // Keep template metadata so client rebuild can still draw this chunk, + // even when the source zone is not materialized server-side. + targetZone.source = { level: source.level, x: source.x >> 3, z: source.z >> 3 }; + targetZone.rotation = rotation; + + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { + if (this.missingSourceZoneLogCount < InstanceController.MAX_MISSING_SOURCE_ZONE_LOGS) { + printDebug(`[Instance] copyZone skipped: source zone missing src=(${source.x},${source.z},L${source.level}) -> dst=(${target.x},${target.z},L${target.level})`); + } else if (this.missingSourceZoneLogCount === InstanceController.MAX_MISSING_SOURCE_ZONE_LOGS) { + printDebug('[Instance] copyZone: additional missing source-zone logs suppressed for this instance creation'); + } + } + this.missingSourceZoneLogCount++; + return; + } - if (targetZone instanceof InstanceZone) { - targetZone.copyFromZone(sourceZone, rotation); + targetZone.copyFromZone(sourceZone, rotation); + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { printDebug(`[Instance] Zone copy complete: src=(${source.x},${source.z},L${source.level}) -> dst=(${target.x},${target.z},L${target.level}) rot=${rotation} locs=${targetZone.totalLocs}`); for (const loc of targetZone.getAllLocsSafe()) { printDebug(` [Loc] type=${loc.type} at (${loc.x},${loc.z},L${loc.level}) shape=${loc.shape} angle=${loc.angle} active=${loc.isActive}`); } - } else { - printDebug(`[Instance] ERROR: Target zone is not InstanceZone, it's ${targetZone.constructor.name}`); } } + /** + * Check whether an instance has any players in currently materialized zones. + * This exists to support reclaiming empty instances and avoid accumulating + * stale zones/collision state. + */ isInstanceEmpty(instance: InstanceRecord): boolean { // Any player in any zone covered by this instance means the instance is still in use. for (let level: number = 0; level < instance.floors; level++) { @@ -110,8 +142,8 @@ export default class InstanceController { for (let north: number = 0; north < instance.zonesNorth; north++) { const x: number = instance.sw.x + (east << 3); const z: number = instance.sw.z + (north << 3); - const zone = World.gameMap.getZone(x, z, level); - if (zone.hasPlayers()) { + const zone = World.gameMap.getZoneIfExists(x, z, level); + if (zone && zone.hasPlayers()) { return false; } } @@ -122,14 +154,18 @@ export default class InstanceController { } /** - * Find an instance that contains the given coordinate. - * @param coord The coordinate to search for. - * @returns The instance record if found, null otherwise. + * Resolve an instance by absolute coordinate. + * This exists for callers that start from packed/world coordinates and need + * to recover instance context. */ findInstanceByCoord(coord: CoordGrid): InstanceRecord | null { return this.findInstanceByTile(coord.level, coord.x, coord.z); } + /** + * Resolve an instance by raw level/x/z tile values. + * This is the core containment test used by teleport/login/instance checks. + */ findInstanceByTile(level: number, x: number, z: number): InstanceRecord | null { for (const instance of this.instances) { // Check if tile falls within this instance's footprint @@ -140,6 +176,11 @@ export default class InstanceController { return null; } + /** + * Resolve an instance by its stable uid. + * This exists because script/runtime flows store uid handles and need + * deterministic lookup. + */ findInstanceByUid(uid: number): InstanceRecord | null { for (const instance of this.instances) { if (instance.uid === uid) { @@ -153,7 +194,11 @@ export default class InstanceController { // Private methods // --- - // Remove all stale instances before selecting the next slot. + /** + * Reclaim all stale instance records that no longer contain players. + * This is purposed to keep slot reuse and cleanup opportunistic during + * instance creation. + */ private clearStaleInstances(): void { // Walk backward so removals do not disturb the remaining indices. for (let index: number = this.instances.length - 1; index >= 0; index--) { @@ -167,7 +212,11 @@ export default class InstanceController { } } - // Find the next pointer using SW-zone occupancy as the canonical slot marker. + /** + * Select the next free slot pointer in the instance grid. + * This exists to prevent slot collisions and keep instance placement + * deterministic. + */ private findNextSlot(): void { // Track occupied slots from active instance records to skip them during probing. const occupiedSlots: Set = new Set(); @@ -175,7 +224,9 @@ export default class InstanceController { occupiedSlots.add(instance.uid); } - printDebug(`[Instance] findNextSlot start: nextPointer=${this.nextInstancePointer}, activeInstances=${this.instances.length}, occupiedSlots=${occupiedSlots.size}`); + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { + printDebug(`[Instance] findNextSlot start: nextPointer=${this.nextInstancePointer}, activeInstances=${this.instances.length}, occupiedSlots=${occupiedSlots.size}`); + } for (let attempts: number = 0; attempts < InstanceController.TOTAL_INSTANCES; attempts++) { const pointer: number = (this.nextInstancePointer + attempts) % InstanceController.TOTAL_INSTANCES; @@ -185,20 +236,24 @@ export default class InstanceController { const slotX: number = pointer % InstanceController.INSTANCES_PER_ROW; const slotZ: number = Math.trunc(pointer / InstanceController.INSTANCES_PER_ROW); - const swX: number = (101 + slotX * 3) << 3; - const swZ: number = (1 + slotZ * 3) << 3; + const firstMapsquareX: number = InstanceController.FIRST_INSTANCE_SW_MAPSQUARE >> 8; + const firstMapsquareZ: number = InstanceController.FIRST_INSTANCE_SW_MAPSQUARE & 0xff; + const swX: number = (firstMapsquareX << 6) + slotX * InstanceController.INSTANCE_SW_STRIDE_TILES; + const swZ: number = (firstMapsquareZ << 6) + slotZ * InstanceController.INSTANCE_SW_STRIDE_TILES; const allocated: boolean = isZoneAllocated(0, swX, swZ); const hasZone: boolean = World.gameMap.hasZone(swX, swZ, 0); // If this slot's SW zone is occupied, the slot is considered in use. if (allocated || hasZone) { - if (attempts < 10) { + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE && attempts < 10) { printDebug(`[Instance] slot ${pointer} blocked: sw=(${swX},${swZ},L0) allocated=${allocated} hasZone=${hasZone}`); } continue; } - printDebug(`[Instance] slot ${pointer} available: sw=(${swX},${swZ},L0)`); + if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { + printDebug(`[Instance] slot ${pointer} available: sw=(${swX},${swZ},L0)`); + } this.nextInstancePointer = pointer; return; } @@ -206,13 +261,22 @@ export default class InstanceController { throw new Error('[InstanceController] No available instance slots found.'); } - // Move pointer forward by one slot and wrap around at capacity. + /** + * Advance the slot pointer with wrap-around. + * This is purposed to keep scanning fair across the fixed-capacity + * instance grid. + */ private incrementSlotPointer(): void { this.nextInstancePointer = (this.nextInstancePointer + 1) % InstanceController.TOTAL_INSTANCES; } - // Remove every zone belonging to this instance footprint from the world map. + /** + * Tear down all materialized zones and collision in the instance footprint. + * This exists so slot reuse cannot leak world-state from prior instances. + */ private deleteInstance(instance: InstanceRecord): void { + printDebug(`[Instance] deleting instance uid=${instance.uid} sw=(${instance.sw.x},${instance.sw.z},L${instance.sw.level}) floors=${instance.floors} size=${instance.zonesEast}x${instance.zonesNorth}`); + // Remove each zone in the instance footprint from the live zone map and collision data. for (let level: number = 0; level < instance.floors; level++) { for (let east: number = 0; east < instance.zonesEast; east++) { @@ -225,5 +289,29 @@ export default class InstanceController { } } } + + printDebug(`[Instance] deleted instance uid=${instance.uid}`); + } + + /** + * Ensure a destination instance zone exists and has collision storage allocated. + * This exists because chunks are created lazily and copy operations need a + * safe one-stop materializer. + */ + private ensureInstanceZone(x: number, z: number, level: number): InstanceZone { + const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); + const existingZone = World.gameMap.getZoneIfExists(x, z, level); + + if (!existingZone) { + const zone = World.gameMap.createInstanceZone(zoneIndex) as InstanceZone; + routeFinder.allocateIfAbsent(x, z, level); + return zone; + } + + if (!(existingZone instanceof InstanceZone)) { + throw new Error(`Instance zone collision at (${x}, ${z}, L${level})`); + } + + return existingZone; } } diff --git a/src/engine/World.ts b/src/engine/World.ts index 93ac2d261..446156d0b 100644 --- a/src/engine/World.ts +++ b/src/engine/World.ts @@ -93,7 +93,7 @@ import { import Environment from '#/util/Environment.js'; import { fromBase37, toBase37, toSafeName } from '#/util/JString.js'; import LinkList from '#/datastruct/LinkList.js'; -import { printDebug, printError, printInfo } from '#/util/Logger.js'; +import { printDebug, printError, printInfo, printWarning } from '#/util/Logger.js'; import { WalkTriggerSetting } from '#/engine/entity/WalkTriggerSetting.js'; import OnDemand from './OnDemand.js'; @@ -944,7 +944,35 @@ class World { player.tele = true; player.moveClickRequest = false; - this.gameMap.getZone(player.x, player.z, player.level).enter(player); + const loginInstance = this.instances.findInstanceByTile(player.level, player.x, player.z); + if (loginInstance) { + const exitCoord = loginInstance.exitCoord; + const hasValidExit = exitCoord && this.gameMap.hasZone(exitCoord.x, exitCoord.z, exitCoord.level); + + if (hasValidExit && exitCoord) { + printWarning(`[World] Player login: player ${player.username} was in an instance, moving to exit at (${exitCoord.x}, ${exitCoord.z}, L${exitCoord.level})`); + player.x = exitCoord.x; + player.z = exitCoord.z; + player.level = exitCoord.level; + } else { + printWarning(`[World] Player login: player ${player.username} was in an instance with invalid exit, teleporting to Lumbridge failsafe`); + player.x = 3222; + player.z = 3222; + player.level = 0; + } + } + + const zone = this.gameMap.getZoneIfExists(player.x, player.z, player.level); + if (zone) { + zone.enter(player); + } else { + printWarning(`[World] Player login: zone does not exist at (${player.x}, ${player.z}, L${player.level}), teleporting to default spawn`); + player.x = 3222; + player.z = 3222; + player.level = 0; + const defaultZone = this.gameMap.getZone(player.x, player.z, player.level); + defaultZone.enter(player); + } player.onLogin(); if (this.shutdownTick != -1) { @@ -1276,7 +1304,14 @@ class World { npc.z = npc.startZ; npc.isActive = true; - const zone = this.gameMap.getZone(npc.x, npc.z, npc.level); + // During initialization, zones can auto-create. After init, zones must be pre-created. + const zone = this.gameMap.isInitializing() ? this.gameMap.getZone(npc.x, npc.z, npc.level) : this.gameMap.getZoneIfExists(npc.x, npc.z, npc.level); + + if (!zone) { + printWarning(`[World] addNpc: zone does not exist at (${npc.x}, ${npc.z}, L${npc.level}), NPC spawn failed`); + npc.isActive = false; + return; + } zone.enter(npc); npc.resetEntity(true); @@ -1295,9 +1330,11 @@ class World { } removeNpc(npc: Npc, duration: number): void { - const zone = this.gameMap.getZone(npc.x, npc.z, npc.level); + const zone = this.gameMap.getZoneIfExists(npc.x, npc.z, npc.level); const adjustedDuration = this.scaleByPlayerCount(duration); - zone.leave(npc); + if (zone) { + zone.leave(npc); + } npc.isActive = false; switch (npc.blockWalk) { @@ -1320,15 +1357,18 @@ class World { } getLoc(x: number, z: number, level: number, locId: number): Loc | null { - return this.gameMap.getZone(x, z, level).getLoc(x, z, locId); + const zone = this.gameMap.getZoneIfExists(x, z, level); + return zone ? zone.getLoc(x, z, locId) : null; } getObj(x: number, z: number, level: number, objId: number, receiver64: bigint): Obj | null { - return this.gameMap.getZone(x, z, level).getObj(x, z, objId, receiver64); + const zone = this.gameMap.getZoneIfExists(x, z, level); + return zone ? zone.getObj(x, z, objId, receiver64) : null; } getObjOfReceiver(x: number, z: number, level: number, objId: number, receiver64: bigint): Obj | null { - return this.gameMap.getZone(x, z, level).getObjOfReceiver(x, z, objId, receiver64); + const zone = this.gameMap.getZoneIfExists(x, z, level); + return zone ? zone.getObjOfReceiver(x, z, objId, receiver64) : null; } trackZone(zone: Zone): void { @@ -1342,7 +1382,11 @@ class World { changeLocCollision(loc.shape, loc.angle, type.blockrange, type.length, type.width, type.active, loc.x, loc.z, loc.level, true); } - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] addLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.addLoc(loc); this.trackZone(zone); loc.setLifeCycle(duration); @@ -1372,7 +1416,11 @@ class World { } // Notify zone that loc has been changed - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] changeLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.changeLoc(loc); this.trackZone(zone); @@ -1388,14 +1436,22 @@ class World { mergeLoc(loc: Loc, player: Player, startCycle: number, endCycle: number, south: number, east: number, north: number, west: number): void { // printDebug(`[World] mergeLoc => name: ${LocType.get(loc.type).name}`); - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] mergeLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.mergeLoc(loc, player, startCycle, endCycle, south, east, north, west); this.trackZone(zone); } animLoc(loc: Loc, seq: number): void { // printDebug(`[World] animLoc => name: ${LocType.get(loc.type).name}, seq: ${seq}`); - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] animLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.animLoc(loc, seq); this.trackZone(zone); } @@ -1411,7 +1467,11 @@ class World { changeLocCollision(loc.shape, loc.angle, type.blockrange, type.length, type.width, type.active, loc.x, loc.z, loc.level, false); } - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] removeLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.removeLoc(loc); this.trackZone(zone); @@ -1442,7 +1502,11 @@ class World { } // Notify zone that loc has been changed - const zone: Zone = this.gameMap.getZone(loc.x, loc.z, loc.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(loc.x, loc.z, loc.level); + if (!zone) { + printWarning(`[World] revertLoc: zone does not exist at (${loc.x}, ${loc.z}, L${loc.level})`); + return; + } zone.changeLoc(loc); loc.setLifeCycle(-1); this.trackZone(zone); @@ -1465,7 +1529,11 @@ class World { } } - const zone: Zone = this.gameMap.getZone(obj.x, obj.z, obj.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(obj.x, obj.z, obj.level); + if (!zone) { + printWarning(`[World] addObj: zone does not exist at (${obj.x}, ${obj.z}, L${obj.level})`); + return; + } zone.addObj(obj, receiver64); this.trackZone(zone); // If the obj is dropped to a specific person @@ -1486,14 +1554,22 @@ class World { } revealObj(obj: Obj): void { - const zone: Zone = this.gameMap.getZone(obj.x, obj.z, obj.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(obj.x, obj.z, obj.level); + if (!zone) { + printWarning(`[World] revealObj: zone does not exist at (${obj.x}, ${obj.z}, L${obj.level})`); + return; + } zone.revealObj(obj); this.trackZone(zone); } changeObj(obj: Obj, newCount: number): void { // printDebug(`[World] changeObj => name: ${ObjType.get(obj.type).name}, receiverId: ${receiverId}, newCount: ${newCount}`); - const zone: Zone = this.gameMap.getZone(obj.x, obj.z, obj.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(obj.x, obj.z, obj.level); + if (!zone) { + printWarning(`[World] changeObj: zone does not exist at (${obj.x}, ${obj.z}, L${obj.level})`); + return; + } zone.changeObj(obj, obj.count, newCount); this.trackZone(zone); } @@ -1505,7 +1581,11 @@ class World { return; } // printDebug(`[World] removeObj => name: ${ObjType.get(obj.type).name}, duration: ${duration}`); - const zone: Zone = this.gameMap.getZone(obj.x, obj.z, obj.level); + const zone: Zone | null = this.gameMap.getZoneIfExists(obj.x, obj.z, obj.level); + if (!zone) { + printWarning(`[World] removeObj: zone does not exist at (${obj.x}, ${obj.z}, L${obj.level})`); + return; + } const adjustedDuration = this.scaleByPlayerCount(duration); zone.removeObj(obj); this.trackZone(zone); @@ -1519,13 +1599,21 @@ class World { } animMap(level: number, x: number, z: number, spotanim: number, height: number, delay: number): void { - const zone: Zone = this.gameMap.getZone(x, z, level); + const zone: Zone | null = this.gameMap.getZoneIfExists(x, z, level); + if (!zone) { + printWarning(`[World] animMap: zone does not exist at (${x}, ${z}, L${level})`); + return; + } zone.animMap(x, z, spotanim, height, delay); this.trackZone(zone); } mapProjAnim(level: number, x: number, z: number, dstX: number, dstZ: number, target: number, spotanim: number, srcHeight: number, dstHeight: number, startDelay: number, endDelay: number, peak: number, arc: number): void { - const zone: Zone = this.gameMap.getZone(x, z, level); + const zone: Zone | null = this.gameMap.getZoneIfExists(x, z, level); + if (!zone) { + printWarning(`[World] mapProjAnim: zone does not exist at (${x}, ${z}, L${level})`); + return; + } zone.mapProjAnim(x, z, dstX, dstZ, target, spotanim, srcHeight, dstHeight, startDelay, endDelay, peak, arc); this.trackZone(zone); } @@ -1596,7 +1684,10 @@ class World { } rsbuf.removePlayer(player.slot); - this.gameMap.getZone(player.x, player.z, player.level).leave(player); + const zone = this.gameMap.getZoneIfExists(player.x, player.z, player.level); + if (zone) { + zone.leave(player); + } delete this.players[player.slot]; player.unlink(); changeNpcCollision(player.width, player.x, player.z, player.level, false); diff --git a/src/engine/entity/BuildArea.ts b/src/engine/entity/BuildArea.ts index c8abc2676..9cf009abb 100644 --- a/src/engine/entity/BuildArea.ts +++ b/src/engine/entity/BuildArea.ts @@ -104,7 +104,7 @@ export default class BuildArea { private isInstanceBuildArea(): boolean { const zoneX: number = CoordGrid.zone(this.player.x) << 3; const zoneZ: number = CoordGrid.zone(this.player.z) << 3; - const zone = World.gameMap.getZone(zoneX, zoneZ, this.player.level); + const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, this.player.level); return zone instanceof InstanceZone; } diff --git a/src/engine/entity/NetworkPlayer.ts b/src/engine/entity/NetworkPlayer.ts index 07f99392b..9f22c2448 100644 --- a/src/engine/entity/NetworkPlayer.ts +++ b/src/engine/entity/NetworkPlayer.ts @@ -277,7 +277,10 @@ export class NetworkPlayer extends Player { // update active zones for (const zoneIndex of activeZones) { - const zone: Zone = World.gameMap.getZoneIndex(zoneIndex); + const zone: Zone | null = World.gameMap.getZoneIndexIfExists(zoneIndex); + if (!zone) { + continue; + } if (!loadedZones.has(zone.index)) { zone.writeFullFollows(this); } diff --git a/src/engine/entity/PathingEntity.ts b/src/engine/entity/PathingEntity.ts index cf15f6bda..2466df669 100644 --- a/src/engine/entity/PathingEntity.ts +++ b/src/engine/entity/PathingEntity.ts @@ -190,8 +190,15 @@ export default abstract class PathingEntity extends Entity { } if (CoordGrid.zone(previousX) !== CoordGrid.zone(this.x) || CoordGrid.zone(previousZ) !== CoordGrid.zone(this.z) || previousLevel != this.level) { - World.gameMap.getZone(previousX, previousZ, previousLevel).leave(this); - World.gameMap.getZone(this.x, this.z, this.level).enter(this); + const previousZone = World.gameMap.getZoneIfExists(previousX, previousZ, previousLevel); + const currentZone = World.gameMap.getZoneIfExists(this.x, this.z, this.level); + + if (previousZone && previousZone !== currentZone) { + previousZone.leave(this); + } + if (currentZone && previousZone !== currentZone) { + currentZone.enter(this); + } } } @@ -224,8 +231,16 @@ export default abstract class PathingEntity extends Entity { } const previousX: number = this.x; const previousZ: number = this.z; - this.x = CoordGrid.moveX(this.x, dir); - this.z = CoordGrid.moveZ(this.z, dir); + const nextX: number = CoordGrid.moveX(this.x, dir); + const nextZ: number = CoordGrid.moveZ(this.z, dir); + + // After map initialization, entities must not move into zones that were never allocated. + if (!World.gameMap.getZoneIfExists(nextX, nextZ, this.level)) { + return -1; + } + + this.x = nextX; + this.z = nextZ; const moveX: number = CoordGrid.moveX(this.x, dir); const moveZ: number = CoordGrid.moveZ(this.z, dir); this.focus(CoordGrid.fine(moveX, this.width), CoordGrid.fine(moveZ, this.length), false); diff --git a/src/engine/script/ScriptIterators.ts b/src/engine/script/ScriptIterators.ts index 97b6ffb7c..7b913702e 100644 --- a/src/engine/script/ScriptIterators.ts +++ b/src/engine/script/ScriptIterators.ts @@ -74,9 +74,11 @@ export class HuntIterator extends ScriptIterator { const zoneX: number = x << 3; for (let z: number = this.maxZ; z >= this.minZ; z--) { const zoneZ: number = z << 3; + const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, this.level); + if (!zone) continue; if (this.type === HuntModeType.PLAYER) { - for (const player of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllPlayersSafe(true)) { + for (const player of zone.getAllPlayersSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -96,7 +98,7 @@ export class HuntIterator extends ScriptIterator { yield player; } } else if (this.type === HuntModeType.NPC) { - for (const npc of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllNpcsSafe(true)) { + for (const npc of zone.getAllNpcsSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -120,7 +122,7 @@ export class HuntIterator extends ScriptIterator { } } else if (this.type === HuntModeType.OBJ) { // scripting only cares about dynamic objs?? - for (const obj of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllObjsSafe(true)) { + for (const obj of zone.getAllObjsSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -143,7 +145,7 @@ export class HuntIterator extends ScriptIterator { yield obj; } } else if (this.type === HuntModeType.SCENERY) { - for (const loc of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllLocsSafe(true)) { + for (const loc of zone.getAllLocsSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -203,8 +205,10 @@ export class PlayerHuntAllCommandIterator extends ScriptIterator { const zoneX: number = x << 3; for (let z: number = this.maxZ; z >= this.minZ; z--) { const zoneZ: number = z << 3; + const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, this.level); + if (!zone) continue; - for (const player of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllPlayersSafe(true)) { + for (const player of zone.getAllPlayersSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -266,8 +270,10 @@ export class NpcHuntAllCommandIterator extends ScriptIterator { const zoneX: number = x << 3; for (let z: number = this.maxZ; z >= this.minZ; z--) { const zoneZ: number = z << 3; + const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, this.level); + if (!zone) continue; - for (const npc of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllNpcsSafe(true)) { + for (const npc of zone.getAllNpcsSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[HuntIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -327,18 +333,23 @@ export class NpcIterator extends ScriptIterator { protected *generator(): IterableIterator { if (this.type === NpcIteratorType.ZONE) { - for (const npc of World.gameMap.getZone(this.x, this.z, this.level).getAllNpcsSafe(true)) { - if (World.currentTick > this.tick) { - throw new Error('[NpcIterator] tried to use an old iterator. Create a new iterator instead.'); + const zone = World.gameMap.getZoneIfExists(this.x, this.z, this.level); + if (zone) { + for (const npc of zone.getAllNpcsSafe(true)) { + if (World.currentTick > this.tick) { + throw new Error('[NpcIterator] tried to use an old iterator. Create a new iterator instead.'); + } + yield npc; } - yield npc; } } else if (this.type === NpcIteratorType.DISTANCE) { for (let x: number = this.maxX; x >= this.minX; x--) { const zoneX: number = x << 3; for (let z: number = this.maxZ; z >= this.minZ; z--) { const zoneZ: number = z << 3; - for (const npc of World.gameMap.getZone(zoneX, zoneZ, this.level).getAllNpcsSafe(true)) { + const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, this.level); + if (!zone) continue; + for (const npc of zone.getAllNpcsSafe(true)) { if (World.currentTick > this.tick) { throw new Error('[NpcIterator] tried to use an old iterator. Create a new iterator instead.'); } @@ -375,11 +386,14 @@ export class LocIterator extends ScriptIterator { } protected *generator(): IterableIterator { - for (const loc of World.gameMap.getZone(this.x, this.z, this.level).getAllLocsSafe(true)) { - if (World.currentTick > this.tick) { - throw new Error('[LocIterator] tried to use an old iterator. Create a new iterator instead.'); + const zone = World.gameMap.getZoneIfExists(this.x, this.z, this.level); + if (zone) { + for (const loc of zone.getAllLocsSafe(true)) { + if (World.currentTick > this.tick) { + throw new Error('[LocIterator] tried to use an old iterator. Create a new iterator instead.'); + } + yield loc; } - yield loc; } } } @@ -397,11 +411,14 @@ export class ObjIterator extends ScriptIterator { } protected *generator(): IterableIterator { - for (const Obj of World.gameMap.getZone(this.x, this.z, this.level).getAllObjsSafe(true)) { - if (World.currentTick > this.tick) { - throw new Error('[ObjIterator] tried to use an old iterator. Create a new iterator instead.'); + const zone = World.gameMap.getZoneIfExists(this.x, this.z, this.level); + if (zone) { + for (const Obj of zone.getAllObjsSafe(true)) { + if (World.currentTick > this.tick) { + throw new Error('[ObjIterator] tried to use an old iterator. Create a new iterator instead.'); + } + yield Obj; } - yield Obj; } } } diff --git a/src/engine/script/handlers/ServerOps.ts b/src/engine/script/handlers/ServerOps.ts index fabc7da94..5d9ab4a41 100644 --- a/src/engine/script/handlers/ServerOps.ts +++ b/src/engine/script/handlers/ServerOps.ts @@ -33,7 +33,9 @@ const ServerOps: CommandHandlers = { let count = 0; for (let x = Math.floor(from.x / 8); x <= Math.ceil(to.x / 8); x++) { for (let z = Math.floor(from.z / 8); z <= Math.ceil(to.z / 8); z++) { - for (const player of World.gameMap.getZone(x << 3, z << 3, from.level).getAllPlayersSafe()) { + const zone = World.gameMap.getZoneIfExists(x << 3, z << 3, from.level); + if (!zone) continue; + for (const player of zone.getAllPlayersSafe()) { if (player.x >= from.x && player.x <= to.x && player.z >= from.z && player.z <= to.z) { count++; } @@ -212,7 +214,12 @@ const ServerOps: CommandHandlers = { [ScriptOpcode.MAP_LOCADDUNSAFE]: state => { const coord: CoordGrid = check(state.popInt(), CoordValid); - for (const loc of World.gameMap.getZone(coord.x, coord.z, coord.level).getAllLocsUnsafe()) { + const zone = World.gameMap.getZoneIfExists(coord.x, coord.z, coord.level); + if (!zone) { + return; + } + + for (const loc of zone.getAllLocsUnsafe()) { const type = check(loc.type, LocTypeValid); if (type.active !== 1) { diff --git a/src/engine/zone/InstanceZone.ts b/src/engine/zone/InstanceZone.ts index cab4af0f8..68c5ea00d 100644 --- a/src/engine/zone/InstanceZone.ts +++ b/src/engine/zone/InstanceZone.ts @@ -5,6 +5,7 @@ import { EntityLifeCycle } from '#/engine/entity/EntityLifeCycle.js'; import Loc from '#/engine/entity/Loc.js'; import World from '#/engine/World.js'; import Zone from '#/engine/zone/Zone.js'; +import ZoneMap from '#/engine/zone/ZoneMap.js'; export default class InstanceZone extends Zone { source: CoordGrid; @@ -158,7 +159,7 @@ export default class InstanceZone extends Zone { private mirrorOutboundEdgeWalls(destCollision: Uint32Array, destBaseX: number, destBaseZ: number, level: number): void { const mirror = (flags: number, sourceMask: number, dstX: number, dstZ: number, mirrorMask: number): void => { - if (flags & sourceMask && World.gameMap.hasZone(dstX, dstZ, level)) { + if (flags & sourceMask && this.ensureMirrorDestinationZone(dstX, dstZ, level)) { routeFinder.collisionFlags.add(dstX, dstZ, level, mirrorMask); } }; @@ -208,6 +209,21 @@ export default class InstanceZone extends Zone { mirror(northEastFlags, CollisionFlag.WALL_NORTH_EAST_ROUTE_BLOCKER, destBaseX + 8, destBaseZ + 8, CollisionFlag.WALL_SOUTH_WEST_ROUTE_BLOCKER); } + private ensureMirrorDestinationZone(x: number, z: number, level: number): boolean { + if (World.gameMap.hasZone(x, z, level)) { + return true; + } + + // Only materialize mirror destinations that are within an active instance footprint. + if (!World.instances.findInstanceByTile(level, x, z)) { + return false; + } + + World.gameMap.createInstanceZone(ZoneMap.zoneIndex(x, z, level)); + routeFinder.allocateIfAbsent(x, z, level); + return true; + } + private rotateCollisionFlags(flags: number, rotation: 0 | 1 | 2 | 3): number { if (rotation === 0) { return flags; diff --git a/src/engine/zone/ZoneMap.ts b/src/engine/zone/ZoneMap.ts index 15bdcd881..bbe491617 100644 --- a/src/engine/zone/ZoneMap.ts +++ b/src/engine/zone/ZoneMap.ts @@ -39,38 +39,64 @@ export default class ZoneMap { } /** - * Get an existing zone, or auto-create it if needed. - * TODO: Once all code paths are audited, enforce zone pre-creation when not initializing. - * Currently zones auto-create to maintain compatibility during refactor. + * Check if zone map is currently initializing (during map load). + */ + isInitializingMap(): boolean { + return this.isInitializing; + } + + /** + * Get an existing zone, or auto-create it during initialization. + * After initialization completes, zones must be pre-created or use getZoneIfExists(). + * @throws Error if zone doesn't exist and initialization is complete. */ getZone(x: number, z: number, level: number): Zone { const zoneIndex: number = ZoneMap.zoneIndex(x, z, level); let zone: Zone | undefined = this.zones.get(zoneIndex); if (typeof zone === 'undefined') { - // Auto-create zone on demand (TODO: enforce pre-creation after refactor) - zone = new Zone(zoneIndex); - this.zones.set(zoneIndex, zone); + if (this.isInitializing) { + // Auto-create zone only during initialization + zone = new Zone(zoneIndex); + this.zones.set(zoneIndex, zone); + } else { + // Enforce zone pre-creation after initialization + throw new Error(`Zone does not exist at (${x}, ${z}, L${level}). Zones must be pre-created during world startup.`); + } } return zone; } /** - * Get an existing zone by index, or auto-create it if needed. - * TODO: Once all code paths are audited, enforce zone pre-creation when not initializing. - * Currently zones auto-create to maintain compatibility during refactor. + * Get an existing zone by index, or auto-create it during initialization. + * After initialization completes, zones must be pre-created or use getZoneIfExists(). + * @throws Error if zone doesn't exist and initialization is complete. */ getZoneByIndex(index: number): Zone { let zone: Zone | undefined = this.zones.get(index); if (typeof zone === 'undefined') { - // Auto-create zone on demand (TODO: enforce pre-creation after refactor) - zone = new Zone(index); - this.zones.set(index, zone); + if (this.isInitializing) { + // Auto-create zone only during initialization + zone = new Zone(index); + this.zones.set(index, zone); + } else { + // Enforce zone pre-creation after initialization + const unpacked = ZoneMap.unpackIndex(index); + throw new Error(`Zone does not exist at (${unpacked.x}, ${unpacked.z}, L${unpacked.level}). Zones must be pre-created during world startup.`); + } } return zone; } + /** + * Get an existing zone by index, or null if it doesn't exist. + * Use this only for optional zone lookups where missing zones are expected. + */ + getZoneByIndexIfExists(index: number): Zone | null { + return this.zones.get(index) ?? null; + } + /** * Get an existing zone, or null if it doesn't exist. * Use this only for optional zone lookups where missing zones are expected. From 6fdd866bc463939d3abfa04637e46a65e2868fdb Mon Sep 17 00:00:00 2001 From: markb5 Date: Sun, 17 May 2026 13:58:59 -0400 Subject: [PATCH 15/18] Teardown behavior and Loc bugs --- src/engine/InstanceController.ts | 26 +++++++++++++---- src/engine/entity/BuildArea.ts | 2 +- src/engine/zone/InstanceZone.ts | 50 ++++++++++++++++++++++---------- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/engine/InstanceController.ts b/src/engine/InstanceController.ts index 93d14840e..a32e2c1b0 100644 --- a/src/engine/InstanceController.ts +++ b/src/engine/InstanceController.ts @@ -107,8 +107,7 @@ export default class InstanceController { if (!sourceZone) { // Keep template metadata so client rebuild can still draw this chunk, // even when the source zone is not materialized server-side. - targetZone.source = { level: source.level, x: source.x >> 3, z: source.z >> 3 }; - targetZone.rotation = rotation; + targetZone.assignTemplate(source, rotation); if (InstanceController.DEBUG_INSTANCE_COPY_VERBOSE) { if (this.missingSourceZoneLogCount < InstanceController.MAX_MISSING_SOURCE_ZONE_LOGS) { @@ -138,11 +137,12 @@ export default class InstanceController { isInstanceEmpty(instance: InstanceRecord): boolean { // Any player in any zone covered by this instance means the instance is still in use. for (let level: number = 0; level < instance.floors; level++) { + const actualLevel: number = instance.sw.level + level; for (let east: number = 0; east < instance.zonesEast; east++) { for (let north: number = 0; north < instance.zonesNorth; north++) { const x: number = instance.sw.x + (east << 3); const z: number = instance.sw.z + (north << 3); - const zone = World.gameMap.getZoneIfExists(x, z, level); + const zone = World.gameMap.getZoneIfExists(x, z, actualLevel); if (zone && zone.hasPlayers()) { return false; } @@ -279,13 +279,29 @@ export default class InstanceController { // Remove each zone in the instance footprint from the live zone map and collision data. for (let level: number = 0; level < instance.floors; level++) { + const actualLevel: number = instance.sw.level + level; for (let east: number = 0; east < instance.zonesEast; east++) { for (let north: number = 0; north < instance.zonesNorth; north++) { const x: number = instance.sw.x + (east << 3); const z: number = instance.sw.z + (north << 3); - World.gameMap.removeZone(ZoneMap.zoneIndex(x, z, level)); + const zone = World.gameMap.getZoneIfExists(x, z, actualLevel); + if (zone) { + for (const npc of Array.from(zone.getAllNpcsUnsafe(true))) { + World.removeNpc(npc, -1); + } + + for (const loc of Array.from(zone.getAllLocsUnsafe(true))) { + World.removeLoc(loc, 0); + } + + for (const obj of Array.from(zone.getAllObjsUnsafe(true))) { + World.removeObj(obj, 0); + } + } + + World.gameMap.removeZone(ZoneMap.zoneIndex(x, z, actualLevel)); // Clean up collision data for this zone - routeFinder.deallocateIfPresent(x, z, level); + routeFinder.deallocateIfPresent(x, z, actualLevel); } } } diff --git a/src/engine/entity/BuildArea.ts b/src/engine/entity/BuildArea.ts index 9cf009abb..5e30e113d 100644 --- a/src/engine/entity/BuildArea.ts +++ b/src/engine/entity/BuildArea.ts @@ -126,7 +126,7 @@ export default class BuildArea { } const zone = World.gameMap.getZone(currentX, currentZ, level); - if (!(zone instanceof InstanceZone)) { + if (!(zone instanceof InstanceZone) || !zone.hasAssignedTemplate) { continue; } diff --git a/src/engine/zone/InstanceZone.ts b/src/engine/zone/InstanceZone.ts index 68c5ea00d..fce35f5cc 100644 --- a/src/engine/zone/InstanceZone.ts +++ b/src/engine/zone/InstanceZone.ts @@ -18,6 +18,10 @@ export default class InstanceZone extends Zone { this.rotation = 0; } + get hasAssignedTemplate(): boolean { + return this.copiedFrom; + } + /** * Copy entities (locations, objects, collision) from the source zone into this instance zone, * applying the specified rotation transformation. @@ -26,14 +30,7 @@ export default class InstanceZone extends Zone { * @param rotation The rotation to apply (0, 1, 2, or 3). */ copyFromZone(sourceZone: Zone, rotation: 0 | 1 | 2 | 3): void { - if (this.copiedFrom) { - throw new Error('InstanceZone has already been copied from a source'); - } - - // Update source and rotation metadata - this.source = { level: sourceZone.level, x: sourceZone.x, z: sourceZone.z }; - this.rotation = rotation; - this.copiedFrom = true; + this.assignSource(sourceZone.level, sourceZone.x, sourceZone.z, rotation); // Copy collision data with rotation applied this.copyCollisionWithRotation(sourceZone, rotation); @@ -42,14 +39,35 @@ export default class InstanceZone extends Zone { this.copyLocsWithRotation(sourceZone, rotation); } + /** + * Record template metadata without copying locs or collision. + * This exists for missing-source copies, which still consume the single + * allowed assignment for the destination instance zone. + */ + assignTemplate(source: CoordGrid, rotation: 0 | 1 | 2 | 3): void { + this.assignSource(source.level, source.x >> 3, source.z >> 3, rotation); + } + + private assignSource(sourceLevel: number, sourceZoneX: number, sourceZoneZ: number, rotation: 0 | 1 | 2 | 3): void { + if (this.copiedFrom) { + throw new Error('InstanceZone has already been copied from a source'); + } + + this.source = { level: sourceLevel, x: sourceZoneX, z: sourceZoneZ }; + this.rotation = rotation; + this.copiedFrom = true; + } + private copyLocsWithRotation(sourceZone: Zone, rotation: 0 | 1 | 2 | 3): void { for (const sourceLoc of sourceZone.getAllLocsSafe()) { // Extract base properties (before any runtime changes) const baseType = sourceLoc.baseType; const baseShape = sourceLoc.baseShape; const baseAngle = sourceLoc.baseAngle; - let width = sourceLoc.width; - let length = sourceLoc.length; + const sourceWidth = sourceLoc.width; + const sourceLength = sourceLoc.length; + let width = sourceWidth; + let length = sourceLength; // Get zone-relative tile coordinates (0-7 range). // sourceZone.x is already in zone-index units (tile >> 3), so shift left to @@ -62,17 +80,17 @@ export default class InstanceZone extends Zone { let rotatedZ = locZ; if (rotation === 1) { // 90° CW - rotatedX = 7 - locZ; + rotatedX = 8 - locZ - sourceLength; rotatedZ = locX; [width, length] = [length, width]; } else if (rotation === 2) { // 180° - rotatedX = 7 - locX; - rotatedZ = 7 - locZ; + rotatedX = 8 - locX - sourceWidth; + rotatedZ = 8 - locZ - sourceLength; } else if (rotation === 3) { // 270° CW rotatedX = locZ; - rotatedZ = 7 - locX; + rotatedZ = 8 - locX - sourceWidth; [width, length] = [length, width]; } @@ -111,9 +129,9 @@ export default class InstanceZone extends Zone { return; // No collision to copy } - const destCollision = new Uint32Array(64); const destBaseX = this.x << 3; const destBaseZ = this.z << 3; + const destCollision = routeFinder.collisionFlags.getZone(destBaseX, destBaseZ, this.level) ?? new Uint32Array(64); // Iterate through the 8x8 zone and apply rotation transformations for (let srcIdx = 0; srcIdx < 64; srcIdx++) { @@ -145,7 +163,7 @@ export default class InstanceZone extends Zone { const dstIdx = dstX | (dstZ << 3); const rotatedFlags = this.rotateCollisionFlags(srcFlags, rotation); - destCollision[dstIdx] = rotatedFlags; + destCollision[dstIdx] |= rotatedFlags; } // Write rotated collision data to destination zone From ad57be517a11df2077450f2156028b0d5d158292 Mon Sep 17 00:00:00 2001 From: markb5 Date: Sun, 17 May 2026 14:07:26 -0400 Subject: [PATCH 16/18] Guard npc spawn triggers from running if npc doesn't exist --- src/engine/World.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/engine/World.ts b/src/engine/World.ts index 446156d0b..a1feec80d 100644 --- a/src/engine/World.ts +++ b/src/engine/World.ts @@ -668,6 +668,11 @@ class World { private processNpcEventQueue(): void { for (const request of this.npcEventQueue.all()) { const npc = request.npc; + if (request.type === NpcEventType.SPAWN && (!npc.isActive || npc.nid === -1 || this.getNpc(npc.nid) !== npc)) { + request.unlink(); + continue; + } + if (!npc.delayed) { request.unlink(); const state = ScriptRunner.init(request.script, npc); From 3565ecf87336d31446af500262c0fd6a5f2a4bf9 Mon Sep 17 00:00:00 2001 From: markb5 Date: Sun, 17 May 2026 14:24:12 -0400 Subject: [PATCH 17/18] fix import --- src/engine/script/ScriptState.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/engine/script/ScriptState.ts b/src/engine/script/ScriptState.ts index c61506323..964122bba 100644 --- a/src/engine/script/ScriptState.ts +++ b/src/engine/script/ScriptState.ts @@ -1,4 +1,5 @@ import DbTableType from '#/cache/config/DbTableType.js'; +import { CoordGrid } from '#/engine/CoordGrid.js'; import Entity from '#/engine/entity/Entity.js'; import Loc from '#/engine/entity/Loc.js'; import Npc from '#/engine/entity/Npc.js'; From 503f4f54268d0d427447a7cab5a8a7b49b0cb94b Mon Sep 17 00:00:00 2001 From: markb5 Date: Fri, 22 May 2026 17:10:07 -0400 Subject: [PATCH 18/18] remove mapzone and zone mapping from overworld --- src/engine/entity/Player.ts | 35 +++++++---------------------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/src/engine/entity/Player.ts b/src/engine/entity/Player.ts index bc74ce8f6..9a451272a 100644 --- a/src/engine/entity/Player.ts +++ b/src/engine/entity/Player.ts @@ -45,7 +45,6 @@ import ScriptRunner from '#/engine/script/ScriptRunner.js'; import ScriptState from '#/engine/script/ScriptState.js'; import ServerTriggerType from '#/engine/script/ServerTriggerType.js'; import World from '#/engine/World.js'; -import InstanceZone from '#/engine/zone/InstanceZone.js'; import Packet from '#/io/Packet.js'; import ChatFilterSettings from '#/network/game/server/model/ChatFilterSettings.js'; import HintArrow from '#/network/game/server/model/HintArrow.js'; @@ -573,13 +572,10 @@ export default class Player extends PathingEntity { const currentZoneX = (this.x >> 3) << 3; const currentZoneZ = (this.z >> 3) << 3; - const previousTriggerZone = this.resolveTriggerZoneBase(previousZoneX, previousZoneZ, previousLevel); - const currentTriggerZone = this.resolveTriggerZoneBase(currentZoneX, currentZoneZ, this.level); - - const previousMapZoneX = (previousTriggerZone.x >> 6) << 6; - const previousMapZoneZ = (previousTriggerZone.z >> 6) << 6; - const currentMapZoneX = (currentTriggerZone.x >> 6) << 6; - const currentMapZoneZ = (currentTriggerZone.z >> 6) << 6; + const previousMapZoneX = (previousZoneX >> 6) << 6; + const previousMapZoneZ = (previousZoneZ >> 6) << 6; + const currentMapZoneX = (currentZoneX >> 6) << 6; + const currentMapZoneZ = (currentZoneZ >> 6) << 6; const mapZoneBoundaryChanged: boolean = initialLogin || (previousX >> 6) << 6 !== (this.x >> 6) << 6 || (previousZ >> 6) << 6 !== (this.z >> 6) << 6; const mapZoneChanged: boolean = initialLogin || (mapZoneBoundaryChanged && (previousMapZoneX !== currentMapZoneX || previousMapZoneZ !== currentMapZoneZ)); @@ -604,29 +600,12 @@ export default class Player extends PathingEntity { this.write(new SetMultiway(nowIsMulti)); } - this.triggerZoneExit(previousTriggerZone.level, previousTriggerZone.x, previousTriggerZone.z); + this.triggerZoneExit(previousLevel, previousZoneX, previousZoneZ); } - this.triggerZone(currentTriggerZone.level, currentTriggerZone.x, currentTriggerZone.z); - this.lastZone = CoordGrid.packCoord(currentTriggerZone.level, currentTriggerZone.x, currentTriggerZone.z); - } - } - - private resolveTriggerZoneBase(zoneX: number, zoneZ: number, level: number): CoordGrid { - const zone = World.gameMap.getZoneIfExists(zoneX, zoneZ, level); - if (zone instanceof InstanceZone) { - return { - level: zone.source.level, - x: zone.source.x << 3, - z: zone.source.z << 3 - }; + this.triggerZone(this.level, currentZoneX, currentZoneZ); + this.lastZone = CoordGrid.packCoord(this.level, currentZoneX, currentZoneZ); } - - return { - level, - x: zoneX, - z: zoneZ - }; } onReconnect() {