openvibers/openvibe.games Commits
Games: Scraplandia Haul, extraction points on a weekly board with achievements (#49)
Changes
nnew file mode 100644@@@ -0,0 +1,20 @@++# Changelog++++All notable changes to OpenVibe.Games are recorded here. The format follows++[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project++adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).++++## [Unreleased]++++### Added++++- **Scraplandia "Haul" mode.** Recovery-bird extractions now score haul points:++ each secured valuable scores `count × valuable.points`. Valuable item definitions++ carry per-item points (`salvage_core` 20, `pepper_tonic` 30,++ `blueprint_scrap_pistol` 40, `blueprint_still` 60), defaulting to 10.++- **Scraplandia haul leaderboard.** `scraplandia` joins the leaderboard, ranked by++ summed haul points across all time and this week. The leaderboard page gains a++ Salvage Rush / Scraplandia haul switch.++- **Haul achievements.** Scraplandia gains First haul, Big haul (≥ 500 in one++ extraction) and Haul week (≥ 2,000 in one week), evaluated server-side after each++ recorded haul and shown in the in-game Achievements panel.@@@ -3,6 +3,8 @@## PurposeOpenVibe.Games hosts Scraplandia at https://openvibe.games and https://play.openvibe.games.++Scraplandia's extraction events double as its scored "Haul" mode: securing at-risk valuables at a++recovery bird earns haul points, with its own all-time/this-week leaderboard and achievements.The same Games process also hosts Salvage Rush, an on-foot timed collection game with its ownplace, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host.See [docs/games.md](docs/games.md) for both games and local progression.@@@ -443,15 +443,20 @@<p>Scrappy multiplayer survival-building in and around <b>Scrap City</b>. Physgun anything,weld it to anything else, cook at a burn barrel, chase supply drops in the wilds — and-- haul your salvage home before another scrapper takes it off your corpse.++ haul your salvage home before another scrapper takes it off your corpse. Hold the circle++ when the recovery bird comes: every valuable you extract scores haul points on this++ week's board.</p><div class="tags"><span>persistent world</span><span>physics sandbox</span><span>survival</span-- ><span>PvP wilds</span><span>bottle caps</span>++ ><span>PvP wilds</span><span>haul points</span>++ </div>++ <div class="cta-row">++ <a class="play-cta" href="https://play.openvibe.games" data-dev-href="/play"++ >▶ PLAY — NO DOWNLOAD</a++ >++ <a class="card-link" href="/leaderboard.html?game=scraplandia">Haul leaderboard →</a></div>-- <a class="play-cta" href="https://play.openvibe.games" data-dev-href="/play"-- >▶ PLAY — NO DOWNLOAD</a-- ></div></div><div class="game-card wide">@@@ -468,7 +473,7 @@</div><div class="cta-row"><a class="play-cta" href="/play?game=salvage-rush">▶ PLAY A ROUND</a>-- <a class="card-link" href="/leaderboard.html">Leaderboard →</a>++ <a class="card-link" href="/leaderboard.html?game=salvage-rush">Leaderboard →</a></div></div></div>@@@ -3,10 +3,10 @@<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />-- <title>Salvage Rush leaderboard · OpenVibe.Games</title>++ <title>Leaderboard · OpenVibe.Games</title><metaname="description"-- content="The best Salvage Rush round scores on OpenVibe.Games, all time and this week. Scores come only from rounds the server played out."++ content="OpenVibe.Games leaderboards: Salvage Rush best rounds and Scraplandia haul points, all time and this week. Scores come only from what the server played out."/><style>:root {@@@ -212,16 +212,21 @@<body><main><nav class="crumbs" aria-label="Breadcrumb">-- <a href="/">OpenVibe.Games</a><a href="/play?game=salvage-rush">Play Salvage Rush</a>++ <a href="/">OpenVibe.Games</a><a href="/play">Play Scraplandia</a++ ><a href="/play?game=salvage-rush">Play Salvage Rush</a></nav><p class="eyebrow">LEADERBOARD</p>-- <h1>Salvage Rush</h1>-- <p class="lede">++ <h1 id="title">Salvage Rush</h1>++ <p class="lede" id="lede">Each player's best round score. Rounds last 90 seconds; every point comes from a pickup theserver counted.</p><section class="board" aria-labelledby="board-title"><h2 id="board-title" hidden>Best scores</h2>++ <div class="tabs" role="group" aria-label="Game">++ <button type="button" data-game="salvage-rush" aria-pressed="true">Salvage Rush</button>++ <button type="button" data-game="scraplandia" aria-pressed="false">Scraplandia haul</button>++ </div><div class="tabs" role="group" aria-label="Period"><button type="button" data-period="all" aria-pressed="true">All time</button><button type="button" data-period="week" aria-pressed="false">This week</button>@@@ -231,7 +236,7 @@<tr><th scope="col">Rank</th><th scope="col">Player</th>-- <th scope="col">Best score</th>++ <th scope="col" id="score-header">Best score</th></tr></thead><tbody id="entries"></tbody>@@@ -242,7 +247,7 @@><button id="next" type="button">Next</button></div></section>-- <a class="play" href="/play?game=salvage-rush">▶ Play a round</a>++ <a class="play" id="play-link" href="/play?game=salvage-rush">▶ Play a round</a><p class="note">"This week" starts on Monday, 00:00 UTC.</p></main></body>@@@ -5,14 +5,62 @@ interface Entry {name: stringscore: number}--// Salvage Rush is the only game with scored rounds today; Scraplandia has no scored mode, so it has no board here.--const GAME = 'salvage-rush'--const tabs = Array.from(document.querySelectorAll<HTMLButtonElement>('.tabs button[data-period]'))++++type Game = 'salvage-rush' | 'scraplandia'++++const gameTabs = Array.from(document.querySelectorAll<HTMLButtonElement>('.tabs button[data-game]'))++const periodTabs = Array.from(++ document.querySelectorAll<HTMLButtonElement>('.tabs button[data-period]'),++)const status = document.getElementById('status') as HTMLElementconst entries = document.getElementById('entries') as HTMLElementconst pageLabel = document.getElementById('page') as HTMLElementconst previous = document.getElementById('previous') as HTMLButtonElementconst next = document.getElementById('next') as HTMLButtonElement++const title = document.getElementById('title') as HTMLElement++const lede = document.getElementById('lede') as HTMLElement++const scoreHeader = document.getElementById('score-header') as HTMLElement++const playLink = document.getElementById('play-link') as HTMLAnchorElement++++const COPY: Record<++ Game,++ {++ title: string++ lede: string++ score: string++ play: string++ playLabel: string++ empty: (period: 'all' | 'week') => string++ }++> = {++ 'salvage-rush': {++ title: 'Salvage Rush',++ lede:++ "Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the server counted.",++ score: 'Best score',++ play: '/play?game=salvage-rush',++ playLabel: '▶ Play a round',++ empty: (period) =>++ period === 'week'++ ? 'No rounds scored this week yet. Play one and take first place.'++ : 'No rounds scored yet. Play one and take first place.',++ },++ scraplandia: {++ title: 'Scraplandia haul',++ lede:++ 'Total haul points from server-confirmed extractions. Hold a recovery-bird circle to secure valuables and score.',++ score: 'Haul points',++ play: '/play',++ playLabel: '▶ Play Scraplandia',++ empty: (period) =>++ period === 'week'++ ? 'No hauls recorded this week yet. Extract some salvage to take first place.'++ : 'No hauls recorded yet. Extract some salvage to take first place.',++ },++}++++let game: Game =++ new URLSearchParams(location.search).get('game') === 'scraplandia' ? 'scraplandia' : 'salvage-rush'let period: 'all' | 'week' =new URLSearchParams(location.search).get('period') === 'week' ? 'week' : 'all'let page = 1@@@ -28,15 +76,27 @@ function cell(text: string, className?: string): HTMLTableCellElement {return td}++function applyGame(): void {++ for (const tab of gameTabs) tab.setAttribute('aria-pressed', String(tab.dataset.game === game))++ const copy = COPY[game]++ title.textContent = copy.title++ lede.textContent = copy.lede++ scoreHeader.textContent = copy.score++ playLink.href = copy.play++ playLink.textContent = copy.playLabel++ document.title = `${copy.title} leaderboard · OpenVibe.Games`++}++async function load(): Promise<void> {-- for (const tab of tabs) tab.setAttribute('aria-pressed', String(tab.dataset.period === period))++ for (const tab of periodTabs) tab.setAttribute('aria-pressed', String(tab.dataset.period === period))++ const copy = COPY[game]status.textContent = 'Loading…'entries.replaceChildren()previous.disabled = page === 1next.disabled = truepageLabel.textContent = `Page ${page}`try {-- const response = await fetch(`/api/v1/games/${GAME}/leaderboard?period=${period}&page=${page}`)++ const response = await fetch(`/api/v1/games/${game}/leaderboard?period=${period}&page=${page}`)if (!response.ok) throw new Error('unavailable')const data = (await response.json()) as { entries: Entry[]; pageSize: number }for (const entry of data.entries) {@@@ -49,18 +109,25 @@ async function load(): Promise<void> {)entries.append(row)}-- status.textContent = data.entries.length-- ? ''-- : period === 'week'-- ? 'No rounds scored this week yet. Play one and take first place.'-- : 'No rounds scored yet. Play one and take first place.'++ status.textContent = data.entries.length ? '' : copy.empty(period)next.disabled = data.entries.length < data.pageSize} catch {status.textContent = 'The leaderboard is unavailable right now. Try again in a minute.'}}--for (const tab of tabs) {++for (const tab of gameTabs) {++ tab.onclick = () => {++ const chosen = tab.dataset.game === 'scraplandia' ? 'scraplandia' : 'salvage-rush'++ if (chosen === game) return++ game = chosen++ page = 1++ applyGame()++ void load()++ }++}++++for (const tab of periodTabs) {tab.onclick = () => {const chosen = tab.dataset.period === 'week' ? 'week' : 'all'if (chosen === period) return@@@ -69,14 +136,18 @@ for (const tab of tabs) {void load()}}++previous.onclick = () => {if (page > 1) {page--void load()}}++next.onclick = () => {page++void load()}++++applyGame()void load()@@@ -39,7 +39,7 @@ export interface EventHooks {/** Players currently inside a circle: [entityId, distance][] */playersIn(x: number, z: number, radius: number): { entityId: string; alive: boolean }[]/** Secure a player's carried valuables + recall them to the city. */-- extractPlayer(entityId: string): void++ extractPlayer(entityId: string, extractionId: string): voidbroadcastSpawn(entity: GameEntity): voidbroadcastDespawn(id: string): void}@@@ -234,7 +234,7 @@ export class EventManager {this.hooks.announceTo(id, `⏱ Hold ${EXTRACTION_HOLD_SECONDS}s to extract…`)} else if (nowMs >= holders[id]) {delete holders[id]-- this.hooks.extractPlayer(id)++ this.hooks.extractPlayer(id, ev.id)}}return false // runs until endsAt@@@ -3,8 +3,10 @@ import { openTestDb, openTestStore } from '@openvibe/persistence/testing'import { accountKey } from '@openvibe/persistence'import {earnedAchievements,++ earnedHaulAchievements,leaderboard,playerAchievements,++ recordHaul,recordJoined,recordRound,} from './progression.js'@@@ -15,9 +17,9 @@ describe('game progression', () => {const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'await recordJoined(db, subject, 100)await recordJoined(db, subject, 200)-- expect((await playerAchievements(db, subject))[0]?.achievements).toMatchObject([-- { id: 'first-visit', unlockedAt: 100 },-- ])++ expect(++ (await playerAchievements(db, subject))[0]?.achievements.filter((a) => a.unlockedAt),++ ).toMatchObject([{ id: 'first-visit', unlockedAt: 100 }])})it('erases a signed-in subject from game progression with account data', async () => {@@@ -143,4 +145,38 @@ describe('game progression', () => {).toEqual([5, 2])expect((await leaderboard(db, 'salvage-rush', 'all', 2, now)).entries).toEqual([])})++++ it('unlocks Scraplandia haul achievements at their thresholds', () => {++ expect(earnedHaulAchievements(1, 1)).toEqual(['first-haul'])++ expect(earnedHaulAchievements(499, 499)).toEqual(['first-haul'])++ expect(earnedHaulAchievements(500, 500)).toEqual(['first-haul', 'big-haul'])++ expect(earnedHaulAchievements(500, 2000)).toEqual(['first-haul', 'big-haul', 'haul-week'])++ })++++ it('records hauls once per extraction and sums them on the Scraplandia leaderboard', async () => {++ const db = await openTestDb()++ const now = Date.UTC(2026, 9, 11, 12) // Sunday++ await recordHaul(db, 'a', 'e1', 'A', 100, now)++ await recordHaul(db, 'a', 'e2', 'A', 50, now)++ await recordHaul(db, 'a', 'e2', 'A', 999, now) // duplicate extraction id, ignored++ await recordHaul(db, 'b', 'e3', 'B', 10, Date.UTC(2026, 9, 4, 23, 59)) // previous week++ await recordHaul(db, 'c', 'e4', 'C', 0, now) // zero-value, not recorded++ expect((await leaderboard(db, 'scraplandia', 'all', 1, now)).entries.map((e) => e.score)).toEqual(++ [150, 10],++ )++ expect((await leaderboard(db, 'scraplandia', 'week', 1, now)).entries.map((e) => e.score)).toEqual(++ [150],++ )++ })++++ it('unlocks haul achievements after a recorded haul', async () => {++ const db = await openTestDb()++ const now = Date.UTC(2026, 9, 11, 12)++ await recordHaul(db, 'guest-test-key', 'e1', 'Guest', 500, now)++ await recordHaul(db, 'guest-test-key', 'e2', 'Guest', 2000, now)++ const unlocks = (await playerAchievements(db, 'guest-test-key'))[0]?.achievements.filter(++ (a) => a.unlockedAt,++ )++ expect(unlocks?.map((a) => a.id)).toEqual(['first-haul', 'big-haul', 'haul-week'])++ })})@@@ -26,6 +26,30 @@ export function earnedAchievements(game: GameId, result: RoundResult): string[].map((def) => def.id)}++/** Scraplandia haul achievements, evaluated after a haul is recorded. */++export function earnedHaulAchievements(score: number, weeklyScore: number): string[] {++ return definitions.scraplandia++ .filter((def) => {++ if (def.criterion.event === 'haul_recorded') {++ return def.criterion.minScore === undefined || score >= def.criterion.minScore++ }++ if (def.criterion.event === 'haul_week') {++ return weeklyScore >= def.criterion.minScore++ }++ return false++ })++ .map((def) => def.id)++}++++function weekStartMs(now: number): number {++ const date = new Date(now)++ return Date.UTC(++ date.getUTCFullYear(),++ date.getUTCMonth(),++ date.getUTCDate() - ((date.getUTCDay() + 6) % 7),++ )++}++/** Server join event: once per account, regardless of how many characters or sessions join. */export async function recordJoined(db: Db, key: string, now: number): Promise<void> {const account = accountKey(key)@@@ -73,10 +97,47 @@ export async function recordRound(})}++/** Record one successful extraction haul (Scraplandia); zero-value extractions are not recorded. */++export async function recordHaul(++ db: Db,++ key: string,++ extractionId: string,++ name: string,++ score: number,++ now: number,++): Promise<void> {++ if (score <= 0) return++ await db.tx(async () => {++ const account = accountKey(key)++ await db.exec(sql`++ INSERT INTO game_hauls (account_key, extraction_id, player_name, score, finished_at)++ VALUES (${account}, ${extractionId}, ${name}, ${score}, ${now})++ ON CONFLICT (account_key, extraction_id) DO NOTHING++ `)++ const weekStart = weekStartMs(now)++ const rows = await db.many<{ total: number }>(sql`++ SELECT COALESCE(SUM(score), 0)::bigint AS total FROM game_hauls++ WHERE account_key = ${account} AND finished_at >= ${weekStart}++ `)++ const weeklyScore = Number(rows[0]?.total ?? 0)++ for (const achievement of earnedHaulAchievements(score, weeklyScore)) {++ await db.exec(sql`++ INSERT INTO game_achievements (game_id, achievement_id, account_key, unlocked_at)++ VALUES ('scraplandia', ${achievement}, ${account}, ${now})++ ON CONFLICT (game_id, achievement_id, account_key) DO NOTHING++ `)++ }++ })++}++/** Write-behind for completed rounds. Failed writes stay queued for retry and shutdown drain. */export class GameProgressWriter {private readonly pending = new Map<string, { results: RoundResult[]; finishedAt: number }>()private readonly joined = new Map<string, number>()++ private readonly hauls = new Map<++ string,++ { key: string; name: string; score: number; extractionId: string; finishedAt: number }++ >()private inFlight: Promise<void> | null = nullprivate readonly timer: ReturnType<typeof setInterval>@@@ -98,14 +159,37 @@ export class GameProgressWriter {void this.flush().catch(this.onError)}++ submitHaul(extractionId: string, key: string, name: string, score: number): void {++ if (score <= 0) return++ this.hauls.set(`${accountKey(key)}\u0000${extractionId}`, {++ key,++ name,++ score,++ extractionId,++ finishedAt: Date.now(),++ })++ void this.flush().catch(this.onError)++ }++async flush(): Promise<void> {if (this.inFlight) return this.inFlightthis.inFlight = (async () => {-- while (this.pending.size > 0 || this.joined.size > 0) {++ while (this.pending.size > 0 || this.joined.size > 0 || this.hauls.size > 0) {for (const [roundId, entry] of this.pending) {await recordRound(this.db, 'salvage-rush', roundId, entry.results, entry.finishedAt)this.pending.delete(roundId)}++ for (const [id, haul] of this.hauls) {++ await recordHaul(++ this.db,++ haul.key,++ haul.extractionId,++ haul.name,++ haul.score,++ haul.finishedAt,++ )++ this.hauls.delete(id)++ }for (const [key, at] of this.joined) {await recordJoined(this.db, key, at)this.joined.delete(key)@@@ -138,15 +222,29 @@ export async function leaderboard(page: number,now: number,) {-- const date = new Date(now)-- const weekStart = Date.UTC(-- date.getUTCFullYear(),-- date.getUTCMonth(),-- date.getUTCDate() - ((date.getUTCDay() + 6) % 7),-- )-- const since = period === 'week' ? weekStart : 0++ const since = period === 'week' ? weekStartMs(now) : 0const limit = 20const offset = (page - 1) * limit++ const entries = (rows: BoardRow[]) =>++ rows.map((row, i) => ({++ rank: offset + i + 1,++ name: row.player_name,++ score: Number(row.score),++ finishedAt: Number(row.finished_at),++ }))++++ if (game === 'scraplandia') {++ const rows = await db.many<BoardRow>(sql`++ SELECT account_key, player_name, SUM(score) AS score, MAX(finished_at) AS finished_at++ FROM game_hauls++ WHERE finished_at >= ${since}++ GROUP BY account_key, player_name++ ORDER BY score DESC, finished_at ASC, account_key ASC++ LIMIT ${limit} OFFSET ${offset}++ `)++ return { game, period, page, pageSize: limit, entries: entries(rows) }++ }++const rows =period === 'all'? await db.many<BoardRow>(sql`@@@ -169,12 +267,7 @@ export async function leaderboard(period,page,pageSize: limit,-- entries: rows.map((row, i) => ({-- rank: offset + i + 1,-- name: row.player_name,-- score: Number(row.score),-- finishedAt: Number(row.finished_at),-- })),++ entries: entries(rows),}}@@@ -68,6 +68,8 @@ export interface GameIntegrations {roundId: string,results: { key: string; name: string; score: number; rank: number }[],) => void++ /** Called for a server-confirmed extraction haul (Scraplandia). Never accepts a client message. */++ haulExtracted?: (key: string, extractionId: string, name: string, score: number) => void/** Durable lifecycle/progression events through the outbox. */events?: GameEventRecorder/** Installed mods (content packs), reconciled every tick. */nnew file mode 100644@@@ -0,0 +1,105 @@++import { createContent } from '@openvibe/content'++import { Inventory, SkillSet } from '@openvibe/gameplay'++import { defaultAppearance, type ServerMessage } from '@openvibe/protocol'++import { newEntityId, newPlayerId } from '@openvibe/shared'++import { describe, expect, it } from 'vitest'++import { createSession, type PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import { WorldEventSystem } from './worldEvents.js'++++const content = createContent()++++function makeSession(token: string, withLoot = true): PlayerSession {++ const inventory = new Inventory(6, 2, content)++ if (withLoot) {++ inventory.addStack({ defId: 'salvage_core', count: 2 }) // 40 points++ inventory.addStack({ defId: 'pepper_tonic', count: 3 }) // 90 points++ inventory.addStack({ defId: 'wood_plank', count: 5 }) // non-valuable++ }++ return createSession({++ playerId: newPlayerId(),++ entityId: newEntityId(),++ token,++ subjectId: null,++ name: 'Scrapper',++ spawn: { x: 0, y: 1.2, z: 4 },++ yaw: 0,++ inventory,++ skills: new SkillSet(content),++ friends: new Set(),++ appearance: defaultAppearance(),++ content,++ send: () => {},++ closeConnection: () => {},++ })++}++++function makeSystem(++ session: PlayerSession,++ onHaul: (key: string, extractionId: string, name: string, score: number) => void,++): { system: WorldEventSystem; sent: ServerMessage[] } {++ const sent: ServerMessage[] = []++ const ctx = {++ world: { content },++ config: { eventIntervalScale: 1, tickRate: 30 },++ sessions: {++ getByEntity: () => session,++ values: () => [],++ },++ net: {++ send: (_s: PlayerSession, msg: ServerMessage) => {++ sent.push(msg)++ },++ sendInventory: () => {},++ broadcastAll: () => {},++ broadcastSpawn: () => {},++ broadcastDespawn: () => {},++ },++ log: {++ child: () => ({ info: () => {} }),++ info: () => {},++ },++ integrations: { haulExtracted: onHaul },++ } as unknown as ServerContext++ return { system: new WorldEventSystem(ctx), sent }++}++++describe('extraction scoring', () => {++ it('scores a haul from the extraction path and recalls the player', () => {++ const session = makeSession('guest-token-1')++ session.move.pos.x = 60++ session.move.pos.z = 40++ let haul: { key: string; extractionId: string; name: string; score: number } | null = null++ const { system, sent } = makeSystem(session, (key, extractionId, name, score) => {++ haul = { key, extractionId, name, score }++ })++ system.extractPlayer(session.entityId, 'evt-1')++ expect(haul).toEqual({++ key: 'guest-token-1',++ extractionId: 'evt-1',++ name: 'Scrapper',++ score: 130,++ })++ expect(session.move.pos.x).toBe(0)++ expect(session.move.pos.y).toBe(1.2)++ expect(session.move.pos.z).toBe(4)++ expect(sent.find((m) => m.t === 'announce')).toMatchObject({++ t: 'announce',++ text: '🚁 Extracted! 2 stacks secured · 130 haul points.',++ })++ })++++ it('does not record a zero-value extraction', () => {++ const session = makeSession('guest-token-2', false)++ let called = false++ const { system, sent } = makeSystem(session, () => {++ called = true++ })++ system.extractPlayer(session.entityId, 'evt-2')++ expect(called).toBe(false)++ expect(sent.find((m) => m.t === 'announce')).toMatchObject({++ t: 'announce',++ text: '🚁 Extracted safely back to Scrap City.',++ })++ })++})@@@ -1,4 +1,5 @@import { worldSpawn } from '@openvibe/content'++import { haulPoints } from '@openvibe/gameplay'import type { EntityId } from '@openvibe/shared'import { EventManager } from '../eventManager.js'import type { ServerContext } from './context.js'@@@ -30,7 +31,7 @@ export class WorldEventSystem implements System {}return out},-- extractPlayer: (entityId) => this.extractPlayer(entityId),++ extractPlayer: (entityId, extractionId) => this.extractPlayer(entityId, extractionId),broadcastSpawn: (entity) => ctx.net.broadcastSpawn(entity),broadcastDespawn: (id) => ctx.net.broadcastDespawn(id),},@@@ -48,12 +49,13 @@ export class WorldEventSystem implements System {* Extraction success: carried valuables become SECURED (they no longer* drop on death) and the player is recalled to the safe city.*/-- private extractPlayer(entityId: string): void {++ extractPlayer(entityId: string, extractionId: string): void {const session = this.ctx.sessions.getByEntity(entityId as EntityId)if (!session) return-- const secured = session.inventory.secureValuables(++ const secured = session.inventory.secureValuablesReport((defId) => this.ctx.world.content.item(defId)?.valuable !== undefined,)++ const points = haulPoints(secured, this.ctx.world.content)const spawn = worldSpawn(this.ctx.world.content.world).possession.move.pos.x = spawn[0]session.move.pos.y = spawn[1]@@@ -65,11 +67,23 @@ export class WorldEventSystem implements System {this.ctx.net.send(session, {t: 'announce',text:-- secured > 0-- ? `🚁 Extracted! ${secured} stack${secured === 1 ? '' : 's'} of loot secured.`++ secured.length > 0++ ? `🚁 Extracted! ${secured.length} stack${secured.length === 1 ? '' : 's'} secured · ${points} haul points.`: '🚁 Extracted safely back to Scrap City.',})this.ctx.net.sendInventory(session)-- this.ctx.log.info('player extracted', { playerId: session.playerId, secured })++ if (points > 0) {++ this.ctx.integrations.haulExtracted?.(++ session.subjectId ?? session.token,++ extractionId,++ session.name,++ points,++ )++ }++ this.ctx.log.info('player extracted', {++ playerId: session.playerId,++ stacks: secured.length,++ points,++ })}}@@@ -298,7 +298,11 @@ async function main(): Promise<void> {...(scripts ? { scripts } : {}),...(progressSummary ? { progressSummary } : {}),...(config.placeId === 'scraplandia'-- ? { playerJoined: (key: string) => progressWriter.submitJoined(key) }++ ? {++ playerJoined: (key: string) => progressWriter.submitJoined(key),++ haulExtracted: (key: string, extractionId: string, name: string, score: number) =>++ progressWriter.submitHaul(extractionId, key, name, score),++ }: {}),...(config.placeId === 'salvage-rush'? {@@@ -363,7 +367,11 @@ async function main(): Promise<void> {...(roomScripts ? { scripts: roomScripts } : {}),...(progressSummary ? { progressSummary } : {}),...(config.placeId === 'scraplandia'-- ? { playerJoined: (key: string) => progressWriter.submitJoined(key) }++ ? {++ playerJoined: (key: string) => progressWriter.submitJoined(key),++ haulExtracted: (key: string, extractionId: string, name: string, score: number) =>++ progressWriter.submitHaul(extractionId, key, name, score),++ }: {}),...(config.placeId === 'salvage-rush'? {@@@ -459,7 +467,11 @@ async function main(): Promise<void> {...(roomScripts ? { scripts: roomScripts } : {}),...(progressSummary ? { progressSummary } : {}),...(secondaryPlace === 'scraplandia'-- ? { playerJoined: (key: string) => progressWriter.submitJoined(key) }++ ? {++ playerJoined: (key: string) => progressWriter.submitJoined(key),++ haulExtracted: (key: string, extractionId: string, name: string, score: number) =>++ progressWriter.submitHaul(extractionId, key, name, score),++ }: {roundFinished: (roundId: string, results: RoundResult[]) =>progressWriter.submit(`${slot}:${roundId}`, results),@@@ -29,4 +29,38 @@ describe('game read routes', () => {expect(status).toBe(404)expect(JSON.parse(body)).toEqual({ error: 'unknown_game' })})++++ it('answers the Scraplandia haul leaderboard', async () => {++ let status = 0++ let body = ''++ const req = {++ method: 'GET',++ url: '/api/v1/games/scraplandia/leaderboard?period=week&page=1',++ } as IncomingMessage++ const res = {++ writeHead: (code: number) => {++ status = code++ },++ end: (value: string) => {++ body = value++ },++ } as ServerResponse++ const db = {++ many: async () => [{ account_key: 'a', player_name: 'A', score: 120, finished_at: 100 }],++ }++ const limits = { publicRead: async () => true }++ handleGameRead(req, res, {++ db: db as never,++ networkAuthUrl: null,++ limits: limits as never,++ address: () => 'test',++ })++ await new Promise((resolve) => setTimeout(resolve, 10))++ expect(status).toBe(200)++ expect(JSON.parse(body)).toMatchObject({++ game: 'scraplandia',++ period: 'week',++ entries: [{ rank: 1, name: 'A', score: 120, finishedAt: 100 }],++ })++ })})@@@ -77,6 +77,7 @@ export function createAccountData(opts: {...(data.gameAchievements.length? [{ name: 'game-achievements.json', content: data.gameAchievements }]: []),++ ...(data.gameHauls.length ? [{ name: 'game-hauls.json', content: data.gameHauls }] : []),]const res = await opts.send(`/internal/account-exports/${id}/parts`, { subject, files })const outcome = res.ok@@@ -3,9 +3,11 @@## ScraplandiaThe default place (`GAMES_PLACE_ID=scraplandia`) loads `SCRAPLANDIA_PACK`. Its persistent--world, inventory, crafting, farming, PvP rules, and editor behavior stay as before. The--pack defines a first-visit achievement unlocked by a server-confirmed join. Its leaderboard--is public but empty until Scraplandia has a scored game event.++world, inventory, crafting, farming, PvP rules, and editor behavior stay as before. Extraction++events are the scored "Haul" mode: a living player who holds the recovery-bird circle secures++their at-risk valuables, and each secured stack scores `count × valuable.points` haul points.++The pack defines a first-visit achievement and three haul achievements (First haul, Big haul,++Haul week), all unlocked by server-confirmed events.## Salvage Rush@@@ -23,15 +25,17 @@ default place; Salvage Rush currently uses the map in its built-in content pack.Completed results are stored in `game_round_results`, keyed by game, round and account key.`game_best_scores` holds each player's all-time best; weekly bests come from the results table.--`game_achievements` stores one unlock per game, criterion and player. Local guest keys are--SHA-256 hashes as for characters; Network subjects remain canonical. No score is written--until a server round ends. `GET /api/v1/games/:game/leaderboard?period=all|week&page=1`--returns 20 personal bests per page, ordered by score, then earliest completion. `week`++Scraplandia hauls are stored one row per extraction in `game_hauls`, keyed by account and++extraction event; its leaderboard ranks by the sum of haul points in the period rather than a++single best. `game_achievements` stores one unlock per game, criterion and player. Local guest++keys are SHA-256 hashes as for characters; Network subjects remain canonical. No score is written++until the server confirms a round or extraction. `GET /api/v1/games/:game/leaderboard?period=all|week&page=1`++returns 20 entries per page, ordered by score, then earliest completion. `week`starts Monday 00:00 UTC. `GET /api/v1/players/me/achievements` accepts the samebearer or `X-Guest-Token` used by the ticket endpoint. Public reads have per-address limits.--The read-only `/leaderboard.html` page shows Salvage Rush's best round scores, all time or this week (from Monday 00:00 UTC), 20 a page. Scraplandia has no scored mode, so it has no board yet.--Network account exports include results, best scores and unlocks; account erasure removes them.--Guest character adoption and Network subject merges move progression to the surviving subject.++The read-only `/leaderboard.html` page shows Salvage Rush's best round scores and Scraplandia's summed haul points, switchable in the page, all time or this week (from Monday 00:00 UTC), 20 a page.++Network account exports include results, best scores, unlocks and hauls; account erasure removes them.++Guest character adoption and Network subject merges move progression (including hauls) to the surviving subject.## Adding another game@@@ -164,7 +164,8 @@ export const ITEMS: ItemDef[] = ['Dense pre-collapse tech. The merchant pays a fortune — IF you get it home. Drops when you die; extract to secure it.',category: 'misc',maxStack: 5,-- valuable: {},++ // Supply-drop staple (1-2 per crate), the baseline haul item.++ valuable: { points: 20 },world: {shape: { type: 'box', size: [0.24, 0.24, 0.24] },massKg: 8,@@@ -265,7 +266,8 @@ export const ITEMS: ItemDef[] = [description: 'Greasy hand-drawn plans. Use (LMB) to learn the recipe for good.',category: 'misc',maxStack: 1,-- valuable: {},++ // Rare crate-only drop that permanently unlocks a recipe.++ valuable: { points: 40 },blueprint: { recipe: 'craft_scrap_pistol' },},{@@@ -274,7 +276,8 @@ export const ITEMS: ItemDef[] = [description: "Somebody's prized still schematics. Use (LMB) to learn the recipe.",category: 'misc',maxStack: 1,-- valuable: {},++ // Merchant-only (40 caps, stock 1) — the rarest valuable.++ valuable: { points: 60 },blueprint: { recipe: 'craft_still' },},@@@ -575,7 +578,8 @@ export const ITEMS: ItemDef[] = ['Ember pepper and flour mash run through a still. Burns twice. The Goose pays absurdly well and asks no questions. At-risk until extracted.',category: 'food',maxStack: 10,-- valuable: {},++ // Craftable but gated behind pepper farming plus a powered still.++ valuable: { points: 30 },food: { hunger: 6, thirst: 10, health: 8 },},{@@@ -45,7 +45,10 @@ export interface ContentPackV2 {id: stringname: stringdescription: string-- criterion: { event: 'player_joined' }++ criterion:++ | { event: 'player_joined' }++ | { event: 'haul_recorded'; minScore?: number }++ | { event: 'haul_week'; minScore: number }}[]/** Built-in game rules. Executable rule evaluation remains server-owned. */game?: {@@@ -14,6 +14,24 @@ export const SCRAPLANDIA_PACK: ContentPackV2 = {description: 'Join Scraplandia.',criterion: { event: 'player_joined' },},++ {++ id: 'first-haul',++ name: 'First Haul',++ description: 'Extract valuables to Scrap City.',++ criterion: { event: 'haul_recorded' },++ },++ {++ id: 'big-haul',++ name: 'Big Haul',++ description: 'Score at least 500 haul points in one extraction.',++ criterion: { event: 'haul_recorded', minScore: 500 },++ },++ {++ id: 'haul-week',++ name: 'Haul Week',++ description: 'Score at least 2,000 haul points in one week.',++ criterion: { event: 'haul_week', minScore: 2000 },++ },],map: {id: 'openvibeville_v2',@@@ -163,9 +163,14 @@ export const ItemDefSchema = z.object({/*** Present iff the item is AT-RISK loot: it drops in a loot bag on death* unless secured by an extraction (stack meta `secured`). The-- * risk/reward heart of expedition play.++ * risk/reward heart of expedition play. `points` is the haul score a++ * secured item is worth; omitted it defaults to 10.*/-- valuable: z.object({}).optional(),++ valuable: z++ .object({++ points: z.number().int().positive().default(10),++ })++ .optional(),/** Present iff consuming this item permanently unlocks a recipe. */blueprint: z.object({ recipe: z.string() }).optional(),@@@ -1,6 +1,6 @@import { describe, expect, it } from 'vitest'import { createContent } from '@openvibe/content'--import { Inventory } from './inventory.js'++import { haulPoints, Inventory } from './inventory.js'const content = createContent()@@@ -113,4 +113,47 @@ describe('Inventory', () => {expect(inv.secureValuables(isValuable)).toBe(0)expect(inv.takeUnsecuredValuables(isValuable)).toEqual([])})++++ it('computes haul points per secured stack and ignores non-valuables', () => {++ const stacks = [++ { defId: 'salvage_core', count: 3 }, // 20 each++ { defId: 'pepper_tonic', count: 2 }, // 30 each++ { defId: 'wood_plank', count: 10 }, // not valuable++ ]++ expect(haulPoints(stacks, content)).toBe(3 * 20 + 2 * 30)++ })++++ it('defaults valuable points to 10', () => {++ const withDefault = createContent([++ {++ defs: {++ items: [++ {++ id: 'test_valuable',++ name: 'Test Valuable',++ description: '',++ category: 'misc',++ maxStack: 5,++ valuable: {},++ },++ ],++ },++ },++ ])++ expect(withDefault.item('test_valuable')?.valuable?.points).toBe(10)++ expect(haulPoints([{ defId: 'test_valuable', count: 2 }], withDefault)).toBe(20)++ })++++ it('reports what it secures so the extraction path can score it', () => {++ const inv = new Inventory(6, 2, content)++ inv.addStack({ defId: 'salvage_core', count: 2 })++ inv.addStack({ defId: 'pepper_tonic', count: 3 })++ inv.addStack({ defId: 'wood_plank', count: 5 })++ const isValuable = (id: string) => content.item(id)?.valuable !== undefined++ expect(inv.secureValuablesReport(isValuable)).toEqual([++ { defId: 'salvage_core', count: 2 },++ { defId: 'pepper_tonic', count: 3 },++ ])++ expect(inv.secureValuables(isValuable)).toBe(0)++ })})@@@ -15,6 +15,26 @@ export interface InventoryDto {slots: { i: number; stack: ItemStack }[]}++/** A secured valuable stack reported by an extraction. */++export interface SecuredStack {++ defId: string++ count: number++}++++/**++ * Haul points for secured stacks: Σ count × the item's `valuable.points`.++ * Non-valuable defs contribute nothing, so callers may pass the whole++ * extracted set and let this filter by capability.++ */++export function haulPoints(stacks: readonly SecuredStack[], content: ContentRegistry): number {++ let total = 0++ for (const stack of stacks) {++ const points = content.item(stack.defId)?.valuable?.points++ if (points !== undefined) total += stack.count * points++ }++ return total++}++export class Inventory {private slots: (ItemStack | null)[]@@@ -182,12 +202,18 @@ export class Inventory {/** Marks every valuable stack secured (extraction completed). Returns* how many stacks were newly secured. */secureValuables(isValuable: (defId: string) => boolean): number {-- let secured = 0++ return this.secureValuablesReport(isValuable).length++ }++++ /** Like `secureValuables`, but reports each newly secured stack's defId++ * and count so the caller can score the haul. */++ secureValuablesReport(isValuable: (defId: string) => boolean): SecuredStack[] {++ const secured: SecuredStack[] = []for (const s of this.slots) {if (!s || !isValuable(s.defId)) continueif (s.meta?.secured === 1) continues.meta = { ...s.meta, secured: 1 }-- secured++++ secured.push({ defId: s.defId, count: s.count })}return secured}nnew file mode 100644@@@ -0,0 +1,11 @@++-- phase: expand++-- M6: Scraplandia "Haul" mode. One row per successful extraction; the haul leaderboard sums these.++CREATE TABLE game_hauls (++ account_key text COLLATE "C" NOT NULL,++ extraction_id text COLLATE "C" NOT NULL,++ player_name text NOT NULL,++ score integer NOT NULL CHECK (score > 0),++ finished_at bigint NOT NULL,++ PRIMARY KEY (account_key, extraction_id)++);++CREATE INDEX game_hauls_board ON game_hauls (finished_at, score DESC);@@@ -70,6 +70,13 @@ export function createIdentityRepository(db: Db): IdentityRepository {unlocked_at = LEAST(game_achievements.unlocked_at, excluded.unlocked_at)`)await db.exec(sql`DELETE FROM game_achievements WHERE account_key = ${from}`)++ await db.exec(sql`++ INSERT INTO game_hauls (account_key, extraction_id, player_name, score, finished_at)++ SELECT ${into}, extraction_id, player_name, score, finished_at++ FROM game_hauls WHERE account_key = ${from}++ ON CONFLICT (account_key, extraction_id) DO NOTHING++ `)++ await db.exec(sql`DELETE FROM game_hauls WHERE account_key = ${from}`)}const recordAdoption = (key: string,@@@ -168,12 +175,16 @@ export function createIdentityRepository(db: Db): IdentityRepository {const gameAchievements = await db.many<Record<string, unknown>>(sql`SELECT game_id, achievement_id, unlocked_at FROM game_achievements WHERE account_key = ${subject} ORDER BY game_id, achievement_id`,)++ const gameHauls = await db.many<Record<string, unknown>>(++ sql`SELECT extraction_id, player_name, score, finished_at FROM game_hauls WHERE account_key = ${subject} ORDER BY finished_at`,++ )return {characters: characters.map(withoutAccountKey),structures,gameResults,gameBestScores,gameAchievements,++ gameHauls,}},@@@ -201,6 +212,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {await db.exec(sql`DELETE FROM game_round_results WHERE account_key IN (${idList(subjects)})`,)++ await db.exec(sql`DELETE FROM game_hauls WHERE account_key IN (${idList(subjects)})`)return out})},@@@ -87,6 +87,7 @@ export interface IdentityRepository {gameResults: Record<string, unknown>[]gameBestScores: Record<string, unknown>[]gameAchievements: Record<string, unknown>[]++ gameHauls: Record<string, unknown>[]}>/*** Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their@@@ -16,6 +16,7 @@ const TABLES = ['game_round_results','game_best_scores','game_achievements',++ 'game_hauls','game_instances','character_instance_positions','world_meta',nnew file mode 100644@@@ -0,0 +1,2 @@++// file: 'apps/server/src/game/systems/worldEvents.test.ts'++// covers: 'apps/server/src/game/systems'
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+# Changelog
+
+All notable changes to OpenVibe.Games are recorded here. The format follows
+[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
+adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+
+- **Scraplandia "Haul" mode.** Recovery-bird extractions now score haul points:
+ each secured valuable scores `count × valuable.points`. Valuable item definitions
+ carry per-item points (`salvage_core` 20, `pepper_tonic` 30,
+ `blueprint_scrap_pistol` 40, `blueprint_still` 60), defaulting to 10.
+- **Scraplandia haul leaderboard.** `scraplandia` joins the leaderboard, ranked by
+ summed haul points across all time and this week. The leaderboard page gains a
+ Salvage Rush / Scraplandia haul switch.
+- **Haul achievements.** Scraplandia gains First haul, Big haul (≥ 500 in one
+ extraction) and Haul week (≥ 2,000 in one week), evaluated server-side after each
+ recorded haul and shown in the in-game Achievements panel.
@@ -3,6 +3,8 @@
@@ -3,6 +3,8 @@
## Purpose
## Purpose
OpenVibe.Games hosts Scraplandia at https://openvibe.games and https://play.openvibe.games.
OpenVibe.Games hosts Scraplandia at https://openvibe.games and https://play.openvibe.games.
+Scraplandia's extraction events double as its scored "Haul" mode: securing at-risk valuables at a
+recovery bird earns haul points, with its own all-time/this-week leaderboard and achievements.
The same Games process also hosts Salvage Rush, an on-foot timed collection game with its own
The same Games process also hosts Salvage Rush, an on-foot timed collection game with its own
place, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host.
place, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host.
See [docs/games.md](docs/games.md) for both games and local progression.
See [docs/games.md](docs/games.md) for both games and local progression.
@@ -443,15 +443,20 @@
@@ -443,15 +443,20 @@
<p>
<p>
Scrappy multiplayer survival-building in and around <b>Scrap City</b>. Physgun anything,
Scrappy multiplayer survival-building in and around <b>Scrap City</b>. Physgun anything,
weld it to anything else, cook at a burn barrel, chase supply drops in the wilds — and
weld it to anything else, cook at a burn barrel, chase supply drops in the wilds — and
- haul your salvage home before another scrapper takes it off your corpse.
+ haul your salvage home before another scrapper takes it off your corpse. Hold the circle
+ when the recovery bird comes: every valuable you extract scores haul points on this
+ week's board.
</p>
</p>
<div class="tags">
<div class="tags">
<span>persistent world</span><span>physics sandbox</span><span>survival</span
<span>persistent world</span><span>physics sandbox</span><span>survival</span
- ><span>PvP wilds</span><span>bottle caps</span>
+ ><span>PvP wilds</span><span>haul points</span>
+ </div>
+ <div class="cta-row">
+ <a class="play-cta" href="https://play.openvibe.games" data-dev-href="/play"
+ >▶ PLAY — NO DOWNLOAD</a
+ >
+ <a class="card-link" href="/leaderboard.html?game=scraplandia">Haul leaderboard →</a>
</div>
</div>
- <a class="play-cta" href="https://play.openvibe.games" data-dev-href="/play"
- >▶ PLAY — NO DOWNLOAD</a
- >
</div>
</div>
</div>
</div>
<div class="game-card wide">
<div class="game-card wide">
@@ -468,7 +473,7 @@
@@ -468,7 +473,7 @@
</div>
</div>
<div class="cta-row">
<div class="cta-row">
<a class="play-cta" href="/play?game=salvage-rush">▶ PLAY A ROUND</a>
<a class="play-cta" href="/play?game=salvage-rush">▶ PLAY A ROUND</a>
- <a class="card-link" href="/leaderboard.html">Leaderboard →</a>
+ <a class="card-link" href="/leaderboard.html?game=salvage-rush">Leaderboard →</a>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -3,10 +3,10 @@
@@ -3,10 +3,10 @@
<head>
<head>
<meta charset="UTF-8" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>Salvage Rush leaderboard · OpenVibe.Games</title>
+ <title>Leaderboard · OpenVibe.Games</title>
<meta
<meta
name="description"
name="description"
- content="The best Salvage Rush round scores on OpenVibe.Games, all time and this week. Scores come only from rounds the server played out."
+ content="OpenVibe.Games leaderboards: Salvage Rush best rounds and Scraplandia haul points, all time and this week. Scores come only from what the server played out."
/>
/>
<style>
<style>
:root {
:root {
@@ -212,16 +212,21 @@
@@ -212,16 +212,21 @@
<body>
<body>
<main>
<main>
<nav class="crumbs" aria-label="Breadcrumb">
<nav class="crumbs" aria-label="Breadcrumb">
- <a href="/">OpenVibe.Games</a><a href="/play?game=salvage-rush">Play Salvage Rush</a>
+ <a href="/">OpenVibe.Games</a><a href="/play">Play Scraplandia</a
+ ><a href="/play?game=salvage-rush">Play Salvage Rush</a>
</nav>
</nav>
<p class="eyebrow">LEADERBOARD</p>
<p class="eyebrow">LEADERBOARD</p>
- <h1>Salvage Rush</h1>
- <p class="lede">
+ <h1 id="title">Salvage Rush</h1>
+ <p class="lede" id="lede">
Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the
Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the
server counted.
server counted.
</p>
</p>
<section class="board" aria-labelledby="board-title">
<section class="board" aria-labelledby="board-title">
<h2 id="board-title" hidden>Best scores</h2>
<h2 id="board-title" hidden>Best scores</h2>
+ <div class="tabs" role="group" aria-label="Game">
+ <button type="button" data-game="salvage-rush" aria-pressed="true">Salvage Rush</button>
+ <button type="button" data-game="scraplandia" aria-pressed="false">Scraplandia haul</button>
+ </div>
<div class="tabs" role="group" aria-label="Period">
<div class="tabs" role="group" aria-label="Period">
<button type="button" data-period="all" aria-pressed="true">All time</button>
<button type="button" data-period="all" aria-pressed="true">All time</button>
<button type="button" data-period="week" aria-pressed="false">This week</button>
<button type="button" data-period="week" aria-pressed="false">This week</button>
@@ -231,7 +236,7 @@
@@ -231,7 +236,7 @@
<tr>
<tr>
<th scope="col">Rank</th>
<th scope="col">Rank</th>
<th scope="col">Player</th>
<th scope="col">Player</th>
- <th scope="col">Best score</th>
+ <th scope="col" id="score-header">Best score</th>
</tr>
</tr>
</thead>
</thead>
<tbody id="entries"></tbody>
<tbody id="entries"></tbody>
@@ -242,7 +247,7 @@
@@ -242,7 +247,7 @@
><button id="next" type="button">Next</button>
><button id="next" type="button">Next</button>
</div>
</div>
</section>
</section>
- <a class="play" href="/play?game=salvage-rush">▶ Play a round</a>
+ <a class="play" id="play-link" href="/play?game=salvage-rush">▶ Play a round</a>
<p class="note">"This week" starts on Monday, 00:00 UTC.</p>
<p class="note">"This week" starts on Monday, 00:00 UTC.</p>
</main>
</main>
</body>
</body>
@@ -5,14 +5,62 @@ interface Entry {
@@ -5,14 +5,62 @@ interface Entry {
name: string
name: string
score: number
score: number
}
}
-// Salvage Rush is the only game with scored rounds today; Scraplandia has no scored mode, so it has no board here.
-const GAME = 'salvage-rush'
-const tabs = Array.from(document.querySelectorAll<HTMLButtonElement>('.tabs button[data-period]'))
+
+type Game = 'salvage-rush' | 'scraplandia'
+
+const gameTabs = Array.from(document.querySelectorAll<HTMLButtonElement>('.tabs button[data-game]'))
+const periodTabs = Array.from(
+ document.querySelectorAll<HTMLButtonElement>('.tabs button[data-period]'),
+)
const status = document.getElementById('status') as HTMLElement
const status = document.getElementById('status') as HTMLElement
const entries = document.getElementById('entries') as HTMLElement
const entries = document.getElementById('entries') as HTMLElement
const pageLabel = document.getElementById('page') as HTMLElement
const pageLabel = document.getElementById('page') as HTMLElement
const previous = document.getElementById('previous') as HTMLButtonElement
const previous = document.getElementById('previous') as HTMLButtonElement
const next = document.getElementById('next') as HTMLButtonElement
const next = document.getElementById('next') as HTMLButtonElement
+const title = document.getElementById('title') as HTMLElement
+const lede = document.getElementById('lede') as HTMLElement
+const scoreHeader = document.getElementById('score-header') as HTMLElement
+const playLink = document.getElementById('play-link') as HTMLAnchorElement
+
+const COPY: Record<
+ Game,
+ {
+ title: string
+ lede: string
+ score: string
+ play: string
+ playLabel: string
+ empty: (period: 'all' | 'week') => string
+ }
+> = {
+ 'salvage-rush': {
+ title: 'Salvage Rush',
+ lede:
+ "Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the server counted.",
+ score: 'Best score',
+ play: '/play?game=salvage-rush',
+ playLabel: '▶ Play a round',
+ empty: (period) =>
+ period === 'week'
+ ? 'No rounds scored this week yet. Play one and take first place.'
+ : 'No rounds scored yet. Play one and take first place.',
+ },
+ scraplandia: {
+ title: 'Scraplandia haul',
+ lede:
+ 'Total haul points from server-confirmed extractions. Hold a recovery-bird circle to secure valuables and score.',
+ score: 'Haul points',
+ play: '/play',
+ playLabel: '▶ Play Scraplandia',
+ empty: (period) =>
+ period === 'week'
+ ? 'No hauls recorded this week yet. Extract some salvage to take first place.'
+ : 'No hauls recorded yet. Extract some salvage to take first place.',
+ },
+}
+
+let game: Game =
+ new URLSearchParams(location.search).get('game') === 'scraplandia' ? 'scraplandia' : 'salvage-rush'
let period: 'all' | 'week' =
let period: 'all' | 'week' =
new URLSearchParams(location.search).get('period') === 'week' ? 'week' : 'all'
new URLSearchParams(location.search).get('period') === 'week' ? 'week' : 'all'
let page = 1
let page = 1
@@ -28,15 +76,27 @@ function cell(text: string, className?: string): HTMLTableCellElement {
@@ -28,15 +76,27 @@ function cell(text: string, className?: string): HTMLTableCellElement {
return td
return td
}
}
+function applyGame(): void {
+ for (const tab of gameTabs) tab.setAttribute('aria-pressed', String(tab.dataset.game === game))
+ const copy = COPY[game]
+ title.textContent = copy.title
+ lede.textContent = copy.lede
+ scoreHeader.textContent = copy.score
+ playLink.href = copy.play
+ playLink.textContent = copy.playLabel
+ document.title = `${copy.title} leaderboard · OpenVibe.Games`
+}
+
async function load(): Promise<void> {
async function load(): Promise<void> {
- for (const tab of tabs) tab.setAttribute('aria-pressed', String(tab.dataset.period === period))
+ for (const tab of periodTabs) tab.setAttribute('aria-pressed', String(tab.dataset.period === period))
+ const copy = COPY[game]
status.textContent = 'Loading…'
status.textContent = 'Loading…'
entries.replaceChildren()
entries.replaceChildren()
previous.disabled = page === 1
previous.disabled = page === 1
next.disabled = true
next.disabled = true
pageLabel.textContent = `Page ${page}`
pageLabel.textContent = `Page ${page}`
try {
try {
- const response = await fetch(`/api/v1/games/${GAME}/leaderboard?period=${period}&page=${page}`)
+ const response = await fetch(`/api/v1/games/${game}/leaderboard?period=${period}&page=${page}`)
if (!response.ok) throw new Error('unavailable')
if (!response.ok) throw new Error('unavailable')
const data = (await response.json()) as { entries: Entry[]; pageSize: number }
const data = (await response.json()) as { entries: Entry[]; pageSize: number }
for (const entry of data.entries) {
for (const entry of data.entries) {
@@ -49,18 +109,25 @@ async function load(): Promise<void> {
@@ -49,18 +109,25 @@ async function load(): Promise<void> {
)
)
entries.append(row)
entries.append(row)
}
}
- status.textContent = data.entries.length
- ? ''
- : period === 'week'
- ? 'No rounds scored this week yet. Play one and take first place.'
- : 'No rounds scored yet. Play one and take first place.'
+ status.textContent = data.entries.length ? '' : copy.empty(period)
next.disabled = data.entries.length < data.pageSize
next.disabled = data.entries.length < data.pageSize
} catch {
} catch {
status.textContent = 'The leaderboard is unavailable right now. Try again in a minute.'
status.textContent = 'The leaderboard is unavailable right now. Try again in a minute.'
}
}
}
}
-for (const tab of tabs) {
+for (const tab of gameTabs) {
+ tab.onclick = () => {
+ const chosen = tab.dataset.game === 'scraplandia' ? 'scraplandia' : 'salvage-rush'
+ if (chosen === game) return
+ game = chosen
+ page = 1
+ applyGame()
+ void load()
+ }
+}
+
+for (const tab of periodTabs) {
tab.onclick = () => {
tab.onclick = () => {
const chosen = tab.dataset.period === 'week' ? 'week' : 'all'
const chosen = tab.dataset.period === 'week' ? 'week' : 'all'
if (chosen === period) return
if (chosen === period) return
@@ -69,14 +136,18 @@ for (const tab of tabs) {
@@ -69,14 +136,18 @@ for (const tab of tabs) {
void load()
void load()
}
}
}
}
+
previous.onclick = () => {
previous.onclick = () => {
if (page > 1) {
if (page > 1) {
page--
page--
void load()
void load()
}
}
}
}
+
next.onclick = () => {
next.onclick = () => {
page++
page++
void load()
void load()
}
}
+
+applyGame()
void load()
void load()
@@ -39,7 +39,7 @@ export interface EventHooks {
@@ -39,7 +39,7 @@ export interface EventHooks {
/** Players currently inside a circle: [entityId, distance][] */
/** Players currently inside a circle: [entityId, distance][] */
playersIn(x: number, z: number, radius: number): { entityId: string; alive: boolean }[]
playersIn(x: number, z: number, radius: number): { entityId: string; alive: boolean }[]
/** Secure a player's carried valuables + recall them to the city. */
/** Secure a player's carried valuables + recall them to the city. */
- extractPlayer(entityId: string): void
+ extractPlayer(entityId: string, extractionId: string): void
broadcastSpawn(entity: GameEntity): void
broadcastSpawn(entity: GameEntity): void
broadcastDespawn(id: string): void
broadcastDespawn(id: string): void
}
}
@@ -234,7 +234,7 @@ export class EventManager {
@@ -234,7 +234,7 @@ export class EventManager {
this.hooks.announceTo(id, `⏱ Hold ${EXTRACTION_HOLD_SECONDS}s to extract…`)
this.hooks.announceTo(id, `⏱ Hold ${EXTRACTION_HOLD_SECONDS}s to extract…`)
} else if (nowMs >= holders[id]) {
} else if (nowMs >= holders[id]) {
delete holders[id]
delete holders[id]
- this.hooks.extractPlayer(id)
+ this.hooks.extractPlayer(id, ev.id)
}
}
}
}
return false // runs until endsAt
return false // runs until endsAt
@@ -3,8 +3,10 @@ import { openTestDb, openTestStore } from '@openvibe/persistence/testing'
@@ -3,8 +3,10 @@ import { openTestDb, openTestStore } from '@openvibe/persistence/testing'
import { accountKey } from '@openvibe/persistence'
import { accountKey } from '@openvibe/persistence'
import {
import {
earnedAchievements,
earnedAchievements,
+ earnedHaulAchievements,
leaderboard,
leaderboard,
playerAchievements,
playerAchievements,
+ recordHaul,
recordJoined,
recordJoined,
recordRound,
recordRound,
} from './progression.js'
} from './progression.js'
@@ -15,9 +17,9 @@ describe('game progression', () => {
@@ -15,9 +17,9 @@ describe('game progression', () => {
const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
await recordJoined(db, subject, 100)
await recordJoined(db, subject, 100)
await recordJoined(db, subject, 200)
await recordJoined(db, subject, 200)
- expect((await playerAchievements(db, subject))[0]?.achievements).toMatchObject([
- { id: 'first-visit', unlockedAt: 100 },
- ])
+ expect(
+ (await playerAchievements(db, subject))[0]?.achievements.filter((a) => a.unlockedAt),
+ ).toMatchObject([{ id: 'first-visit', unlockedAt: 100 }])
})
})
it('erases a signed-in subject from game progression with account data', async () => {
it('erases a signed-in subject from game progression with account data', async () => {
@@ -143,4 +145,38 @@ describe('game progression', () => {
@@ -143,4 +145,38 @@ describe('game progression', () => {
).toEqual([5, 2])
).toEqual([5, 2])
expect((await leaderboard(db, 'salvage-rush', 'all', 2, now)).entries).toEqual([])
expect((await leaderboard(db, 'salvage-rush', 'all', 2, now)).entries).toEqual([])
})
})
+
+ it('unlocks Scraplandia haul achievements at their thresholds', () => {
+ expect(earnedHaulAchievements(1, 1)).toEqual(['first-haul'])
+ expect(earnedHaulAchievements(499, 499)).toEqual(['first-haul'])
+ expect(earnedHaulAchievements(500, 500)).toEqual(['first-haul', 'big-haul'])
+ expect(earnedHaulAchievements(500, 2000)).toEqual(['first-haul', 'big-haul', 'haul-week'])
+ })
+
+ it('records hauls once per extraction and sums them on the Scraplandia leaderboard', async () => {
+ const db = await openTestDb()
+ const now = Date.UTC(2026, 9, 11, 12) // Sunday
+ await recordHaul(db, 'a', 'e1', 'A', 100, now)
+ await recordHaul(db, 'a', 'e2', 'A', 50, now)
+ await recordHaul(db, 'a', 'e2', 'A', 999, now) // duplicate extraction id, ignored
+ await recordHaul(db, 'b', 'e3', 'B', 10, Date.UTC(2026, 9, 4, 23, 59)) // previous week
+ await recordHaul(db, 'c', 'e4', 'C', 0, now) // zero-value, not recorded
+ expect((await leaderboard(db, 'scraplandia', 'all', 1, now)).entries.map((e) => e.score)).toEqual(
+ [150, 10],
+ )
+ expect((await leaderboard(db, 'scraplandia', 'week', 1, now)).entries.map((e) => e.score)).toEqual(
+ [150],
+ )
+ })
+
+ it('unlocks haul achievements after a recorded haul', async () => {
+ const db = await openTestDb()
+ const now = Date.UTC(2026, 9, 11, 12)
+ await recordHaul(db, 'guest-test-key', 'e1', 'Guest', 500, now)
+ await recordHaul(db, 'guest-test-key', 'e2', 'Guest', 2000, now)
+ const unlocks = (await playerAchievements(db, 'guest-test-key'))[0]?.achievements.filter(
+ (a) => a.unlockedAt,
+ )
+ expect(unlocks?.map((a) => a.id)).toEqual(['first-haul', 'big-haul', 'haul-week'])
+ })
})
})
@@ -26,6 +26,30 @@ export function earnedAchievements(game: GameId, result: RoundResult): string[]
@@ -26,6 +26,30 @@ export function earnedAchievements(game: GameId, result: RoundResult): string[]
.map((def) => def.id)
.map((def) => def.id)
}
}
+/** Scraplandia haul achievements, evaluated after a haul is recorded. */
+export function earnedHaulAchievements(score: number, weeklyScore: number): string[] {
+ return definitions.scraplandia
+ .filter((def) => {
+ if (def.criterion.event === 'haul_recorded') {
+ return def.criterion.minScore === undefined || score >= def.criterion.minScore
+ }
+ if (def.criterion.event === 'haul_week') {
+ return weeklyScore >= def.criterion.minScore
+ }
+ return false
+ })
+ .map((def) => def.id)
+}
+
+function weekStartMs(now: number): number {
+ const date = new Date(now)
+ return Date.UTC(
+ date.getUTCFullYear(),
+ date.getUTCMonth(),
+ date.getUTCDate() - ((date.getUTCDay() + 6) % 7),
+ )
+}
+
/** Server join event: once per account, regardless of how many characters or sessions join. */
/** Server join event: once per account, regardless of how many characters or sessions join. */
export async function recordJoined(db: Db, key: string, now: number): Promise<void> {
export async function recordJoined(db: Db, key: string, now: number): Promise<void> {
const account = accountKey(key)
const account = accountKey(key)
@@ -73,10 +97,47 @@ export async function recordRound(
@@ -73,10 +97,47 @@ export async function recordRound(
})
})
}
}
+/** Record one successful extraction haul (Scraplandia); zero-value extractions are not recorded. */
+export async function recordHaul(
+ db: Db,
+ key: string,
+ extractionId: string,
+ name: string,
+ score: number,
+ now: number,
+): Promise<void> {
+ if (score <= 0) return
+ await db.tx(async () => {
+ const account = accountKey(key)
+ await db.exec(sql`
+ INSERT INTO game_hauls (account_key, extraction_id, player_name, score, finished_at)
+ VALUES (${account}, ${extractionId}, ${name}, ${score}, ${now})
+ ON CONFLICT (account_key, extraction_id) DO NOTHING
+ `)
+ const weekStart = weekStartMs(now)
+ const rows = await db.many<{ total: number }>(sql`
+ SELECT COALESCE(SUM(score), 0)::bigint AS total FROM game_hauls
+ WHERE account_key = ${account} AND finished_at >= ${weekStart}
+ `)
+ const weeklyScore = Number(rows[0]?.total ?? 0)
+ for (const achievement of earnedHaulAchievements(score, weeklyScore)) {
+ await db.exec(sql`
+ INSERT INTO game_achievements (game_id, achievement_id, account_key, unlocked_at)
+ VALUES ('scraplandia', ${achievement}, ${account}, ${now})
+ ON CONFLICT (game_id, achievement_id, account_key) DO NOTHING
+ `)
+ }
+ })
+}
+
/** Write-behind for completed rounds. Failed writes stay queued for retry and shutdown drain. */
/** Write-behind for completed rounds. Failed writes stay queued for retry and shutdown drain. */
export class GameProgressWriter {
export class GameProgressWriter {
private readonly pending = new Map<string, { results: RoundResult[]; finishedAt: number }>()
private readonly pending = new Map<string, { results: RoundResult[]; finishedAt: number }>()
private readonly joined = new Map<string, number>()
private readonly joined = new Map<string, number>()
+ private readonly hauls = new Map<
+ string,
+ { key: string; name: string; score: number; extractionId: string; finishedAt: number }
+ >()
private inFlight: Promise<void> | null = null
private inFlight: Promise<void> | null = null
private readonly timer: ReturnType<typeof setInterval>
private readonly timer: ReturnType<typeof setInterval>
@@ -98,14 +159,37 @@ export class GameProgressWriter {
@@ -98,14 +159,37 @@ export class GameProgressWriter {
void this.flush().catch(this.onError)
void this.flush().catch(this.onError)
}
}
+ submitHaul(extractionId: string, key: string, name: string, score: number): void {
+ if (score <= 0) return
+ this.hauls.set(`${accountKey(key)}\u0000${extractionId}`, {
+ key,
+ name,
+ score,
+ extractionId,
+ finishedAt: Date.now(),
+ })
+ void this.flush().catch(this.onError)
+ }
+
async flush(): Promise<void> {
async flush(): Promise<void> {
if (this.inFlight) return this.inFlight
if (this.inFlight) return this.inFlight
this.inFlight = (async () => {
this.inFlight = (async () => {
- while (this.pending.size > 0 || this.joined.size > 0) {
+ while (this.pending.size > 0 || this.joined.size > 0 || this.hauls.size > 0) {
for (const [roundId, entry] of this.pending) {
for (const [roundId, entry] of this.pending) {
await recordRound(this.db, 'salvage-rush', roundId, entry.results, entry.finishedAt)
await recordRound(this.db, 'salvage-rush', roundId, entry.results, entry.finishedAt)
this.pending.delete(roundId)
this.pending.delete(roundId)
}
}
+ for (const [id, haul] of this.hauls) {
+ await recordHaul(
+ this.db,
+ haul.key,
+ haul.extractionId,
+ haul.name,
+ haul.score,
+ haul.finishedAt,
+ )
+ this.hauls.delete(id)
+ }
for (const [key, at] of this.joined) {
for (const [key, at] of this.joined) {
await recordJoined(this.db, key, at)
await recordJoined(this.db, key, at)
this.joined.delete(key)
this.joined.delete(key)
@@ -138,15 +222,29 @@ export async function leaderboard(
@@ -138,15 +222,29 @@ export async function leaderboard(
page: number,
page: number,
now: number,
now: number,
) {
) {
- const date = new Date(now)
- const weekStart = Date.UTC(
- date.getUTCFullYear(),
- date.getUTCMonth(),
- date.getUTCDate() - ((date.getUTCDay() + 6) % 7),
- )
- const since = period === 'week' ? weekStart : 0
+ const since = period === 'week' ? weekStartMs(now) : 0
const limit = 20
const limit = 20
const offset = (page - 1) * limit
const offset = (page - 1) * limit
+ const entries = (rows: BoardRow[]) =>
+ rows.map((row, i) => ({
+ rank: offset + i + 1,
+ name: row.player_name,
+ score: Number(row.score),
+ finishedAt: Number(row.finished_at),
+ }))
+
+ if (game === 'scraplandia') {
+ const rows = await db.many<BoardRow>(sql`
+ SELECT account_key, player_name, SUM(score) AS score, MAX(finished_at) AS finished_at
+ FROM game_hauls
+ WHERE finished_at >= ${since}
+ GROUP BY account_key, player_name
+ ORDER BY score DESC, finished_at ASC, account_key ASC
+ LIMIT ${limit} OFFSET ${offset}
+ `)
+ return { game, period, page, pageSize: limit, entries: entries(rows) }
+ }
+
const rows =
const rows =
period === 'all'
period === 'all'
? await db.many<BoardRow>(sql`
? await db.many<BoardRow>(sql`
@@ -169,12 +267,7 @@ export async function leaderboard(
@@ -169,12 +267,7 @@ export async function leaderboard(
period,
period,
page,
page,
pageSize: limit,
pageSize: limit,
- entries: rows.map((row, i) => ({
- rank: offset + i + 1,
- name: row.player_name,
- score: Number(row.score),
- finishedAt: Number(row.finished_at),
- })),
+ entries: entries(rows),
}
}
}
}
@@ -68,6 +68,8 @@ export interface GameIntegrations {
@@ -68,6 +68,8 @@ export interface GameIntegrations {
roundId: string,
roundId: string,
results: { key: string; name: string; score: number; rank: number }[],
results: { key: string; name: string; score: number; rank: number }[],
) => void
) => void
+ /** Called for a server-confirmed extraction haul (Scraplandia). Never accepts a client message. */
+ haulExtracted?: (key: string, extractionId: string, name: string, score: number) => void
/** Durable lifecycle/progression events through the outbox. */
/** Durable lifecycle/progression events through the outbox. */
events?: GameEventRecorder
events?: GameEventRecorder
/** Installed mods (content packs), reconciled every tick. */
/** Installed mods (content packs), reconciled every tick. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+import { createContent } from '@openvibe/content'
+import { Inventory, SkillSet } from '@openvibe/gameplay'
+import { defaultAppearance, type ServerMessage } from '@openvibe/protocol'
+import { newEntityId, newPlayerId } from '@openvibe/shared'
+import { describe, expect, it } from 'vitest'
+import { createSession, type PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import { WorldEventSystem } from './worldEvents.js'
+
+const content = createContent()
+
+function makeSession(token: string, withLoot = true): PlayerSession {
+ const inventory = new Inventory(6, 2, content)
+ if (withLoot) {
+ inventory.addStack({ defId: 'salvage_core', count: 2 }) // 40 points
+ inventory.addStack({ defId: 'pepper_tonic', count: 3 }) // 90 points
+ inventory.addStack({ defId: 'wood_plank', count: 5 }) // non-valuable
+ }
+ return createSession({
+ playerId: newPlayerId(),
+ entityId: newEntityId(),
+ token,
+ subjectId: null,
+ name: 'Scrapper',
+ spawn: { x: 0, y: 1.2, z: 4 },
+ yaw: 0,
+ inventory,
+ skills: new SkillSet(content),
+ friends: new Set(),
+ appearance: defaultAppearance(),
+ content,
+ send: () => {},
+ closeConnection: () => {},
+ })
+}
+
+function makeSystem(
+ session: PlayerSession,
+ onHaul: (key: string, extractionId: string, name: string, score: number) => void,
+): { system: WorldEventSystem; sent: ServerMessage[] } {
+ const sent: ServerMessage[] = []
+ const ctx = {
+ world: { content },
+ config: { eventIntervalScale: 1, tickRate: 30 },
+ sessions: {
+ getByEntity: () => session,
+ values: () => [],
+ },
+ net: {
+ send: (_s: PlayerSession, msg: ServerMessage) => {
+ sent.push(msg)
+ },
+ sendInventory: () => {},
+ broadcastAll: () => {},
+ broadcastSpawn: () => {},
+ broadcastDespawn: () => {},
+ },
+ log: {
+ child: () => ({ info: () => {} }),
+ info: () => {},
+ },
+ integrations: { haulExtracted: onHaul },
+ } as unknown as ServerContext
+ return { system: new WorldEventSystem(ctx), sent }
+}
+
+describe('extraction scoring', () => {
+ it('scores a haul from the extraction path and recalls the player', () => {
+ const session = makeSession('guest-token-1')
+ session.move.pos.x = 60
+ session.move.pos.z = 40
+ let haul: { key: string; extractionId: string; name: string; score: number } | null = null
+ const { system, sent } = makeSystem(session, (key, extractionId, name, score) => {
+ haul = { key, extractionId, name, score }
+ })
+ system.extractPlayer(session.entityId, 'evt-1')
+ expect(haul).toEqual({
+ key: 'guest-token-1',
+ extractionId: 'evt-1',
+ name: 'Scrapper',
+ score: 130,
+ })
+ expect(session.move.pos.x).toBe(0)
+ expect(session.move.pos.y).toBe(1.2)
+ expect(session.move.pos.z).toBe(4)
+ expect(sent.find((m) => m.t === 'announce')).toMatchObject({
+ t: 'announce',
+ text: '🚁 Extracted! 2 stacks secured · 130 haul points.',
+ })
+ })
+
+ it('does not record a zero-value extraction', () => {
+ const session = makeSession('guest-token-2', false)
+ let called = false
+ const { system, sent } = makeSystem(session, () => {
+ called = true
+ })
+ system.extractPlayer(session.entityId, 'evt-2')
+ expect(called).toBe(false)
+ expect(sent.find((m) => m.t === 'announce')).toMatchObject({
+ t: 'announce',
+ text: '🚁 Extracted safely back to Scrap City.',
+ })
+ })
+})
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
import { worldSpawn } from '@openvibe/content'
import { worldSpawn } from '@openvibe/content'
+import { haulPoints } from '@openvibe/gameplay'
import type { EntityId } from '@openvibe/shared'
import type { EntityId } from '@openvibe/shared'
import { EventManager } from '../eventManager.js'
import { EventManager } from '../eventManager.js'
import type { ServerContext } from './context.js'
import type { ServerContext } from './context.js'
@@ -30,7 +31,7 @@ export class WorldEventSystem implements System {
@@ -30,7 +31,7 @@ export class WorldEventSystem implements System {
}
}
return out
return out
},
},
- extractPlayer: (entityId) => this.extractPlayer(entityId),
+ extractPlayer: (entityId, extractionId) => this.extractPlayer(entityId, extractionId),
broadcastSpawn: (entity) => ctx.net.broadcastSpawn(entity),
broadcastSpawn: (entity) => ctx.net.broadcastSpawn(entity),
broadcastDespawn: (id) => ctx.net.broadcastDespawn(id),
broadcastDespawn: (id) => ctx.net.broadcastDespawn(id),
},
},
@@ -48,12 +49,13 @@ export class WorldEventSystem implements System {
@@ -48,12 +49,13 @@ export class WorldEventSystem implements System {
* Extraction success: carried valuables become SECURED (they no longer
* Extraction success: carried valuables become SECURED (they no longer
* drop on death) and the player is recalled to the safe city.
* drop on death) and the player is recalled to the safe city.
*/
*/
- private extractPlayer(entityId: string): void {
+ extractPlayer(entityId: string, extractionId: string): void {
const session = this.ctx.sessions.getByEntity(entityId as EntityId)
const session = this.ctx.sessions.getByEntity(entityId as EntityId)
if (!session) return
if (!session) return
- const secured = session.inventory.secureValuables(
+ const secured = session.inventory.secureValuablesReport(
(defId) => this.ctx.world.content.item(defId)?.valuable !== undefined,
(defId) => this.ctx.world.content.item(defId)?.valuable !== undefined,
)
)
+ const points = haulPoints(secured, this.ctx.world.content)
const spawn = worldSpawn(this.ctx.world.content.world).pos
const spawn = worldSpawn(this.ctx.world.content.world).pos
session.move.pos.x = spawn[0]
session.move.pos.x = spawn[0]
session.move.pos.y = spawn[1]
session.move.pos.y = spawn[1]
@@ -65,11 +67,23 @@ export class WorldEventSystem implements System {
@@ -65,11 +67,23 @@ export class WorldEventSystem implements System {
this.ctx.net.send(session, {
this.ctx.net.send(session, {
t: 'announce',
t: 'announce',
text:
text:
- secured > 0
- ? `🚁 Extracted! ${secured} stack${secured === 1 ? '' : 's'} of loot secured.`
+ secured.length > 0
+ ? `🚁 Extracted! ${secured.length} stack${secured.length === 1 ? '' : 's'} secured · ${points} haul points.`
: '🚁 Extracted safely back to Scrap City.',
: '🚁 Extracted safely back to Scrap City.',
})
})
this.ctx.net.sendInventory(session)
this.ctx.net.sendInventory(session)
- this.ctx.log.info('player extracted', { playerId: session.playerId, secured })
+ if (points > 0) {
+ this.ctx.integrations.haulExtracted?.(
+ session.subjectId ?? session.token,
+ extractionId,
+ session.name,
+ points,
+ )
+ }
+ this.ctx.log.info('player extracted', {
+ playerId: session.playerId,
+ stacks: secured.length,
+ points,
+ })
}
}
}
}
@@ -298,7 +298,11 @@ async function main(): Promise<void> {
@@ -298,7 +298,11 @@ async function main(): Promise<void> {
...(scripts ? { scripts } : {}),
...(scripts ? { scripts } : {}),
...(progressSummary ? { progressSummary } : {}),
...(progressSummary ? { progressSummary } : {}),
...(config.placeId === 'scraplandia'
...(config.placeId === 'scraplandia'
- ? { playerJoined: (key: string) => progressWriter.submitJoined(key) }
+ ? {
+ playerJoined: (key: string) => progressWriter.submitJoined(key),
+ haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
+ progressWriter.submitHaul(extractionId, key, name, score),
+ }
: {}),
: {}),
...(config.placeId === 'salvage-rush'
...(config.placeId === 'salvage-rush'
? {
? {
@@ -363,7 +367,11 @@ async function main(): Promise<void> {
@@ -363,7 +367,11 @@ async function main(): Promise<void> {
...(roomScripts ? { scripts: roomScripts } : {}),
...(roomScripts ? { scripts: roomScripts } : {}),
...(progressSummary ? { progressSummary } : {}),
...(progressSummary ? { progressSummary } : {}),
...(config.placeId === 'scraplandia'
...(config.placeId === 'scraplandia'
- ? { playerJoined: (key: string) => progressWriter.submitJoined(key) }
+ ? {
+ playerJoined: (key: string) => progressWriter.submitJoined(key),
+ haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
+ progressWriter.submitHaul(extractionId, key, name, score),
+ }
: {}),
: {}),
...(config.placeId === 'salvage-rush'
...(config.placeId === 'salvage-rush'
? {
? {
@@ -459,7 +467,11 @@ async function main(): Promise<void> {
@@ -459,7 +467,11 @@ async function main(): Promise<void> {
...(roomScripts ? { scripts: roomScripts } : {}),
...(roomScripts ? { scripts: roomScripts } : {}),
...(progressSummary ? { progressSummary } : {}),
...(progressSummary ? { progressSummary } : {}),
...(secondaryPlace === 'scraplandia'
...(secondaryPlace === 'scraplandia'
- ? { playerJoined: (key: string) => progressWriter.submitJoined(key) }
+ ? {
+ playerJoined: (key: string) => progressWriter.submitJoined(key),
+ haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
+ progressWriter.submitHaul(extractionId, key, name, score),
+ }
: {
: {
roundFinished: (roundId: string, results: RoundResult[]) =>
roundFinished: (roundId: string, results: RoundResult[]) =>
progressWriter.submit(`${slot}:${roundId}`, results),
progressWriter.submit(`${slot}:${roundId}`, results),
@@ -29,4 +29,38 @@ describe('game read routes', () => {
@@ -29,4 +29,38 @@ describe('game read routes', () => {
expect(status).toBe(404)
expect(status).toBe(404)
expect(JSON.parse(body)).toEqual({ error: 'unknown_game' })
expect(JSON.parse(body)).toEqual({ error: 'unknown_game' })
})
})
+
+ it('answers the Scraplandia haul leaderboard', async () => {
+ let status = 0
+ let body = ''
+ const req = {
+ method: 'GET',
+ url: '/api/v1/games/scraplandia/leaderboard?period=week&page=1',
+ } as IncomingMessage
+ const res = {
+ writeHead: (code: number) => {
+ status = code
+ },
+ end: (value: string) => {
+ body = value
+ },
+ } as ServerResponse
+ const db = {
+ many: async () => [{ account_key: 'a', player_name: 'A', score: 120, finished_at: 100 }],
+ }
+ const limits = { publicRead: async () => true }
+ handleGameRead(req, res, {
+ db: db as never,
+ networkAuthUrl: null,
+ limits: limits as never,
+ address: () => 'test',
+ })
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ expect(status).toBe(200)
+ expect(JSON.parse(body)).toMatchObject({
+ game: 'scraplandia',
+ period: 'week',
+ entries: [{ rank: 1, name: 'A', score: 120, finishedAt: 100 }],
+ })
+ })
})
})
@@ -77,6 +77,7 @@ export function createAccountData(opts: {
@@ -77,6 +77,7 @@ export function createAccountData(opts: {
...(data.gameAchievements.length
...(data.gameAchievements.length
? [{ name: 'game-achievements.json', content: data.gameAchievements }]
? [{ name: 'game-achievements.json', content: data.gameAchievements }]
: []),
: []),
+ ...(data.gameHauls.length ? [{ name: 'game-hauls.json', content: data.gameHauls }] : []),
]
]
const res = await opts.send(`/internal/account-exports/${id}/parts`, { subject, files })
const res = await opts.send(`/internal/account-exports/${id}/parts`, { subject, files })
const outcome = res.ok
const outcome = res.ok
@@ -3,9 +3,11 @@
@@ -3,9 +3,11 @@
## Scraplandia
## Scraplandia
The default place (`GAMES_PLACE_ID=scraplandia`) loads `SCRAPLANDIA_PACK`. Its persistent
The default place (`GAMES_PLACE_ID=scraplandia`) loads `SCRAPLANDIA_PACK`. Its persistent
-world, inventory, crafting, farming, PvP rules, and editor behavior stay as before. The
-pack defines a first-visit achievement unlocked by a server-confirmed join. Its leaderboard
-is public but empty until Scraplandia has a scored game event.
+world, inventory, crafting, farming, PvP rules, and editor behavior stay as before. Extraction
+events are the scored "Haul" mode: a living player who holds the recovery-bird circle secures
+their at-risk valuables, and each secured stack scores `count × valuable.points` haul points.
+The pack defines a first-visit achievement and three haul achievements (First haul, Big haul,
+Haul week), all unlocked by server-confirmed events.
## Salvage Rush
## Salvage Rush
@@ -23,15 +25,17 @@ default place; Salvage Rush currently uses the map in its built-in content pack.
@@ -23,15 +25,17 @@ default place; Salvage Rush currently uses the map in its built-in content pack.
Completed results are stored in `game_round_results`, keyed by game, round and account key.
Completed results are stored in `game_round_results`, keyed by game, round and account key.
`game_best_scores` holds each player's all-time best; weekly bests come from the results table.
`game_best_scores` holds each player's all-time best; weekly bests come from the results table.
-`game_achievements` stores one unlock per game, criterion and player. Local guest keys are
-SHA-256 hashes as for characters; Network subjects remain canonical. No score is written
-until a server round ends. `GET /api/v1/games/:game/leaderboard?period=all|week&page=1`
-returns 20 personal bests per page, ordered by score, then earliest completion. `week`
+Scraplandia hauls are stored one row per extraction in `game_hauls`, keyed by account and
+extraction event; its leaderboard ranks by the sum of haul points in the period rather than a
+single best. `game_achievements` stores one unlock per game, criterion and player. Local guest
+keys are SHA-256 hashes as for characters; Network subjects remain canonical. No score is written
+until the server confirms a round or extraction. `GET /api/v1/games/:game/leaderboard?period=all|week&page=1`
+returns 20 entries per page, ordered by score, then earliest completion. `week`
starts Monday 00:00 UTC. `GET /api/v1/players/me/achievements` accepts the same
starts Monday 00:00 UTC. `GET /api/v1/players/me/achievements` accepts the same
bearer or `X-Guest-Token` used by the ticket endpoint. Public reads have per-address limits.
bearer or `X-Guest-Token` used by the ticket endpoint. Public reads have per-address limits.
-The read-only `/leaderboard.html` page shows Salvage Rush's best round scores, all time or this week (from Monday 00:00 UTC), 20 a page. Scraplandia has no scored mode, so it has no board yet.
-Network account exports include results, best scores and unlocks; account erasure removes them.
-Guest character adoption and Network subject merges move progression to the surviving subject.
+The read-only `/leaderboard.html` page shows Salvage Rush's best round scores and Scraplandia's summed haul points, switchable in the page, all time or this week (from Monday 00:00 UTC), 20 a page.
+Network account exports include results, best scores, unlocks and hauls; account erasure removes them.
+Guest character adoption and Network subject merges move progression (including hauls) to the surviving subject.
## Adding another game
## Adding another game
@@ -164,7 +164,8 @@ export const ITEMS: ItemDef[] = [
@@ -164,7 +164,8 @@ export const ITEMS: ItemDef[] = [
'Dense pre-collapse tech. The merchant pays a fortune — IF you get it home. Drops when you die; extract to secure it.',
'Dense pre-collapse tech. The merchant pays a fortune — IF you get it home. Drops when you die; extract to secure it.',
category: 'misc',
category: 'misc',
maxStack: 5,
maxStack: 5,
- valuable: {},
+ // Supply-drop staple (1-2 per crate), the baseline haul item.
+ valuable: { points: 20 },
world: {
world: {
shape: { type: 'box', size: [0.24, 0.24, 0.24] },
shape: { type: 'box', size: [0.24, 0.24, 0.24] },
massKg: 8,
massKg: 8,
@@ -265,7 +266,8 @@ export const ITEMS: ItemDef[] = [
@@ -265,7 +266,8 @@ export const ITEMS: ItemDef[] = [
description: 'Greasy hand-drawn plans. Use (LMB) to learn the recipe for good.',
description: 'Greasy hand-drawn plans. Use (LMB) to learn the recipe for good.',
category: 'misc',
category: 'misc',
maxStack: 1,
maxStack: 1,
- valuable: {},
+ // Rare crate-only drop that permanently unlocks a recipe.
+ valuable: { points: 40 },
blueprint: { recipe: 'craft_scrap_pistol' },
blueprint: { recipe: 'craft_scrap_pistol' },
},
},
{
{
@@ -274,7 +276,8 @@ export const ITEMS: ItemDef[] = [
@@ -274,7 +276,8 @@ export const ITEMS: ItemDef[] = [
description: "Somebody's prized still schematics. Use (LMB) to learn the recipe.",
description: "Somebody's prized still schematics. Use (LMB) to learn the recipe.",
category: 'misc',
category: 'misc',
maxStack: 1,
maxStack: 1,
- valuable: {},
+ // Merchant-only (40 caps, stock 1) — the rarest valuable.
+ valuable: { points: 60 },
blueprint: { recipe: 'craft_still' },
blueprint: { recipe: 'craft_still' },
},
},
@@ -575,7 +578,8 @@ export const ITEMS: ItemDef[] = [
@@ -575,7 +578,8 @@ export const ITEMS: ItemDef[] = [
'Ember pepper and flour mash run through a still. Burns twice. The Goose pays absurdly well and asks no questions. At-risk until extracted.',
'Ember pepper and flour mash run through a still. Burns twice. The Goose pays absurdly well and asks no questions. At-risk until extracted.',
category: 'food',
category: 'food',
maxStack: 10,
maxStack: 10,
- valuable: {},
+ // Craftable but gated behind pepper farming plus a powered still.
+ valuable: { points: 30 },
food: { hunger: 6, thirst: 10, health: 8 },
food: { hunger: 6, thirst: 10, health: 8 },
},
},
{
{
@@ -45,7 +45,10 @@ export interface ContentPackV2 {
@@ -45,7 +45,10 @@ export interface ContentPackV2 {
id: string
id: string
name: string
name: string
description: string
description: string
- criterion: { event: 'player_joined' }
+ criterion:
+ | { event: 'player_joined' }
+ | { event: 'haul_recorded'; minScore?: number }
+ | { event: 'haul_week'; minScore: number }
}[]
}[]
/** Built-in game rules. Executable rule evaluation remains server-owned. */
/** Built-in game rules. Executable rule evaluation remains server-owned. */
game?: {
game?: {
@@ -14,6 +14,24 @@ export const SCRAPLANDIA_PACK: ContentPackV2 = {
@@ -14,6 +14,24 @@ export const SCRAPLANDIA_PACK: ContentPackV2 = {
description: 'Join Scraplandia.',
description: 'Join Scraplandia.',
criterion: { event: 'player_joined' },
criterion: { event: 'player_joined' },
},
},
+ {
+ id: 'first-haul',
+ name: 'First Haul',
+ description: 'Extract valuables to Scrap City.',
+ criterion: { event: 'haul_recorded' },
+ },
+ {
+ id: 'big-haul',
+ name: 'Big Haul',
+ description: 'Score at least 500 haul points in one extraction.',
+ criterion: { event: 'haul_recorded', minScore: 500 },
+ },
+ {
+ id: 'haul-week',
+ name: 'Haul Week',
+ description: 'Score at least 2,000 haul points in one week.',
+ criterion: { event: 'haul_week', minScore: 2000 },
+ },
],
],
map: {
map: {
id: 'openvibeville_v2',
id: 'openvibeville_v2',
@@ -163,9 +163,14 @@ export const ItemDefSchema = z.object({
@@ -163,9 +163,14 @@ export const ItemDefSchema = z.object({
/**
/**
* Present iff the item is AT-RISK loot: it drops in a loot bag on death
* Present iff the item is AT-RISK loot: it drops in a loot bag on death
* unless secured by an extraction (stack meta `secured`). The
* unless secured by an extraction (stack meta `secured`). The
- * risk/reward heart of expedition play.
+ * risk/reward heart of expedition play. `points` is the haul score a
+ * secured item is worth; omitted it defaults to 10.
*/
*/
- valuable: z.object({}).optional(),
+ valuable: z
+ .object({
+ points: z.number().int().positive().default(10),
+ })
+ .optional(),
/** Present iff consuming this item permanently unlocks a recipe. */
/** Present iff consuming this item permanently unlocks a recipe. */
blueprint: z.object({ recipe: z.string() }).optional(),
blueprint: z.object({ recipe: z.string() }).optional(),
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { createContent } from '@openvibe/content'
import { createContent } from '@openvibe/content'
-import { Inventory } from './inventory.js'
+import { haulPoints, Inventory } from './inventory.js'
const content = createContent()
const content = createContent()
@@ -113,4 +113,47 @@ describe('Inventory', () => {
@@ -113,4 +113,47 @@ describe('Inventory', () => {
expect(inv.secureValuables(isValuable)).toBe(0)
expect(inv.secureValuables(isValuable)).toBe(0)
expect(inv.takeUnsecuredValuables(isValuable)).toEqual([])
expect(inv.takeUnsecuredValuables(isValuable)).toEqual([])
})
})
+
+ it('computes haul points per secured stack and ignores non-valuables', () => {
+ const stacks = [
+ { defId: 'salvage_core', count: 3 }, // 20 each
+ { defId: 'pepper_tonic', count: 2 }, // 30 each
+ { defId: 'wood_plank', count: 10 }, // not valuable
+ ]
+ expect(haulPoints(stacks, content)).toBe(3 * 20 + 2 * 30)
+ })
+
+ it('defaults valuable points to 10', () => {
+ const withDefault = createContent([
+ {
+ defs: {
+ items: [
+ {
+ id: 'test_valuable',
+ name: 'Test Valuable',
+ description: '',
+ category: 'misc',
+ maxStack: 5,
+ valuable: {},
+ },
+ ],
+ },
+ },
+ ])
+ expect(withDefault.item('test_valuable')?.valuable?.points).toBe(10)
+ expect(haulPoints([{ defId: 'test_valuable', count: 2 }], withDefault)).toBe(20)
+ })
+
+ it('reports what it secures so the extraction path can score it', () => {
+ const inv = new Inventory(6, 2, content)
+ inv.addStack({ defId: 'salvage_core', count: 2 })
+ inv.addStack({ defId: 'pepper_tonic', count: 3 })
+ inv.addStack({ defId: 'wood_plank', count: 5 })
+ const isValuable = (id: string) => content.item(id)?.valuable !== undefined
+ expect(inv.secureValuablesReport(isValuable)).toEqual([
+ { defId: 'salvage_core', count: 2 },
+ { defId: 'pepper_tonic', count: 3 },
+ ])
+ expect(inv.secureValuables(isValuable)).toBe(0)
+ })
})
})
@@ -15,6 +15,26 @@ export interface InventoryDto {
@@ -15,6 +15,26 @@ export interface InventoryDto {
slots: { i: number; stack: ItemStack }[]
slots: { i: number; stack: ItemStack }[]
}
}
+/** A secured valuable stack reported by an extraction. */
+export interface SecuredStack {
+ defId: string
+ count: number
+}
+
+/**
+ * Haul points for secured stacks: Σ count × the item's `valuable.points`.
+ * Non-valuable defs contribute nothing, so callers may pass the whole
+ * extracted set and let this filter by capability.
+ */
+export function haulPoints(stacks: readonly SecuredStack[], content: ContentRegistry): number {
+ let total = 0
+ for (const stack of stacks) {
+ const points = content.item(stack.defId)?.valuable?.points
+ if (points !== undefined) total += stack.count * points
+ }
+ return total
+}
+
export class Inventory {
export class Inventory {
private slots: (ItemStack | null)[]
private slots: (ItemStack | null)[]
@@ -182,12 +202,18 @@ export class Inventory {
@@ -182,12 +202,18 @@ export class Inventory {
/** Marks every valuable stack secured (extraction completed). Returns
/** Marks every valuable stack secured (extraction completed). Returns
* how many stacks were newly secured. */
* how many stacks were newly secured. */
secureValuables(isValuable: (defId: string) => boolean): number {
secureValuables(isValuable: (defId: string) => boolean): number {
- let secured = 0
+ return this.secureValuablesReport(isValuable).length
+ }
+
+ /** Like `secureValuables`, but reports each newly secured stack's defId
+ * and count so the caller can score the haul. */
+ secureValuablesReport(isValuable: (defId: string) => boolean): SecuredStack[] {
+ const secured: SecuredStack[] = []
for (const s of this.slots) {
for (const s of this.slots) {
if (!s || !isValuable(s.defId)) continue
if (!s || !isValuable(s.defId)) continue
if (s.meta?.secured === 1) continue
if (s.meta?.secured === 1) continue
s.meta = { ...s.meta, secured: 1 }
s.meta = { ...s.meta, secured: 1 }
- secured++
+ secured.push({ defId: s.defId, count: s.count })
}
}
return secured
return secured
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+-- phase: expand
+-- M6: Scraplandia "Haul" mode. One row per successful extraction; the haul leaderboard sums these.
+CREATE TABLE game_hauls (
+ account_key text COLLATE "C" NOT NULL,
+ extraction_id text COLLATE "C" NOT NULL,
+ player_name text NOT NULL,
+ score integer NOT NULL CHECK (score > 0),
+ finished_at bigint NOT NULL,
+ PRIMARY KEY (account_key, extraction_id)
+);
+CREATE INDEX game_hauls_board ON game_hauls (finished_at, score DESC);
@@ -70,6 +70,13 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -70,6 +70,13 @@ export function createIdentityRepository(db: Db): IdentityRepository {
unlocked_at = LEAST(game_achievements.unlocked_at, excluded.unlocked_at)
unlocked_at = LEAST(game_achievements.unlocked_at, excluded.unlocked_at)
`)
`)
await db.exec(sql`DELETE FROM game_achievements WHERE account_key = ${from}`)
await db.exec(sql`DELETE FROM game_achievements WHERE account_key = ${from}`)
+ await db.exec(sql`
+ INSERT INTO game_hauls (account_key, extraction_id, player_name, score, finished_at)
+ SELECT ${into}, extraction_id, player_name, score, finished_at
+ FROM game_hauls WHERE account_key = ${from}
+ ON CONFLICT (account_key, extraction_id) DO NOTHING
+ `)
+ await db.exec(sql`DELETE FROM game_hauls WHERE account_key = ${from}`)
}
}
const recordAdoption = (
const recordAdoption = (
key: string,
key: string,
@@ -168,12 +175,16 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -168,12 +175,16 @@ export function createIdentityRepository(db: Db): IdentityRepository {
const gameAchievements = await db.many<Record<string, unknown>>(
const gameAchievements = await db.many<Record<string, unknown>>(
sql`SELECT game_id, achievement_id, unlocked_at FROM game_achievements WHERE account_key = ${subject} ORDER BY game_id, achievement_id`,
sql`SELECT game_id, achievement_id, unlocked_at FROM game_achievements WHERE account_key = ${subject} ORDER BY game_id, achievement_id`,
)
)
+ const gameHauls = await db.many<Record<string, unknown>>(
+ sql`SELECT extraction_id, player_name, score, finished_at FROM game_hauls WHERE account_key = ${subject} ORDER BY finished_at`,
+ )
return {
return {
characters: characters.map(withoutAccountKey),
characters: characters.map(withoutAccountKey),
structures,
structures,
gameResults,
gameResults,
gameBestScores,
gameBestScores,
gameAchievements,
gameAchievements,
+ gameHauls,
}
}
},
},
@@ -201,6 +212,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -201,6 +212,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
await db.exec(
await db.exec(
sql`DELETE FROM game_round_results WHERE account_key IN (${idList(subjects)})`,
sql`DELETE FROM game_round_results WHERE account_key IN (${idList(subjects)})`,
)
)
+ await db.exec(sql`DELETE FROM game_hauls WHERE account_key IN (${idList(subjects)})`)
return out
return out
})
})
},
},
@@ -87,6 +87,7 @@ export interface IdentityRepository {
@@ -87,6 +87,7 @@ export interface IdentityRepository {
gameResults: Record<string, unknown>[]
gameResults: Record<string, unknown>[]
gameBestScores: Record<string, unknown>[]
gameBestScores: Record<string, unknown>[]
gameAchievements: Record<string, unknown>[]
gameAchievements: Record<string, unknown>[]
+ gameHauls: Record<string, unknown>[]
}>
}>
/**
/**
* Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their
* Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their
@@ -16,6 +16,7 @@ const TABLES = [
@@ -16,6 +16,7 @@ const TABLES = [
'game_round_results',
'game_round_results',
'game_best_scores',
'game_best_scores',
'game_achievements',
'game_achievements',
+ 'game_hauls',
'game_instances',
'game_instances',
'character_instance_positions',
'character_instance_positions',
'world_meta',
'world_meta',
new file mode 100644
new file mode 100644
@@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
+// file: 'apps/server/src/game/systems/worldEvents.test.ts'
+// covers: 'apps/server/src/game/systems'