diff --git a/doc/npm_scripts.md b/doc/npm_scripts.md index 819c7c1b..403f8bef 100644 --- a/doc/npm_scripts.md +++ b/doc/npm_scripts.md @@ -28,7 +28,18 @@ For example for `npm start`, `npm run production`, `npm run start:relay`. | name | values | default | description | | ---- | ------ | ------- | ----------- | -| `PORT` | a number | `8080` | sets the port for the backend server | -| `API_PORT` | a number | `undefined` | TODO: describe | +| `PORT` | Port number | `8080` | sets the port for the backend server | +| `API_PORT` | Port number | `undefined` | sets the port for the separate API server | +| `NODE_ENV` | `development`, `production` | set by npm scripts | Selects development or production behavior. | +| `GAME_ACTIVITY_FILE` | file path | `games/.lean4game/activity.json` | Optional path for the game activity metadata file. | +| `LEAN4GAME_GITHUB_USER` | GitHub username | not set | GitHub username sent with GitHub artifact download requests. | +| `LEAN4GAME_GITHUB_TOKEN` | GitHub access token | not set | Token used to request and download game artifacts from GitHub. For public games, a read-only token is enough. | +| `ISSUE_CONTACT` | URL | not set | Link shown when an import cannot start because of the disk-space check. | +| `RESERVED_DISC_SPACE_MB` | number of megabytes | not set | Value used by the disk-space check before importing a game artifact. | | `NO_BWRAP` | `true`, `false` | `false` | to disable to use of `bubblewrap` in production mode. This means `Lean` runs without any container on your system, which imposes a security risk! | -| ... | | | TODO | + +#### API + +In production, `/api/game-activity` is not exposed on the public server. It is available on the separate API server configured with `API_PORT`, together with `/api/game-sessions`. + +The `/api/game-activity` endpoint lists all games known to the activity registry, including games that are not shown on the landing page. diff --git a/relay/src/import.ts b/relay/src/import.ts index e62aa676..a0531d4c 100644 --- a/relay/src/import.ts +++ b/relay/src/import.ts @@ -5,6 +5,7 @@ import { safeImport } from './middleware.js' import { Octokit } from 'octokit'; import { spawn } from 'child_process' import { fileURLToPath } from 'url'; +import { gameActivityStore } from './services/GameActivityStore.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -41,7 +42,8 @@ async function runProcess(id, cmd, args, cwd) { }); ls.on('close', (code) => { - resolve() + if (code === 0) resolve() + else reject(new Error(cmd + " exited with code " + code)) }); }) } @@ -117,6 +119,15 @@ async function doImport (owner, repo, id) { // Install necessary toolchain await runProcess(id, "/bin/bash", [toolchainScript, gamesPath, owner.toLowerCase(), repo.toLowerCase()], path.join(__dirname, "..", "..")) + const gameJsonPath = path.join( + gamesPath, owner.toLowerCase(), repo.toLowerCase(), ".lake", "gamedata", "game.json" + ) + if (!fs.existsSync(gameJsonPath)) { + throw new Error("Imported artifact does not contain " + gameJsonPath) + } + await gameActivityStore.recordImport(owner, repo) + progress[id].output += `Activity registry updated.\n` + // let manifest = fs.readFileSync(`tmp/artifact_${artifactId}_inner/manifest.json`); // manifest = JSON.parse(manifest); diff --git a/relay/src/index.ts b/relay/src/index.ts index 3f27cc0f..47d6c49e 100644 --- a/relay/src/index.ts +++ b/relay/src/index.ts @@ -9,6 +9,7 @@ import { spawn } from 'child_process' import { GameManager } from './serverProcess.js'; import { GameSessionsObserver } from './websocket.js'; import { WebSocketServer } from 'ws'; +import { gameActivityStore } from './services/GameActivityStore.js'; // import fs from 'fs' // const __filename = url.fileURLToPath(import.meta.url); @@ -22,6 +23,28 @@ const API = process.env.API_PORT const environment = process.env.NODE_ENV; const isDevelopment = environment === 'development'; +async function registerInstalledGames() { + const gamesPath = path.join(process.cwd(), 'games') + try { + const owners = await fs.promises.readdir(gamesPath, { withFileTypes: true }) + for (const owner of owners) { + if (!owner.isDirectory() || owner.name.startsWith('.') || owner.name === 'tmp') continue + const repos = await fs.promises.readdir(path.join(gamesPath, owner.name), { withFileTypes: true }) + for (const repo of repos) { + if (!repo.isDirectory() || repo.name.startsWith('.')) continue + await gameActivityStore.recordSeen(owner.name, repo.name) + } + } + } catch (error: any) { + if (error?.code !== 'ENOENT') { + console.error("Failed to register installed games: " + error) + } + } +} + +registerInstalledGames() + .catch(error => console.error("Failed to initialize game activity: " + error)) + let router = express.Router(); router.get('/import/status/:owner/:repo', importStatus) router.get('/import/trigger/:owner/:repo', importTrigger) @@ -104,6 +127,18 @@ const server = app } }) }) + .get('/api/game-activity', async (_req, res) => { + if (!isDevelopment) { + res.sendStatus(404) + return + } + try { + res.json({ games: await gameActivityStore.getAll() }) + } catch (error) { + console.error("Failed to read game activity: " + error) + res.status(500).json({ error: 'Failed to read game activity' }) + } + }) // endpoint `games`: list of available games for landing page .use('/api/games', async (req: any, res: any) => { try { @@ -177,4 +212,12 @@ observer.use('/api/game-sessions', (req, res) => { const measurement = observerService.getAllConnectedPlayers() res.status(200).send(measurement) }) + .use('/api/game-activity', async (_req, res) => { + try { + res.json({ games: await gameActivityStore.getAll() }) + } catch (error) { + console.error("Failed to read game activity: " + error) + res.status(500).json({ error: 'Failed to read game activity' }) + } + }) .listen(API, () => console.log(`API listening on ${API}`)); diff --git a/relay/src/serverProcess.ts b/relay/src/serverProcess.ts index 8d950c68..af044f6b 100644 --- a/relay/src/serverProcess.ts +++ b/relay/src/serverProcess.ts @@ -141,7 +141,8 @@ export class GameManager { socketConnection: jsonrpcserver.IConnection, serverConnection: jsonrpcserver.IConnection, gameDir: string, - usesCustomLeanServer: boolean + usesCustomLeanServer: boolean, + onLevelOpened?: () => void ) { let shift = (line: number, offset: number) => Math.max(0, line + offset) @@ -196,6 +197,7 @@ export class GameManager { // backwards compatibility for versions ≤ v4.7.0 if (usesCustomLeanServer) { if (isDevelopment) { console.log(`CLIENT: ${JSON.stringify(message)}`); } + if (message.method === "textDocument/didOpen") onLevelOpened?.() return message } @@ -226,6 +228,7 @@ export class GameManager { console.error(`[${new Date()}] Missing level data: ${levelDataPath}`) } const levelData = JSON.parse(fs.readFileSync(levelDataPath, 'utf8')) + onLevelOpened?.() if (difficulty === undefined || inventory === undefined) { console.error("Did not receive difficulty/inventory from client!") diff --git a/relay/src/services/GameActivityStore.ts b/relay/src/services/GameActivityStore.ts new file mode 100644 index 00000000..be0bccd8 --- /dev/null +++ b/relay/src/services/GameActivityStore.ts @@ -0,0 +1,106 @@ +import fs from 'fs' +import path from 'path' + +export interface GameActivity { + firstSeenAt: string + firstImportedAt: string | null + lastImportedAt: string | null + lastPlayedAt: string | null +} + +export type GameActivityRegistry = Record + +export enum GameActivityEvent { + Import = 'import', + Play = 'play', + Seen = 'seen', +} + +/** Persist import and real-play timestamps without storing player identities. */ +export class GameActivityStore { + private readonly filePath: string + private pendingWrite: Promise = Promise.resolve() + + constructor(filePath: string) { + this.filePath = filePath + } + + getPath(): string { + return this.filePath + } + + async getAll(): Promise { + await this.pendingWrite + return this.read() + } + + recordImport(owner: string, repo: string, now: Date = new Date()): Promise { + return this.update(owner, repo, GameActivityEvent.Import, now) + } + + recordPlay(owner: string, repo: string, now: Date = new Date()): Promise { + return this.update(owner, repo, GameActivityEvent.Play, now) + } + + recordSeen(owner: string, repo: string, now: Date = new Date()): Promise { + return this.update(owner, repo, GameActivityEvent.Seen, now) + } + + private update(owner: string, repo: string, event: GameActivityEvent, now: Date): Promise { + const key = `${owner.toLowerCase()}/${repo.toLowerCase()}` + const timestamp = now.toISOString() + + const operation = this.pendingWrite.then(async () => { + const registry = await this.read() + const activity: GameActivity = registry[key] ?? { + firstSeenAt: timestamp, + firstImportedAt: null, + lastImportedAt: null, + lastPlayedAt: null, + } + + if (event === GameActivityEvent.Import) { + activity.firstImportedAt ??= timestamp + activity.lastImportedAt = timestamp + } else if (event === GameActivityEvent.Play) { + activity.lastPlayedAt = timestamp + } + registry[key] = activity + + await this.write(registry) + }) + + // A failed write must not permanently poison the serialization queue. + this.pendingWrite = operation.catch(() => undefined) + return operation + } + + private async read(): Promise { + try { + const raw = await fs.promises.readFile(this.filePath, 'utf8') + const registry = JSON.parse(raw) as GameActivityRegistry + for (const activity of Object.values(registry)) { + delete (activity as any).status + delete (activity as any).inactiveSince + } + return registry + } catch (error: any) { + if (error?.code === 'ENOENT') return {} + throw error + } + } + + private async write(registry: GameActivityRegistry): Promise { + const directory = path.dirname(this.filePath) + const temporaryPath = `${this.filePath}.${process.pid}.tmp` + await fs.promises.mkdir(directory, { recursive: true }) + await fs.promises.writeFile(temporaryPath, `${JSON.stringify(registry, null, 2)}\n`, 'utf8') + await fs.promises.rename(temporaryPath, this.filePath) + } +} + +const defaultActivityPath = path.join(process.cwd(), 'games', '.lean4game', 'activity.json') + +export const gameActivityStore = new GameActivityStore( + process.env.GAME_ACTIVITY_FILE || defaultActivityPath +) diff --git a/relay/src/websocket.ts b/relay/src/websocket.ts index 2a02aaff..8a688502 100644 --- a/relay/src/websocket.ts +++ b/relay/src/websocket.ts @@ -6,6 +6,7 @@ import { ChildProcess } from 'child_process'; import { GameManager, GameSession } from './serverProcess.js' import { IncomingMessage } from 'http'; import { randomUUID, UUID } from 'crypto'; +import { gameActivityStore } from './services/GameActivityStore.js'; interface Player { id: UUID, @@ -107,8 +108,17 @@ export class GameSessionsObserver { }); const serverConnection = jsonrpcserver.createProcessStreamConnection(this.players.get(ws).process); + let activityRecorded = false this.gameManager.messageTranslation( - socketConnection, serverConnection, gameDir, gameSession.usesCustomLeanServer + socketConnection, serverConnection, gameDir, gameSession.usesCustomLeanServer, + () => { + if (activityRecorded) return + activityRecorded = true + const [owner, repo] = game.split('/', 2) + gameActivityStore.recordPlay(owner, repo) + .then(() => console.info("Recorded play activity for " + game)) + .catch(error => console.error("Failed to record play activity for " + game + ": " + error)) + } ) socketConnection.onClose(() => {