diff --git a/packages/engine/src/core/game.ts b/packages/engine/src/core/game.ts index 7471f3d..e7aa4fe 100644 --- a/packages/engine/src/core/game.ts +++ b/packages/engine/src/core/game.ts @@ -93,6 +93,7 @@ export class RoverGame { on('mined', () => a.mine()); on('built', () => a.build()); on('blockPlaced', () => a.place()); + on('demolished', () => a.place()); on('crafted', () => a.craft()); on('scan', () => a.scan()); on('photo', () => a.photo()); @@ -105,6 +106,8 @@ export class RoverGame { on('weather', ({ phase }) => phase === 'start' && a.weather()); on('roverLost', () => a.lost()); on('buildFailed', () => a.error()); + on('rotateFailed', () => a.error()); + on('demolishFailed', () => a.error()); on('craftFailed', () => a.error()); on('launchFailed', () => a.error()); on('upgradeFailed', () => a.error()); @@ -222,6 +225,16 @@ export class RoverGame { return this.sim.build(type) !== null; } + /** Rotate a placed structure's cosmetic facing 90°. */ + rotateStructure(id: string): boolean { + return this.sim.rotateStructure(id); + } + + /** Demolish a placed structure (no resource refund). */ + demolish(id: string): boolean { + return this.sim.demolish(id); + } + repair(): boolean { return this.sim.repair(); } diff --git a/packages/engine/src/sim/simulation.ts b/packages/engine/src/sim/simulation.ts index deefa01..3b8a763 100644 --- a/packages/engine/src/sim/simulation.ts +++ b/packages/engine/src/sim/simulation.ts @@ -549,7 +549,11 @@ export class Simulation { this.events.emit('buildFailed', { reason: 'Ground too uneven.' }); return null; } - if (this.structures.some((s) => s.pos.x === tx && s.pos.y === ty)) { + const category = def.category ?? 'functional'; + const sameCategoryHere = this.structures.some( + (s) => s.pos.x === tx && s.pos.y === ty && (STRUCTURES[s.type].category ?? 'functional') === category, + ); + if (sameCategoryHere) { this.events.emit('buildFailed', { reason: 'Something is already built there.' }); return null; } @@ -562,12 +566,55 @@ export class Simulation { for (const [res, qty] of Object.entries(def.cost) as [ResourceKey, number][]) { this.removeCargo(res, qty); } - const s: Structure = { id: makeId('str'), type, pos: { x: tx, y: ty }, buffer: {} }; + const s: Structure = { id: makeId('str'), type, pos: { x: tx, y: ty }, buffer: {}, facing: r.facing }; this.structures.push(s); this.events.emit('built', { structure: s }); return s; } + /** + * Rotate a placed structure 90° (cosmetic orientation only — see + * `Structure.facing`). The rover must be adjacent to it, same range as + * other structure interactions (deposit/launch). + */ + rotateStructure(id: string): boolean { + const r = this.rover; + const s = this.structures.find((st) => st.id === id); + if (!s) { + this.events.emit('rotateFailed', { reason: 'No such structure.' }); + return false; + } + if (Math.abs(s.pos.x - r.pos.x) > 1 || Math.abs(s.pos.y - r.pos.y) > 1) { + this.events.emit('rotateFailed', { reason: 'Too far away.' }); + return false; + } + s.facing = (((s.facing ?? 0) + 1) % 4) as 0 | 1 | 2 | 3; + this.events.emit('rotated', { id: s.id, facing: s.facing }); + return true; + } + + /** + * Demolish a placed structure. The rover must be adjacent to it. No + * resource refund in this first pass — demolition is a deliberate, + * lossy action, not an undo. + */ + demolish(id: string): boolean { + const r = this.rover; + const idx = this.structures.findIndex((st) => st.id === id); + if (idx < 0) { + this.events.emit('demolishFailed', { reason: 'No such structure.' }); + return false; + } + const s = this.structures[idx]; + if (Math.abs(s.pos.x - r.pos.x) > 1 || Math.abs(s.pos.y - r.pos.y) > 1) { + this.events.emit('demolishFailed', { reason: 'Too far away.' }); + return false; + } + this.structures.splice(idx, 1); + this.events.emit('demolished', { id: s.id, type: s.type, pos: s.pos }); + return true; + } + /** Deposit all cargo into an adjacent cache, banking it as mission yield. */ depositCargo(): number { const r = this.rover; diff --git a/packages/engine/src/sim/structures.ts b/packages/engine/src/sim/structures.ts index d7385a2..eb8f5f5 100644 --- a/packages/engine/src/sim/structures.ts +++ b/packages/engine/src/sim/structures.ts @@ -12,6 +12,7 @@ export const STRUCTURES: Record = { name: 'Nav Beacon', cost: { iron: 3, copper: 2 }, description: 'Marks a site on the map and lights the area at night.', + category: 'decorative', }, 'drill-rig': { type: 'drill-rig', diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index be0efe1..fe4e181 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -252,6 +252,14 @@ export type StructureType = | 'generator' | 'pylon'; +/** + * `functional` structures participate in the economy/power grid and are + * strictly one-per-tile. `decorative` structures (paths, beacons, lights) + * carry no mechanical function beyond marking/dressing a site, so they may + * share a tile with one functional structure. Defaults to `functional`. + */ +export type StructureCategory = 'functional' | 'decorative'; + export interface StructureDef { type: StructureType; name: string; @@ -260,6 +268,8 @@ export interface StructureDef { /** Hidden from the build menu (e.g. `habitat`, which is built by upgrading * a habitat-frame, not placed directly). Defaults to buildable. */ buildable?: boolean; + /** See `StructureCategory`. Defaults to `functional`. */ + category?: StructureCategory; } export interface Structure { @@ -272,6 +282,11 @@ export interface Structure { cooldownUntil?: number; /** habitat-frame: 0..1 construction progress toward a finished habitat. */ progress?: number; + /** Orientation in iso space (SE, SW, NW, NE), same convention as rover + * `facing`. Purely cosmetic today (sprite/render hint) — set at build time + * from the rover's facing and changeable via `rotateStructure`. Optional + * for saves predating this field; treated as 0 when absent. */ + facing?: 0 | 1 | 2 | 3; } export interface PhotoMeta { @@ -351,6 +366,10 @@ export interface GameEvents { anomalyDocumented: { anomaly: Anomaly }; built: { structure: Structure }; buildFailed: { reason: string }; + rotated: { id: string; facing: 0 | 1 | 2 | 3 }; + rotateFailed: { reason: string }; + demolished: { id: string; type: StructureType; pos: Vec2 }; + demolishFailed: { reason: string }; crafted: { recipe: string; resource: ResourceKey; amount: number }; craftFailed: { reason: string }; blockPlaced: { pos: Vec2 }; diff --git a/packages/engine/test/engine.test.ts b/packages/engine/test/engine.test.ts index fdf1dc0..e871a2a 100644 --- a/packages/engine/test/engine.test.ts +++ b/packages/engine/test/engine.test.ts @@ -307,6 +307,66 @@ describe('simulation', () => { }); }); +describe('structure placement mechanics', () => { + it('lets a decorative structure share a tile with a functional one', () => { + const sim = makeSim(); + sim.rover.cargo = { silica: 6, iron: 7, copper: 2 }; + const solar = sim.build('solar-array'); + expect(solar).not.toBeNull(); + const beacon = sim.build('beacon'); + expect(beacon).not.toBeNull(); + expect(beacon!.pos).toEqual(solar!.pos); + expect(sim.structures).toHaveLength(2); + }); + + it('still blocks two functional structures on the same tile', () => { + const sim = makeSim(); + sim.rover.cargo = { silica: 6, iron: 12, titanium: 2 }; + expect(sim.build('solar-array')).not.toBeNull(); + expect(sim.build('drill-rig')).toBeNull(); + expect(sim.structures).toHaveLength(1); + }); + + it('rotates a structure through all four facings and back', () => { + const sim = makeSim(); + sim.rover.cargo = { silica: 6, iron: 4 }; + const s = sim.build('solar-array')!; + expect(s.facing).toBe(sim.rover.facing); + expect(sim.rotateStructure(s.id)).toBe(true); + expect(s.facing).toBe(1); + sim.rotateStructure(s.id); + sim.rotateStructure(s.id); + sim.rotateStructure(s.id); + expect(s.facing).toBe(0); + }); + + it('refuses to rotate a structure the rover is not adjacent to', () => { + const sim = makeSim(); + sim.rover.cargo = { silica: 6, iron: 4 }; + const s = sim.build('solar-array')!; + sim.rover.pos = { x: s.pos.x + 5, y: s.pos.y + 5 }; + expect(sim.rotateStructure(s.id)).toBe(false); + }); + + it('demolishes a structure the rover is adjacent to', () => { + const sim = makeSim(); + sim.rover.cargo = { silica: 6, iron: 4 }; + const s = sim.build('solar-array')!; + expect(sim.demolish(s.id)).toBe(true); + expect(sim.structures).toHaveLength(0); + }); + + it('refuses to demolish a structure the rover is not adjacent to, and an unknown id', () => { + const sim = makeSim(); + sim.rover.cargo = { silica: 6, iron: 4 }; + const s = sim.build('solar-array')!; + sim.rover.pos = { x: s.pos.x + 5, y: s.pos.y + 5 }; + expect(sim.demolish(s.id)).toBe(false); + expect(sim.demolish('nope')).toBe(false); + expect(sim.structures).toHaveLength(1); + }); +}); + describe('mission credits', () => { it('pays for banked resources, cargo, documented anomalies and photos', () => { const sim = makeSim('moon');