From cce854a942ab89d7327e0223c9883c64bf753370 Mon Sep 17 00:00:00 2001 From: Guy Nir Date: Tue, 14 Apr 2026 16:51:20 +0300 Subject: [PATCH] phase 4: main engine for user actions --- src/common/gameLogic/GameEngine.js | 110 ++++++++++ src/common/gameLogic/errors.js | 8 + .../gameLogic/handlers/BaseActionHandler.js | 190 ++++++++++++++++++ .../gameLogic/handlers/construction/plan.js | 135 +++++++++++++ src/common/gameLogic/helpers/idGenerator.js | 24 +++ .../gameLogic/helpers/syntheticEvent.js | 129 ++++++++++++ src/common/gameLogic/validators/access.js | 91 +++++++++ src/common/gameLogic/validators/crew.js | 57 ++++++ src/common/gameLogic/validators/inventory.js | 58 ++++++ src/common/gameLogic/validators/location.js | 43 ++++ .../gameLogic/validators/stateMachine.js | 41 ++++ src/common/storage/db/models/Counter.js | 8 + .../storage/db/models/Events/Starknet.js | 10 +- src/common/storage/db/models/index.js | 2 + 14 files changed, 905 insertions(+), 1 deletion(-) create mode 100644 src/common/gameLogic/GameEngine.js create mode 100644 src/common/gameLogic/errors.js create mode 100644 src/common/gameLogic/handlers/BaseActionHandler.js create mode 100644 src/common/gameLogic/handlers/construction/plan.js create mode 100644 src/common/gameLogic/helpers/idGenerator.js create mode 100644 src/common/gameLogic/helpers/syntheticEvent.js create mode 100644 src/common/gameLogic/validators/access.js create mode 100644 src/common/gameLogic/validators/crew.js create mode 100644 src/common/gameLogic/validators/inventory.js create mode 100644 src/common/gameLogic/validators/location.js create mode 100644 src/common/gameLogic/validators/stateMachine.js create mode 100644 src/common/storage/db/models/Counter.js diff --git a/src/common/gameLogic/GameEngine.js b/src/common/gameLogic/GameEngine.js new file mode 100644 index 0000000..79c19a5 --- /dev/null +++ b/src/common/gameLogic/GameEngine.js @@ -0,0 +1,110 @@ +const mongoose = require('mongoose'); +const logger = require('@common/lib/logger'); +const SyntheticEvent = require('./helpers/syntheticEvent'); +const { ValidationError } = require('./errors'); + +const loadHandlers = () => ({ + // eslint-disable-next-line global-require + ConstructionPlan: require('./handlers/construction/plan') + // TODO: Add remaining handlers as they are implemented: + // ConstructionStart: require('./handlers/construction/start'), + // ConstructionFinish: require('./handlers/construction/finish'), + // ConstructionDeconstruct: require('./handlers/construction/deconstruct'), + // ConstructionAbandon: require('./handlers/construction/abandon'), + // ResourceExtractionStart: require('./handlers/production/extractStart'), + // ResourceExtractionFinish: require('./handlers/production/extractFinish'), + // MaterialProcessingStart: require('./handlers/production/processStart'), + // MaterialProcessingFinish: require('./handlers/production/processFinish'), + // CrewStation: require('./handlers/crew/station'), + // CrewForm: require('./handlers/crew/form'), + // ... etc +}); + +class GameEngine { + static _handlers = null; + + static get handlers() { + if (!this._handlers) this._handlers = loadHandlers(); + return this._handlers; + } + + /** + * Execute a game action. This is the main entry point called by the + * actions controller (POST /v2/actions/:action). + * + * Two-phase execution: + * Phase 1: validate + write components + create synthetic event. + * Runs inside a MongoDB session/transaction, but note that only the + * Entity upsert and synthetic event creation honour the session. + * ComponentService writes bypass it (no session param support). + * Idempotency keys provide crash-safety for the overall operation. + * Phase 2 (non-transactional): run existing Dispatcher handler for side effects + * + * @param {object} params + * @param {string} params.action - Action name (e.g. 'ConstructionPlan') + * @param {string} params.address - Caller's wallet address + * @param {object} params.callerCrew - { id, label } of the calling crew + * @param {object} params.vars - Action-specific variables + * @param {object} params.meta - Optional metadata + * @param {string} params.idempotencyKey - Client-provided key for crash-safe retries + * @returns {object} Action result + */ + static async execute({ action, address, callerCrew, vars, meta, idempotencyKey }) { + // ── Idempotency check ──────────────────────────────────────────── + if (idempotencyKey) { + const existing = await SyntheticEvent.findByIdempotencyKey(idempotencyKey); + if (existing) return { event: existing, replayed: true }; + } + + const HandlerClass = this.handlers[action]; + if (!HandlerClass) { + throw new ValidationError(`Unknown action: ${action}`); + } + + const handler = new HandlerClass({ action, address, callerCrew, vars, meta, idempotencyKey }); + + // ── Phase 1: Validate + Write (session-scoped) ──────────────────── + const session = await mongoose.startSession(); + let result; + + try { + session.startTransaction(); + handler.setSession(session); + + await handler.validate(); + result = await handler.writePhase(); + + await session.commitTransaction(); + } catch (error) { + await session.abortTransaction(); + + // Duplicate idempotency key from a concurrent request - treat as replay + if (idempotencyKey && error.code === 11000 && error.message?.includes('idempotency')) { + const existing = await SyntheticEvent.findByIdempotencyKey(idempotencyKey); + if (existing) return { event: existing, replayed: true }; + } + + throw error; + } finally { + session.endSession(); + } + + // ── Phase 2: Side effects (non-transactional) ──────────────────── + try { + await handler.sideEffectPhase(); + } catch (error) { + logger.error(`Side effect phase failed for ${action}:`, error); + } + + // 3. Emit Socket.IO events + try { + await handler.emitEvents(); + } catch (error) { + logger.error(`Socket event emission failed for ${action}:`, error); + } + + return result; + } +} + +module.exports = GameEngine; diff --git a/src/common/gameLogic/errors.js b/src/common/gameLogic/errors.js new file mode 100644 index 0000000..4931f81 --- /dev/null +++ b/src/common/gameLogic/errors.js @@ -0,0 +1,8 @@ +class ValidationError extends Error { + constructor(message) { + super(message); + this.name = 'ValidationError'; + } +} + +module.exports = { ValidationError }; diff --git a/src/common/gameLogic/handlers/BaseActionHandler.js b/src/common/gameLogic/handlers/BaseActionHandler.js new file mode 100644 index 0000000..432471c --- /dev/null +++ b/src/common/gameLogic/handlers/BaseActionHandler.js @@ -0,0 +1,190 @@ +const mongoose = require('mongoose'); +const Entity = require('@common/lib/Entity'); +const { ComponentService, ElasticSearchService } = require('@common/services'); +const SyntheticEvent = require('../helpers/syntheticEvent'); + +class BaseActionHandler { + constructor({ action, address, callerCrew, vars, meta, idempotencyKey }) { + this.action = action; + this.address = address; + this.callerCrew = callerCrew; + this.vars = vars; + this.meta = meta; + this.idempotencyKey = idempotencyKey; + this.systemEvent = null; + this.session = null; + this._dispatcherHandler = null; + } + + /** + * Called by GameEngine to inject the MongoDB session for Phase 1. + */ + setSession(session) { + this.session = session; + } + + // ── Subclass interface ─────────────────────────────────────────────── + + // eslint-disable-next-line class-methods-use-this + async validate() { throw new Error('Must implement validate()'); } + + // eslint-disable-next-line class-methods-use-this + async applyStateChanges() { throw new Error('Must implement applyStateChanges()'); } + + // eslint-disable-next-line class-methods-use-this + getEventName() { throw new Error('Must implement getEventName()'); } + + // eslint-disable-next-line class-methods-use-this + getReturnValues() { throw new Error('Must implement getReturnValues()'); } + + /** + * Return the existing Dispatcher system handler class for this action. + * @returns {Class} e.g., require('...Dispatcher/systems/ConstructionPlanned') + */ + // eslint-disable-next-line class-methods-use-this + getDispatcherSystemHandler() { throw new Error('Must implement getDispatcherSystemHandler()'); } + + // ── Phase 1: Write (runs inside transaction) ───────────────────────── + + /** + * Called by GameEngine inside the transaction. Creates synthetic events + * and writes components. The synthetic event is saved with the session + * so it rolls back on abort. + * + * NOTE: ComponentService.updateOrCreateFromEvent() does not currently + * accept a session parameter — its internal save() calls run outside + * the transaction. The transaction protects the synthetic event creation; + * idempotency keys provide crash-safety for the overall operation. + */ + async writePhase() { + const result = await this.applyStateChanges(); + + this.systemEvent = await SyntheticEvent.create({ + eventName: this.getEventName(), + returnValues: this.getReturnValues(), + session: this.session, + idempotencyKey: this.idempotencyKey + }); + + return result; + } + + // ── Phase 2: Side effects (runs after transaction commit) ──────────── + + /** + * Called by GameEngine AFTER the transaction commits. Runs the existing + * Dispatcher system handler against the synthetic event. The handler's + * DB reads can now see the committed component data from Phase 1. + */ + async sideEffectPhase() { + const HandlerClass = this.getDispatcherSystemHandler(); + this._dispatcherHandler = new HandlerClass(this.systemEvent); + await this._dispatcherHandler.processEvent(); + await this._dispatcherHandler.finalizeEvent(); + } + + /** + * Emit Socket.IO events collected by the Dispatcher handler. + * Called by GameEngine after sideEffectPhase() completes. + */ + async emitEvents() { + if (this._dispatcherHandler) { + await this._dispatcherHandler.emitSocketEvents(); + } + } + + // ── Component write helpers ────────────────────────────────────────── + + /** + * Create an Entity document in the Entity collection. + * Uses updateOne with upsert — same pattern as the entitiesPlugin. + */ + async createEntity(entityRef) { + const entityData = Entity.toEntity(entityRef); + await mongoose.model('Entity').updateOne( + { uuid: entityData.uuid }, + entityData.toObject(), + { upsert: true, session: this.session } + ); + return entityData; + } + + /** + * Create a new entity and all its initial components atomically. + * Creates the Entity document first, then writes each component via + * ComponentService.updateOrCreateFromEvent(). + */ + async createEntityWithComponents(entityRef, components) { + const entity = Entity.toEntity(entityRef); + await mongoose.model('Entity').updateOne( + { uuid: entity.uuid }, + entity.toObject(), + { upsert: true, session: this.session } + ); + + const componentResults = []; + for (const { component, data, options } of components) { + // eslint-disable-next-line no-await-in-loop + const componentEvent = await SyntheticEvent.createComponentEvent({ + parentEvent: this.systemEvent, + componentName: component, + returnValues: { ...data, entity: entity.toObject() }, + session: this.session + }); + + // eslint-disable-next-line no-await-in-loop + const result = await ComponentService.updateOrCreateFromEvent({ + component, + event: componentEvent, + data: { ...data, entity: entity.toObject() }, + replace: options?.replace !== false + }); + + if (result.updated) { + // eslint-disable-next-line no-await-in-loop + await ElasticSearchService.queueEntityForIndexing(entity); + } + + componentResults.push(result); + } + + return { entity, componentResults }; + } + + /** + * Write a single component. Use for updating existing entities + * (e.g., changing Building status). For new entities, prefer + * createEntityWithComponents(). + */ + async writeComponent(componentName, data, options = {}) { + const componentEvent = await SyntheticEvent.createComponentEvent({ + parentEvent: this.systemEvent, + componentName, + returnValues: data, + session: this.session + }); + + const result = await ComponentService.updateOrCreateFromEvent({ + component: componentName, + event: componentEvent, + data, + replace: options.replace !== false + }); + + if (result.updated && data.entity) { + await ElasticSearchService.queueEntityForIndexing(data.entity); + } + + return result; + } + + /** + * Delete a component (for actions like ConstructionAbandon). + */ + // eslint-disable-next-line class-methods-use-this + async deleteComponent(componentName, data, filter) { + return ComponentService.deleteOne({ component: componentName, data, filter }); + } +} + +module.exports = BaseActionHandler; diff --git a/src/common/gameLogic/handlers/construction/plan.js b/src/common/gameLogic/handlers/construction/plan.js new file mode 100644 index 0000000..8a02783 --- /dev/null +++ b/src/common/gameLogic/handlers/construction/plan.js @@ -0,0 +1,135 @@ +const { Building, Entity, Lot, Permission } = require('@influenceth/sdk'); +const EntityLib = require('@common/lib/Entity'); +const { EntityService, LocationComponentService } = require('@common/services'); +const BaseActionHandler = require('../BaseActionHandler'); +const AccessValidator = require('../../validators/access'); +const CrewValidator = require('../../validators/crew'); +const { ValidationError } = require('../../errors'); +const IdGenerator = require('../../helpers/idGenerator'); + +class ConstructionPlanHandler extends BaseActionHandler { + // eslint-disable-next-line class-methods-use-this + getEventName() { return 'ConstructionPlanned'; } + + async validate() { + const { building_type: buildingType, caller_crew: callerCrewRef, lot: lotRef } = this.vars || {}; + if (!buildingType) throw new ValidationError('vars.building_type is required'); + if (!callerCrewRef?.id) throw new ValidationError('vars.caller_crew with id is required'); + if (!lotRef?.id) throw new ValidationError('vars.lot with id is required'); + + 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', '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 (not busy) + CrewValidator.assertReady(this.crew); + + // 3. Lot must exist on an asteroid + this.lot = await EntityService.getEntity({ + id: lotRef.id, + label: Entity.IDS.LOT, + components: ['Location'], + format: true + }); + if (!this.lot) throw new ValidationError('Lot not found'); + + // 4. Lot must not already have a building + const existing = await EntityService.getEntities({ + label: Entity.IDS.BUILDING, + match: { + 'Location.location.id': lotRef.id, + 'Location.location.label': Entity.IDS.LOT + } + }); + if (existing.length > 0) throw new ValidationError('Lot already has a building'); + + // 5. Must have USE_LOT permission on the lot + await AccessValidator.assertPermission(this.crew, this.lot, Permission.IDS.USE_LOT); + + // 6. Valid building type + if (!Building.TYPES[buildingType]) throw new ValidationError('Invalid building type'); + } + + async applyStateChanges() { + const buildingType = this.vars.building_type; + const callerCrewRef = this.vars.caller_crew; + const lotRef = this.vars.lot; + + // Generate a new building ID + this.newBuildingId = await IdGenerator.next(Entity.IDS.BUILDING); + + // Resolve full location chain for the lot + const lotEntity = EntityLib.toEntity(lotRef); + const fullLocation = await LocationComponentService.getFullLocation(lotEntity); + + // Create the new Building entity and all its initial components + await this.createEntityWithComponents( + { id: this.newBuildingId, label: Entity.IDS.BUILDING }, + [ + { + component: 'Building', + data: { + buildingType: Number(buildingType), + status: Building.CONSTRUCTION_STATUSES.PLANNED, + plannedAt: this.now, + finishTime: 0 + } + }, + { + component: 'Control', + data: { + controller: EntityLib.toEntity(callerCrewRef).toObject() + } + }, + { + component: 'Location', + data: { + location: lotEntity.toObject(), + locations: fullLocation + } + }, + { + component: 'Name', + data: { name: '' } + } + ] + ); + + return { buildingId: this.newBuildingId }; + } + + // returnValues must match what the chain's ConstructionPlanned event produces. + // The existing Dispatcher/systems/ConstructionPlanned handler reads these fields + // from this.eventDoc.returnValues in its processEvent() method. + getReturnValues() { + const buildingType = this.vars.building_type; + const callerCrewRef = this.vars.caller_crew; + const lotRef = this.vars.lot; + const { asteroidId } = Lot.toPosition(lotRef.id); + return { + building: { id: this.newBuildingId, label: Entity.IDS.BUILDING }, + buildingType: Number(buildingType), + asteroid: { id: asteroidId, label: Entity.IDS.ASTEROID }, + lot: lotRef, + callerCrew: callerCrewRef, + caller: this.address, + gracePeriodEnd: this.now + 86400 + }; + } + + // eslint-disable-next-line class-methods-use-this + getDispatcherSystemHandler() { + // eslint-disable-next-line global-require + return require('@common/lib/events/handlers/starknet/Dispatcher/systems/ConstructionPlanned'); + } +} + +module.exports = ConstructionPlanHandler; diff --git a/src/common/gameLogic/helpers/idGenerator.js b/src/common/gameLogic/helpers/idGenerator.js new file mode 100644 index 0000000..50baa58 --- /dev/null +++ b/src/common/gameLogic/helpers/idGenerator.js @@ -0,0 +1,24 @@ +const mongoose = require('mongoose'); + +const LOCAL_ID_OFFSET = 100_000_000; // avoid colliding with on-chain IDs + +class IdGenerator { + /** + * Returns the next unique ID for the given entity label. + * Uses MongoDB findOneAndUpdate for atomic increment — safe under concurrency. + * + * @param {number} entityLabel - e.g. Entity.IDS.BUILDING + * @returns {Promise} unique ID starting above LOCAL_ID_OFFSET + */ + static async next(entityLabel) { + const Counter = mongoose.model('Counter'); + const counter = await Counter.findOneAndUpdate( + { key: `entity_${entityLabel}` }, + { $inc: { seq: 1 } }, + { upsert: true, new: true } + ); + return LOCAL_ID_OFFSET + counter.seq; + } +} + +module.exports = IdGenerator; diff --git a/src/common/gameLogic/helpers/syntheticEvent.js b/src/common/gameLogic/helpers/syntheticEvent.js new file mode 100644 index 0000000..76b11db --- /dev/null +++ b/src/common/gameLogic/helpers/syntheticEvent.js @@ -0,0 +1,129 @@ +const crypto = require('crypto'); +const mongoose = require('mongoose'); +const logger = require('@common/lib/logger'); + +const BLOCK_OFFSET = 9_000_000_000; // high offset to never collide with real blocks + +// logCounter is per-action (reset on each create(), incremented by createComponentEvent()). +// Safe in-memory because component events are always written sequentially within one request. +let logCounter = 0; + +class SyntheticEvent { + /** + * Atomically increment a shared counter via MongoDB. Safe across clustered workers. + */ + static async _nextSeq(key) { + const Counter = mongoose.model('Counter'); + const counter = await Counter.findOneAndUpdate( + { key }, + { $inc: { seq: 1 } }, + { upsert: true, new: true } + ); + return counter.seq; + } + + /** + * Look up the event key hash from the Dispatcher system handler's eventConfig. + * This is the keccak256 of the event name, used by handler routing and + * included in on-chain events. + */ + static _getEventKeys(eventName) { + try { + // eslint-disable-next-line global-require + const systemHandlers = require('@common/lib/events/handlers/starknet/Dispatcher/systems'); + const handler = systemHandlers[eventName]; + if (handler?.eventConfig?.keys) return handler.eventConfig.keys; + logger.warn(`SyntheticEvent: no event keys found for handler "${eventName}"`); + } catch (e) { + logger.warn(`SyntheticEvent: failed to load handler for "${eventName}": ${e.message}`); + } + return []; + } + + /** + * Checks if an action with this idempotency key has already been executed. + * Returns the existing synthetic event if found, null otherwise. + */ + static async findByIdempotencyKey(idempotencyKey) { + if (!idempotencyKey) return null; + const StarknetEvent = mongoose.model('Starknet'); + return StarknetEvent.findOne({ 'returnValues.idempotencyKey': idempotencyKey }).lean(); + } + + /** + * Creates and persists a real Starknet Event document in MongoDB. + * The resulting doc has a valid _id, __t, timestamp, blockNumber, + * transactionIndex, logIndex — everything ComponentService needs. + * + * @param {string} eventName - e.g. 'ConstructionPlanned' + * @param {object} returnValues - the decoded event payload + * @param {string} [transactionHash] - optional tx hash (auto-generated if omitted) + * @param {object} [session] - MongoDB session for transactional writes + * @param {string} [idempotencyKey] - client-provided key for crash-safe retries + * @returns {Document} a saved Mongoose Event (Starknet discriminator) document + */ + static async create({ eventName, returnValues, transactionHash, session, idempotencyKey }) { + const StarknetEvent = mongoose.model('Starknet'); + + const now = Math.floor(Date.now() / 1000); + const blockSeq = await this._nextSeq('synthetic_block'); + const txSeq = await this._nextSeq('synthetic_tx'); + logCounter = 0; // reset log counter for each new "transaction" + + const event = new StarknetEvent({ + address: 'local-hybrid-server', + blockHash: `0xlocal_block_${blockSeq}`, + blockNumber: BLOCK_OFFSET + blockSeq, + event: eventName, + name: eventName, + keys: this._getEventKeys(eventName), + logIndex: 0, + returnValues: { + ...returnValues, + ...(idempotencyKey && { idempotencyKey }) + }, + timestamp: now, + transactionHash: transactionHash || this._generateTxHash(), + transactionIndex: txSeq, + status: 'ACCEPTED_ON_L2', + lastProcessed: new Date() // mark as already processed so EventProcessor skips it + }); + + await event.save({ session }); + return event; + } + + /** + * Creates additional synthetic events for component updates within the same + * "transaction" (same txHash, incrementing logIndex). This preserves ordering + * guarantees when multiple components are written for one action. + */ + static async createComponentEvent({ parentEvent, componentName, returnValues, session }) { + const StarknetEvent = mongoose.model('Starknet'); + logCounter += 1; + + const event = new StarknetEvent({ + address: 'local-hybrid-server', + blockHash: parentEvent.blockHash, + blockNumber: parentEvent.blockNumber, + event: `ComponentUpdated_${componentName}`, + name: `ComponentUpdated_${componentName}`, + logIndex: logCounter, + returnValues, + timestamp: parentEvent.timestamp, + transactionHash: parentEvent.transactionHash, + transactionIndex: parentEvent.transactionIndex, + status: 'ACCEPTED_ON_L2', + lastProcessed: new Date() + }); + + await event.save({ session }); + return event; + } + + static _generateTxHash() { + return `0x${crypto.randomBytes(31).toString('hex')}`; + } +} + +module.exports = SyntheticEvent; diff --git a/src/common/gameLogic/validators/access.js b/src/common/gameLogic/validators/access.js new file mode 100644 index 0000000..edc66d2 --- /dev/null +++ b/src/common/gameLogic/validators/access.js @@ -0,0 +1,91 @@ +const { Address } = require('@influenceth/sdk'); +const Entity = require('@common/lib/Entity'); +const { ComponentService } = require('@common/services'); +const { ValidationError } = require('../errors'); + +class AccessValidator { + /** + * Asserts that the given entity is controlled by the specified address. + * Follows the NFT ownership chain: entity -> Control component -> controller crew -> Nft owner. + * + * @param {object} entity - Entity with Control component (formatted) + * @param {string} address - Wallet address to check + */ + static async assertControlledBy(entity, address) { + if (!entity) throw new ValidationError('Entity not found'); + if (!address) throw new ValidationError('Address required'); + + const controllerEntity = entity.Control?.controller; + if (!controllerEntity) throw new ValidationError('Entity has no controller'); + + // For crews: check the Nft component owner matches the address + const nft = await ComponentService.findOneByEntity('Nft', controllerEntity); + if (!nft) throw new ValidationError('Controller NFT not found'); + + const ownerAddress = nft.owners?.starknet || nft.owners?.ethereum; + if (!ownerAddress) throw new ValidationError('Controller has no owner'); + + if (Address.toStandard(ownerAddress) !== Address.toStandard(address)) { + throw new ValidationError('Not authorized: address does not control this entity'); + } + } + + /** + * Asserts that the crew has the specified permission on the target entity. + * Multi-tier check: public policy -> controller -> whitelist -> prepaid -> contract. + * + * @param {object} crew - Crew entity (formatted, with Control component) + * @param {object} target - Target entity (e.g., lot, building) + * @param {number} permissionId - Permission.IDS value + */ + static async assertPermission(crew, target, permissionId) { + if (!crew || !target) throw new ValidationError('Crew and target required'); + + const targetEntity = Entity.toEntity(target); + + // 1. Check if target has a public policy for this permission + const publicPolicy = await ComponentService.findOne('PublicPolicy', { + 'entity.uuid': targetEntity.uuid, + permission: permissionId + }); + if (publicPolicy) return; + + // 2. Check if crew is the controller + const control = await ComponentService.findOneByEntity('Control', targetEntity); + if (control?.controller) { + const controllerEntity = Entity.toEntity(control.controller); + const crewEntity = Entity.toEntity(crew); + if (controllerEntity.uuid === crewEntity.uuid) return; + } + + // 3. Check whitelist + const crewEntity = Entity.toEntity(crew); + const whitelist = await ComponentService.findOne('Whitelist', { + 'entity.uuid': targetEntity.uuid, + 'target.uuid': crewEntity.uuid, + permission: permissionId + }); + if (whitelist) return; + + // 4. Check prepaid agreements + const now = Math.floor(Date.now() / 1000); + const prepaid = await ComponentService.findOne('PrepaidPolicy', { + 'entity.uuid': targetEntity.uuid, + 'target.uuid': crewEntity.uuid, + permission: permissionId, + endTime: { $gt: now } + }); + if (prepaid) return; + + // 5. Check contract policy + const contractPolicy = await ComponentService.findOne('ContractPolicy', { + 'entity.uuid': targetEntity.uuid, + permission: permissionId + }); + if (contractPolicy) return; + + throw new ValidationError(`Permission denied: missing permission ${permissionId} on entity`); + } +} + +module.exports = AccessValidator; diff --git a/src/common/gameLogic/validators/crew.js b/src/common/gameLogic/validators/crew.js new file mode 100644 index 0000000..387b9ca --- /dev/null +++ b/src/common/gameLogic/validators/crew.js @@ -0,0 +1,57 @@ +const { Address } = require('@influenceth/sdk'); +const { ValidationError } = require('../errors'); + +class CrewValidator { + /** + * Asserts the crew is ready (not currently performing another action). + * @param {object} crew - Formatted crew entity with Crew component + */ + static assertReady(crew) { + if (!crew?.Crew) throw new ValidationError('Crew component not found'); + + const now = Math.floor(Date.now() / 1000); + if (crew.Crew.readyAt && crew.Crew.readyAt > now) { + throw new ValidationError(`Crew is busy until ${crew.Crew.readyAt}`); + } + } + + /** + * Asserts the crew has been fed (not starving). + * Food consumption is tracked via lastFed timestamp. + * @param {object} crew - Formatted crew entity with Crew component + */ + static assertFed(crew) { + if (!crew?.Crew) throw new ValidationError('Crew component not found'); + + // TODO: Implement food/starvation validation. Currently a no-op because + // the contract allows unfed crews to act until the starvation penalty + // kicks in, and we don't yet replicate that penalty calculation locally. + } + + /** + * Asserts the crew has a valid roster (at least one crewmate). + * @param {object} crew - Formatted crew entity with Crew component + */ + static assertHasRoster(crew) { + if (!crew?.Crew) throw new ValidationError('Crew component not found'); + if (!crew.Crew.roster || crew.Crew.roster.length === 0) { + throw new ValidationError('Crew has no crewmates'); + } + } + + /** + * Asserts the crew is delegated to the given address. + * @param {object} crew - Formatted crew entity with Crew component + * @param {string} address - Wallet address to check delegation for + */ + static assertDelegated(crew, address) { + if (!crew?.Crew) throw new ValidationError('Crew component not found'); + if (!crew.Crew.delegatedTo) throw new ValidationError('Crew is not delegated'); + + if (Address.toStandard(crew.Crew.delegatedTo) !== Address.toStandard(address)) { + throw new ValidationError('Crew is not delegated to this address'); + } + } +} + +module.exports = CrewValidator; diff --git a/src/common/gameLogic/validators/inventory.js b/src/common/gameLogic/validators/inventory.js new file mode 100644 index 0000000..28d08d0 --- /dev/null +++ b/src/common/gameLogic/validators/inventory.js @@ -0,0 +1,58 @@ +const { Inventory, Product } = require('@influenceth/sdk'); +const { ValidationError } = require('../errors'); + +class InventoryValidator { + /** + * Asserts that the inventory has sufficient capacity for the given product amount. + * Checks both mass and volume constraints from the SDK's Inventory.TYPES. + * + * @param {object} inventory - InventoryComponent document + * @param {number} productId - Product ID from Product.IDS + * @param {number} amount - Amount to add + */ + static assertCapacity(inventory, productId, amount) { + if (!inventory) throw new ValidationError('Inventory not found'); + + const product = Product.TYPES[productId]; + if (!product) throw new ValidationError(`Unknown product: ${productId}`); + + const invType = Inventory.TYPES[inventory.inventoryType]; + if (!invType) throw new ValidationError(`Unknown inventory type: ${inventory.inventoryType}`); + + const addedMass = (product.massPerUnit || 0) * amount; + const addedVolume = (product.volumePerUnit || 0) * amount; + + const currentMass = inventory.mass || 0; + const currentVolume = inventory.volume || 0; + const reservedMass = inventory.reservedMass || 0; + const reservedVolume = inventory.reservedVolume || 0; + + if (currentMass + addedMass + reservedMass > invType.massConstraint) { + throw new ValidationError('Insufficient mass capacity'); + } + + if (currentVolume + addedVolume + reservedVolume > invType.volumeConstraint) { + throw new ValidationError('Insufficient volume capacity'); + } + } + + /** + * Asserts that the inventory contains at least the specified amount of a product. + * + * @param {object} inventory - InventoryComponent document with contents array + * @param {number} productId - Product ID from Product.IDS + * @param {number} amount - Required amount + */ + static assertContains(inventory, productId, amount) { + if (!inventory) throw new ValidationError('Inventory not found'); + + const item = (inventory.contents || []).find((c) => c.product === productId); + const available = item?.amount || 0; + + if (available < amount) { + throw new ValidationError(`Insufficient product ${productId}: have ${available}, need ${amount}`); + } + } +} + +module.exports = InventoryValidator; diff --git a/src/common/gameLogic/validators/location.js b/src/common/gameLogic/validators/location.js new file mode 100644 index 0000000..3d4d49b --- /dev/null +++ b/src/common/gameLogic/validators/location.js @@ -0,0 +1,43 @@ +const Entity = require('@common/lib/Entity'); +const { LocationComponentService } = require('@common/services'); +const { ValidationError } = require('../errors'); + +class LocationValidator { + /** + * Asserts two entities are at the same location (same lot or asteroid). + * + * @param {object} entityA - Entity with Location component (formatted) + * @param {object} entityB - Entity with Location component (formatted) + */ + static assertSameLocation(entityA, entityB) { + if (!entityA?.Location?.location || !entityB?.Location?.location) { + throw new ValidationError('Both entities must have a location'); + } + + const locA = Entity.toEntity(entityA.Location.location); + const locB = Entity.toEntity(entityB.Location.location); + + if (locA.uuid !== locB.uuid) { + throw new ValidationError('Entities are not at the same location'); + } + } + + /** + * Asserts an entity is located on the specified asteroid. + * + * @param {object} entity - Entity with Location component (formatted) + * @param {number} asteroidId - Expected asteroid ID + */ + static async assertOnAsteroid(entity, asteroidId) { + if (!entity?.Location) throw new ValidationError('Entity has no location'); + + const asteroidEntity = await LocationComponentService.getAsteroidForEntity(entity); + if (!asteroidEntity) throw new ValidationError('Entity is not on an asteroid'); + + if (asteroidEntity.id !== asteroidId) { + throw new ValidationError(`Entity is on asteroid ${asteroidEntity.id}, expected ${asteroidId}`); + } + } +} + +module.exports = LocationValidator; diff --git a/src/common/gameLogic/validators/stateMachine.js b/src/common/gameLogic/validators/stateMachine.js new file mode 100644 index 0000000..28bbcd7 --- /dev/null +++ b/src/common/gameLogic/validators/stateMachine.js @@ -0,0 +1,41 @@ +const { ValidationError } = require('../errors'); + +class StateMachineValidator { + /** + * Asserts a component's status matches the expected value. + * Used for equipment status transitions (e.g., Extractor must be IDLE to start). + * + * @param {object} component - Component document with a status field + * @param {number} expectedStatus - Expected status value + * @param {string} [label] - Human-readable label for error messages + */ + static assertStatus(component, expectedStatus, label = 'Component') { + if (!component) throw new ValidationError(`${label} not found`); + if (component.status !== expectedStatus) { + throw new ValidationError( + `${label} status is ${component.status}, expected ${expectedStatus}` + ); + } + } + + /** + * Asserts a time-gated operation has finished (finishTime has passed). + * Used for construction, extraction, processing, transit completions. + * + * @param {object} component - Component with a finishTime field + * @param {string} [label] - Human-readable label for error messages + */ + static assertFinished(component, label = 'Component') { + if (!component) throw new ValidationError(`${label} not found`); + if (!component.finishTime) throw new ValidationError(`${label} has no finish time`); + + const now = Math.floor(Date.now() / 1000); + if (component.finishTime > now) { + throw new ValidationError( + `${label} not finished yet (finishes at ${component.finishTime}, now ${now})` + ); + } + } +} + +module.exports = StateMachineValidator; diff --git a/src/common/storage/db/models/Counter.js b/src/common/storage/db/models/Counter.js new file mode 100644 index 0000000..f239f76 --- /dev/null +++ b/src/common/storage/db/models/Counter.js @@ -0,0 +1,8 @@ +const { Schema, model } = require('mongoose'); + +const schema = new Schema({ + key: { type: String, required: true, unique: true }, + seq: { type: Number, default: 0 } +}); + +module.exports = model('Counter', schema); diff --git a/src/common/storage/db/models/Events/Starknet.js b/src/common/storage/db/models/Events/Starknet.js index 28a68ae..7c2d674 100644 --- a/src/common/storage/db/models/Events/Starknet.js +++ b/src/common/storage/db/models/Events/Starknet.js @@ -33,6 +33,14 @@ schema partialFilterExpression: { event: 'EventAnnotated' } } ) - .index({ status: 1, blockNumber: -1 }); + .index({ status: 1, blockNumber: -1 }) + .index( + { 'returnValues.idempotencyKey': 1 }, + { + name: 'idempotency_key_unique', + unique: true, + partialFilterExpression: { 'returnValues.idempotencyKey': { $exists: true } } + } + ); module.exports = EventModel.discriminator('Starknet', schema); diff --git a/src/common/storage/db/models/index.js b/src/common/storage/db/models/index.js index 3e661b3..b2da995 100644 --- a/src/common/storage/db/models/index.js +++ b/src/common/storage/db/models/index.js @@ -2,6 +2,7 @@ const Activity = require('./Activity'); const ApiKey = require('./ApiKey'); const AsteroidSale = require('./AsteroidSale'); const Constant = require('./Constant'); +const Counter = require('./Counter'); const Crossing = require('./Crossing'); const DirectMessage = require('./DirectMessage'); const Entity = require('./Entity'); @@ -25,6 +26,7 @@ module.exports = { ApiKey, AsteroidSale, Constant, + Counter, Crossing, DirectMessage, Entity,