import express from 'express'; import morgan from 'morgan'; import bodyParser from 'body-parser'; import cors from 'cors'; import argumentMap from './argumentMap.json' with { type: 'json' }; const errorCodes = [ 1000, // Name not available. 1001, // Invalid password. 1002, // Object does not exist. 1003, // Too much information. 1004, // Password incorrect. 1005, // Contains invalid characters. 1006, // Session expired. 1007, // Overspend. 1008, // Underspend. 1009, // Invalid range. 1010, // Insufficient privileges. 1011, // Not enough resources in storage. 1012, // Not enough resources in production. 1013, // Missing prerequisites. 1014, // Captcha not valid. 1015, // Restricted for sitter logins. 1016, // Needs to solve a captcha. 1017, // Pending Parliamentary Vote 1018, // Already Voted 1100, // Empire not founded. 1101, // Empire not founded, and you tried to create it, but had the wrong password. 1200, // Game Over. ] as const; type ReqModule = string; type ReqMethod = string; type MethodRef = `/${ReqModule}/${ReqMethod}`; type Argument = { name: string; required: boolean; }; type RPCRequest = { id: number; jsonrpc: '2.0'; method: ReqMethod; params: any; }; type RPCError = { jsonrpc: '2.0'; id: number; error: { code: (typeof errorCodes)[number]; message: string; data?: any; }; }; // Maps each /module/method the v2 API serves to its legacy positional Perl RPC argument order, // derived from lib/Lacuna/RPC/**/*.pm + the OpenAPI spec. Covers ~1100 of the spec's ~1157 // methods as a first pass - some couldn't be resolved automatically (a handful of buildings that // the spec exposes under a consolidated name with no single legacy equivalent, e.g. "beach", plus // a few one-off spec/backend naming or shape mismatches). Update this file directly as gaps are // found through testing. const namedArgumentMapper: Record = argumentMap.positional as Record< MethodRef, Argument[] >; // Special handling for Perl methods that take named arguments or hash refs or lists // Three versions of this are needed because the server accepts different forms. Fun! // params => { session_id, ...args } const namedOnlyArgumentHashSet: Set = new Set( argumentMap.namedOnlyArgumentHash as MethodRef[] ); // params => [{ session_id, ...args }] const namedOnlyArgumentListFirstPositionHashSet: Set = new Set( argumentMap.namedOnlyArgumentListFirstPositionHash as MethodRef[] ); // params => [session_id, { ...args }] const namedOnlyArgumentListSecondPositionHashSet: Set = new Set( argumentMap.namedOnlyArgumentListSecondPositionHash as MethodRef[] ); // Methods called before a session exists - never inject a session ID into these, even if the // client sends an Authorization header. const sessionlessMethods: Set = new Set([ '/empire/login', '/empire/create', '/empire/found', '/empire/send_password_reset_message', ]); const port = process.env.PORT || 5999; const app = express(); app.set('trust proxy', 'uniquelocal'); app.use(cors()); app.use(morgan('common', { immediate: true })); app.use(bodyParser.json()); app.post('/v2/:module/:method', async (req, res) => { const { module: reqModule, method: reqMethod } = req.params; const methodRef: MethodRef = `/${reqModule}/${reqMethod}`; const token = !sessionlessMethods.has(methodRef) && req.headers['authorization']?.startsWith('Token') ? req.headers['authorization'].split(' ')[1] : undefined; const prepareBody = (data: any): RPCRequest | RPCError => { console.log('handling body', data); const body: RPCRequest = { id: 1, jsonrpc: '2.0', method: reqMethod, params: null, }; if (data['jsonrpc']) { body.id = data['id']; body.params = data['params']; } else if (Array.isArray(data) || (typeof data === 'object' && data !== null)) { body.params = data; } // Every method the v2 API serves must be accounted for in the generated argument map - // either as a positional conversion or as a known named-only passthrough. A method // reaching neither means it wasn't found in the OpenAPI spec / Perl backend when the map // was generated (or the spec and backend have drifted since) - fail loudly rather than // guess at an unverified argument order. if ( !namedArgumentMapper[methodRef] && !namedOnlyArgumentListFirstPositionHashSet.has(methodRef) && !namedOnlyArgumentListSecondPositionHashSet.has(methodRef) && !namedOnlyArgumentHashSet.has(methodRef) ) { return { id: 1, jsonrpc: '2.0', error: { code: 1002, message: `Method ${methodRef} is not recognized by the v2 API's argument map.`, data: { methodRef }, }, }; } // Use the mapping to convert named args into legacy positional arguments // This quasi-proxy approach allows us to maintain backwards compatibility with // previous iterations of the game/scripts as well as inject some validation when helpful. if (!Array.isArray(body.params) && !!namedArgumentMapper[methodRef]) { const newArgList = [...namedArgumentMapper[methodRef]]; for (const arg of newArgList) { if ( (arg.required && typeof body.params[arg.name] === 'undefined') || body.params[arg.name] === null ) { return { id: 1, jsonrpc: '2.0', error: { code: 1002, message: `Parameter ${arg.name} required by v2 API but not provided.`, data: { methodRef, schema: newArgList, }, }, }; } } body.params = [...newArgList.map(({ name }) => body.params[name])]; } // Certain backend methods only accept a hashref/hash-style method's (namedOnly) // single argument when it arrives as a one-element params array - sending the named // object directly as `params` makes it flatten the object's key/value pairs into // positional args instead, which crashes those methods (confirmed via curl against // /map/get_star_map: bare-object params throws "Can't use string (...) as a HASH ref"). // In this case we add the session ID into the same object as well, which you could call "combined" // However that said.. if ( namedOnlyArgumentListFirstPositionHashSet.has(methodRef) && !Array.isArray(body.params) && body.params !== null ) { body.params = [token ? { session_id: token, ...body.params } : body.params]; } // .. certain methods want the session ID "separate" to the rest of the request. // And indeed.. else if ( namedOnlyArgumentListSecondPositionHashSet.has(methodRef) && !Array.isArray(body.params) && body.params !== null ) { // Always keep the hash in the second slot so a missing token fails as a session error // rather than the hash being read as the session ID. body.params = [token ?? null, body.params]; } // .. there are cases when we need to support a bare hash being passed in as well! else if ( namedOnlyArgumentHashSet.has(methodRef) && !Array.isArray(body.params) && body.params !== null ) { body.params = token ? { session_id: token, ...body.params } : body.params; } else if (!!token && body.params !== null) { body.params = Array.isArray(body.params) ? [token, ...body.params] : { session_id: token, ...body.params }; } return body; }; const body = prepareBody(req.body ?? {}); if ('error' in body) { console.error('Processing/validation error', body); return res.status(500).json(body); } console.log(`hitting /${reqModule} with:`, body); try { const response = await fetch(`http://server:5000/${reqModule}`, { method: 'POST', headers: { // TODO pass real port through 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); const result = await response.json(); console.log('status', response.status); res.status(response.status).json(result); } catch (e: unknown) { console.error('Unknown error', e); res.status(500).json({ id: 1, jsonrpc: '2.0', error: { code: 1002, message: 'An unknown server error occurred in the TLE infrastructure.', }, }); } }); app.listen(port, () => { console.log(`TLE Community v2 API app listening on port ${port}`); });