import { CrystalAssets } from "../rom/assets"; import { INITIAL_POSITION, NATIVE_MAPS, SYMBOLS } from "../rom/profile"; import { runScript, type ScriptResult, type ScriptState, } from "../scripts/interpreter"; import { DIRECTION_ORDER, VECTORS, type Direction, type MapObject, } from "./types"; export interface NativeSave { format: "crystal-native"; version: 1; map: string; x: number; y: number; direction: Direction; name: string; avatar: "chris" | "kris"; tilesWalked: number; events: number[]; flags: number[]; variables: Record; } /** Deterministic native world rules. Rendering, browser APIs and emulator * registers are deliberately absent; callers provide time and input. */ export class GameWorld { mapId: string = INITIAL_POSITION.map; x: number = INITIAL_POSITION.x; y: number = INITIAL_POSITION.y; direction: Direction = "down"; name = "CHRIS"; avatar: "chris" | "kris" = "chris"; tilesWalked = 0; state: ScriptState = { events: new Set(), flags: new Set(), variables: {} }; dialogue: { pages: string[]; index: number; result: ScriptResult } | undefined; notice = ""; diagnostic = ""; private objectDirections = new Map(); constructor( readonly assets: CrystalAssets, private now: () => Date = () => new Date(), ) { // Port the original initialization event table through the script engine; // this supplies NPC visibility flags without distributing a copied table. const initial = assets.reader.far(SYMBOLS.standardScripts + 24 * 3); this.state = runScript(assets.reader, initial, this.state, this.name); assets.map(this.mapId); } get map() { return this.assets.map(this.mapId); } get timeOfDay(): number { const mode = this.map.paletteMode; if (mode === 1) return 1; if (mode === 2) return 2; if (mode === 3) return 0; if (mode === 4) return 3; const hour = this.now().getHours(); return hour >= 4 && hour < 10 ? 0 : hour >= 10 && hour < 18 ? 1 : 2; } objects(): MapObject[] { const hour = this.now().getHours(), timeMask = 1 << this.timeOfDay; return this.map.objects.filter((object) => { if ( object.eventFlag !== 0xffff && this.state.events.has(object.eventFlag) ) return false; if (!this.assets.sprite(object.sprite)) return false; if (object.hour1 === 0xff) return object.hour2 === 0xff || Boolean(object.hour2 & timeMask); if (object.hour1 === object.hour2) return true; return object.hour1 < object.hour2 ? hour >= object.hour1 && hour < object.hour2 : hour >= object.hour1 || hour < object.hour2; }); } objectDirection(object: MapObject): Direction { return ( this.objectDirections.get(object.id) ?? DIRECTION_ORDER[object.movement - 6] ?? "down" ); } private clearNotice() { this.notice = ""; this.diagnostic = ""; } private canStand(x: number, y: number): boolean { if (x < 0 || y < 0 || x >= this.map.width || y >= this.map.height) return false; if (this.assets.permission(this.assets.collision(this.map, x, y)) !== 0) return false; return !this.objects().some((object) => object.x === x && object.y === y); } move(direction: Direction): boolean { if (this.dialogue) return false; this.clearNotice(); this.direction = direction; const [dx, dy] = VECTORS[direction], x = this.x + dx, y = this.y + dy; if (x < 0 || y < 0 || x >= this.map.width || y >= this.map.height) { if ( this.map.connections.some( (connection) => connection.direction === direction, ) ) this.notice = "Routes beyond New Bark Town are not ported yet."; return false; } const collision = this.assets.collision(this.map, x, y); // Forced movement, surfing and ledges need their own translated rules. // Refuse them rather than treating a land permission as ordinary walking. if ([0x30, 0x40, 0x50, 0x60, 0xa0].includes(collision & 0xf0)) { this.notice = "This movement type is not ported yet."; return false; } if (!this.canStand(x, y)) return false; // Directional edge walls: up/down/left/right encoded as hardware direction IDs. const edges: Record = { 0xb0: ["right"], 0xb1: ["left"], 0xb2: ["up"], 0xb3: ["down"], 0xb4: ["down", "right"], 0xb5: ["down", "left"], 0xb6: ["up", "right"], 0xb7: ["up", "left"], }; const source = this.assets.collision(this.map, this.x, this.y); const opposite: Record = { down: "up", up: "down", left: "right", right: "left", }; if ( edges[source]?.includes(direction) || edges[collision]?.includes(opposite[direction]) ) return false; const warp = this.map.warps.find((warp) => warp.x === x && warp.y === y); if (warp) { if ( !Object.hasOwn(NATIVE_MAPS, warp.targetMap) || warp.targetWarp === 0 || warp.targetWarp === 255 ) { this.notice = "This destination is not ported yet."; return false; } const target = this.assets.map(warp.targetMap), arrival = target.warps[warp.targetWarp - 1]; if (!arrival) throw new Error("Warp destination is invalid."); this.mapId = target.id; this.x = arrival.x; this.y = arrival.y; this.objectDirections.clear(); this.direction = "down"; const arrivalCollision = this.assets.collision(target, this.x, this.y); const exitY = arrivalCollision === 0x70 ? this.y - 1 : arrivalCollision === 0x71 ? this.y + 1 : this.y; if (exitY !== this.y && this.canStand(this.x, exitY)) { this.direction = exitY < this.y ? "up" : "down"; this.y = exitY; } } else { this.x = x; this.y = y; } this.tilesWalked++; return true; } interact() { if (this.dialogue) { this.advanceDialogue(); return; } this.clearNotice(); const [dx, dy] = VECTORS[this.direction]; let x = this.x + dx, y = this.y + dy; const collision = this.assets.collision(this.map, x, y); if (collision === 0x90) { x += dx; y += dy; } const object = this.objects().find( (object) => object.x === x && object.y === y, ); let script: number | undefined; if (object) { if (object.kind !== 0) { this.notice = "This interaction is not ported yet."; return; } script = object.script; } else { const event = this.map.backgroundEvents.find( (event) => event.x === x && event.y === y, ); if (event) { if (event.kind >= 5) { this.notice = "This conditional interaction is not ported yet."; return; } const requiredDirection = ["up", "down", "right", "left"][ event.kind - 1 ]; if (event.kind && requiredDirection !== this.direction) return; script = event.script; } else { const standard: Record = { 0x91: 2, 0x97: 9, 0x9d: 8, 0x96: 6, 0x9f: 5, }; if (standard[collision] !== undefined && this.direction === "up") script = this.assets.reader.far( SYMBOLS.standardScripts + standard[collision] * 3, ); } } if (script === undefined) { this.notice = "Nothing to interact with here."; return; } try { const result = runScript( this.assets.reader, script, this.state, this.name, ); if (result.facePlayer && object) { const opposite: Record = { down: "up", up: "down", left: "right", right: "left", }; this.objectDirections.set(object.id, opposite[this.direction]); } if (result.pages.length) this.dialogue = { pages: result.pages, index: 0, result }; else this.commit(result); } catch (error) { this.notice = "This interaction needs game logic that has not been ported yet. Your progress is unchanged."; this.diagnostic = error instanceof Error ? error.message : String(error); } } advanceDialogue() { if (!this.dialogue) return; if (++this.dialogue.index < this.dialogue.pages.length) return; this.commit(this.dialogue.result); this.dialogue = undefined; } private commit(result: ScriptResult) { this.state = { events: result.events, flags: result.flags, variables: result.variables, }; for (const turn of result.turns) { if (DIRECTION_ORDER[turn.direction]) this.objectDirections.set(turn.object, DIRECTION_ORDER[turn.direction]); } } save(): NativeSave { // Incomplete interactions are replayed after reload; no partial quest writes. return { format: "crystal-native", version: 1, map: this.mapId, x: this.x, y: this.y, direction: this.direction, name: this.name, avatar: this.avatar, tilesWalked: this.tilesWalked, events: [...this.state.events].sort((a, b) => a - b), flags: [...this.state.flags].sort((a, b) => a - b), variables: { ...this.state.variables }, }; } restore(value: unknown) { const save = value as NativeSave; const integer = ( value: unknown, min: number, max: number, ): value is number => Number.isInteger(value) && Number(value) >= min && Number(value) <= max; const ids = (value: unknown): value is number[] => Array.isArray(value) && value.length <= 2048 && value.every((value) => integer(value, 0, 0xffff)) && new Set(value).size === value.length; if ( !save || save.format !== "crystal-native" || save.version !== 1 || !Object.hasOwn(NATIVE_MAPS, save.map) || !integer(save.x, 0, 127) || !integer(save.y, 0, 127) || !DIRECTION_ORDER.includes(save.direction) || typeof save.name !== "string" || !/^[A-Za-z0-9 -]{1,7}$/.test(save.name) || !["chris", "kris"].includes(save.avatar) || !integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) || !ids(save.events) || !ids(save.flags) || !save.variables || typeof save.variables !== "object" || Array.isArray(save.variables) || Object.entries(save.variables).some( ([id, value]) => !/^\d+$/.test(id) || !integer(Number(id), 0, 255) || !integer(value, 0, 255), ) ) { throw new Error( "Invalid native save. Emulator snapshots cannot be imported into this port.", ); } const map = this.assets.map(save.map); if ( save.x >= map.width || save.y >= map.height || this.assets.permission(this.assets.collision(map, save.x, save.y)) !== 0 ) throw new Error("The saved position is outside the walkable map."); // Validate completely before changing live state. this.mapId = save.map; this.x = save.x; this.y = save.y; this.direction = save.direction; this.name = save.name; this.avatar = save.avatar; this.tilesWalked = save.tilesWalked; this.state = { events: new Set(save.events), flags: new Set(save.flags), variables: { ...save.variables }, }; this.dialogue = undefined; this.objectDirections.clear(); this.clearNotice(); } }