openvibers/openvibe.games Commits

Vibe Heroes on OpenVibe.Play: a furthest-stage board and six stage milestones (first win … all 100 stages), synced from the saves like rounds and hauls (#151)

OP OpenVibers committed 19 days ago · 32687d1afc · 2 files, +92 −7

Changes

ddiff --git a/apps/server/src/platform/playSync.test.ts b/apps/server/src/platform/playSync.test.ts
iindex 670b52a..f3f0707 100644
---- a/apps/server/src/platform/playSync.test.ts
++++ b/apps/server/src/platform/playSync.test.ts
@@@ -4,7 +4,7 @@ import { openTestStore } from '@openvibe/persistence/testing'
import { loadPlatformConfig } from '../config.js'
import { recordHaul, recordJoined, recordRound } from '../game/progression.js'
import { createPlatformClient } from './serviceClient.js'
--import { PLAY_TITLES, PlaySync, toPlayer } from './playSync.js'
++import { HEROES_MILESTONES, PLAY_TITLES, PlaySync, toPlayer } from './playSync.js'
const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
const guest = `guest:${'a'.repeat(64)}`
@@@ -137,7 +137,7 @@ describe('PlaySync (OpenVibe.Play, ADR-0007 decision 10)', () => {
// Nothing new: the second pass posts nothing and does not register the titles again.
const before = fake.posts.length
-- expect(await sync.syncOnce()).toEqual({ rounds: 0, hauls: 0, unlocks: 0, adoptions: 0 })
++ expect(await sync.syncOnce()).toEqual({ rounds: 0, hauls: 0, heroes: 0, unlocks: 0, adoptions: 0 })
expect(fake.posts.length).toBe(before)
})
@@@ -172,4 +172,32 @@ describe('PlaySync (OpenVibe.Play, ADR-0007 decision 10)', () => {
expect(adopts.every((p) => p.body.person === subject)).toBe(true)
expect((await sync.syncOnce()).adoptions).toBe(0)
})
++
++ it('posts Vibe Heroes progress: the furthest stage, and the milestones it reaches, once each', async () => {
++ const { store, fake, sync } = await setup()
++ const save = (key: string, name: string | null, stage: number, at: number) =>
++ store.db.exec(sql`
++ INSERT INTO casual_saves (game_id, account_key, state, version, player_name, stage, power, created_at, updated_at)
++ VALUES ('vibe-heroes', ${key}, '{}'::jsonb, 1, ${name}, ${stage}, 100, ${at}, ${at})
++ ON CONFLICT (game_id, account_key) DO UPDATE SET stage = excluded.stage, updated_at = excluded.updated_at
++ `)
++ await save(subject, 'Hero', 12, 5000)
++ await save('not-a-player', 'Nobody', 40, 5001)
++ const vh = await sync.syncOnce()
++ expect(vh.heroes).toBe(2)
++ const title = fake.posts.find((p) => p.method === 'PUT' && p.path === '/titles/vibe-heroes')!.body
++ expect(title.boards).toEqual([{ id: 'stage', name: 'Furthest stage', kind: 'best' }])
++ expect((title.achievements as { id: string }[]).map((a) => a.id)).toEqual(HEROES_MILESTONES.map((m) => m.id))
++ const scores = fake.posts.filter((p) => p.path === '/titles/vibe-heroes/scores')
++ expect(scores.map((p) => p.body)).toEqual([
++ { board: 'stage', ref: 'stage-12', at: 5000, entries: [{ player: subject, name: 'Hero', score: 12 }] },
++ ])
++ const unlocks = fake.posts.filter((p) => p.path === '/titles/vibe-heroes/unlocks')
++ expect(unlocks.map((p) => p.body.achievements)).toEqual([['first-win', 'chapter-one']])
++ expect((await sync.syncOnce()).heroes).toBe(0)
++ await save(subject, 'Hero', 26, 6000)
++ expect((await sync.syncOnce()).heroes).toBe(1)
++ const later = fake.posts.filter((p) => p.path === '/titles/vibe-heroes/unlocks').at(-1)!
++ expect(later.body).toEqual({ player: subject, achievements: ['first-win', 'chapter-one', 'stage-25'], at: 6000 })
++ })
})
ddiff --git a/apps/server/src/platform/playSync.ts b/apps/server/src/platform/playSync.ts
iindex caeea71..7b0fd3d 100644
---- a/apps/server/src/platform/playSync.ts
++++ b/apps/server/src/platform/playSync.ts
@@@ -1,11 +1,14 @@
/**
* OpenVibe.Play (ADR-0007 decision 10): Games' titles, scores and unlocks on the platform's game services.
*
-- * At start Games registers its two titles (Scraplandia and Salvage Rush: their achievements from the packs, and one
-- * board each). Every SYNC_MS it posts what the local tables recorded after its last position, stream by stream:
++ * At start Games registers its titles (Scraplandia and Salvage Rush: their achievements from the packs, and one board
++ * each; Vibe Heroes: its stage milestones and a furthest-stage board). Every SYNC_MS it posts what the local tables
++ * recorded after its last position, stream by stream:
*
* rounds game_round_results → salvage-rush / best (a round's id is the ref)
* hauls game_hauls → scraplandia / haul (an extraction's id is the ref; the board sums them)
++ * heroes casual_saves → vibe-heroes / stage, and the stage milestones reached (stage-<n> is the ref, so each
++ * stage a player reaches is one row and the board keeps their best)
* unlocks game_achievements → the game's unlocks (only achievements the title defines)
* adoptions identity_adoptions (guest) → the guest's rows in Play become the person's, in both titles
*
@@@ -23,6 +26,19 @@ import { sql, type Db } from 'openvibe-sdk/db'
import { SCRAPLANDIA_PACK } from '@openvibe/content'
import { SALVAGE_RUSH_PACK } from '@openvibe/content/salvage-rush'
++/**
++ * Vibe Heroes' achievements: milestones of stages cleared (100 stages, a boss every tenth), so each is a fact the save
++ * records, unlocked in Play as soon as the save shows it.
++ */
++export const HEROES_MILESTONES = [
++ { id: 'first-win', stage: 1, name: 'First win', description: 'Clear the first stage.' },
++ { id: 'chapter-one', stage: 10, name: 'Chapter one', description: "Beat the first chapter's boss on stage 10." },
++ { id: 'stage-25', stage: 25, name: 'A quarter of the way', description: 'Clear stage 25.' },
++ { id: 'stage-50', stage: 50, name: 'Halfway there', description: 'Clear stage 50.' },
++ { id: 'stage-75', stage: 75, name: 'Almost there', description: 'Clear stage 75.' },
++ { id: 'champion', stage: 100, name: 'Vibe Champion', description: 'Clear all 100 stages.' },
++] as const
++
const AUDIENCE = 'openvibe.play'
const SCOPE = 'play.title.manage play.result.write'
const SYNC_MS = 30_000
@@@ -54,6 +70,13 @@ export const PLAY_TITLES = {
})),
boards: [{ id: 'best', name: 'Best round', kind: 'best' as const }],
},
++ 'vibe-heroes': {
++ name: 'Vibe Heroes',
++ url: 'https://openvibe.games/play/vibe-heroes',
++ summary: 'Collect heroes, fuse their shards and push through 100 stages, even while you are away.',
++ achievements: HEROES_MILESTONES.map((m) => ({ id: m.id, name: m.name, description: m.description })),
++ boards: [{ id: 'stage', name: 'Furthest stage', kind: 'best' as const }],
++ },
}
type Tokens = { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> }
@@@ -214,6 +237,39 @@ export class PlaySync {
return rows.length
}
++ /** Vibe Heroes saves → vibe-heroes/stage (the stages cleared) and the milestones they reach. */
++ private async syncHeroes(): Promise<number> {
++ const c = await this.cursor('heroes')
++ const rows = await this.opts.db.many<{
++ account_key: string
++ player_name: string | null
++ stage: number
++ updated_at: number | string
++ }>(sql`
++ SELECT account_key, player_name, stage, updated_at FROM casual_saves
++ WHERE game_id = 'vibe-heroes' AND stage > 0
++ AND (updated_at > ${c.at} OR (updated_at = ${c.at} AND account_key > ${c.key}))
++ ORDER BY updated_at, account_key LIMIT ${BATCH}
++ `)
++ for (const r of rows) {
++ const player = toPlayer(r.account_key)
++ const stage = Number(r.stage)
++ if (player) {
++ await this.call('POST', '/titles/vibe-heroes/scores', {
++ board: 'stage',
++ ref: `stage-${stage}`,
++ at: Number(r.updated_at),
++ entries: [{ player, name: (r.player_name ?? '').slice(0, 40) || 'Player', score: stage }],
++ })
++ const reached = HEROES_MILESTONES.filter((m) => stage >= m.stage).map((m) => m.id)
++ if (reached.length)
++ await this.call('POST', '/titles/vibe-heroes/unlocks', { player, achievements: reached, at: Number(r.updated_at) })
++ }
++ await this.advance('heroes', { at: Number(r.updated_at), key: r.account_key })
++ }
++ return rows.length
++ }
++
/** Unlocked achievements of both games. */
private async syncUnlocks(): Promise<number> {
const c = await this.cursor('unlocks')
@@@ -269,13 +325,14 @@ export class PlaySync {
return rows.length
}
-- /** One pass over the four streams; a failure stops the pass where it was (the next tick resumes there). */
-- async syncOnce(): Promise<{ rounds: number; hauls: number; unlocks: number; adoptions: number }> {
++ /** One pass over the five streams; a failure stops the pass where it was (the next tick resumes there). */
++ async syncOnce(): Promise<{ rounds: number; hauls: number; heroes: number; unlocks: number; adoptions: number }> {
if (!this.defined) await this.defineTitles()
const rounds = await this.syncRounds()
const hauls = await this.syncHauls()
++ const heroes = await this.syncHeroes()
const unlocks = await this.syncUnlocks()
-- return { rounds, hauls, unlocks, adoptions: await this.syncAdoptions() }
++ return { rounds, hauls, heroes, unlocks, adoptions: await this.syncAdoptions() }
}
start(): void {