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
17 changes: 14 additions & 3 deletions doc/npm_scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 12 additions & 1 deletion relay/src/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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))
});
})
}
Expand Down Expand Up @@ -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);
Expand Down
43 changes: 43 additions & 0 deletions relay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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)
Expand Down Expand Up @@ -104,6 +127,18 @@ const server = app
}
})
})
.get('/api/game-activity', async (_req, res) => {
Comment thread
joneugster marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -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}`));
5 changes: 4 additions & 1 deletion relay/src/serverProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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!")
Expand Down
106 changes: 106 additions & 0 deletions relay/src/services/GameActivityStore.ts
Original file line number Diff line number Diff line change
@@ -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<string, GameActivity>

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<void> = Promise.resolve()

constructor(filePath: string) {
this.filePath = filePath
}

getPath(): string {
return this.filePath
}

async getAll(): Promise<GameActivityRegistry> {
await this.pendingWrite
return this.read()
}

recordImport(owner: string, repo: string, now: Date = new Date()): Promise<void> {
return this.update(owner, repo, GameActivityEvent.Import, now)
}

recordPlay(owner: string, repo: string, now: Date = new Date()): Promise<void> {
return this.update(owner, repo, GameActivityEvent.Play, now)
}

recordSeen(owner: string, repo: string, now: Date = new Date()): Promise<void> {
return this.update(owner, repo, GameActivityEvent.Seen, now)
}

private update(owner: string, repo: string, event: GameActivityEvent, now: Date): Promise<void> {
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<GameActivityRegistry> {
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<void> {
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In lean4web there is a list of all env variables: https://github.com/leanprover-community/lean4web/blob/main/doc/Installation.md#environment-variables

Maybe you could add something similar to the documentation?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good! I’ll also update the documentation for the new environment variable and the /api/game-activity endpoint

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

)
12 changes: 11 additions & 1 deletion relay/src/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(() => {
Expand Down