openvibers/openvibe.games Commits

Games: Scraplandia Haul, extraction points on a weekly board with achievements (#49)

OP OpenVibers committed 12 Oct 2026 · 72a656ac81 · 26 files, +602 −72

Plan T8: "Next: a scored Scraplandia mode." Scraplandia now has **Haul**: when the recovery bird comes, every valuable a player extracts scores haul points on a weekly and all-time board.

- **Points:** an item's `valuable` gains an optional `points` (default 10). The four valuables are scored by how rare and hard they are: salvage core 20, pepper tonic 30, the scrap pistol blueprint 40, the still blueprint 60.
- **Scoring:** only on the server's extraction path, which already requires holding the circle alive. `secureValuablesReport` lists what was secured, and the haul is Σ count × points. One row per account per extraction (migration `0005_game_hauls.sql`, expand, primary key `(account_key, extraction_id)`; the extraction id is the world event's unique id). The announcement says the points.
- **Leaderboard:** `scraplandia` ranks accounts by summed haul points, all time and this week; Salvage Rush's best-round board is unchanged. `leaderboard.html` gets a game switch, deep-linkable with `?game=scraplandia`.
- **Achievements:** First haul, Big haul (500 points in one extraction) and Haul week (2,000 in a week).
- **Account data:** hauls move with guest adoption and merges, appear in the export and are erased with the account.
- **Home:** the Scraplandia card explains Haul and links its board.

Built by a DeepSeek job and reviewed. `pnpm typecheck` and `pnpm lint` pass, and the full suite passes apart from `instanceHub`, a tick-time assertion that fails only under local load (7.06 ms against 6.24 at load average 12.7) and passes on its own.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..ff72fc9
---- /dev/null
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/README.md b/README.md
iindex 6730b80..31ad214 100644
---- a/README.md
++++ b/README.md
@@@ -3,6 +3,8 @@
## Purpose
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
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.
ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex 1097691..c06429e 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -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>
ddiff --git a/apps/client/leaderboard.html b/apps/client/leaderboard.html
iindex 6bea2af..5575e3e 100644
---- a/apps/client/leaderboard.html
++++ b/apps/client/leaderboard.html
@@@ -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>
<meta
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>
: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 the
server 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>
ddiff --git a/apps/client/src/pages/leaderboard.ts b/apps/client/src/pages/leaderboard.ts
iindex c7071ff..37b9131 100644
---- a/apps/client/src/pages/leaderboard.ts
++++ b/apps/client/src/pages/leaderboard.ts
@@@ -5,14 +5,62 @@ interface Entry {
name: string
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 entries = document.getElementById('entries') as HTMLElement
const pageLabel = document.getElementById('page') as HTMLElement
const previous = document.getElementById('previous') 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' =
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 === 1
next.disabled = true
pageLabel.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()
ddiff --git a/apps/server/src/game/eventManager.ts b/apps/server/src/game/eventManager.ts
iindex 224cb74..2dbae15 100644
---- a/apps/server/src/game/eventManager.ts
++++ b/apps/server/src/game/eventManager.ts
@@@ -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): void
broadcastSpawn(entity: GameEntity): void
broadcastDespawn(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
ddiff --git a/apps/server/src/game/progression.test.ts b/apps/server/src/game/progression.test.ts
iindex 2ea94e9..3916c92 100644
---- a/apps/server/src/game/progression.test.ts
++++ b/apps/server/src/game/progression.test.ts
@@@ -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'])
++ })
})
ddiff --git a/apps/server/src/game/progression.ts b/apps/server/src/game/progression.ts
iindex f31304a..ca9bbb0 100644
---- a/apps/server/src/game/progression.ts
++++ b/apps/server/src/game/progression.ts
@@@ -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 = null
private 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.inFlight
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) {
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) : 0
const limit = 20
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 =
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),
}
}
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
iindex fb3bf8b..ee6f00a 100644
---- a/apps/server/src/game/systems/context.ts
++++ b/apps/server/src/game/systems/context.ts
@@@ -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. */
ddiff --git a/apps/server/src/game/systems/worldEvents.test.ts b/apps/server/src/game/systems/worldEvents.test.ts
nnew file mode 100644
iindex 0000000..6050dcc
---- /dev/null
++++ b/apps/server/src/game/systems/worldEvents.test.ts
@@@ -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.',
++ })
++ })
++})
ddiff --git a/apps/server/src/game/systems/worldEvents.ts b/apps/server/src/game/systems/worldEvents.ts
iindex a250ef1..b5ea332 100644
---- a/apps/server/src/game/systems/worldEvents.ts
++++ b/apps/server/src/game/systems/worldEvents.ts
@@@ -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).pos
session.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,
++ })
}
}
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 98483b2..3b48623 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -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),
ddiff --git a/apps/server/src/net/gameRoutes.test.ts b/apps/server/src/net/gameRoutes.test.ts
iindex 56c7c4b..a173991 100644
---- a/apps/server/src/net/gameRoutes.test.ts
++++ b/apps/server/src/net/gameRoutes.test.ts
@@@ -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 }],
++ })
++ })
})
ddiff --git a/apps/server/src/platform/accountData.ts b/apps/server/src/platform/accountData.ts
iindex 8e98ced..24bfd98 100644
---- a/apps/server/src/platform/accountData.ts
++++ b/apps/server/src/platform/accountData.ts
@@@ -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
ddiff --git a/docs/games.md b/docs/games.md
iindex 93060a9..2edb9d5 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -3,9 +3,11 @@
## Scraplandia
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
@@@ -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 same
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
ddiff --git a/packages/content/src/defs/items.ts b/packages/content/src/defs/items.ts
iindex ed5ee67..3497c51 100644
---- a/packages/content/src/defs/items.ts
++++ b/packages/content/src/defs/items.ts
@@@ -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 },
},
{
ddiff --git a/packages/content/src/pack.ts b/packages/content/src/pack.ts
iindex 06096d8..5ba7a52 100644
---- a/packages/content/src/pack.ts
++++ b/packages/content/src/pack.ts
@@@ -45,7 +45,10 @@ export interface ContentPackV2 {
id: string
name: 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. */
game?: {
ddiff --git a/packages/content/src/packs/scraplandia.ts b/packages/content/src/packs/scraplandia.ts
iindex 3b62bce..fb2cac3 100644
---- a/packages/content/src/packs/scraplandia.ts
++++ b/packages/content/src/packs/scraplandia.ts
@@@ -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',
ddiff --git a/packages/content/src/schema/item.ts b/packages/content/src/schema/item.ts
iindex d131623..c12c2da 100644
---- a/packages/content/src/schema/item.ts
++++ b/packages/content/src/schema/item.ts
@@@ -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(),
ddiff --git a/packages/gameplay/src/inventory/inventory.test.ts b/packages/gameplay/src/inventory/inventory.test.ts
iindex ad071cd..b3284e1 100644
---- a/packages/gameplay/src/inventory/inventory.test.ts
++++ b/packages/gameplay/src/inventory/inventory.test.ts
@@@ -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)
++ })
})
ddiff --git a/packages/gameplay/src/inventory/inventory.ts b/packages/gameplay/src/inventory/inventory.ts
iindex 8d071a9..2a30fb7 100644
---- a/packages/gameplay/src/inventory/inventory.ts
++++ b/packages/gameplay/src/inventory/inventory.ts
@@@ -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)) continue
if (s.meta?.secured === 1) continue
s.meta = { ...s.meta, secured: 1 }
-- secured++
++ secured.push({ defId: s.defId, count: s.count })
}
return secured
}
ddiff --git a/packages/persistence/migrations/0005_game_hauls.sql b/packages/persistence/migrations/0005_game_hauls.sql
nnew file mode 100644
iindex 0000000..39f780e
---- /dev/null
++++ b/packages/persistence/migrations/0005_game_hauls.sql
@@@ -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);
ddiff --git a/packages/persistence/src/pg/platformRepositories.ts b/packages/persistence/src/pg/platformRepositories.ts
iindex d331ead..349bad3 100644
---- a/packages/persistence/src/pg/platformRepositories.ts
++++ b/packages/persistence/src/pg/platformRepositories.ts
@@@ -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
})
},
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex 0a8adec..65c3e0f 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -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
ddiff --git a/packages/persistence/src/testing.ts b/packages/persistence/src/testing.ts
iindex 2bb6eef..7e68375 100644
---- a/packages/persistence/src/testing.ts
++++ b/packages/persistence/src/testing.ts
@@@ -16,6 +16,7 @@ const TABLES = [
'game_round_results',
'game_best_scores',
'game_achievements',
++ 'game_hauls',
'game_instances',
'character_instance_positions',
'world_meta',
ddiff --git a/test/apps__server__src__game__systems__worldEvents.test.js b/test/apps__server__src__game__systems__worldEvents.test.js
nnew file mode 100644
iindex 0000000..dbb55b5
---- /dev/null
++++ b/test/apps__server__src__game__systems__worldEvents.test.js
@@@ -0,0 +1,2 @@
++// file: 'apps/server/src/game/systems/worldEvents.test.ts'
++// covers: 'apps/server/src/game/systems'