-
Notifications
You must be signed in to change notification settings - Fork 95
feat: Track game activity metadata #536
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done! |
||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.