openvibers/openvibe.games Commits

Scraplandia achievements past the first haul: The Goose's Trust (a contract), Green Thumb (a harvest), Journeyman and Old Hand (skill levels 5 and 10), from server-confirmed milestones through the progress writer and on to OpenVibe.Play (#131)

OP OpenVibers committed 24 days ago · 57a3989bf8 · 10 files, +165 −3

Changes

ddiff --git a/apps/server/src/game/interactions.ts b/apps/server/src/game/interactions.ts
iindex 26453bb..57ba3d3 100644
---- a/apps/server/src/game/interactions.ts
++++ b/apps/server/src/game/interactions.ts
@@@ -70,6 +70,8 @@ export interface GatherResult {
/** Entity spawned as a side effect (felled tree trunk), if any. */
spawned: GameEntity | null
levelUps: LevelUp[]
++ /** A crop was harvested from a planter (a Scraplandia milestone). */
++ harvested?: boolean
xpChanged: boolean
}
@@@ -190,6 +192,7 @@ export function handleUse(
changed: entity,
levelUps,
xpChanged: true,
++ harvested: true,
}
}
const seed = heldDef?.seed
ddiff --git a/apps/server/src/game/progression.test.ts b/apps/server/src/game/progression.test.ts
iindex ec19db8..6f05388 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 { sql, type Db } from 'openvibe-sdk/db'
import {
++ GameProgressWriter,
earnedAchievements,
earnedHaulAchievements,
++ earnedMilestoneAchievements,
playerAchievements,
recordHaul,
recordJoined,
@@@ -188,4 +190,41 @@ describe('game progression', () => {
)
expect(unlocks?.map((a) => a.id)).toEqual(['first-haul', 'big-haul', 'haul-week'])
})
++
++ it('earns the after-the-haul achievements from milestones', () => {
++ expect(earnedMilestoneAchievements({ event: 'contract_completed' })).toEqual(['first-contract'])
++ expect(earnedMilestoneAchievements({ event: 'crop_harvested' })).toEqual(['green-thumb'])
++ expect(
++ earnedMilestoneAchievements({ event: 'skill_level', skill: 'mining', level: 4 }),
++ ).toEqual([])
++ expect(
++ earnedMilestoneAchievements({ event: 'skill_level', skill: 'mining', level: 5 }),
++ ).toEqual(['journeyman'])
++ expect(
++ earnedMilestoneAchievements({ event: 'skill_level', skill: 'farming', level: 10 }),
++ ).toEqual(['journeyman', 'old-hand'])
++ })
++
++ it('records milestone unlocks once per account through the writer', async () => {
++ const db = await openTestDb()
++ const committed: string[] = []
++ const writer = new GameProgressWriter(
++ db,
++ (error) => {
++ throw error
++ },
++ (subject) => committed.push(subject),
++ )
++ const subject = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
++ writer.submitMilestone(subject, { event: 'contract_completed' })
++ writer.submitMilestone(subject, { event: 'contract_completed' })
++ writer.submitMilestone(subject, { event: 'skill_level', skill: 'woodcutting', level: 3 }) // earns nothing
++ writer.submitMilestone(subject, { event: 'skill_level', skill: 'woodcutting', level: 5 })
++ await writer.stop()
++ const unlocked = (await playerAchievements(db, subject))[0]?.achievements.filter(
++ (a) => a.unlockedAt,
++ )
++ expect(unlocked?.map((a) => a.id).sort()).toEqual(['first-contract', 'journeyman'])
++ expect(committed.length).toBeGreaterThan(0)
++ })
})
ddiff --git a/apps/server/src/game/progression.ts b/apps/server/src/game/progression.ts
iindex 3dd99b7..bdba3a8 100644
---- a/apps/server/src/game/progression.ts
++++ b/apps/server/src/game/progression.ts
@@@ -41,6 +41,45 @@ export function earnedHaulAchievements(score: number, weeklyScore: number): stri
.map((def) => def.id)
}
++/** A server-confirmed step past the first haul (never a client message). */
++export type Milestone =
++ | { event: 'contract_completed' }
++ | { event: 'crop_harvested' }
++ | { event: 'skill_level'; skill: string; level: number }
++
++/** Scraplandia achievements a milestone earns. */
++export function earnedMilestoneAchievements(milestone: Milestone): string[] {
++ return definitions.scraplandia
++ .filter((def) => {
++ const c = def.criterion
++ if (c.event !== milestone.event) return false
++ if (c.event === 'skill_level' && milestone.event === 'skill_level') {
++ return (
++ milestone.level >= c.minLevel && (c.skill === undefined || c.skill === milestone.skill)
++ )
++ }
++ return true
++ })
++ .map((def) => def.id)
++}
++
++/** Record unlocked Scraplandia achievements (idempotent per account). */
++export async function recordUnlocks(
++ db: Db,
++ key: string,
++ achievements: readonly string[],
++ now: number,
++): Promise<void> {
++ const account = accountKey(key)
++ for (const achievement of achievements) {
++ 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
++ `)
++ }
++}
++
function weekStartMs(now: number): number {
const date = new Date(now)
return Date.UTC(
@@@ -138,6 +177,8 @@ export class GameProgressWriter {
string,
{ key: string; name: string; score: number; extractionId: string; finishedAt: number }
>()
++ /** Achievement unlocks from milestones, one entry per account and achievement. */
++ private readonly unlocks = new Map<string, { key: string; achievement: string; at: number }>()
private inFlight: Promise<void> | null = null
private readonly timer: ReturnType<typeof setInterval>
@@@ -172,10 +213,26 @@ export class GameProgressWriter {
void this.flush().catch(this.onError)
}
++ submitMilestone(key: string, milestone: Milestone): void {
++ const earned = earnedMilestoneAchievements(milestone)
++ if (earned.length === 0) return
++ const now = Date.now()
++ for (const achievement of earned) {
++ const id = `${accountKey(key)}\u0000${achievement}`
++ if (!this.unlocks.has(id)) this.unlocks.set(id, { key, achievement, at: 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 || this.hauls.size > 0) {
++ while (
++ this.pending.size > 0 ||
++ this.joined.size > 0 ||
++ this.hauls.size > 0 ||
++ this.unlocks.size > 0
++ ) {
for (const [roundId, entry] of this.pending) {
await recordRound(this.db, 'salvage-rush', roundId, entry.results, entry.finishedAt)
this.pending.delete(roundId)
@@@ -198,6 +255,11 @@ export class GameProgressWriter {
this.joined.delete(key)
this.afterCommit?.(key)
}
++ for (const [id, unlock] of this.unlocks) {
++ await recordUnlocks(this.db, unlock.key, [unlock.achievement], unlock.at)
++ this.unlocks.delete(id)
++ this.afterCommit?.(unlock.key)
++ }
}
})()
try {
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
iindex a70d4c7..6ce637b 100644
---- a/apps/server/src/game/systems/context.ts
++++ b/apps/server/src/game/systems/context.ts
@@@ -30,6 +30,7 @@ import type { SessionsSystem } from './sessions.js'
import type { SocialSystem } from './social.js'
import type { VehicleSystem } from './vehicles.js'
import type { WorldEventSystem } from './worldEvents.js'
++import type { Milestone } from '../progression.js'
/**
* A network connection as the game sees it — transport-agnostic.
@@@ -72,6 +73,8 @@ export interface GameIntegrations {
) => void
/** Called for a server-confirmed extraction haul (Scraplandia). Never accepts a client message. */
haulExtracted?: (key: string, extractionId: string, name: string, score: number) => void
++ /** A server-confirmed milestone past the first haul (Scraplandia): a contract, a harvest, a skill level. */
++ milestone?: (key: string, milestone: Milestone) => 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/economy.ts b/apps/server/src/game/systems/economy.ts
iindex fa84fe7..58cd0df 100644
---- a/apps/server/src/game/systems/economy.ts
++++ b/apps/server/src/game/systems/economy.ts
@@@ -197,12 +197,20 @@ export class EconomySystem implements System {
addReputation(session.reputation, market.faction, job.reward.reputation)
this.sendReputation(session)
}
++ const key = session.subjectId ?? session.token
if (job.reward.xp) {
const ups = session.skills.addXp(job.reward.xp.skill, job.reward.xp.amount)
-- for (const up of ups)
++ for (const up of ups) {
this.ctx.net.send(session, { t: 'levelup', skill: up.skill, level: up.level })
++ this.ctx.integrations.milestone?.(key, {
++ event: 'skill_level',
++ skill: up.skill,
++ level: up.level,
++ })
++ }
this.ctx.net.sendSkills(session)
}
++ this.ctx.integrations.milestone?.(key, { event: 'contract_completed' })
session.activeJob = null
session.dirty = true
this.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })
ddiff --git a/apps/server/src/game/systems/interaction.ts b/apps/server/src/game/systems/interaction.ts
iindex 497b6ca..9b44c68 100644
---- a/apps/server/src/game/systems/interaction.ts
++++ b/apps/server/src/game/systems/interaction.ts
@@@ -48,9 +48,16 @@ export class InteractionSystem implements System {
session.lastUseTick = this.ctx.clock.tick
this.ctx.net.sendInventory(session)
if (gather.xpChanged) this.ctx.net.sendSkills(session)
++ const key = session.subjectId ?? session.token
for (const up of gather.levelUps) {
this.ctx.net.send(session, { t: 'levelup', skill: up.skill, level: up.level })
++ this.ctx.integrations.milestone?.(key, {
++ event: 'skill_level',
++ skill: up.skill,
++ level: up.level,
++ })
}
++ if (gather.harvested) this.ctx.integrations.milestone?.(key, { event: 'crop_harvested' })
if (gather.pickedUp) {
if (session.held?.entityId === gather.pickedUp.id) {
this.ctx.systems.manipulation.releaseHeld(session)
ddiff --git a/apps/server/src/game/systems/production.ts b/apps/server/src/game/systems/production.ts
iindex 969b840..00d5c08 100644
---- a/apps/server/src/game/systems/production.ts
++++ b/apps/server/src/game/systems/production.ts
@@@ -219,6 +219,11 @@ export class ProductionSystem implements System {
const ups = session.skills.addXp(recipeSkill(recipe.category), recipeXp(recipe))
for (const up of ups) {
this.ctx.net.send(session, { t: 'levelup', skill: up.skill, level: up.level })
++ this.ctx.integrations.milestone?.(session.subjectId ?? session.token, {
++ event: 'skill_level',
++ skill: up.skill,
++ level: up.level,
++ })
}
}
this.ctx.net.sendSkills(session)
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex cd39592..e05b264 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -77,7 +77,7 @@ import {
} from './platform/revocationEvents.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
import { createWsTicketStore } from './net/wsTicket.js'
--import { GameProgressWriter } from './game/progression.js'
++import { GameProgressWriter, type Milestone } from './game/progression.js'
import type { RoundResult } from './game/salvageRush.js'
import { handleGameRead } from './net/gameRoutes.js'
import { createPlayBoards } from './platform/playBoards.js'
@@@ -374,6 +374,8 @@ async function main(): Promise<void> {
playerJoined: (key: string) => progressWriter.submitJoined(key),
haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
progressWriter.submitHaul(extractionId, key, name, score),
++ milestone: (key: string, milestone: Milestone) =>
++ progressWriter.submitMilestone(key, milestone),
}
: {}),
...(config.placeId === 'salvage-rush'
@@@ -444,6 +446,8 @@ async function main(): Promise<void> {
playerJoined: (key: string) => progressWriter.submitJoined(key),
haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
progressWriter.submitHaul(extractionId, key, name, score),
++ milestone: (key: string, milestone: Milestone) =>
++ progressWriter.submitMilestone(key, milestone),
}
: {}),
...(config.placeId === 'salvage-rush'
@@@ -545,6 +549,8 @@ async function main(): Promise<void> {
playerJoined: (key: string) => progressWriter.submitJoined(key),
haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
progressWriter.submitHaul(extractionId, key, name, score),
++ milestone: (key: string, milestone: Milestone) =>
++ progressWriter.submitMilestone(key, milestone),
}
: {
roundFinished: (roundId: string, results: RoundResult[]) =>
ddiff --git a/packages/content/src/pack.ts b/packages/content/src/pack.ts
iindex d296edf..6035e2a 100644
---- a/packages/content/src/pack.ts
++++ b/packages/content/src/pack.ts
@@@ -50,6 +50,9 @@ export interface ContentPackV2 {
| { event: 'player_joined' }
| { event: 'haul_recorded'; minScore?: number }
| { event: 'haul_week'; minScore: number }
++ | { event: 'contract_completed' }
++ | { event: 'crop_harvested' }
++ | { event: 'skill_level'; minLevel: number; skill?: string }
}[]
/** 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 2adef78..bc6c52e 100644
---- a/packages/content/src/packs/scraplandia.ts
++++ b/packages/content/src/packs/scraplandia.ts
@@@ -35,6 +35,32 @@ export const SCRAPLANDIA_PACK: ContentPackV2 = {
description: 'Score at least 2,000 haul points in one week.',
criterion: { event: 'haul_week', minScore: 2000 },
},
++ // What there is to do after the first haul (roadmap "Scraplandia's first hour"): the Goose's contracts, a crop of
++ // your own, and skills worth levelling. They show in the Achievements tab and on OpenVibe.Play.
++ {
++ id: 'first-contract',
++ name: "The Goose's Trust",
++ description: "Complete a contract from Goose's Trading Post.",
++ criterion: { event: 'contract_completed' },
++ },
++ {
++ id: 'green-thumb',
++ name: 'Green Thumb',
++ description: 'Harvest a crop you grew in a planter.',
++ criterion: { event: 'crop_harvested' },
++ },
++ {
++ id: 'journeyman',
++ name: 'Journeyman',
++ description: 'Reach level 5 in any skill.',
++ criterion: { event: 'skill_level', minLevel: 5 },
++ },
++ {
++ id: 'old-hand',
++ name: 'Old Hand',
++ description: 'Reach level 10 in any skill.',
++ criterion: { event: 'skill_level', minLevel: 10 },
++ },
],
map: {
// Scrap Island (apps/server/src/maps/scrapIsland.ts) is the map document the server loads from MAP_PATH. The id