From 9f06ab1a9db3e1926dd8da6053b3c581d67eb5ea Mon Sep 17 00:00:00 2001 From: Guy Nir Date: Wed, 15 Apr 2026 07:04:37 +0300 Subject: [PATCH] game logic: add crew commands API --- src/common/gameLogic/GameEngine.js | 5 + src/common/gameLogic/handlers/crew/arrange.js | 67 +++++++++++++ src/common/gameLogic/handlers/crew/eject.js | 91 ++++++++++++++++++ .../gameLogic/handlers/crew/exchange.js | 96 +++++++++++++++++++ .../gameLogic/handlers/crew/resupplyFood.js | 73 ++++++++++++++ src/common/gameLogic/handlers/crew/station.js | 79 +++++++++++++++ 6 files changed, 411 insertions(+) create mode 100644 src/common/gameLogic/handlers/crew/arrange.js create mode 100644 src/common/gameLogic/handlers/crew/eject.js create mode 100644 src/common/gameLogic/handlers/crew/exchange.js create mode 100644 src/common/gameLogic/handlers/crew/resupplyFood.js create mode 100644 src/common/gameLogic/handlers/crew/station.js diff --git a/src/common/gameLogic/GameEngine.js b/src/common/gameLogic/GameEngine.js index c68f867..0bab10f 100644 --- a/src/common/gameLogic/GameEngine.js +++ b/src/common/gameLogic/GameEngine.js @@ -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 }); diff --git a/src/common/gameLogic/handlers/crew/arrange.js b/src/common/gameLogic/handlers/crew/arrange.js new file mode 100644 index 0000000..0965c62 --- /dev/null +++ b/src/common/gameLogic/handlers/crew/arrange.js @@ -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; diff --git a/src/common/gameLogic/handlers/crew/eject.js b/src/common/gameLogic/handlers/crew/eject.js new file mode 100644 index 0000000..a96b1e8 --- /dev/null +++ b/src/common/gameLogic/handlers/crew/eject.js @@ -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; diff --git a/src/common/gameLogic/handlers/crew/exchange.js b/src/common/gameLogic/handlers/crew/exchange.js new file mode 100644 index 0000000..9ee365f --- /dev/null +++ b/src/common/gameLogic/handlers/crew/exchange.js @@ -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; diff --git a/src/common/gameLogic/handlers/crew/resupplyFood.js b/src/common/gameLogic/handlers/crew/resupplyFood.js new file mode 100644 index 0000000..37f93ed --- /dev/null +++ b/src/common/gameLogic/handlers/crew/resupplyFood.js @@ -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; diff --git a/src/common/gameLogic/handlers/crew/station.js b/src/common/gameLogic/handlers/crew/station.js new file mode 100644 index 0000000..d0c6f07 --- /dev/null +++ b/src/common/gameLogic/handlers/crew/station.js @@ -0,0 +1,79 @@ +const { Entity, Permission } = require('@influenceth/sdk'); +const { EntityService, LocationComponentService } = require('@common/services'); +const EntityLib = require('@common/lib/Entity'); +const BaseActionHandler = require('../BaseActionHandler'); +const AccessValidator = require('../../validators/access'); +const CrewValidator = require('../../validators/crew'); +const { ValidationError } = require('../../errors'); + +class CrewStationHandler extends BaseActionHandler { + // eslint-disable-next-line class-methods-use-this + getEventName() { return 'CrewStationed'; } + + async validate() { + const { destination: destRef, caller_crew: callerCrewRef } = this.vars || {}; + if (!callerCrewRef?.id) throw new ValidationError('vars.caller_crew with id is required'); + if (!destRef?.id || !destRef?.label) throw new ValidationError('vars.destination with id and label is required'); + + // 1. 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('Crew not found'); + await AccessValidator.assertControlledBy(this.crew, this.address); + + // 2. Crew must be ready + CrewValidator.assertReady(this.crew); + + // 3. Destination must exist + this.destination = await EntityService.getEntity({ + id: destRef.id, + label: destRef.label, + components: ['Location', 'Control'], + format: true + }); + if (!this.destination) throw new ValidationError('Destination not found'); + + // 4. Must have STATION_CREW permission on the destination + await AccessValidator.assertPermission(this.crew, this.destination, Permission.IDS.STATION_CREW); + + // 5. Capture origin station from crew's current location + this.originStation = this.crew.Location?.location || null; + } + + async applyStateChanges() { + const destRef = this.vars.destination; + const destEntity = EntityLib.toEntity(destRef); + const fullLocation = await LocationComponentService.getFullLocation(destEntity); + + // Update crew's location to the destination + await this.writeComponent('Location', { + entity: { id: this.crew.id, label: Entity.IDS.CREW }, + location: destEntity.toObject(), + locations: fullLocation + }); + + return { crewId: this.crew.id }; + } + + getReturnValues() { + return { + originStation: this.originStation || { id: 0, label: 0 }, + destinationStation: this.vars.destination, + 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/CrewStationed/v1'); + } +} + +module.exports = CrewStationHandler;