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)
Changes
@@@ -70,6 +70,8 @@ export interface GatherResult {/** Entity spawned as a side effect (felled tree trunk), if any. */spawned: GameEntity | nulllevelUps: LevelUp[]++ /** A crop was harvested from a planter (a Scraplandia milestone). */++ harvested?: booleanxpChanged: boolean}@@@ -190,6 +192,7 @@ export function handleUse(changed: entity,levelUps,xpChanged: true,++ harvested: true,}}const seed = heldDef?.seed@@@ -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)++ })})@@@ -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 = nullprivate 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.inFlightthis.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 {@@@ -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. */@@@ -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.tokenif (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 = nullsession.dirty = truethis.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })@@@ -48,9 +48,16 @@ export class InteractionSystem implements System {session.lastUseTick = this.ctx.clock.tickthis.ctx.net.sendInventory(session)if (gather.xpChanged) this.ctx.net.sendSkills(session)++ const key = session.subjectId ?? session.tokenfor (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)@@@ -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)@@@ -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[]) =>@@@ -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?: {@@@ -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
@@ -70,6 +70,8 @@ export interface GatherResult {
@@ -70,6 +70,8 @@ export interface GatherResult {
/** Entity spawned as a side effect (felled tree trunk), if any. */
/** Entity spawned as a side effect (felled tree trunk), if any. */
spawned: GameEntity | null
spawned: GameEntity | null
levelUps: LevelUp[]
levelUps: LevelUp[]
+ /** A crop was harvested from a planter (a Scraplandia milestone). */
+ harvested?: boolean
xpChanged: boolean
xpChanged: boolean
}
}
@@ -190,6 +192,7 @@ export function handleUse(
@@ -190,6 +192,7 @@ export function handleUse(
changed: entity,
changed: entity,
levelUps,
levelUps,
xpChanged: true,
xpChanged: true,
+ harvested: true,
}
}
}
}
const seed = heldDef?.seed
const seed = heldDef?.seed
@@ -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 { sql, type Db } from 'openvibe-sdk/db'
import { sql, type Db } from 'openvibe-sdk/db'
import {
import {
+ GameProgressWriter,
earnedAchievements,
earnedAchievements,
earnedHaulAchievements,
earnedHaulAchievements,
+ earnedMilestoneAchievements,
playerAchievements,
playerAchievements,
recordHaul,
recordHaul,
recordJoined,
recordJoined,
@@ -188,4 +190,41 @@ describe('game progression', () => {
@@ -188,4 +190,41 @@ describe('game progression', () => {
)
)
expect(unlocks?.map((a) => a.id)).toEqual(['first-haul', 'big-haul', 'haul-week'])
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)
+ })
})
})
@@ -41,6 +41,45 @@ export function earnedHaulAchievements(score: number, weeklyScore: number): stri
@@ -41,6 +41,45 @@ export function earnedHaulAchievements(score: number, weeklyScore: number): stri
.map((def) => def.id)
.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 {
function weekStartMs(now: number): number {
const date = new Date(now)
const date = new Date(now)
return Date.UTC(
return Date.UTC(
@@ -138,6 +177,8 @@ export class GameProgressWriter {
@@ -138,6 +177,8 @@ export class GameProgressWriter {
string,
string,
{ key: string; name: string; score: number; extractionId: string; finishedAt: number }
{ 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 inFlight: Promise<void> | null = null
private readonly timer: ReturnType<typeof setInterval>
private readonly timer: ReturnType<typeof setInterval>
@@ -172,10 +213,26 @@ export class GameProgressWriter {
@@ -172,10 +213,26 @@ export class GameProgressWriter {
void this.flush().catch(this.onError)
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> {
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 || 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) {
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)
@@ -198,6 +255,11 @@ export class GameProgressWriter {
@@ -198,6 +255,11 @@ export class GameProgressWriter {
this.joined.delete(key)
this.joined.delete(key)
this.afterCommit?.(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 {
try {
@@ -30,6 +30,7 @@ import type { SessionsSystem } from './sessions.js'
@@ -30,6 +30,7 @@ import type { SessionsSystem } from './sessions.js'
import type { SocialSystem } from './social.js'
import type { SocialSystem } from './social.js'
import type { VehicleSystem } from './vehicles.js'
import type { VehicleSystem } from './vehicles.js'
import type { WorldEventSystem } from './worldEvents.js'
import type { WorldEventSystem } from './worldEvents.js'
+import type { Milestone } from '../progression.js'
/**
/**
* A network connection as the game sees it — transport-agnostic.
* A network connection as the game sees it — transport-agnostic.
@@ -72,6 +73,8 @@ export interface GameIntegrations {
@@ -72,6 +73,8 @@ export interface GameIntegrations {
) => void
) => void
/** Called for a server-confirmed extraction haul (Scraplandia). Never accepts a client message. */
/** Called for a server-confirmed extraction haul (Scraplandia). Never accepts a client message. */
haulExtracted?: (key: string, extractionId: string, name: string, score: number) => void
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. */
/** 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. */
@@ -197,12 +197,20 @@ export class EconomySystem implements System {
@@ -197,12 +197,20 @@ export class EconomySystem implements System {
addReputation(session.reputation, market.faction, job.reward.reputation)
addReputation(session.reputation, market.faction, job.reward.reputation)
this.sendReputation(session)
this.sendReputation(session)
}
}
+ const key = session.subjectId ?? session.token
if (job.reward.xp) {
if (job.reward.xp) {
const ups = session.skills.addXp(job.reward.xp.skill, job.reward.xp.amount)
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.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.net.sendSkills(session)
}
}
+ this.ctx.integrations.milestone?.(key, { event: 'contract_completed' })
session.activeJob = null
session.activeJob = null
session.dirty = true
session.dirty = true
this.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })
this.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })
@@ -48,9 +48,16 @@ export class InteractionSystem implements System {
@@ -48,9 +48,16 @@ export class InteractionSystem implements System {
session.lastUseTick = this.ctx.clock.tick
session.lastUseTick = this.ctx.clock.tick
this.ctx.net.sendInventory(session)
this.ctx.net.sendInventory(session)
if (gather.xpChanged) this.ctx.net.sendSkills(session)
if (gather.xpChanged) this.ctx.net.sendSkills(session)
+ const key = session.subjectId ?? session.token
for (const up of gather.levelUps) {
for (const up of gather.levelUps) {
this.ctx.net.send(session, { t: 'levelup', skill: up.skill, level: up.level })
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 (gather.pickedUp) {
if (session.held?.entityId === gather.pickedUp.id) {
if (session.held?.entityId === gather.pickedUp.id) {
this.ctx.systems.manipulation.releaseHeld(session)
this.ctx.systems.manipulation.releaseHeld(session)
@@ -219,6 +219,11 @@ export class ProductionSystem implements System {
@@ -219,6 +219,11 @@ export class ProductionSystem implements System {
const ups = session.skills.addXp(recipeSkill(recipe.category), recipeXp(recipe))
const ups = session.skills.addXp(recipeSkill(recipe.category), recipeXp(recipe))
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.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)
this.ctx.net.sendSkills(session)
@@ -77,7 +77,7 @@ import {
@@ -77,7 +77,7 @@ import {
} from './platform/revocationEvents.js'
} from './platform/revocationEvents.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
import { createWsTicketStore } from './net/wsTicket.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 type { RoundResult } from './game/salvageRush.js'
import { handleGameRead } from './net/gameRoutes.js'
import { handleGameRead } from './net/gameRoutes.js'
import { createPlayBoards } from './platform/playBoards.js'
import { createPlayBoards } from './platform/playBoards.js'
@@ -374,6 +374,8 @@ async function main(): Promise<void> {
@@ -374,6 +374,8 @@ async function main(): Promise<void> {
playerJoined: (key: string) => progressWriter.submitJoined(key),
playerJoined: (key: string) => progressWriter.submitJoined(key),
haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
progressWriter.submitHaul(extractionId, key, name, score),
progressWriter.submitHaul(extractionId, key, name, score),
+ milestone: (key: string, milestone: Milestone) =>
+ progressWriter.submitMilestone(key, milestone),
}
}
: {}),
: {}),
...(config.placeId === 'salvage-rush'
...(config.placeId === 'salvage-rush'
@@ -444,6 +446,8 @@ async function main(): Promise<void> {
@@ -444,6 +446,8 @@ async function main(): Promise<void> {
playerJoined: (key: string) => progressWriter.submitJoined(key),
playerJoined: (key: string) => progressWriter.submitJoined(key),
haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
progressWriter.submitHaul(extractionId, key, name, score),
progressWriter.submitHaul(extractionId, key, name, score),
+ milestone: (key: string, milestone: Milestone) =>
+ progressWriter.submitMilestone(key, milestone),
}
}
: {}),
: {}),
...(config.placeId === 'salvage-rush'
...(config.placeId === 'salvage-rush'
@@ -545,6 +549,8 @@ async function main(): Promise<void> {
@@ -545,6 +549,8 @@ async function main(): Promise<void> {
playerJoined: (key: string) => progressWriter.submitJoined(key),
playerJoined: (key: string) => progressWriter.submitJoined(key),
haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
haulExtracted: (key: string, extractionId: string, name: string, score: number) =>
progressWriter.submitHaul(extractionId, key, name, score),
progressWriter.submitHaul(extractionId, key, name, score),
+ milestone: (key: string, milestone: Milestone) =>
+ progressWriter.submitMilestone(key, milestone),
}
}
: {
: {
roundFinished: (roundId: string, results: RoundResult[]) =>
roundFinished: (roundId: string, results: RoundResult[]) =>
@@ -50,6 +50,9 @@ export interface ContentPackV2 {
@@ -50,6 +50,9 @@ export interface ContentPackV2 {
| { event: 'player_joined' }
| { event: 'player_joined' }
| { event: 'haul_recorded'; minScore?: number }
| { event: 'haul_recorded'; minScore?: number }
| { event: 'haul_week'; 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. */
/** Built-in game rules. Executable rule evaluation remains server-owned. */
game?: {
game?: {
@@ -35,6 +35,32 @@ export const SCRAPLANDIA_PACK: ContentPackV2 = {
@@ -35,6 +35,32 @@ export const SCRAPLANDIA_PACK: ContentPackV2 = {
description: 'Score at least 2,000 haul points in one week.',
description: 'Score at least 2,000 haul points in one week.',
criterion: { event: 'haul_week', minScore: 2000 },
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: {
map: {
// Scrap Island (apps/server/src/maps/scrapIsland.ts) is the map document the server loads from MAP_PATH. The id
// Scrap Island (apps/server/src/maps/scrapIsland.ts) is the map document the server loads from MAP_PATH. The id