// dev/escort-delivery-repro.mjs — REPRO: escort delivery with the convoy // OFF-SCREEN at the destination (the real CombatSystem + real GameScene // escort law, run in the true per-frame order; node dev/escort-delivery-repro.mjs) process.env.NODE_ENV = 'dev'; import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import './phaser-loader.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const fs = await import('node:fs'); const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); const configData = {}; for (const f of fs.readdirSync(join(__dirname, '../data'))) { if (!f.endsWith('.json') || f === 'manifest.json') continue; configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(__dirname, '../data', f), 'utf8')); } config.init(configData); const { sideQuestPostingsFor, escortDestinationFor, SideQuestState, } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href); const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href); const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href); // ------------------------------------------------------------------ scene class V2 { constructor(x = 0, y = 0) { this.x = x; this.y = y; } set(x, y) { this.x = x; this.y = y; return this; } length() { return Math.hypot(this.x, this.y); } } function makeBody() { const base = { velocity: new V2(), setVelocity(x, y) { this.velocity.set(x, y); }, setCircle() {}, setAllowGravity() {}, }; return new Proxy(base, { get(t, k) { if (k in t) return t[k]; return () => {}; }, }); } const SEED = 77031; const JOB = 'sq-sys-home-p1-escort-0'; const RECS = { 'sys-home': { id: 'sys-home', name: 'Hearth', faction: null, d: 5, zone: 'near', type: 'main' }, 'sys-a': { id: 'sys-a', name: 'Aldrin', faction: null, d: 35, zone: 'mid', type: 'void' }, 'sys-b': { id: 'sys-b', name: 'Basalt', faction: null, d: 65, zone: 'far', type: 'void' }, }; const ADJ = { 'sys-home': ['sys-a', 'sys-zenith-excluded'], 'sys-a': ['sys-home', 'sys-b'], 'sys-b': ['sys-a'], }; const CONTENT = { 'sys-home': { planets: [{ id: 'sys-home-p1', name: 'Hearth One', ordinal: 1, x: 0, y: 0 }], settlements: [] }, 'sys-a': { planets: [{ id: 'sys-a-p1', name: 'Aldrin One', ordinal: 1, x: 100, y: 200 }], settlements: [] }, 'sys-b': { planets: [], settlements: [{ id: 'sys-b-s1', name: 'Basalt Relay', anchor: { type: 'space' }, x: 300, y: 400 }] }, }; const galaxy = { seed: SEED, byId: new Map(Object.entries(RECS).map(([k, v]) => [k, v])), records: Object.values(RECS), jumpGatesFor: (id) => (ADJ[id] ?? []).map((t) => RECS[t]).filter(Boolean), ensureContent: (id) => CONTENT[id], contentOf: (id) => CONTENT[id], }; function makeScene(systemId) { const view = { left: 0, top: 0, width: 1280, height: 720 }; const rec = RECS[systemId]; const scene = Object.create(GameScene.prototype); scene.playSfx = () => {}; scene.consoleToast = (msg) => { console.log(' [toast]', msg); }; scene.time = { now: 0 }; scene.galaxy = galaxy; scene.systemRecord = { ...rec }; scene.systemContent = galaxy.ensureContent(systemId); scene.systemPlanets = []; scene.systemStations = []; scene.systemGates = []; scene.planet = null; scene.tetherField = null; scene.isHomeSystem = false; scene.ship = { x: 0, y: 0, radius: 16, body: null, stop() {}, setVisible() {} }; scene.scale = { width: 1280, height: 720 }; // Both camera surfaces: GameScene's witness (scrollX/scrollY) and // CombatSystem's worldView (the on-screen rule). const syncView = () => { view.left = view._sx; view.top = view._sy; cam.worldView = { x: view._sx, y: view._sy, width: 1280, height: 720, left: view._sx, top: view._sy }; }; const cam = { scrollX: 0, scrollY: 0, worldView: null }; Object.defineProperty(cam, 'scrollX', { get: () => view._sx, set: (v) => { view._sx = v; syncView(); } }); Object.defineProperty(cam, 'scrollY', { get: () => view._sy, set: (v) => { view._sy = v; syncView(); } }); view._sx = 0; view._sy = 0; syncView(); scene.cameras = { main: cam }; // Combat scene surface (combat.test.mjs's makeScene shape). scene.textures = { exists: () => true }; scene.make = { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) }; scene.add = { existing: (o) => o, image: (x, y) => ({ x, y, setTint() {}, setAlpha() {}, setVisible() {}, setScale() {}, destroy() {} }), graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }), text: () => ({ setText() {}, setDepth() {}, setVisible() {}, destroy() {}, set() { return this; } }), rectangle: (x, y) => ({ x, y, setFillStyle() {}, setAlpha() {}, destroy() {} }), }; scene.physics = { add: { existing: (o) => { if (!o.body) o.body = makeBody(); return o; } } }; scene.refreshCreditsHud = () => {}; scene.sideQuestState = new SideQuestState(); scene.claimSideQuest = GameScene.prototype.claimSideQuest; return scene; } const DEST = escortDestinationFor(SEED, JOB, 'sys-home', galaxy); console.log('destination:', DEST.destSystemId, DEST.destObjectId, 'hops', DEST.hops); const DEST_REC = RECS[DEST.destSystemId]; const DEST_OBJ = (galaxy.ensureContent(DEST.destSystemId).planets.concat(galaxy.ensureContent(DEST.destSystemId).settlements)) .find((o) => o.id === DEST.destObjectId); const DEST_X = DEST_OBJ.x + 500; // park the ship in front of the world const DEST_Y = DEST_OBJ.y; // ------------------------------------------------------------------ boot const scene = makeScene('sys-home'); const st = scene.sideQuestState; const posting = { id: JOB, systemId: 'sys-home', systemName: 'Hearth', category: 'escort', title: 'Convoy Night', label: 'ESCORT', reward: { credits: 150 } }; st.accept(JOB); st.setActive(JOB); scene._establishEscort(JOB, posting); const combat = new CombatSystem(scene, { ship: scene.ship, onKill: () => {} }); scene.combat = combat; scene.escortDirector = { state: 'idle', nextRollAt: null, quietUntil: 0, update() { return null; } }; // Frame driver — the true order from GameScene.update: // time.update → combat.update → (windows) → updateSideQuestRoute → [physics step] let now = 0; const DT = 16.666; function frame() { now += DT; scene.time.now = now; if (scene.combat) scene.combat.update(now, DT); // its ally AI + steering scene.updateEscort(); // Arcade physics step (the engine integrates body velocity after update). const dt = DT / 1000; for (const c of [...combat.allies, ...combat.enemies]) { if (c && !c.dead && c.body?.velocity) { c.x += c.body.velocity.x * dt; c.y += c.body.velocity.y * dt; } } } const inView = (c) => { const v = scene.cameras.main.worldView; return c.x >= v.x - 20 && c.x <= v.x + v.width + 20 && c.y >= v.y - 20 && c.y <= v.y + v.height + 20; }; function convoy() { return combat.allies.filter((a) => a && !a.dead && (a._escortCrew || a.isTransport)); } // ------------------------------------------------------------------ run // LINK at the rendezvous (origin system). { const wp = st.routeFor(JOB).waypoints[0]; scene.ship.x = wp[0]; scene.ship.y = wp[1]; scene.cameras.main.scrollX = wp[0] - 640; scene.cameras.main.scrollY = wp[1] - 360; frame(); console.log('after link: phase =', st.routeFor(JOB).escort.phase, '| convoy =', convoy().map((c) => `${c.isTransport ? 'T' : 'F'}(${Math.round(c.x)},${Math.round(c.y)}) onscreen=${inView(c)}`).join(' ')); } // JUMP to the destination system (scene restart tears the sprites — the // record re-stands the convoy). Sky clear. The ship parks in front of the // destination world; the world is ON SCREEN; the convoy is OFF SCREEN // (far behind the player — the reported condition). { scene.systemRecord = { ...DEST_REC }; scene.systemContent = galaxy.ensureContent(DEST.destSystemId); scene.ship.x = DEST_X; scene.ship.y = DEST_Y; scene.cameras.main.scrollX = scene.ship.x - 640; scene.cameras.main.scrollY = scene.ship.y - 360; // The scene restart: the sprites are gone. for (const a of combat.allies) a.destroy(); combat.allies.length = 0; scene._escortTransport = null; frame(); // the convoy re-forms (transit law) // Push the convoy OFF-SCREEN (far behind the player). for (const c of convoy()) { c.x = scene.ship.x - 2200; c.y = scene.ship.y; c.body?.setVelocity?.(0, 0); } console.log('\ndestination reached — world on screen, sky clear, convoy off-screen:'); console.log(' phase =', st.routeFor(JOB).escort.phase, '| convoy =', convoy().map((c) => `${c.isTransport ? 'T' : 'F'}(${Math.round(c.x)},${Math.round(c.y)}) onscreen=${inView(c)} state=${c.state}`).join(' ')); } let sealed = false; let moved = false; const t0 = convoy().map((c) => [c, c.x, c.y]); for (let i = 1; i <= 420; i++) { // up to 7 s of frames frame(); const ph = st.routeFor(JOB).escort.phase; if (i % 30 === 0 || ph === 'delivering' || ph === 'delivered') { console.log(` t=${(i * DT / 1000).toFixed(2)}s phase=${ph} convoy=${convoy().map((c) => `${c.isTransport ? 'T' : 'F'}(${Math.round(c.x)},${Math.round(c.y)}) onscreen=${inView(c)} state=${c.state} alpha=${(c.alpha ?? 1).toFixed(2)}`).join(' ')}`); } for (const [c, x0, y0] of t0) if (Math.hypot(c.x - x0, c.y - y0) > 5) moved = true; if (ph === 'delivered') { sealed = true; break; } } console.log('\nRESULT: convoy moved =', moved, '| delivered =', sealed, '| isCompleted =', st.isCompleted(JOB));