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
110 changes: 110 additions & 0 deletions src/common/gameLogic/GameEngine.js
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions src/common/gameLogic/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}

module.exports = { ValidationError };
190 changes: 190 additions & 0 deletions src/common/gameLogic/handlers/BaseActionHandler.js
Original file line number Diff line number Diff line change
@@ -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;
Loading