Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/common/gameLogic/GameEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const loadHandlers = () => ({
ConstructionFinish: require('./handlers/construction/finish'),
ConstructionDeconstruct: require('./handlers/construction/deconstruct'),
ConstructionAbandon: require('./handlers/construction/abandon'),
StationCrew: require('./handlers/crew/station'),
EjectCrew: require('./handlers/crew/eject'),
ArrangeCrew: require('./handlers/crew/arrange'),
ExchangeCrew: require('./handlers/crew/exchange'),
ResupplyFood: require('./handlers/crew/resupplyFood'),
// TODO: Add remaining handlers as they are implemented
});

Expand Down
67 changes: 67 additions & 0 deletions src/common/gameLogic/handlers/crew/arrange.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const { Entity } = require('@influenceth/sdk');
const { EntityService } = require('@common/services');
const BaseActionHandler = require('../BaseActionHandler');
const AccessValidator = require('../../validators/access');
const { ValidationError } = require('../../errors');

class CrewArrangeHandler extends BaseActionHandler {
// eslint-disable-next-line class-methods-use-this
getEventName() { return 'CrewmatesArranged'; }

async validate() {
const { composition, caller_crew: callerCrewRef } = this.vars || {};
if (!callerCrewRef?.id) throw new ValidationError('vars.caller_crew with id is required');
if (!Array.isArray(composition) || composition.length === 0) {
throw new ValidationError('vars.composition must be a non-empty array');
}

// 1. Crew must exist and be controlled by this address
this.crew = await EntityService.getEntity({
id: callerCrewRef.id,
label: Entity.IDS.CREW,
components: ['Crew', 'Control'],
format: true
});
if (!this.crew) throw new ValidationError('Crew not found');
await AccessValidator.assertControlledBy(this.crew, this.address);

// 2. New composition must contain the same crewmates as the current roster
this.oldRoster = this.crew.Crew?.roster || [];
const newSet = new Set(composition.map(Number));
const oldSet = new Set(this.oldRoster.map(Number));
if (newSet.size !== oldSet.size || ![...newSet].every((id) => oldSet.has(id))) {
throw new ValidationError('New composition must contain the same crewmates as the current roster');
}
}

async applyStateChanges() {
const newRoster = this.vars.composition.map(Number);

await this.writeComponent('Crew', {
entity: { id: this.crew.id, label: Entity.IDS.CREW },
roster: newRoster,
lastFed: this.crew.Crew.lastFed,
readyAt: this.crew.Crew.readyAt,
delegatedTo: this.crew.Crew.delegatedTo
});

return { crewId: this.crew.id };
}

getReturnValues() {
return {
compositionOld: this.oldRoster,
compositionNew: this.vars.composition.map(Number),
callerCrew: this.vars.caller_crew,
caller: this.address
};
}

// eslint-disable-next-line class-methods-use-this
getDispatcherSystemHandler() {
// eslint-disable-next-line global-require
return require('@common/lib/events/handlers/starknet/Dispatcher/systems/CrewmatesArranged/v1');
}
}

module.exports = CrewArrangeHandler;
91 changes: 91 additions & 0 deletions src/common/gameLogic/handlers/crew/eject.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
const { Entity } = require('@influenceth/sdk');
const { EntityService } = require('@common/services');
const BaseActionHandler = require('../BaseActionHandler');
const AccessValidator = require('../../validators/access');
const { ValidationError } = require('../../errors');

class CrewEjectHandler extends BaseActionHandler {
// eslint-disable-next-line class-methods-use-this
getEventName() { return 'CrewEjected'; }

async validate() {
const { ejected_crew: ejectedRef, caller_crew: callerCrewRef } = this.vars || {};
if (!callerCrewRef?.id) throw new ValidationError('vars.caller_crew with id is required');
if (!ejectedRef?.id) throw new ValidationError('vars.ejected_crew with id is required');

// 1. Caller crew must exist and be controlled by this address
this.crew = await EntityService.getEntity({
id: callerCrewRef.id,
label: Entity.IDS.CREW,
components: ['Crew', 'Location', 'Control'],
format: true
});
if (!this.crew) throw new ValidationError('Caller crew not found');
await AccessValidator.assertControlledBy(this.crew, this.address);

// 2. Ejected crew must exist
this.ejectedCrew = await EntityService.getEntity({
id: ejectedRef.id,
label: Entity.IDS.CREW,
components: ['Crew', 'Location', 'Control'],
format: true
});
if (!this.ejectedCrew) throw new ValidationError('Ejected crew not found');

// 3. Both crews must be at the same station
const callerStation = this.crew.Location?.location;
const ejectedStation = this.ejectedCrew.Location?.location;
if (!callerStation || !ejectedStation) {
throw new ValidationError('Crews must be stationed at a location');
}
if (callerStation.id !== ejectedStation.id || callerStation.label !== ejectedStation.label) {
throw new ValidationError('Crews are not at the same station');
}

this.station = callerStation;

// 4. Caller must control the station (building/ship)
const stationEntity = await EntityService.getEntity({
id: this.station.id,
label: this.station.label,
components: ['Control'],
format: true
});
if (stationEntity) {
await AccessValidator.assertControlledBy(stationEntity, this.address);
}
}

async applyStateChanges() {
// Eject moves the crew to the asteroid (up one level in the location chain)
const ejectedLocation = this.ejectedCrew.Location?.locations || [];
const asteroid = ejectedLocation.find((l) => l.label === Entity.IDS.ASTEROID);
if (!asteroid) throw new ValidationError('Cannot determine asteroid for ejection');

await this.writeComponent('Location', {
entity: { id: this.ejectedCrew.id, label: Entity.IDS.CREW },
location: asteroid,
locations: [asteroid]
});

return { ejectedCrewId: this.ejectedCrew.id };
}

getReturnValues() {
return {
station: this.station,
ejectedCrew: this.vars.ejected_crew,
finishTime: 0,
callerCrew: this.vars.caller_crew,
caller: this.address
};
}

// eslint-disable-next-line class-methods-use-this
getDispatcherSystemHandler() {
// eslint-disable-next-line global-require
return require('@common/lib/events/handlers/starknet/Dispatcher/systems/CrewEjected');
}
}

module.exports = CrewEjectHandler;
96 changes: 96 additions & 0 deletions src/common/gameLogic/handlers/crew/exchange.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
const { Entity } = require('@influenceth/sdk');
const { EntityService } = require('@common/services');
const BaseActionHandler = require('../BaseActionHandler');
const AccessValidator = require('../../validators/access');
const { ValidationError } = require('../../errors');

class CrewExchangeHandler extends BaseActionHandler {
// eslint-disable-next-line class-methods-use-this
getEventName() { return 'CrewmatesExchanged'; }

async validate() {
const { crew1: crew1Ref, comp1, _crew2: crew2Ref, comp2 } = this.vars || {};
if (!crew1Ref?.id) throw new ValidationError('vars.crew1 with id is required');
if (!crew2Ref?.id) throw new ValidationError('vars._crew2 with id is required');
if (!Array.isArray(comp1)) throw new ValidationError('vars.comp1 must be an array');
if (!Array.isArray(comp2)) throw new ValidationError('vars.comp2 must be an array');

// 1. Both crews must exist and be controlled by this address
this.crew1 = await EntityService.getEntity({
id: crew1Ref.id,
label: Entity.IDS.CREW,
components: ['Crew', 'Location', 'Control'],
format: true
});
if (!this.crew1) throw new ValidationError('Crew 1 not found');
await AccessValidator.assertControlledBy(this.crew1, this.address);

this.crew2 = await EntityService.getEntity({
id: crew2Ref.id,
label: Entity.IDS.CREW,
components: ['Crew', 'Location', 'Control'],
format: true
});
if (!this.crew2) throw new ValidationError('Crew 2 not found');
await AccessValidator.assertControlledBy(this.crew2, this.address);

// 2. Both crews must be at the same location
const loc1 = this.crew1.Location?.location;
const loc2 = this.crew2.Location?.location;
if (!loc1 || !loc2 || loc1.id !== loc2.id || loc1.label !== loc2.label) {
throw new ValidationError('Crews must be at the same location to exchange crewmates');
}

// 3. New compositions must contain the same total crewmates as the old ones
this.oldRoster1 = this.crew1.Crew?.roster || [];
this.oldRoster2 = this.crew2.Crew?.roster || [];
const allOld = new Set([...this.oldRoster1, ...this.oldRoster2].map(Number));
const allNew = new Set([...comp1, ...comp2].map(Number));
if (allOld.size !== allNew.size || ![...allOld].every((id) => allNew.has(id))) {
throw new ValidationError('New compositions must redistribute the same crewmates');
}
}

async applyStateChanges() {
const newRoster1 = this.vars.comp1.map(Number);
const newRoster2 = this.vars.comp2.map(Number);

await this.writeComponent('Crew', {
entity: { id: this.crew1.id, label: Entity.IDS.CREW },
roster: newRoster1,
lastFed: this.crew1.Crew.lastFed,
readyAt: this.crew1.Crew.readyAt,
delegatedTo: this.crew1.Crew.delegatedTo
});

await this.writeComponent('Crew', {
entity: { id: this.crew2.id, label: Entity.IDS.CREW },
roster: newRoster2,
lastFed: this.crew2.Crew.lastFed,
readyAt: this.crew2.Crew.readyAt,
delegatedTo: this.crew2.Crew.delegatedTo
});

return { crew1Id: this.crew1.id, crew2Id: this.crew2.id };
}

getReturnValues() {
return {
crew1: this.vars.crew1,
crew1CompositionOld: this.oldRoster1.map((id) => ({ id, label: Entity.IDS.CREWMATE })),
crew1CompositionNew: this.vars.comp1.map((id) => ({ id: Number(id), label: Entity.IDS.CREWMATE })),
crew2: this.vars._crew2,
crew2CompositionOld: this.oldRoster2.map((id) => ({ id, label: Entity.IDS.CREWMATE })),
crew2CompositionNew: this.vars.comp2.map((id) => ({ id: Number(id), label: Entity.IDS.CREWMATE })),
caller: this.address
};
}

// eslint-disable-next-line class-methods-use-this
getDispatcherSystemHandler() {
// eslint-disable-next-line global-require
return require('@common/lib/events/handlers/starknet/Dispatcher/systems/CrewmatesExchanged');
}
}

module.exports = CrewExchangeHandler;
73 changes: 73 additions & 0 deletions src/common/gameLogic/handlers/crew/resupplyFood.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
const { Entity } = require('@influenceth/sdk');
const { EntityService } = require('@common/services');
const BaseActionHandler = require('../BaseActionHandler');
const AccessValidator = require('../../validators/access');
const { ValidationError } = require('../../errors');

class CrewResupplyFoodHandler extends BaseActionHandler {
// eslint-disable-next-line class-methods-use-this
getEventName() { return 'FoodSupplied'; }

async validate() {
const { origin: originRef, origin_slot: originSlot, food, caller_crew: callerCrewRef } = this.vars || {};
if (!callerCrewRef?.id) throw new ValidationError('vars.caller_crew with id is required');
if (!originRef?.id || !originRef?.label) throw new ValidationError('vars.origin with id and label is required');
if (originSlot === undefined || originSlot === null) throw new ValidationError('vars.origin_slot is required');
if (!food || food <= 0) throw new ValidationError('vars.food must be a positive number');

this.now = Math.floor(Date.now() / 1000);

// 1. Crew must exist and be controlled by this address
this.crew = await EntityService.getEntity({
id: callerCrewRef.id,
label: Entity.IDS.CREW,
components: ['Crew', 'Control'],
format: true
});
if (!this.crew) throw new ValidationError('Crew not found');
await AccessValidator.assertControlledBy(this.crew, this.address);

// 2. Origin must exist
this.origin = await EntityService.getEntity({
id: originRef.id,
label: originRef.label,
components: ['Location', 'Control'],
format: true
});
if (!this.origin) throw new ValidationError('Origin not found');
}

async applyStateChanges() {
const food = Number(this.vars.food);

// Update crew's lastFed timestamp
await this.writeComponent('Crew', {
entity: { id: this.crew.id, label: Entity.IDS.CREW },
roster: this.crew.Crew.roster,
lastFed: this.now,
readyAt: this.crew.Crew.readyAt,
delegatedTo: this.crew.Crew.delegatedTo
});

return { crewId: this.crew.id, food };
}

getReturnValues() {
return {
food: Number(this.vars.food),
lastFed: this.now,
origin: this.vars.origin,
originSlot: Number(this.vars.origin_slot),
callerCrew: this.vars.caller_crew,
caller: this.address
};
}

// eslint-disable-next-line class-methods-use-this
getDispatcherSystemHandler() {
// eslint-disable-next-line global-require
return require('@common/lib/events/handlers/starknet/Dispatcher/systems/FoodSupplied/v1');
}
}

module.exports = CrewResupplyFoodHandler;
Loading