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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/engine/src/core/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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());
Expand Down Expand Up @@ -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();
}
Expand Down
51 changes: 49 additions & 2 deletions packages/engine/src/sim/simulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/sim/structures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const STRUCTURES: Record<StructureType, StructureDef> = {
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',
Expand Down
19 changes: 19 additions & 0 deletions packages/engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 };
Expand Down
60 changes: 60 additions & 0 deletions packages/engine/test/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading