Skip to content

Commit

Permalink
Add scaffolding for PRC-1 achievements API (#358)
Browse files Browse the repository at this point in the history
* Tweak tsoa imports per todo comment

* Add AchievementsController

* Include time in getValidity

* Log API errors to make debugging actually possible

* Move AchievementService to utils-backend so it can be imported

* Support games that export 'AchievementService' classes

* Fix eslint errors

* Interfaces too

* Run pgtyped against a temporary Postgres instance

* Add achievement tables and queries

* Use new SQL queries in AchievementsController, remove AchievementService

* Update for PRC-1 changes

* Add table defs for new tables, clarify TODOs, fix issues

* Update paima-db README to describe Docker

* Use getMainAddress in /wallet/X

* Add achievement_language table to back Accept-Language

* TODO -> Future in AchievementsController

* Improve import.ts documentation

* Move checkedForPackedGameCode, rename importTsoa -> importEndpoints

* Use import instead of DB for constant achievement definitions

* Fix bugs and linter errors

* Fix table definition mismatch

* Add setAchievementProgress query

* Use wallet ID instead of address to handle delegation
  • Loading branch information
SpaceManiac authored May 14, 2024
1 parent 9ee0fe3 commit 819e32e
Show file tree
Hide file tree
Showing 25 changed files with 1,005 additions and 54 deletions.
6 changes: 3 additions & 3 deletions packages/engine/paima-rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"types": "build/index.d.ts",
"scripts": {
"lint:eslint": "eslint .",
"build": "tsc --build tsconfig.build.json",
"prebuild": "npm run compile:api",
"compile:api": "npx tsoa spec-and-routes"
"build": "npm run compile:api && tsc --build tsconfig.build.json",
"prebuild": "",
"compile:api": "tsoa spec-and-routes"
},
"author": "Paima Studios",
"dependencies": {
Expand Down
34 changes: 23 additions & 11 deletions packages/engine/paima-rest/src/EngineService.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import type { GameStateMachine } from '@paima/sm';
import type { AchievementMetadata } from '@paima/utils-backend';

export class EngineService {
public static INSTANCE = new EngineService();
// Useful stuff
readonly stateMachine: GameStateMachine;
readonly achievements: Promise<AchievementMetadata> | null;

private runtime: GameStateMachine | undefined = undefined;
constructor(alike: {
readonly stateMachine: GameStateMachine;
readonly achievements: Promise<AchievementMetadata> | null;
}) {
this.stateMachine = alike.stateMachine;
this.achievements = alike.achievements;
}

getSM = (): GameStateMachine => {
if (this.runtime == null) {
throw new Error('EngineService: SM not initialized');
}
return this.runtime;
};
getSM = (): GameStateMachine => this.stateMachine;

updateSM = (runtime: GameStateMachine): void => {
this.runtime = runtime;
};
// Singleton
private static _instance?: EngineService;
static get INSTANCE(): EngineService {
if (!this._instance) {
throw new Error('EngineService not initialized');
}
return this._instance;
}
static set INSTANCE(value: EngineService) {
this._instance = value;
}
}
143 changes: 143 additions & 0 deletions packages/engine/paima-rest/src/controllers/AchievementsController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { getAchievementProgress, getMainAddress } from '@paima/db';
import { ENV } from '@paima/utils';
import {
getNftOwner,
type Achievement,
type AchievementMetadata,
type AchievementPublicList,
type Player,
type PlayerAchievements,
type Validity,
} from '@paima/utils-backend';
import { Controller, Get, Header, Path, Query, Route } from 'tsoa';
import { EngineService } from '../EngineService.js';

// ----------------------------------------------------------------------------
// Controller and routes per PRC-1

function applyLanguage(
achievement: Achievement,
languages: AchievementMetadata['languages'],
accept: string[]
): Achievement {
for (const acceptLang of accept) {
const override = languages?.[acceptLang]?.[achievement.name];
if (override) {
return {
...achievement,
displayName: override.displayName || achievement.displayName,
description: override.description || achievement.description,
};
}
}
return achievement;
}

@Route('achievements')
export class AchievementsController extends Controller {
private async meta(): Promise<AchievementMetadata> {
const meta = EngineService.INSTANCE.achievements;
if (!meta) {
this.setStatus(501);
throw new Error('Achievements are not supported by this game');
}
return await meta;
}

private async validity(): Promise<Validity> {
return {
// Note: will need updating when we support non-EVM data availability layers.
caip2: `eip155:${ENV.CHAIN_ID}`,
block: await EngineService.INSTANCE.getSM().latestProcessedBlockHeight(),
time: new Date().toISOString(),
};
}

@Get('public/list')
public async public_list(
@Query() category?: string,
@Query() isActive?: boolean,
@Header('Accept-Language') acceptLanguage?: string
): Promise<AchievementPublicList> {
// Future expansion: import a real Accept-Language parser so user can
// ask for more than one, handle 'pt-BR' also implying 'pt', etc.
const acceptLanguages = acceptLanguage ? [acceptLanguage] : [];

const meta = await this.meta();
const filtered = meta.list
.filter(ach => category === undefined || category === ach.category)
.filter(ach => isActive === undefined || isActive === ach.isActive);

this.setHeader('Content-Language', acceptLanguages[0]);
return {
...(await this.validity()),
...meta.game,
achievements: meta.languages
? filtered.map(ach => applyLanguage(ach, meta.languages, acceptLanguages))
: filtered,
};
}

@Get('wallet/{wallet}')
public async wallet(
@Path() wallet: string,
/** Comma-separated list. */
@Query() name?: string
): Promise<PlayerAchievements> {
const db = EngineService.INSTANCE.getSM().getReadonlyDbConn();
const { address, id } = await getMainAddress(wallet, db);

const player: Player = {
wallet: address,
userId: String(id),
// walletType and userName aren't fulfilled here because Paima Engine's
// own DB tables don't include them at the moment.
};

const names = name ? name.split(',') : ['*'];
const rows = await getAchievementProgress.run({ wallet: id, names }, db);

return {
...(await this.validity()),
...player,
completed: rows.reduce((n, row) => n + (row.completed_date ? 1 : 0), 0),
achievements: rows.map(row => ({
name: row.name,
completed: Boolean(row.completed_date),
completedDate: row.completed_date ?? undefined,
completedRate: row.total
? {
progress: row.progress ?? 0,
total: row.total,
}
: undefined,
})),
};
}

@Get('erc/{erc}/{cde}/{token_id}')
public async nft(
@Path() erc: string,
@Path() cde: string,
@Path() token_id: string,
@Query() name?: string
): Promise<PlayerAchievements> {
const db = EngineService.INSTANCE.getSM().getReadonlyDbConn();

switch (erc) {
case 'erc721':
const wallet = await getNftOwner(db, cde, BigInt(token_id));
if (wallet) {
return await this.wallet(wallet, name);
} else {
// Future improvement: throw a different error if no CDE with that name exists
this.setStatus(404);
throw new Error('No owner for that NFT');
}
// Future improvement: erc6551
default:
this.setStatus(404);
throw new Error(`No support for /erc/${erc}`);
}
}
}
4 changes: 1 addition & 3 deletions packages/engine/paima-rest/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { RegisterRoutes } from './tsoa/routes.js';
import * as basicControllerJson from './tsoa/swagger.json';
// replace the above import with the one below once this is merged and released: https://github.com/prettier/prettier/issues/15699
// import { default as basicControllerJson } from './tsoa/swagger.json' with { type: "json" };
import { default as basicControllerJson } from './tsoa/swagger.json' with { type: 'json' };
export default RegisterRoutes;
export { basicControllerJson };
export { EngineService } from './EngineService.js';
158 changes: 158 additions & 0 deletions packages/engine/paima-rest/src/tsoa/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { EmulatedBlockActiveController } from './../controllers/BasicControllers
import { DeploymentBlockheightToEmulatedController } from './../controllers/BasicControllers';
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
import { ConfirmInputAcceptanceController } from './../controllers/BasicControllers';
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
import { AchievementsController } from './../controllers/AchievementsController';
import type { Request as ExRequest, Response as ExResponse, RequestHandler, Router } from 'express';


Expand Down Expand Up @@ -89,6 +91,66 @@ const models: TsoaRoute.Models = {
"type": {"ref":"Result_boolean_","validators":{}},
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"Achievement": {
"dataType": "refObject",
"properties": {
"name": {"dataType":"string","required":true},
"score": {"dataType":"double"},
"category": {"dataType":"string"},
"percentCompleted": {"dataType":"double"},
"isActive": {"dataType":"boolean","required":true},
"displayName": {"dataType":"string","required":true},
"description": {"dataType":"string","required":true},
"spoiler": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["all"]},{"dataType":"enum","enums":["description"]}]},
"iconURI": {"dataType":"string"},
"iconGreyURI": {"dataType":"string"},
"startDate": {"dataType":"string"},
"endDate": {"dataType":"string"},
},
"additionalProperties": false,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"AchievementPublicList": {
"dataType": "refObject",
"properties": {
"id": {"dataType":"string","required":true},
"name": {"dataType":"string"},
"version": {"dataType":"string"},
"block": {"dataType":"double","required":true},
"caip2": {"dataType":"string","required":true},
"time": {"dataType":"string"},
"achievements": {"dataType":"array","array":{"dataType":"refObject","ref":"Achievement"},"required":true},
},
"additionalProperties": false,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"PlayerAchievement": {
"dataType": "refObject",
"properties": {
"name": {"dataType":"string","required":true},
"completed": {"dataType":"boolean","required":true},
"completedDate": {"dataType":"datetime"},
"completedRate": {"dataType":"nestedObjectLiteral","nestedProperties":{"total":{"dataType":"double","required":true},"progress":{"dataType":"double","required":true}}},
},
"additionalProperties": false,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
"PlayerAchievements": {
"dataType": "refObject",
"properties": {
"block": {"dataType":"double","required":true},
"caip2": {"dataType":"string","required":true},
"time": {"dataType":"string"},
"wallet": {"dataType":"string","required":true},
"walletType": {"dataType":"string"},
"userId": {"dataType":"string"},
"userName": {"dataType":"string"},
"completed": {"dataType":"double","required":true},
"achievements": {"dataType":"array","array":{"dataType":"refObject","ref":"PlayerAchievement"},"required":true},
},
"additionalProperties": false,
},
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
};
const templateService = new ExpressTemplateService(models, {"noImplicitAdditionalProperties":"throw-on-extras","bodyCoercion":true});

Expand Down Expand Up @@ -279,6 +341,102 @@ export function RegisterRoutes(app: Router) {
}
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.get('/achievements/public/list',
...(fetchMiddlewares<RequestHandler>(AchievementsController)),
...(fetchMiddlewares<RequestHandler>(AchievementsController.prototype.public_list)),

function AchievementsController_public_list(request: ExRequest, response: ExResponse, next: any) {
const args: Record<string, TsoaRoute.ParameterSchema> = {
category: {"in":"query","name":"category","dataType":"string"},
isActive: {"in":"query","name":"isActive","dataType":"boolean"},
acceptLanguage: {"in":"header","name":"Accept-Language","dataType":"string"},
};

// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa

let validatedArgs: any[] = [];
try {
validatedArgs = templateService.getValidatedArgs({ args, request, response });

const controller = new AchievementsController();

templateService.apiHandler({
methodName: 'public_list',
controller,
response,
next,
validatedArgs,
successStatus: undefined,
});
} catch (err) {
return next(err);
}
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.get('/achievements/wallet/:wallet',
...(fetchMiddlewares<RequestHandler>(AchievementsController)),
...(fetchMiddlewares<RequestHandler>(AchievementsController.prototype.wallet)),

function AchievementsController_wallet(request: ExRequest, response: ExResponse, next: any) {
const args: Record<string, TsoaRoute.ParameterSchema> = {
wallet: {"in":"path","name":"wallet","required":true,"dataType":"string"},
name: {"in":"query","name":"name","dataType":"string"},
};

// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa

let validatedArgs: any[] = [];
try {
validatedArgs = templateService.getValidatedArgs({ args, request, response });

const controller = new AchievementsController();

templateService.apiHandler({
methodName: 'wallet',
controller,
response,
next,
validatedArgs,
successStatus: undefined,
});
} catch (err) {
return next(err);
}
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
app.get('/achievements/erc/:erc/:cde/:token_id',
...(fetchMiddlewares<RequestHandler>(AchievementsController)),
...(fetchMiddlewares<RequestHandler>(AchievementsController.prototype.nft)),

function AchievementsController_nft(request: ExRequest, response: ExResponse, next: any) {
const args: Record<string, TsoaRoute.ParameterSchema> = {
erc: {"in":"path","name":"erc","required":true,"dataType":"string"},
cde: {"in":"path","name":"cde","required":true,"dataType":"string"},
token_id: {"in":"path","name":"token_id","required":true,"dataType":"string"},
name: {"in":"query","name":"name","dataType":"string"},
};

// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa

let validatedArgs: any[] = [];
try {
validatedArgs = templateService.getValidatedArgs({ args, request, response });

const controller = new AchievementsController();

templateService.apiHandler({
methodName: 'nft',
controller,
response,
next,
validatedArgs,
successStatus: undefined,
});
} catch (err) {
return next(err);
}
});
// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa

// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa

Expand Down
Loading

0 comments on commit 819e32e

Please sign in to comment.