openvibers/openvibe.games Commits
M1: gameServer.ts (≈2,700 lines, one class) is split into 20 systems under apps/server/src/game/systems/ (sessions, movement, replication, manipulation, combat, vehicles, inventory, interaction, economy, production, npcs, social, worldEvents, persistence, mods, editor, environment, physics) sharing an explicit ServerContext; GameServer (299 lines) builds the context and systems, owns the one handler table (a duplicate message type is a startup error) and step(), which calls the systems in the exact previous order, written once as a list. Behaviour is unchanged: the persistence system keeps the forced final checkpoint and the returning player's drain(5000); ClientEditMode is exported from the protocol because movement uses the type. New handlerTable.test.ts: a duplicate type is refused and every protocol message type has exactly one handler. Built by DeepSeek from Claude's spec; reviewed independently on Command Code/MiniMax M3 (VERDICT: SHIP, method-by-method against HEAD). pnpm typecheck, lint, test 875/875, slice test OK.
Changes
@@@ -1,271 +1,146 @@--import {-- BLEED_SECONDS,-- Buttons,-- DEFAULT_MOVEMENT,-- Inventory,-- SkillSet,-- activeStatuses,-- ambientTemperature,-- applyDamage,-- applyStatus,-- canFire,-- causesBleeding,-- clearStatus,-- completeJob,-- containerAdd,-- containerSort,-- createEnvironment,-- eat,-- hullHeightFor,-- mitigate,-- plantProgress,-- plantStage,-- precipitation,-- recordShot,-- REP_DELTAS,-- RegionTracker,-- addReputation,-- stanceToward,-- startReload,-- stepEnvironment,-- stepMovement,-- tickSurvival,-- tryStartJob,-- updatePlant,-- waterPlant,-- weaponStateFromMeta,-- weaponStateToMeta,-- type CollisionQueries,-- type DamageEvent,-- type DamageType,-- type EnvironmentState,-- type GameEntity,-- type WeatherKind,--} from '@openvibe/gameplay'--import { STARTER_ITEMS, WATER_LEVEL, recipeSkill, recipeXp, terrainHeight } from '@openvibe/content'--import type { PersistenceStore, PlayerDto } from '@openvibe/persistence'--import { CollisionLayer, type BodyId } from '@openvibe/physics'--import {-- PROTOCOL_VERSION,-- encodeServerMessage,-- type ClientHello,-- type ClientMessage,-- type ClientPhysgun,-- type ServerMessage,--} from '@openvibe/protocol'--import {-- asPlayerId,-- newEntityId,-- newPlayerId,-- qfromYaw,-- qrotateVec,-- quat,-- v3dist,-- vec3,-- type EntityId,-- type Logger,-- type PlayerId,--} from '@openvibe/shared'++import { createEnvironment } from '@openvibe/gameplay'++import type { PersistenceStore } from '@openvibe/persistence'++import type { ClientMessage } from '@openvibe/protocol'++import type { Logger } from '@openvibe/shared'import type { ServerConfig } from '../config.js'--import { canEditMap, resolveNetworkUser } from '../net/networkAuth.js'--import { isGuestToken } from '../platform/accounts.js'--import type { EventPlayer, GameEventRecorder, ProgressSnapshot } from '../platform/gameEvents.js'--import type { ProgressSummaryWriter } from '../platform/progressSummary.js'--import type { ModHost, ModRuntime } from '../mods/runtime.js'--import { worldSpawn } from '@openvibe/content'++import { nearbyWorkstationKinds } from './interactions.js'++import type { PlayerSession } from './playerSession.js'++import { ServerContext } from './systems/context.js'++import type { GameConnection, GameIntegrations, ServerSystems } from './systems/context.js'++import {++ CombatSystem,++ EconomySystem,++ EditorSystem,++ EnvironmentSystem,++ InteractionSystem,++ InventorySystem,++ ManipulationSystem,++ ModSystem,++ MovementSystem,++ NpcSystem,++ PersistenceSystem,++ PhysicsSystem,++ ProductionSystem,++ ReplicationSystem,++ SessionsSystem,++ SocialSystem,++ VehicleSystem,++ WorldEventSystem,++ buildHandlerTable,++} from './systems/index.js'++import type { MessageHandler, System } from './systems/system.js'import type { ServerMetrics } from '../observability/metrics.js'import type { GameWorld } from './gameWorld.js'--import {-- equippedTool,-- handleConstraint,-- handleCraft,-- handleDrop,-- handleInvMove,-- handlePlace,-- handleUse,-- nearbyWorkstationKinds,--} from './interactions.js'--import type { ConstraintRecord } from './gameWorld.js'--import { adjustDistance, driveHeld, freezeHeld, release, rotateHeld, tryGrab } from './physgun.js'--import {-- createSession,-- eyePosition,-- HOTBAR_SIZE,-- INVENTORY_SIZE,-- viewDirection,-- type PlayerSession,--} from './playerSession.js'--import { buildSnapshot, updateInterest, wireEntityFor } from './replication.js'--import { NpcManager } from './npcManager.js'--import { EventManager } from './eventManager.js'--import { Flusher, type FlushCharacter, type FlushPayload } from './flusher.js'--/** A network connection as the game sees it — transport-agnostic. */--export interface GameConnection {-- send(text: string): void-- close(code: number, reason: string): void--}----const MOVE = DEFAULT_MOVEMENT--const MAX_INPUT_QUEUE = 6--/** The five weather kinds a persisted env_weather may name. */--const WEATHERS = new Set<WeatherKind>(['clear', 'cloudy', 'rain', 'storm', 'fog'])++export type { GameConnection, GameIntegrations }/**-- * Platform adapters the server drives (roadmap Wave 12). Both optional: the-- * game runs exactly as before without them.++ * The coordinator (ADR-0007 M1). It owns the shared context, constructs the systems, assembles++ * their message handlers into one table (a duplicate type is a startup error), and drives the tick:++ * `step()` below is the one place the simulation order is written down.++ *++ * Gameplay itself lives in `./systems/`; this class holds no game state beyond wiring.*/--export interface GameIntegrations {-- /** Durable lifecycle/progression events through the outbox. */-- events?: GameEventRecorder-- /** Installed mods (content packs), reconciled every tick. */-- mods?: ModRuntime-- /** The person's games.progress.summary user module on Network, written when a character leaves. */-- progressSummary?: ProgressSummaryWriter--}----/** A JWT's iat (seconds), read from a token the Network has already accepted; null otherwise. */--function tokenIat(jwt: string): number | null {-- const part = jwt.split('.')[1]-- if (!part) return null-- try {-- const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as { iat?: unknown }-- return typeof claims.iat === 'number' ? claims.iat : null-- } catch {-- return null-- }--}--export class GameServer {-- private readonly sessions = new Map<PlayerId, PlayerSession>()-- private readonly sessionsByConn = new Map<GameConnection, PlayerSession>()-- private readonly sessionsByEntity = new Map<EntityId, PlayerSession>()-- private readonly playerBodies = new Map<PlayerId, BodyId>()-- private readonly heldEntityIds = new Set<string>()-- /** Short-lived cache of OFFLINE owners' friend lists (prop protection). */-- private readonly offlineFriendsCache = new Map<string, { friends: Set<string>; at: number }>()-- /** Offline-owner lookups in flight, so the tick never issues the same read twice. */-- private readonly friendsLoadInFlight = new Set<string>()-- /** Connections whose hello is still resolving (a second one while it is refused). */-- private readonly hellosInFlight = new Set<GameConnection>()-- /** Write-behind persistence: one transaction in flight, the tick never awaits it. */-- private readonly flusher: Flusher-- private tick = 0-- private readonly moveQueries: CollisionQueries-- /** Body excluded from the current movement sweep (the moving player's own). */-- private sweepSelf: BodyId | undefined-- private lastFlushTick = 0-- /** Authoritative world environment: clock, weather, temperature. */-- private readonly env: EnvironmentState-- /** Coarse activation regions (NPC LOD and event relevance hang off this). */-- readonly regions = new RegionTracker(32)-- /** Server-authoritative NPC simulation (LOD-aware). */-- readonly npcs: NpcManager-- /** Generic world events (supply drops, extraction). */-- readonly events: EventManager-- /** Players who recently attacked someone (defensive NPCs respond). */-- private readonly aggro = new Map<string, number>()-- /** Sound events (gunshots, fights) accumulated for NPC hearing. */-- private sounds: { x: number; z: number }[] = []---- /** Players connected now (the readiness `sessions` check; a restart would disconnect them). */-- onlineCount(): number {-- return this.sessions.size-- }++ private readonly ctx: ServerContext++ private readonly systems: ServerSystems++ /** The systems in construction order: the handler table and the join/leave hooks both use it. */++ private readonly ordered: System[]++ private readonly handlers: Map<string, MessageHandler<never>>constructor(-- private readonly config: ServerConfig,-- private readonly world: GameWorld,-- private readonly store: PersistenceStore,-- private readonly metrics: ServerMetrics,-- private readonly log: Logger,-- private readonly integrations: GameIntegrations = {},++ config: ServerConfig,++ world: GameWorld,++ store: PersistenceStore,++ metrics: ServerMetrics,++ log: Logger,++ integrations: GameIntegrations = {},) {// The world clock and weather survive restarts (a rainy dusk stays a rainy dusk); they are// loaded asynchronously in load(), before the tick loop starts.-- this.env = createEnvironment(0.34)-- this.npcs = new NpcManager(++ this.ctx = new ServerContext(++ config,world,-- this.regions,-- {-- onAttackPlayer: (npcId, playerEntityId, damage) => {-- const victim = this.sessionsByEntity.get(playerEntityId as EntityId)-- const npcEntity = this.world.entities.get(npcId)-- if (!victim || !npcEntity) return-- // NPC melee has reach limits too (no cross-map slaps).-- const d = v3dist(npcEntity.transform.pos, victim.move.pos)-- if (d > 3) return-- this.damagePlayer(null, victim, { type: 'blunt', amount: damage, sourceId: npcId })-- },-- soundsThisTick: () => this.sounds,-- hostilesNear: (x, z, radius, faction) => {-- const stance = this.world.content.faction(faction)?.playerStance ?? 'neutral'-- const nowMs = Date.now()-- const out: { id: string; x: number; z: number; hostile: boolean }[] = []-- for (const other of this.sessions.values()) {-- const dx = other.move.pos.x - x-- const dz = other.move.pos.z - z-- if (dx * dx + dz * dz > radius * radius) continue-- const aggro = (this.aggro.get(other.entityId as string) ?? 0) > nowMs-- const hostile = stance === 'hostile' || aggro-- out.push({-- id: other.entityId as string,-- x: other.move.pos.x,-- z: other.move.pos.z,-- hostile,-- })-- }-- return out-- },-- },-- log.child({ system: 'npc' }),-- )---- this.events = new EventManager(-- world,-- {-- announce: (text) => this.broadcastAll({ t: 'announce', text }),-- announceTo: (entityId, text) => {-- const target = this.sessionsByEntity.get(entityId as EntityId)-- if (target) this.send(target, { t: 'announce', text })-- },-- playersIn: (x, z, radius) => {-- const out: { entityId: string; alive: boolean }[] = []-- for (const s of this.sessions.values()) {-- if (Math.hypot(s.move.pos.x - x, s.move.pos.z - z) <= radius) {-- out.push({ entityId: s.entityId as string, alive: s.stats.health > 0 })-- }-- }-- return out-- },-- extractPlayer: (entityId) => this.extractPlayer(entityId),-- broadcastSpawn: (entity) => this.broadcastSpawn(entity),-- broadcastDespawn: (id) => this.broadcastDespawn(id),-- },-- config.eventIntervalScale,-- log.child({ system: 'events' }),-- )---- this.flusher = new Flusher(store,metrics,-- log.child({ system: 'persist' }),-- integrations.events,++ log,++ integrations,++ createEnvironment(0.34),)---- // Players block players: sweeps include the Player layer, minus the-- // mover's own kinematic body.-- this.moveQueries = {-- sweepCapsule: (from, to, radius, height) =>-- world.physics.sweepCapsule(-- from,-- to,-- radius,-- height,-- CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,-- this.sweepSelf,-- ),++ const ctx = this.ctx++ const sessions = new SessionsSystem(ctx)++ const movement = new MovementSystem(ctx)++ // The wire surface every system sends through.++ const replication = new ReplicationSystem(ctx)++ ctx.net = replication++ const manipulation = new ManipulationSystem(ctx)++ const combat = new CombatSystem(ctx)++ const vehicles = new VehicleSystem(ctx)++ const inventory = new InventorySystem(ctx)++ const interaction = new InteractionSystem(ctx)++ const economy = new EconomySystem(ctx)++ const production = new ProductionSystem(ctx)++ const npcs = new NpcSystem(ctx)++ const social = new SocialSystem(ctx)++ const worldEvents = new WorldEventSystem(ctx)++ const persistence = new PersistenceSystem(ctx)++ const mods = new ModSystem(ctx)++ const editor = new EditorSystem(ctx)++ const environment = new EnvironmentSystem(ctx)++ const physics = new PhysicsSystem(ctx)++++ this.systems = {++ sessions,++ movement,++ replication,++ manipulation,++ combat,++ vehicles,++ inventory,++ interaction,++ economy,++ production,++ npcs,++ social,++ worldEvents,++ persistence,++ mods,++ editor,++ environment,++ physics,++ }++ // Bound together once, here: a system may reach its peers only from a handler, a tick or a++ // lifecycle hook, never during construction.++ ctx.systems = this.systems++ this.ordered = [++ sessions,++ movement,++ replication,++ manipulation,++ combat,++ vehicles,++ inventory,++ interaction,++ economy,++ production,++ npcs,++ social,++ worldEvents,++ persistence,++ mods,++ editor,++ environment,++ physics,++ ]++ ctx.lifecycle = {++ joined: (session) => {++ for (const system of this.ordered) system.onJoin?.(session)++ },++ left: (session) => {++ for (const system of this.ordered) system.onLeave?.(session)++ },}++ this.handlers = buildHandlerTable(this.ordered)}/**@@@ -273,2620 +148,152 @@ export class GameServer {* main.ts awaits this before starting the tick loop.*/async load(): Promise<void> {-- const rawTime = await this.store.meta.get('env_time')-- const savedTime = Number(rawTime ?? Number.NaN)-- if (Number.isFinite(savedTime)) this.env.timeOfDay = savedTime-- const savedWeather = await this.store.meta.get('env_weather')-- if (typeof savedWeather === 'string' && WEATHERS.has(savedWeather as WeatherKind)) {-- this.env.weather = savedWeather as WeatherKind-- }-- for (const { key, value } of await this.store.meta.list('market_')) {-- const market = new Map<string, { stock: number; at: number }>()-- if (value && typeof value === 'object') {-- for (const [item, state] of Object.entries(-- value as Record<string, { stock: number; at: number }>,-- )) {-- market.set(item, state)-- }-- }-- this.marketStock.set(key.slice('market_'.length), market)-- }-- await this.npcs.seedOrRestore(this.store)++ await this.systems.environment.load()++ await this.systems.economy.load()++ await this.systems.npcs.load()}get currentTick(): number {-- return this.tick-- }---- /**-- * Prop protection: world props (no owner) are free; otherwise the owner-- * or anyone the OWNER trusts may manipulate. Works for offline owners via-- * a TTL-cached repository lookup.-- */-- private canManipulate(session: PlayerSession, entity: GameEntity): boolean {-- if (entity.owner === undefined) return true-- if (entity.owner === session.playerId) return true-- const ownerSession = this.sessions.get(entity.owner)-- if (ownerSession) return ownerSession.friends.has(session.playerId)-- const cached = this.offlineFriendsCache.get(entity.owner)-- if (cached && Date.now() - cached.at < 30_000) {-- return cached.friends.has(session.playerId)-- }-- // Offline owner, no cached answer: start an async load and DENY this tick. The tick never awaits-- // I/O; the cached answer is used from the next tick on (the same 30 s TTL).-- const owner = entity.owner-- if (!this.friendsLoadInFlight.has(owner)) {-- this.friendsLoadInFlight.add(owner)-- void this.store.players-- .findById(owner)-- .then((found) => {-- this.offlineFriendsCache.set(owner, {-- friends: new Set(found?.friends ?? []),-- at: Date.now(),-- })-- })-- .catch((err: unknown) => {-- this.log.warn('offline owner lookup failed', { owner, error: String(err) })-- })-- .finally(() => this.friendsLoadInFlight.delete(owner))-- }-- return false-- }---- // ── Connection lifecycle ───────────────────────────────────────────---- onMessage(conn: GameConnection, msg: ClientMessage): void {-- const session = this.sessionsByConn.get(conn)-- if (!session) {-- if (msg.t === 'hello') {-- // A second hello while the first is still resolving (it awaits Network) is refused, so two-- // frames cannot both create a session for one connection.-- if (this.hellosInFlight.has(conn)) {-- conn.close(4008, 'hello_in_flight')-- return-- }-- void this.handleHello(conn, msg)-- } else conn.close(4001, 'hello_first')-- return-- }-- switch (msg.t) {-- case 'hello':-- break // duplicate hello ignored-- case 'input':-- if (session.inputQueue.length < MAX_INPUT_QUEUE) session.inputQueue.push(msg)-- break-- case 'attack': {-- // Swing cooldown: silently drop spam faster than ~5 swings/sec.-- if (this.tick - session.lastUseTick < 6) break-- const victimSession = this.sessionsByEntity.get(msg.target as EntityId)-- if (victimSession) {-- this.handleMelee(session, victimSession)-- break-- }-- const propTarget = this.world.entities.get(msg.target as EntityId)-- if (propTarget?.prop) {-- this.handlePropAttack(session, propTarget)-- break-- }-- if (propTarget?.kind === 'npc') {-- this.handleNpcAttack(session, propTarget)-- break-- }-- this.send(session, { t: 'result', action: 'attack', ok: false, error: 'no_target' })-- break-- }-- case 'fire': {-- this.handleFire(session)-- break-- }-- case 'reload': {-- const held = session.holstered ? null : session.inventory.get(session.activeHotbar)-- const spec = held ? this.world.content.item(held.defId)?.rangedWeapon : undefined-- if (!held || !spec) {-- this.send(session, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })-- break-- }-- const state = weaponStateFromMeta(held.meta)-- const available = session.inventory.countOf(spec.ammoItem)-- const took = startReload(state, spec, available, Date.now())-- if (took <= 0) {-- this.send(session, { t: 'result', action: 'attack', ok: false, error: 'no_ammo' })-- break-- }-- session.inventory.consume([{ item: spec.ammoItem, count: took }])-- held.meta = weaponStateToMeta(state, held.meta)-- session.dirty = true-- this.send(session, { t: 'result', action: 'attack', ok: true })-- this.sendInventory(session)-- break-- }-- case 'equip_armor': {-- if (msg.slot === undefined) {-- // Unequip into the first free inventory slot.-- if (!session.armor) {-- this.send(session, { t: 'result', action: 'inv_move', ok: false, error: 'no_armor' })-- break-- }-- if (!session.inventory.addStack(session.armor)) {-- this.send(session, {-- t: 'result',-- action: 'inv_move',-- ok: false,-- error: 'inventory_full',-- })-- break-- }-- session.armor = null-- session.dirty = true-- this.send(session, { t: 'result', action: 'inv_move', ok: true })-- this.sendInventory(session)-- break-- }-- const stack = session.inventory.get(msg.slot)-- const armorDef = stack ? this.world.content.item(stack.defId)?.armor : undefined-- if (!stack || !armorDef) {-- this.send(session, { t: 'result', action: 'inv_move', ok: false, error: 'not_armor' })-- break-- }-- const removed = session.inventory.removeFromSlot(msg.slot, 1)-- if (!removed) break-- // Fresh pieces get their full durability stamped into the stack.-- if (removed.meta?.dur === undefined) {-- removed.meta = { ...removed.meta, dur: armorDef.durability }-- }-- const previous = session.armor-- session.armor = removed-- if (previous) session.inventory.addStack(previous)-- session.dirty = true-- this.send(session, { t: 'result', action: 'inv_move', ok: true })-- this.sendInventory(session)-- break-- }-- case 'use': {-- // Swing cooldown: silently drop spam faster than ~5 swings/sec.-- if (this.tick - session.lastUseTick < 6) break-- const targetSession = this.sessionsByEntity.get(msg.target as EntityId)-- if (targetSession) {-- // Legacy path: melee also arrives as 'use' from older flows.-- this.handleMelee(session, targetSession)-- break-- }-- const vehicleTarget = this.world.entities.get(msg.target as EntityId)-- if (-- vehicleTarget?.prop &&-- this.world.content.item(vehicleTarget.prop.defId)?.vehiclePart?.part === 'chassis'-- ) {-- this.handleVehicleUse(session, vehicleTarget)-- break-- }-- const gather = handleUse(-- session,-- this.world,-- msg,-- Date.now(),-- (e) => this.canManipulate(session, e),-- {-- nowMs: Date.now(),-- raining: precipitation(this.env) > 0,-- ambientC: ambientTemperature(this.env),-- },-- )-- this.send(session, gather.outcome)-- if (!gather.outcome.ok) break-- session.lastUseTick = this.tick-- this.sendInventory(session)-- if (gather.xpChanged) this.sendSkills(session)-- for (const up of gather.levelUps) {-- this.send(session, { t: 'levelup', skill: up.skill, level: up.level })-- }-- if (gather.pickedUp) {-- if (session.held?.entityId === gather.pickedUp.id) this.releaseHeld(session)-- this.broadcastDespawn(gather.pickedUp.id)-- }-- if (gather.spawned) this.broadcastSpawn(gather.spawned)-- if (gather.changed?.prop) {-- // Door swing / plant change / repair: pin authoritative state.-- const e = gather.changed-- const plant = e.prop?.plant-- const max = this.world.content.item(e.prop!.defId)?.health?.max-- this.broadcastToKnowing(e.id, {-- t: 'entity',-- id: e.id,-- pos: [e.transform.pos.x, e.transform.pos.y, e.transform.pos.z],-- rot: [e.transform.rot.x, e.transform.rot.y, e.transform.rot.z, e.transform.rot.w],-- plant: (() => {-- const crop = plant ? this.world.content.crop(plant.crop) : undefined-- return plant && crop-- ? { crop: plant.crop, t: plantProgress(plant, crop), water: plant.water }-- : null-- })(),-- ...(max !== undefined ? { health: Math.round(e.prop!.health ?? max) } : {}),-- })-- }-- if (gather.changed?.resource) {-- this.broadcastToKnowing(gather.changed.id, {-- t: 'entity',-- id: gather.changed.id,-- remaining: gather.changed.resource.remaining,-- })-- }-- break-- }-- case 'trust': {-- void this.handleTrust(session, msg.player, msg.trusted)-- break-- }-- case 'constraint': {-- const { outcome, created } = handleConstraint(session, this.world, msg, (e) =>-- this.canManipulate(session, e),-- )-- this.send(session, outcome)-- if (created) {-- this.broadcastConstraintState(created, true)-- this.sendSkills(session)-- // Rope/spring/motor constraints consume materials.-- this.sendInventory(session)-- }-- break-- }-- case 'constraint_remove': {-- const tool = equippedTool(session)-- if (tool?.kind !== 'rigging') {-- this.send(session, {-- t: 'result',-- action: 'constraint',-- ok: false,-- error: 'requires_rigging_tool',-- })-- break-- }-- const target = this.world.entities.get(msg.target as EntityId)-- if (!target?.prop) {-- this.send(session, { t: 'result', action: 'constraint', ok: false, error: 'no_target' })-- break-- }-- if (!this.canManipulate(session, target)) {-- this.send(session, { t: 'result', action: 'constraint', ok: false, error: 'not_owner' })-- break-- }-- eyePosition(session, _eyeScratch)-- if (v3dist(_eyeScratch, target.transform.pos) > (tool.range ?? 6) + 1.5) {-- this.send(session, {-- t: 'result',-- action: 'constraint',-- ok: false,-- error: 'out_of_range',-- })-- break-- }-- const removed = this.world.removeConstraintsFor(target.id)-- this.send(session, {-- t: 'result',-- action: 'constraint',-- ok: removed.length > 0,-- ...(removed.length === 0 ? { error: 'no_constraints' } : {}),-- })-- for (const rec of removed) this.broadcastConstraintState(rec, false)-- break-- }-- case 'craft': {-- const outcome = handleCraft(session, this.world, msg, this.tick, this.config.tickRate)-- this.send(session, outcome)-- if (outcome.ok) {-- this.sendInventory(session)-- this.sendCraftState(session)-- }-- break-- }-- case 'drop': {-- const { outcome, droppedId } = handleDrop(session, this.world, msg)-- this.send(session, outcome)-- if (outcome.ok) {-- this.sendInventory(session)-- if (droppedId) {-- const entity = this.world.entities.get(droppedId as EntityId)-- if (entity) this.broadcastSpawn(entity)-- }-- }-- break-- }-- case 'place': {-- const { outcome, placedId } = handlePlace(session, this.world, msg)-- this.send(session, outcome)-- if (outcome.ok) {-- this.sendInventory(session)-- if (placedId) {-- const entity = this.world.entities.get(placedId as EntityId)-- if (entity) this.broadcastSpawn(entity)-- }-- }-- break-- }-- case 'inv_move': {-- const outcome = handleInvMove(session, msg)-- this.send(session, outcome)-- this.sendInventory(session)-- break-- }-- case 'hotbar':-- if (msg.slot < HOTBAR_SIZE) {-- // Re-pressing the active slot holsters/unholsters (empty hands).-- if (msg.slot === session.activeHotbar) {-- session.holstered = !session.holstered-- } else {-- session.activeHotbar = msg.slot-- session.holstered = false-- }-- // Switching away from the physgun drops beam and anything held.-- if (equippedTool(session)?.kind !== 'physgun') {-- session.grabbing = false-- if (session.held) this.releaseHeld(session)-- }-- }-- break-- case 'editmode': {-- // Rank-gated noclip build mode. The flag lives in the move state so-- // server sim and client prediction stay in lockstep via the normal-- // self-state replication path.-- if (canEditMap(session.rank)) {-- session.move.noclip = msg.on-- session.move.vel.x = 0-- session.move.vel.y = 0-- session.move.vel.z = 0-- this.send(session, {-- t: 'announce',-- text: msg.on ? '🛠 Edit mode ON — noclip flight' : '🛠 Edit mode OFF',-- })-- }-- break-- }-- case 'physgun':-- this.handlePhysgun(session, msg)-- break-- case 'drink': {-- if (this.tick - session.lastUseTick < 15) break-- const ground = terrainHeight(-- this.world.content.world,-- session.move.pos.x,-- session.move.pos.z,-- )-- if (ground > WATER_LEVEL - 0.03) {-- this.send(session, { t: 'result', action: 'consume', ok: false, error: 'no_water' })-- break-- }-- session.lastUseTick = this.tick-- // A held watering can fills instead of drinking.-- const held = session.holstered ? null : session.inventory.get(session.activeHotbar)-- const fluidCap = held ? this.world.content.item(held.defId)?.fluidContainer : undefined-- if (held && fluidCap) {-- held.meta = { ...held.meta, fluid: fluidCap.capacity }-- session.dirty = true-- this.send(session, { t: 'result', action: 'consume', ok: true })-- this.sendInventory(session)-- break-- }-- session.stats.thirst = Math.min(100, session.stats.thirst + 30)-- session.statsDirty = true-- session.dirty = true-- this.send(session, { t: 'result', action: 'consume', ok: true })-- this.send(session, { t: 'stats', ...this.statsWire(session) })-- session.statsDirty = false-- break-- }-- case 'consume': {-- const stack = session.inventory.get(msg.slot)-- const def = stack ? this.world.content.item(stack.defId) : undefined-- // Blueprints: using one permanently unlocks its recipe.-- if (stack && def?.blueprint) {-- if (session.unlocks.has(def.blueprint.recipe)) {-- this.send(session, {-- t: 'result',-- action: 'consume',-- ok: false,-- error: 'already_known',-- })-- break-- }-- session.inventory.removeFromSlot(msg.slot, 1)-- session.unlocks.add(def.blueprint.recipe)-- session.dirty = true-- const recipeName = this.world.content.recipe(def.blueprint.recipe)?.name ?? 'recipe'-- this.send(session, { t: 'result', action: 'consume', ok: true })-- this.send(session, { t: 'announce', text: `📜 Blueprint learned: ${recipeName}!` })-- this.sendInventory(session)-- this.sendSkills(session)-- break-- }-- if (!stack || (!def?.food && !def?.medical)) {-- this.send(session, { t: 'result', action: 'consume', ok: false, error: 'not_food' })-- break-- }-- session.inventory.removeFromSlot(msg.slot, 1)-- if (def.food) eat(session.stats, def.food, Date.now())-- if (def.medical) {-- session.stats.health = Math.min(100, session.stats.health + def.medical.heal)-- if (def.medical.curesBleeding) clearStatus(session.stats, 'bleeding')-- }-- session.statsDirty = true-- session.dirty = true-- this.send(session, { t: 'result', action: 'consume', ok: true })-- this.send(session, { t: 'stats', ...this.statsWire(session) })-- session.statsDirty = false-- this.sendInventory(session)-- break-- }-- case 'market_open':-- case 'market_buy':-- case 'market_sell': {-- this.handleMarket(session, msg)-- break-- }-- case 'job_accept':-- case 'job_turnin': {-- this.handleJob(session, msg)-- break-- }-- case 'container_open': {-- const entity = this.world.entities.get(msg.target as EntityId)-- const denied = this.containerAccessDenied(session, entity)-- if (denied || !entity?.prop?.container) {-- this.send(session, {-- t: 'result',-- action: 'container',-- ok: false,-- error: denied ?? 'no_container',-- })-- break-- }-- session.openContainer = entity.id-- this.sendContainer(session, entity)-- break-- }-- case 'container_move': {-- const entity = this.world.entities.get(msg.target as EntityId)-- const denied = this.containerAccessDenied(session, entity)-- const box = entity?.prop?.container-- if (denied || !entity || !box) {-- this.send(session, {-- t: 'result',-- action: 'container',-- ok: false,-- error: denied ?? 'no_container',-- })-- break-- }-- const maxStackOf = (defId: string) => this.world.content.item(defId)?.maxStack ?? 1-- let ok = false-- if (msg.dir === 'in') {-- const stack = session.inventory.get(msg.slot)-- if (stack) {-- const want = msg.count !== undefined ? Math.min(msg.count, stack.count) : stack.count-- const moved = containerAdd(box, stack.defId, want, maxStackOf)-- if (moved > 0) {-- session.inventory.removeFromSlot(msg.slot, moved)-- ok = true-- }-- }-- } else {-- const slot = box[msg.slot]-- if (slot) {-- const want = msg.count !== undefined ? Math.min(msg.count, slot.count) : slot.count-- const leftover = session.inventory.add(slot.defId, want)-- const moved = want - leftover-- if (moved > 0) {-- slot.count -= moved-- if (slot.count <= 0) box[msg.slot] = null-- ok = true-- }-- }-- }-- if (ok) {-- entity.dirty = true-- session.dirty = true-- this.sendInventory(session)-- // Push fresh contents to EVERYONE with this container open.-- for (const other of this.sessions.values()) {-- if (other.openContainer === entity.id) this.sendContainer(other, entity)-- }-- } else {-- this.send(session, { t: 'result', action: 'container', ok: false, error: 'no_space' })-- }-- break-- }-- case 'container_sort': {-- const entity = this.world.entities.get(msg.target as EntityId)-- const denied = this.containerAccessDenied(session, entity)-- const box = entity?.prop?.container-- if (denied || !entity || !box) {-- this.send(session, {-- t: 'result',-- action: 'container',-- ok: false,-- error: denied ?? 'no_container',-- })-- break-- }-- containerSort(box, (defId) => this.world.content.item(defId)?.maxStack ?? 1)-- entity.dirty = true-- this.send(session, { t: 'result', action: 'container', ok: true })-- for (const other of this.sessions.values()) {-- if (other.openContainer === entity.id) this.sendContainer(other, entity)-- }-- break-- }-- }++ return this.ctx.clock.tick}-- // ── Markets ────────────────────────────────────────────────────────---- /** Live sell-stock per market: item -> remaining + next restock time. */-- private readonly marketStock = new Map<string, Map<string, { stock: number; at: number }>>()---- /** Lazily restocked stock entry for one market sell line. */-- private stockOf(-- marketId: string,-- entry: { item: string; stock: number; restockSeconds: number },-- nowMs: number,-- ): { stock: number; at: number } {-- let market = this.marketStock.get(marketId)-- if (!market) {-- // Persisted market blobs are loaded at boot (GameServer.load); an unknown market starts fresh.-- market = new Map()-- this.marketStock.set(marketId, market)-- }-- let state = market.get(entry.item)-- if (!state) {-- state = { stock: entry.stock, at: nowMs + entry.restockSeconds * 1000 }-- market.set(entry.item, state)-- }-- if (nowMs >= state.at) {-- state.stock = entry.stock-- state.at = nowMs + entry.restockSeconds * 1000-- }-- return state-- }---- /**-- * Market interactions: open/buy/sell against the market a shop prop-- * references. Everything server-atomic: proximity, faction stance,-- * live stock, coins and space all validated here.-- */-- private handleMarket(-- session: PlayerSession,-- msg: { t: string; target: string; item?: string },-- ): void {-- const deny = (error: string) =>-- this.send(session, { t: 'result', action: 'trade', ok: false, error })-- const entity = this.world.entities.get(msg.target as EntityId)-- const marketId = entity?.prop-- ? this.world.content.item(entity.prop.defId)?.shop?.market-- : undefined-- const market = marketId ? this.world.content.market(marketId) : undefined-- if (!entity || !market) return deny('no_merchant')-- const d = Math.hypot(-- entity.transform.pos.x - session.move.pos.x,-- entity.transform.pos.z - session.move.pos.z,-- )-- if (d > 5) return deny('out_of_range')-- const faction = this.world.content.faction(market.faction)-- const stance = faction ? stanceToward(faction, session.reputation) : 'neutral'-- if (stance === 'hostile') return deny('they_hate_you')-- const nowMs = Date.now()---- if (msg.t === 'market_buy' && msg.item) {-- const entry = market.sells.find((s) => s.item === msg.item)-- if (!entry) return deny('no_such_trade')-- const state = this.stockOf(market.id, entry, nowMs)-- if (state.stock <= 0) return deny('out_of_stock')-- if (session.inventory.countOf('coin') < entry.price) return deny('missing_coins')-- if (!session.inventory.canFit(entry.item, entry.count)) return deny('inventory_full')-- const paid = session.inventory.consume([{ item: 'coin', count: entry.price }])-- if (!paid.ok) return deny('missing_coins')-- session.inventory.add(entry.item, entry.count)-- state.stock -= 1-- addReputation(session.reputation, market.faction, REP_DELTAS.trade)-- session.dirty = true-- this.send(session, { t: 'result', action: 'trade', ok: true })-- this.sendInventory(session)-- this.sendReputation(session)-- } else if (msg.t === 'market_sell' && msg.item) {-- const entry = market.buys.find((b) => b.item === msg.item)-- if (!entry) return deny('no_such_trade')-- if (session.inventory.countOf(entry.item) < entry.count) return deny('missing_items')-- if (!session.inventory.canFit('coin', entry.price)) return deny('inventory_full')-- const taken = session.inventory.consume([{ item: entry.item, count: entry.count }])-- if (!taken.ok) return deny('missing_items')-- session.inventory.add('coin', entry.price)-- addReputation(session.reputation, market.faction, REP_DELTAS.trade)-- session.dirty = true-- this.send(session, { t: 'result', action: 'trade', ok: true })-- this.sendInventory(session)-- this.sendReputation(session)-- }-- // Every path (including plain open) refreshes the market + contracts.-- this.sendJobs(session, market.id)-- this.send(session, {-- t: 'market',-- id: market.id,-- name: market.name,-- stance,-- sells: market.sells.map((s) => ({-- item: s.item,-- count: s.count,-- price: s.price,-- stock: this.stockOf(market.id, s, nowMs).stock,-- })),-- buys: market.buys.map((b) => ({ item: b.item, count: b.count, price: b.price })),-- })-- }---- /** Contract accept/turn-in at a trading post. */-- private handleJob(-- session: PlayerSession,-- msg: { t: string; target: string; job?: string },-- ): void {-- const deny = (error: string) =>-- this.send(session, { t: 'result', action: 'trade', ok: false, error })-- const entity = this.world.entities.get(msg.target as EntityId)-- const marketId = entity?.prop-- ? this.world.content.item(entity.prop.defId)?.shop?.market-- : undefined-- if (!entity || !marketId) return deny('no_merchant')-- const d = Math.hypot(-- entity.transform.pos.x - session.move.pos.x,-- entity.transform.pos.z - session.move.pos.z,-- )-- if (d > 5) return deny('out_of_range')---- if (msg.t === 'job_accept' && msg.job) {-- const job = this.world.content.job(msg.job)-- if (!job || job.market !== marketId) return deny('no_such_job')-- if (session.activeJob) return deny('job_in_progress')-- session.activeJob = { job: job.id, progress: 0 }-- session.dirty = true-- this.send(session, { t: 'result', action: 'trade', ok: true })-- this.send(session, { t: 'announce', text: `📋 Contract accepted: ${job.name}` })-- } else if (msg.t === 'job_turnin') {-- const job = session.activeJob ? this.world.content.job(session.activeJob.job) : undefined-- if (!session.activeJob || !job) return deny('no_active_job')-- if (job.market !== marketId) return deny('wrong_merchant')-- if (job.objective.kind === 'deliver') {-- const need = { item: job.objective.item, count: job.objective.count }-- if (session.inventory.countOf(need.item) < need.count) return deny('missing_items')-- const taken = session.inventory.consume([need])-- if (!taken.ok) return deny('missing_items')-- } else if (session.activeJob.progress < job.objective.count) {-- return deny('not_finished')-- }-- // Rewards: coins, reputation, items, XP — all atomic-enough (coins-- // and items overflow to the floor is prevented by canFit pre-check).-- if (job.reward.coins > 0 && !session.inventory.canFit('coin', job.reward.coins)) {-- return deny('inventory_full')-- }-- if (job.reward.coins > 0) session.inventory.add('coin', job.reward.coins)-- for (const it of job.reward.items) session.inventory.add(it.item, it.count)-- const market = this.world.content.market(marketId)-- if (job.reward.reputation !== 0 && market) {-- addReputation(session.reputation, market.faction, job.reward.reputation)-- this.sendReputation(session)-- }-- if (job.reward.xp) {-- const ups = session.skills.addXp(job.reward.xp.skill, job.reward.xp.amount)-- for (const up of ups) this.send(session, { t: 'levelup', skill: up.skill, level: up.level })-- this.sendSkills(session)-- }-- session.activeJob = null-- session.dirty = true-- this.send(session, { t: 'result', action: 'trade', ok: true })-- this.send(session, { t: 'announce', text: `✅ Contract complete: ${job.name}` })-- this.sendInventory(session)-- }-- this.sendJobs(session, marketId)-- }---- private sendJobs(session: PlayerSession, marketId: string): void {-- const active = session.activeJob-- ? (() => {-- const job = this.world.content.job(session.activeJob!.job)-- if (!job) return null-- const goal = job.objective.count-- const progress =-- job.objective.kind === 'deliver'-- ? Math.min(goal, session.inventory.countOf(job.objective.item))-- : session.activeJob!.progress-- return {-- job: job.id,-- name: job.name,-- progress,-- goal,-- ready: progress >= goal,-- }-- })()-- : null-- this.send(session, {-- t: 'jobs',-- market: marketId,-- available: this.world.content.jobsForMarket(marketId).map((j) => ({-- id: j.id,-- name: j.name,-- description: j.description,-- done: false,-- })),-- active,-- })-- }---- private sendReputation(session: PlayerSession): void {-- this.send(session, {-- t: 'reputation',-- factions: this.world.content.allFactions().map((f) => ({-- id: f.id,-- name: f.name,-- value: Math.round(session.reputation[f.id] ?? 0),-- stance: stanceToward(f, session.reputation),-- })),-- })-- }---- /** Range + prop-protection gate shared by all container operations. */-- private containerAccessDenied(-- session: PlayerSession,-- entity: GameEntity | undefined,-- ): string | null {-- if (!entity?.prop) return 'no_target'-- const d = Math.hypot(-- entity.transform.pos.x - session.move.pos.x,-- entity.transform.pos.y - session.move.pos.y,-- entity.transform.pos.z - session.move.pos.z,-- )-- if (d > 4.5) return 'out_of_range'-- if (!this.canManipulate(session, entity)) return 'not_owner'-- return null-- }---- private sendContainer(session: PlayerSession, entity: GameEntity): void {-- const box = entity.prop?.container ?? []-- this.send(session, {-- t: 'container',-- id: entity.id,-- size: box.length,-- slots: box.flatMap((slot, i) => (slot ? [{ i, def: slot.defId, count: slot.count }] : [])),-- })-- }---- private timeWire(): ServerMessage {-- return { t: 'time', frac: this.env.timeOfDay, weather: this.env.weather }++ /** Players connected now (the readiness `sessions` check; a restart would disconnect them). */++ onlineCount(): number {++ return this.systems.sessions.onlineCount()}/**-- * Utility + production sweep, once a second over prop entities. Plants-- * stay timestamp-lazy (a slower cadence touches them); machines and-- * generators are few, and their jobs are timestamp-based too — this-- * sweep only notices completions/starts, it never simulates.++ * The client message types this server dispatches, one handler each. Diagnostics, and the++ * handler-table test that pins it against the protocol.*/-- private tickProduction(nowMs: number, raining: boolean, ambientC: number): void {-- const content = this.world.content-- const maxStackOf = (defId: string) => content.item(defId)?.maxStack ?? 1-- // Pass 1: generators burn fuel; remember active ones for power checks.-- const activePower: { x: number; z: number; radius: number }[] = []-- for (const entity of this.world.entities.ofKind('prop')) {-- if (!entity.prop) continue-- const def = content.item(entity.prop.defId)-- if (!def?.powerProducer) continue-- if ((entity.prop.burnUntil ?? 0) <= nowMs && entity.prop.container) {-- // Reload: burn the first fuel item in the hopper.-- for (let i = 0; i < entity.prop.container.length; i++) {-- const slot = entity.prop.container[i]-- const fuel = slot ? content.item(slot.defId)?.fuel : undefined-- if (!slot || !fuel) continue-- slot.count -= 1-- if (slot.count <= 0) entity.prop.container[i] = null-- entity.prop.burnUntil =-- Math.max(nowMs, entity.prop.burnUntil ?? 0) + fuel.burnSeconds * 1000-- entity.dirty = true-- for (const other of this.sessions.values()) {-- if (other.openContainer === entity.id) this.sendContainer(other, entity)-- }-- break-- }-- }-- if ((entity.prop.burnUntil ?? 0) > nowMs) {-- activePower.push({-- x: entity.transform.pos.x,-- z: entity.transform.pos.z,-- radius: def.powerProducer.radius,-- })-- }-- }-- const powered = (x: number, z: number): boolean =>-- activePower.some((p) => Math.hypot(p.x - x, p.z - z) <= p.radius)---- // Pass 2: machines complete/start jobs (timestamp-based).-- for (const entity of this.world.entities.ofKind('prop')) {-- if (!entity.prop?.container) continue-- const def = content.item(entity.prop.defId)-- const machine = def?.machine-- if (!machine) continue-- entity.prop.machine ??= {}-- const shape = { inputSlots: machine.inputSlots, outputSlots: machine.outputSlots }-- let changed = false-- if (-- completeJob(-- entity.prop.machine,-- entity.prop.container,-- shape,-- (id) => content.recipe(id),-- maxStackOf,-- nowMs,-- )-- ) {-- changed = true-- }-- const hasPower =-- !machine.needsPower || powered(entity.transform.pos.x, entity.transform.pos.z)-- if (-- hasPower &&-- tryStartJob(-- entity.prop.machine,-- entity.prop.container,-- shape,-- content.machineRecipes(machine.kind),-- nowMs,-- )-- ) {-- changed = true-- }-- if (changed) {-- entity.dirty = true-- for (const other of this.sessions.values()) {-- if (other.openContainer === entity.id) this.sendContainer(other, entity)-- }-- }-- }---- // Pass 3 (every 5s): tanks catch rain; sprinklers water nearby planters.-- if (this.tick % (this.config.tickRate * 5) === 0) {-- const tanks: { entity: GameEntity; capacity: number }[] = []-- for (const entity of this.world.entities.ofKind('prop')) {-- const cap = entity.prop ? content.item(entity.prop.defId)?.waterTank : undefined-- if (!entity.prop || !cap) continue-- if (raining && (entity.prop.waterAmount ?? 0) < cap.capacity) {-- entity.prop.waterAmount = Math.min(cap.capacity, (entity.prop.waterAmount ?? 0) + 2.5)-- entity.dirty = true-- }-- tanks.push({ entity, capacity: cap.capacity })-- }-- for (const entity of this.world.entities.ofKind('prop')) {-- const spr = entity.prop ? content.item(entity.prop.defId)?.sprinkler : undefined-- if (!entity.prop || !spr) continue-- const tank = tanks.find(-- (t) =>-- (t.entity.prop!.waterAmount ?? 0) >= 0.5 &&-- Math.hypot(-- t.entity.transform.pos.x - entity.transform.pos.x,-- t.entity.transform.pos.z - entity.transform.pos.z,-- ) <= spr.tankRange,-- )-- if (!tank) continue-- // Planters via the spatial hash (no all-props walk per sprinkler).-- this.world.spatial.forEachInRadius(-- entity.transform.pos.x,-- entity.transform.pos.z,-- spr.radius,-- (id) => {-- const planter = this.world.entities.get(id)-- const plant = planter?.prop?.plant-- if (!planter || !plant) return-- if (-- Math.hypot(-- planter.transform.pos.x - entity.transform.pos.x,-- planter.transform.pos.z - entity.transform.pos.z,-- ) > spr.radius-- ) {-- return-- }-- const crop = content.crop(plant.crop)-- if (!crop) return-- updatePlant(plant, crop, { nowMs, raining, ambientC })-- if ((tank.entity.prop!.waterAmount ?? 0) >= 0.5 && waterPlant(plant)) {-- tank.entity.prop!.waterAmount = (tank.entity.prop!.waterAmount ?? 0) - 0.5-- tank.entity.dirty = true-- planter.dirty = true-- }-- },-- )-- }-- }---- // Pass 4 (every 15s): lazy plant advance + growth-stage broadcasts.-- if (this.tick % (this.config.tickRate * 15) !== 0) return-- for (const entity of this.world.entities.ofKind('prop')) {-- const plant = entity.prop?.plant-- if (!plant) continue-- const crop = content.crop(plant.crop)-- if (!crop) continue-- const beforeStage = plantStage(plant, crop)-- const beforeWater = plant.water-- updatePlant(plant, crop, { nowMs, raining, ambientC })-- entity.dirty = true-- const stageChanged = plantStage(plant, crop) !== beforeStage-- const driedOut = beforeWater > 0 && plant.water <= 0-- if (stageChanged || driedOut) {-- this.broadcastToKnowing(entity.id, {-- t: 'entity',-- id: entity.id,-- plant: { crop: plant.crop, t: plantProgress(plant, crop), water: plant.water },-- })-- }-- }-- }---- onDisconnect(conn: GameConnection): void {-- const session = this.sessionsByConn.get(conn)-- if (!session) return-- this.sessionsByConn.delete(conn)-- this.sessions.delete(session.playerId)-- this.sessionsByEntity.delete(session.entityId)-- // Keep protection checks fresh once the owner goes offline.-- this.offlineFriendsCache.set(session.playerId as string, {-- friends: new Set(session.friends),-- at: Date.now(),-- })-- if (session.held) {-- this.heldEntityIds.delete(session.held.entityId)-- session.held = null-- }-- const bodyId = this.playerBodies.get(session.playerId)-- if (bodyId !== undefined) {-- this.world.physics.removeBody(bodyId)-- this.playerBodies.delete(session.playerId)-- }-- this.world.entities.remove(session.entityId)-- this.world.spatial.remove(session.entityId)-- this.savePlayer(session, true)-- void this.integrations.progressSummary?.playerLeft(-- session.playerId as string,-- session.subjectId,-- sessionProgress(session).levels,-- this.world.content.world.id,-- )-- this.broadcastDespawn(session.entityId)-- this.metrics.sessions = this.sessions.size-- this.log.info('player disconnected', { playerId: session.playerId, name: session.name })++ get messageTypes(): readonly string[] {++ return [...this.handlers.keys()]}-- /** Guards one hello per connection while it resolves (it awaits Network); see onMessage. */-- private async handleHello(conn: GameConnection, msg: ClientHello): Promise<void> {-- this.hellosInFlight.add(conn)-- try {-- await this.resolveHello(conn, msg)-- } finally {-- this.hellosInFlight.delete(conn)-- }-- }---- private async resolveHello(conn: GameConnection, msg: ClientHello): Promise<void> {-- if (msg.v !== PROTOCOL_VERSION) {-- conn.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))-- conn.close(4002, 'protocol_mismatch')-- return-- }-- if (this.sessions.size >= this.config.maxPlayers) {-- conn.send(encodeServerMessage({ t: 'reject', reason: 'server_full' }))-- conn.close(4003, 'server_full')-- return-- }---- const slot = msg.slot ?? 0-- // Account resolution. openvibe.network sign-in keys the account on the-- // canonical subject (3 slots, follows you across devices). Guests get ONE-- // character bound to their connection's browser token.-- let token = msg.token-- let subjectId: string | null = null-- let authIat: number | null = null-- let rank: 'owner' | 'admin' | 'moderator' | null = null-- if (msg.auth) {-- const user = await resolveNetworkUser(this.config.networkAuthUrl, msg.auth)-- // A signed-in account is keyed by its canonical subject (ADR-0006); a-- // token that carries none cannot name an account.-- if (!user || !user.subjectId) {-- conn.send(encodeServerMessage({ t: 'reject', reason: 'auth_failed' }))-- conn.close(4009, 'auth_failed')-- return-- }-- token = user.subjectId-- subjectId = user.subjectId-- authIat = tokenIat(msg.auth)-- rank = user.rank-- // Guest conversion (WS-B task 8): the character this browser played as a guest joins the-- // account in its first free slot, once.-- if (isGuestToken(msg.token)) {-- const g = await this.store.identity.adoptGuestCharacter(msg.token, subjectId, Date.now())-- if (g.moved > 0)-- this.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })-- else if (g.full)-- this.log.info('guest character kept: account slots full', { subject: subjectId })-- }-- } else {-- // A guest token may never look like an account key (`usr_…`): that-- // would open someone else's characters. The client mints plain-- // alphanumerics; anything key-shaped is refused.-- if (!isGuestToken(msg.token)) {-- conn.send(encodeServerMessage({ t: 'reject', reason: 'invalid_hello' }))-- conn.close(4007, 'invalid_guest_token')-- return-- }-- if (slot > 0) {-- conn.send(encodeServerMessage({ t: 'reject', reason: 'guest_one_character' }))-- conn.close(4010, 'guest_one_character')-- return-- }-- }-- // A disconnect save of this character may still be queued: read after it lands, not before.-- await this.flusher.drain(5000)-- const existing = await this.store.players.findByTokenSlot(token, slot)-- // One live session per CHARACTER; other characters of the same account-- // may stay online (an account still only plays one at a time in-- // practice — same token kicks apply per slot).-- for (const s of this.sessions.values()) {-- if (s.token === token && s.charSlot === slot) {-- s.closeConnection(4004, 'session_superseded')-- }-- }---- const world = this.world.content.world-- const playerId = existing ? asPlayerId(existing.id) : newPlayerId()-- const mapSpawn = worldSpawn(world)-- const spawn = existing-- ? vec3(existing.pos[0], existing.pos[1], existing.pos[2])-- : vec3(mapSpawn.pos[0], mapSpawn.pos[1], mapSpawn.pos[2])-- const inventory = existing-- ? Inventory.fromDto(existing.inventory, this.world.content)-- : new Inventory(INVENTORY_SIZE, HOTBAR_SIZE, this.world.content)-- const skills = existing-- ? SkillSet.fromDto(existing.skills, this.world.content)-- : new SkillSet(this.world.content)-- const friends = new Set(existing?.friends ?? [])-- // The client's customization is authoritative for looks (validated by-- // the protocol schema) — EXCEPT body size: everyone shares one hull and-- // silhouette so combat stays fair.-- const appearance = { ...msg.appearance, height: 1, build: 1 }---- // Starter kit: every scrapper carries a physgun. Also grants it to-- // players from before the tool system existed.-- for (const grant of STARTER_ITEMS) {-- if (inventory.countOf(grant.item) === 0) {-- const leftover = inventory.add(grant.item, grant.count)-- if (leftover > 0) {-- this.log.warn('starter item did not fit', { item: grant.item, playerId })-- }-- }-- }---- const session = createSession({-- playerId,-- charSlot: slot,-- entityId: newEntityId(),-- token,-- subjectId,-- authIat,-- rank,-- name: msg.name,-- spawn,-- yaw: existing?.yaw ?? mapSpawn.yaw,-- inventory,-- skills,-- friends,-- appearance,-- stats: existing?.stats ?? undefined,-- armor: existing?.armor ?? null,-- reputation: existing?.reputation ?? {},-- unlocks: existing?.unlocks ?? [],-- activeJob: existing?.activeJob ?? null,-- content: this.world.content,-- send: (text) => conn.send(text),-- closeConnection: (code, reason) => conn.close(code, reason),-- })-- this.sessions.set(playerId, session)-- this.sessionsByConn.set(conn, session)-- this.sessionsByEntity.set(session.entityId, session)-- this.offlineFriendsCache.delete(playerId as string)---- // Player entity (transient — players persist via the player repository).-- const entity: GameEntity = {-- id: session.entityId,-- kind: 'player',-- transform: { pos: session.move.pos, rot: qfromYaw(quat(), session.yaw) },-- persistent: false,-- dirty: false,-- }-- this.world.entities.add(entity)-- this.world.spatial.insert(entity.id, session.move.pos.x, session.move.pos.z)---- // Kinematic capsule so props collide with players. Shorter than the-- // movement hull and lifted off the feet: standing ON a prop must not-- // press it down (that caused sink/jitter loops when prop-surfing).-- const bodyId = this.world.physics.addBody({-- shape: { type: 'capsule', radius: MOVE.capsuleRadius, height: MOVE.capsuleHeight - 0.3 },-- motion: 'kinematic',-- pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),-- layer: CollisionLayer.Player,-- collidesWith: CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,-- })-- this.playerBodies.set(playerId, bodyId)---- this.send(session, {-- t: 'welcome',-- v: PROTOCOL_VERSION,-- rank,-- playerId: playerId as string,-- entityId: session.entityId as string,-- tick: this.tick,-- tickRate: this.config.tickRate,-- snapshotRate: this.config.tickRate / this.config.snapshotEvery,-- })-- this.sendInventory(session)-- this.sendSkills(session)-- await this.sendFriends(session)-- this.send(session, this.timeWire())-- this.sendReputation(session)-- this.metrics.sessions = this.sessions.size-- this.integrations.progressSummary?.playerJoined(session.playerId as string)-- const recorder = this.integrations.events-- if (recorder) {-- // Baseline = what the database holds for this character (nothing for a-- // new one), so the first save reports only what was earned since.-- const stored = existing-- ? progressOf(SkillSet.fromDto(existing.skills, this.world.content), existing.unlocks)-- : { levels: {}, unlocks: [] }-- await this.store.transaction(async (t) => {-- await recorder.recordJoin(t, eventPlayer(session), stored, existing !== null)-- })-- }-- this.log.info('player connected', {-- playerId: playerId as string,-- name: msg.name,-- restored: existing !== null,-- })-- }---- private handlePhysgun(session: PlayerSession, msg: ClientPhysgun): void {-- // Physgun actions require the physgun in the active hotbar slot.-- if ((msg.a === 'grab' || msg.a === 'unfreeze') && equippedTool(session)?.kind !== 'physgun') {-- this.send(session, {-- t: 'result',-- action: 'physgun',-- ok: false,-- error: 'no_physgun_equipped',-- })-- return-- }-- if (msg.a === 'grab') {-- if (session.held) return-- // Beam on: even with nothing under the crosshair, keep trying each-- // tick — sweeping the beam onto a prop picks it up (GMod behavior).-- session.grabbing = true-- const denied = this.attemptGrab(session)-- // Only meaningful denials are surfaced; an empty beam is not an error.-- if (denied && denied !== 'no_target') {-- this.send(session, { t: 'result', action: 'physgun', ok: false, error: denied })-- }-- } else if (msg.a === 'release') {-- session.grabbing = false-- this.releaseHeld(session)-- } else if (msg.a === 'adjust') {-- adjustDistance(session, msg.dist)-- } else if (msg.a === 'rotate') {-- rotateHeld(session, msg.dyaw, msg.dpitch, msg.snap ?? false, msg.snapStep)-- } else if (msg.a === 'grid') {-- if (session.held) {-- session.held.grid = msg.on-- if (msg.size !== undefined) session.held.gridSize = msg.size-- }-- } else if (msg.a === 'freeze') {-- // Freezing ends the beam — otherwise the sweep-to-grab retry would-- // immediately unfreeze what was just frozen.-- session.grabbing = false-- const frozen = freezeHeld(session, this.world)-- if (frozen) {-- this.heldEntityIds.delete(frozen.id)-- this.broadcastToKnowing(frozen.id, {-- t: 'entity',-- id: frozen.id,-- motion: 'frozen',-- pos: [frozen.transform.pos.x, frozen.transform.pos.y, frozen.transform.pos.z],-- rot: [-- frozen.transform.rot.x,-- frozen.transform.rot.y,-- frozen.transform.rot.z,-- frozen.transform.rot.w,-- ],-- })-- this.broadcastAll({ t: 'physgun_state', player: session.entityId, target: null })-- }-- } else if (msg.a === 'unfreeze') {-- const entity = this.world.entities.get(msg.target as EntityId)-- if (!entity?.prop || entity.prop.motion !== 'frozen') {-- this.send(session, { t: 'result', action: 'physgun', ok: false, error: 'no_target' })-- return-- }-- if (!this.world.zones.rulesAt(entity.transform.pos).physgun) {-- this.send(session, { t: 'result', action: 'physgun', ok: false, error: 'zone' })-- return-- }-- if (!this.canManipulate(session, entity)) {-- this.send(session, { t: 'result', action: 'physgun', ok: false, error: 'not_owner' })-- return-- }-- eyePosition(session, _eyeScratch)-- if (-- Math.hypot(-- entity.transform.pos.x - _eyeScratch.x,-- entity.transform.pos.y - _eyeScratch.y,-- entity.transform.pos.z - _eyeScratch.z,-- ) > 10-- ) {-- this.send(session, { t: 'result', action: 'physgun', ok: false, error: 'out_of_range' })-- return-- }-- this.world.setPropMotion(entity, 'dynamic')-- this.broadcastToKnowing(entity.id, { t: 'entity', id: entity.id, motion: 'dynamic' })-- }-- }---- private async handleTrust(-- session: PlayerSession,-- targetId: string,-- trusted: boolean,-- ): Promise<void> {-- if (targetId === (session.playerId as string)) {-- this.send(session, { t: 'result', action: 'trust', ok: false, error: 'self' })-- return-- }-- // Target must be a real player (online or persisted).-- const online = this.sessions.get(targetId as PlayerId)-- const known = online ?? (await this.store.players.findById(targetId))-- if (!known) {-- this.send(session, { t: 'result', action: 'trust', ok: false, error: 'unknown_player' })++ onMessage(conn: GameConnection, msg: ClientMessage): void {++ const session = this.ctx.sessions.getByConn(conn)++ // Everything but `hello` needs a session; anything else first is a protocol error.++ if (!session && msg.t !== 'hello') {++ conn.close(4001, 'hello_first')return}-- if (trusted) session.friends.add(targetId)-- else session.friends.delete(targetId)-- session.dirty = true-- this.send(session, { t: 'result', action: 'trust', ok: true })-- await this.sendFriends(session)-- }---- private async sendFriends(session: PlayerSession): Promise<void> {-- const friends: { id: string; name: string }[] = []-- for (const id of session.friends) {-- const online = this.sessions.get(id as PlayerId)-- const name = online?.name ?? (await this.store.players.findById(id))?.name ?? 'unknown'-- friends.push({ id, name })-- }-- this.send(session, { t: 'friends', friends })++ const handler = this.handlers.get(msg.t)++ if (!handler) return++ // The table guarantees `msg.t` matches the handler's declared message type. The one exception is++ // `hello`, whose handler takes `PlayerSession | undefined` and ignores the argument.++ handler(session as PlayerSession, msg as never, conn)}-- /** One grab attempt down the view ray; latches + broadcasts on success. */-- private attemptGrab(session: PlayerSession): string | null {-- const grabbed = tryGrab(session, this.world, this.heldEntityIds, (e) =>-- this.canManipulate(session, e),-- )-- if (typeof grabbed === 'string') return grabbed-- this.heldEntityIds.add(grabbed.id)-- // Grabbing a frozen prop unfreezes it — tell clients about the motion-- // change (physics resumes; frozen visuals must clear).-- this.broadcastToKnowing(grabbed.id, { t: 'entity', id: grabbed.id, motion: 'dynamic' })-- const grab = session.held?.localOffset-- this.broadcastAll({-- t: 'physgun_state',-- player: session.entityId,-- target: grabbed.id,-- ...(grab ? { grab: [grab.x, grab.y, grab.z] as [number, number, number] } : {}),-- })-- return null-- }---- private releaseHeld(session: PlayerSession): void {-- if (!session.held) return-- // Wake the released body: if it was driven into a sleeping neighbor,-- // the depenetration solver needs it active to push them apart.-- const bodyId = this.world.bodyOf(session.held.entityId)-- if (bodyId !== undefined) {-- this.world.physics.wake(bodyId)-- // Source-feel throw cap: the drive can move props at 45 m/s, but a-- // LET-GO should toss, not rocket-launch. Clamp exit velocity.-- this.world.physics.getLinearVelocity(bodyId, _relVel)-- const speed = Math.hypot(_relVel.x, _relVel.y, _relVel.z)-- const cap = 9-- if (speed > cap) {-- const k = cap / speed-- _relVel.x *= k-- _relVel.y *= k-- _relVel.z *= k-- this.world.physics.setLinearVelocity(bodyId, _relVel)-- }-- }-- this.heldEntityIds.delete(session.held.entityId)-- release(session)-- this.broadcastAll({ t: 'physgun_state', player: session.entityId, target: null })-- }---- /** Melee swing on another player: range + zone PvP rules + tool damage. */-- private handleMelee(attacker: PlayerSession, victim: PlayerSession): void {-- const d = Math.hypot(-- victim.move.pos.x - attacker.move.pos.x,-- victim.move.pos.y - attacker.move.pos.y,-- victim.move.pos.z - attacker.move.pos.z,-- )-- const tool = equippedTool(attacker)-- if (tool?.kind === 'physgun') {-- this.send(attacker, { t: 'result', action: 'use', ok: false, error: 'not_a_weapon' })-- return-- }-- // ANY held item swings; weapon capability > tool power > improvised.-- const heldDef = attacker.holstered-- ? undefined-- : this.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')-- const weapon = heldDef?.weapon-- const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)-- if (d > range) {-- this.send(attacker, { t: 'result', action: 'use', ok: false, error: 'out_of_range' })-- return-- }-- // PvP must be legal where BOTH players stand (no shooting into the city).-- if (-- !this.world.zones.rulesAt(attacker.move.pos).pvp ||-- !this.world.zones.rulesAt(victim.move.pos).pvp-- ) {-- this.send(attacker, { t: 'result', action: 'use', ok: false, error: 'safe_zone' })-- return-- }-- attacker.lastUseTick = this.tick-- // Swinging costs stamina; an exhausted swing lands soft.-- const exhausted = attacker.stats.stamina < 10-- attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)-- attacker.statsDirty = true-- const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)-- // Edged tools cut (can open wounds); everything else is blunt.-- const type: DamageType =-- weapon || tool?.kind === 'axe' || tool?.kind === 'pickaxe' ? 'cutting' : 'blunt'-- // Knockback: shove the victim away (replicates through prediction).-- const kx = victim.move.pos.x - attacker.move.pos.x-- const kz = victim.move.pos.z - attacker.move.pos.z-- const kl = Math.hypot(kx, kz) || 1-- victim.move.vel.x += (kx / kl) * 4.5-- victim.move.vel.z += (kz / kl) * 4.5-- victim.move.vel.y += 2.2-- this.send(attacker, { t: 'result', action: 'use', ok: true })-- this.damagePlayer(attacker, victim, { type, amount: base * (exhausted ? 0.5 : 1) })++ onDisconnect(conn: GameConnection): void {++ this.systems.sessions.onDisconnect(conn)}/**-- * Ranged fire: the client sent pure intent — the shot leaves the-- * player's AUTHORITATIVE eye along their AUTHORITATIVE view (from-- * movement inputs) plus server-rolled spread. Magazine, cadence and-- * reload state live in the weapon stack's meta and are validated here.++ * Live map edit: every client refetches and rebuilds its terrain. The reconcile itself is++ * main.ts's (it owns the map document); this is the wire half.*/-- private handleFire(session: PlayerSession): void {-- const held = session.holstered ? null : session.inventory.get(session.activeHotbar)-- const spec = held ? this.world.content.item(held.defId)?.rangedWeapon : undefined-- if (!held || !spec) {-- this.send(session, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })-- return-- }-- if (!this.world.zones.rulesAt(session.move.pos).pvp) {-- this.send(session, { t: 'result', action: 'attack', ok: false, error: 'safe_zone' })-- return-- }-- const nowMs = Date.now()-- const state = weaponStateFromMeta(held.meta)-- const verdict = canFire(state, spec, nowMs)-- if (verdict !== 'ok') {-- this.send(session, { t: 'result', action: 'attack', ok: false, error: verdict })-- return-- }-- recordShot(state, nowMs)-- held.meta = weaponStateToMeta(state, held.meta)-- session.dirty = true---- eyePosition(session, _eyeScratch)-- viewDirection(session, _fireDir)-- // Server-rolled spread: deflect the view ray inside the cone.-- const spread = (spec.spreadDeg * Math.PI) / 180-- const dyaw = (Math.random() * 2 - 1) * spread-- const dpitch = (Math.random() * 2 - 1) * spread-- const cosP = Math.cos(dpitch)-- const yaw = Math.atan2(_fireDir.x, _fireDir.z) + dyaw-- const pitch = Math.asin(Math.max(-1, Math.min(1, _fireDir.y))) + dpitch-- _fireDir.x = Math.sin(yaw) * Math.cos(pitch)-- _fireDir.y = Math.sin(pitch)-- _fireDir.z = Math.cos(yaw) * Math.cos(pitch)-- void cosP-- // Start past the shooter's own capsule (raycast cannot exclude bodies).-- _fireFrom.x = _eyeScratch.x + _fireDir.x * 0.6-- _fireFrom.y = _eyeScratch.y + _fireDir.y * 0.6-- _fireFrom.z = _eyeScratch.z + _fireDir.z * 0.6-- _fireTo.x = _fireFrom.x + _fireDir.x * spec.range-- _fireTo.y = _fireFrom.y + _fireDir.y * spec.range-- _fireTo.z = _fireFrom.z + _fireDir.z * spec.range-- const hit = this.world.physics.raycast(-- _fireFrom,-- _fireTo,-- CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,-- )-- let end = _fireTo-- let landed = false-- if (hit) {-- end = hit.point-- // Player hit?-- let victim: PlayerSession | null = null-- for (const other of this.sessions.values()) {-- if (this.playerBodies.get(other.playerId) === hit.bodyId) {-- victim = other-- break-- }-- }-- if (victim && victim !== session) {-- if (this.world.zones.rulesAt(victim.move.pos).pvp) {-- landed = true-- this.damagePlayer(session, victim, {-- type: 'projectile',-- amount: spec.damage,-- sourceId: session.entityId as string,-- })-- }-- } else if (!victim) {-- const entity = this.world.entityOfBody(hit.bodyId)-- if (entity?.kind === 'npc') {-- landed = this.applyNpcDamage(session, entity, spec.damage)-- } else if (entity?.prop && this.world.content.item(entity.prop.defId)?.health) {-- landed = this.applyPropDamage(entity, spec.damage, 'projectile')-- }-- }-- }-- this.sounds.push({ x: session.move.pos.x, z: session.move.pos.z })-- this.send(session, { t: 'result', action: 'attack', ok: true })-- const tracer: ServerMessage = {-- t: 'tracer',-- shooter: session.entityId as string,-- from: [_fireFrom.x, _fireFrom.y, _fireFrom.z],-- to: [end.x, end.y, end.z],-- hit: landed,-- }-- const encoded = encodeServerMessage(tracer)-- for (const other of this.sessions.values()) {-- if (other === session || other.known.has(session.entityId)) this.sendRaw(other, encoded)-- }-- this.sendInventory(session)++ broadcastMapReload(): void {++ this.systems.editor.broadcastMapReload()}/**-- * The one place player damage lands: armor mitigation (with durability-- * wear), wound statuses, death handling, and hit feedback. Melee, bullets-- * and future explosions all converge here.++ * Network moved this person's token cutoff (sign out everywhere, password changed, banned): close++ * every session they opened with an older Network sign-in. Returns how many closed.*/-- private damagePlayer(-- attacker: PlayerSession | null,-- victim: PlayerSession,-- event: DamageEvent,-- ): void {-- const armorDef = victim.armor ? this.world.content.item(victim.armor.defId)?.armor : undefined-- const damage = mitigate(event, armorDef ? { reduction: armorDef.reduction } : null)-- // Armor wears: every mitigated hit costs a point of durability.-- if (victim.armor && armorDef && damage < event.amount) {-- const dur = Number(victim.armor.meta?.dur ?? armorDef.durability) - 1-- if (dur <= 0) {-- victim.armor = null-- this.send(victim, { t: 'announce', text: '🧥 Your armor fell apart!' })-- } else {-- victim.armor.meta = { ...victim.armor.meta, dur }-- }-- this.sendInventory(victim)-- }-- if (causesBleeding(event.type, damage)) {-- applyStatus(victim.stats, 'bleeding', BLEED_SECONDS, Date.now())-- }-- const died = applyDamage(victim.stats, damage)-- victim.statsDirty = true-- victim.dirty = true-- this.send(victim, { t: 'stats', ...this.statsWire(victim), ...(died ? { died: true } : {}) })-- victim.statsDirty = false-- this.broadcastToKnowing(victim.entityId, {-- t: 'fx',-- kind: died ? 'death' : 'hurt',-- id: victim.entityId as string,-- })-- if (died) {-- this.log.info('player killed', {-- victim: victim.playerId,-- attacker: attacker?.playerId ?? 'world',-- type: event.type,-- })-- this.respawn(victim, true)-- }++ revokeSubject(subjectId: string, validAfterMs: number): number {++ return this.systems.sessions.revokeSubject(subjectId, validAfterMs)}/**-- * Melee swing on a damageable prop: range + zone build rules + the same-- * stamina/cooldown economics as PvP. Structures are only destructible-- * where building is legal (safe city props are untouchable).++ * Graceful stop (WS-P lifecycle): everyone in the world is told the server is restarting. main.ts++ * then closes every socket with 1012 (service restart); each close saves that character and records++ * games.player.left, as when a player leaves, and the client reconnects on its own.*/-- private handlePropAttack(attacker: PlayerSession, entity: GameEntity): void {-- const def = entity.prop ? this.world.content.item(entity.prop.defId) : undefined-- const cap = def?.health-- if (!entity.prop || !cap) {-- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_damageable' })-- return-- }-- const tool = equippedTool(attacker)-- if (tool?.kind === 'physgun') {-- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })-- return-- }-- const heldDef = attacker.holstered-- ? undefined-- : this.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')-- const weapon = heldDef?.weapon-- const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)-- eyePosition(attacker, _eyeScratch)-- if (v3dist(_eyeScratch, entity.transform.pos) > range + 1) {-- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'out_of_range' })-- return-- }-- if (!this.world.zones.rulesAt(entity.transform.pos).build) {-- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'safe_zone' })-- return-- }-- attacker.lastUseTick = this.tick-- const exhausted = attacker.stats.stamina < 10-- attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)-- attacker.statsDirty = true-- const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)-- this.send(attacker, { t: 'result', action: 'attack', ok: true })-- this.applyPropDamage(entity, base * (exhausted ? 0.5 : 1), 'blunt')-- }---- /** Melee swing on an NPC: reach + the shared stamina economics. */-- private handleNpcAttack(attacker: PlayerSession, entity: GameEntity): void {-- const tool = equippedTool(attacker)-- if (tool?.kind === 'physgun') {-- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })-- return-- }-- const heldDef = attacker.holstered-- ? undefined-- : this.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')-- const weapon = heldDef?.weapon-- const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)-- eyePosition(attacker, _eyeScratch)-- if (v3dist(_eyeScratch, entity.transform.pos) > range + 1) {-- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'out_of_range' })-- return-- }-- attacker.lastUseTick = this.tick-- const exhausted = attacker.stats.stamina < 10-- attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)-- attacker.statsDirty = true-- const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)-- this.send(attacker, { t: 'result', action: 'attack', ok: true })-- this.applyNpcDamage(attacker, entity, base * (exhausted ? 0.5 : 1))-- }---- /** NPC damage sink: aggro marking, death, loot scatter, feedback. */-- private applyNpcDamage(-- attacker: PlayerSession | null,-- entity: GameEntity,-- amount: number,-- ): boolean {-- const nowMs = Date.now()-- const result = this.npcs.damage(entity.id, amount, nowMs)-- if (result === null) return false-- // Defensive NPCs (wardens) now treat this player as hostile for a while.-- if (attacker) this.aggro.set(attacker.entityId as string, nowMs + 45_000)-- if (result === 'hurt') {-- this.broadcastToKnowing(entity.id, { t: 'fx', kind: 'hurt', id: entity.id as string })-- return true-- }-- // Death: scatter loot rolls as physical props. The entity was already-- // dematerialized; interest diffs despawn it on the next snapshot.-- const arch = this.npcs.npcStateOf(entity.id)-- ? this.world.content.npc(this.npcs.npcStateOf(entity.id)!.archetype)-- : undefined-- const around = entity.transform.pos-- this.broadcastToKnowing(entity.id, { t: 'fx', kind: 'death', id: entity.id as string })-- let slot = 0-- for (const loot of arch?.loot ?? []) {-- if (Math.random() > loot.chance) continue-- const angle = slot * 1.6-- slot++-- const spawned = this.world.spawnProp({-- defId: loot.item,-- pos: vec3(-- around.x + Math.cos(angle) * 0.4,-- around.y + 0.3,-- around.z + Math.sin(angle) * 0.4,-- ),-- rot: qfromYaw(quat(), angle),-- motion: 'dynamic',-- lootCount: loot.count,-- velocity: vec3(Math.cos(angle) * 1.2, 1.5, Math.sin(angle) * 1.2),-- })-- this.broadcastSpawn(spawned)-- }-- if (attacker && arch) {-- addReputation(attacker.reputation, arch.faction, REP_DELTAS.killMember)-- // Kill contracts count matching archetypes.-- const job = attacker.activeJob ? this.world.content.job(attacker.activeJob.job) : undefined-- if (-- attacker.activeJob &&-- job?.objective.kind === 'kill' &&-- job.objective.archetype === arch.id &&-- attacker.activeJob.progress < job.objective.count-- ) {-- attacker.activeJob.progress++-- this.send(attacker, {-- t: 'announce',-- text: `📋 ${job.name}: ${attacker.activeJob.progress}/${job.objective.count}`,-- })-- this.sendJobs(attacker, job.market)-- }-- attacker.dirty = true-- this.sendReputation(attacker)-- }-- this.log.info('npc killed', {-- archetype: arch?.id ?? 'unknown',-- by: attacker?.playerId ?? 'world',-- })-- return true-- }---- /** Damage application for props: resistance, destruction, feedback.-- * Returns false when the prop has no health capability. */-- private applyPropDamage(entity: GameEntity, rawAmount: number, _type: DamageType): boolean {-- const cap = entity.prop ? this.world.content.item(entity.prop.defId)?.health : undefined-- if (!entity.prop || !cap) return false-- if (!this.world.zones.rulesAt(entity.transform.pos).build) return false-- entity.prop.health = (entity.prop.health ?? cap.max) - rawAmount * cap.resistance-- entity.dirty = true-- if (entity.prop.health <= 0) {-- this.destroyProp(entity)-- return true-- }-- this.broadcastToKnowing(entity.id, {-- t: 'entity',-- id: entity.id,-- health: Math.round(entity.prop.health),-- })-- this.broadcastToKnowing(entity.id, { t: 'fx', kind: 'hurt', id: entity.id as string })-- return true-- }---- /** Destruction: scatter salvage + stored contents as physical props. */-- private destroyProp(entity: GameEntity): void {-- const def = entity.prop ? this.world.content.item(entity.prop.defId) : undefined-- const drops: { defId: string; count: number }[] = []-- for (const loot of def?.health?.destroyLoot ?? []) {-- drops.push({ defId: loot.item, count: loot.count })-- }-- // A destroyed container spills everything it held.-- for (const slot of entity.prop?.container ?? []) {-- if (slot) drops.push({ defId: slot.defId, count: slot.count })-- }-- const around = entity.transform.pos-- this.world.despawn(entity.id)-- this.broadcastDespawn(entity.id)-- for (const [i, drop] of drops.entries()) {-- if (!this.world.content.item(drop.defId)) continue-- const angle = (i / Math.max(drops.length, 1)) * Math.PI * 2-- const spawned = this.world.spawnProp({-- defId: drop.defId,-- pos: vec3(-- around.x + Math.cos(angle) * 0.4,-- around.y + 0.4,-- around.z + Math.sin(angle) * 0.4,-- ),-- rot: qfromYaw(quat(), angle),-- motion: 'dynamic',-- lootCount: drop.count,-- velocity: vec3(Math.cos(angle) * 1.5, 2, Math.sin(angle) * 1.5),-- })-- this.broadcastSpawn(spawned)-- }-- this.log.info('prop destroyed', { def: def?.id ?? 'unknown', drops: drops.length })++ announceRestart(): void {++ this.systems.social.announceRestart()}-- /**-- * 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 {-- const session = this.sessionsByEntity.get(entityId as EntityId)-- if (!session) return-- const secured = session.inventory.secureValuables(-- (defId) => this.world.content.item(defId)?.valuable !== undefined,-- )-- const spawn = worldSpawn(this.world.content.world).pos-- session.move.pos.x = spawn[0]-- session.move.pos.y = spawn[1]-- session.move.pos.z = spawn[2]-- session.move.vel.x = 0-- session.move.vel.y = 0-- session.move.vel.z = 0-- session.dirty = true-- this.send(session, {-- t: 'announce',-- text:-- secured > 0-- ? `🚁 Extracted! ${secured} stack${secured === 1 ? '' : 's'} of loot secured.`-- : '🚁 Extracted safely back to Scrap City.',-- })-- this.sendInventory(session)-- this.log.info('player extracted', { playerId: session.playerId, secured })++ /** Full save on shutdown; main.ts awaits drain() after this. */++ shutdown(): void {++ this.systems.persistence.shutdown()}-- /** E on a chassis: mount a valid assembly, or dismount if driving it. */-- private handleVehicleUse(session: PlayerSession, chassis: GameEntity): void {-- if (session.driving === chassis.id) {-- this.dismount(session, chassis)-- this.send(session, { t: 'result', action: 'use', ok: true })-- return-- }-- if (session.driving) {-- this.send(session, { t: 'result', action: 'use', ok: false, error: 'already_driving' })-- return-- }-- const d = v3dist(chassis.transform.pos, session.move.pos)-- if (d > 4) {-- this.send(session, { t: 'result', action: 'use', ok: false, error: 'out_of_range' })-- return-- }-- if (!this.canManipulate(session, chassis)) {-- this.send(session, { t: 'result', action: 'use', ok: false, error: 'not_owner' })-- return-- }-- // Somebody else at the wheel?-- for (const other of this.sessions.values()) {-- if (other.driving === chassis.id) {-- this.send(session, { t: 'result', action: 'use', ok: false, error: 'seat_taken' })-- return-- }-- }-- // A drivable assembly: >= 2 wheels rigged with axis/motor links.-- const wheels = this.world.constraintsFor(chassis.id).filter((rec) => {-- if (rec.type !== 'axis' && rec.type !== 'motor') return false-- const otherId = rec.a === chassis.id ? rec.b : rec.a-- const other = this.world.entities.get(otherId)-- return (-- other?.prop !== undefined &&-- this.world.content.item(other.prop.defId)?.vehiclePart?.part === 'wheel'-- )-- })-- if (wheels.length < 2) {-- this.send(session, { t: 'result', action: 'use', ok: false, error: 'needs_wheels' })-- return-- }-- // The wheel must turn: frozen carts wake up when mounted.-- if (chassis.prop!.motion !== 'dynamic') this.world.setPropMotion(chassis, 'dynamic')-- session.driving = chassis.id-- if (session.held) this.releaseHeld(session)-- this.send(session, { t: 'result', action: 'use', ok: true })-- this.send(session, { t: 'announce', text: '🛻 Driving — WASD steers, E dismounts.' })++ /** Resolves when the write-behind queue is empty (shutdown). */++ async drain(timeoutMs = 25_000): Promise<boolean> {++ return this.systems.persistence.drain(timeoutMs)}-- private dismount(session: PlayerSession, chassis: GameEntity | undefined): void {-- session.driving = null-- if (chassis) {-- // Step off beside the cart.-- const rot = chassis.transform.rot-- const side = qrotateVec(vec3(), rot, vec3(1.4, 0, 0))-- session.move.pos.x = chassis.transform.pos.x + side.x-- session.move.pos.y = chassis.transform.pos.y + 0.8-- session.move.pos.z = chassis.transform.pos.z + side.z-- session.move.vel.x = 0-- session.move.vel.y = 0-- session.move.vel.z = 0-- }++ /** Exposes crafting context for the client-facing recipe availability (welcome-time). */++ workstationsNear(session: PlayerSession): ReadonlySet<string> {++ return nearbyWorkstationKinds(session, this.ctx.world)}/**-- * Vehicle drive: intent (WASD) becomes thrust force + steering on the-- * chassis body; wheels roll on their rigged bearings; fuel burns from-- * the trunk like a generator. The rider is carried kinematically.++ * The simulation tick, 30 Hz. This is the ONE place the order is written down; the systems'++ * internal cadence (1 Hz sweeps, every-2-tick snapshots) is their own.*/-- private driveVehicle(session: PlayerSession): void {-- const chassis = session.driving ? this.world.entities.get(session.driving) : undefined-- const def = chassis?.prop ? this.world.content.item(chassis.prop.defId) : undefined-- const part = def?.vehiclePart-- const bodyId = chassis ? this.world.bodyOf(chassis.id) : undefined-- if (!chassis || !part || bodyId === undefined || chassis.prop!.motion !== 'dynamic') {-- this.dismount(session, chassis)-- return-- }-- // Drain inputs (ack them; the client does not predict while driving).-- let input = session.lastInput-- while (session.inputQueue.length > 0) {-- input = session.inputQueue.shift()!-- session.lastInput = input-- session.lastProcessedSeq = input.seq-- session.yaw = input.yaw-- session.pitch = input.pitch-- session.buttons = input.buttons-- }-- const nowMs = Date.now()-- // Fuel: burn items from the trunk while driving.-- if ((chassis.prop!.burnUntil ?? 0) <= nowMs && chassis.prop!.container) {-- let reloaded = false-- for (let i = 0; i < chassis.prop!.container.length; i++) {-- const slot = chassis.prop!.container[i]-- const fuel = slot ? this.world.content.item(slot.defId)?.fuel : undefined-- if (!slot || !fuel) continue-- slot.count -= 1-- if (slot.count <= 0) chassis.prop!.container[i] = null-- chassis.prop!.burnUntil = nowMs + fuel.burnSeconds * 1000-- chassis.dirty = true-- reloaded = true-- for (const other of this.sessions.values()) {-- if (other.openContainer === chassis.id) this.sendContainer(other, chassis)-- }-- break-- }-- if (!reloaded && input && (input.moveZ !== 0 || input.moveX !== 0)) {-- // Out of fuel: coast only. (A once-per-mount nag would need state;-- // the HUD prompt shows the tank.)-- }-- }-- const fueled = (chassis.prop!.burnUntil ?? 0) > nowMs-- if (input && fueled) {-- this.world.physics.wake(bodyId)-- // Thrust along the chassis' flat forward.-- qrotateVec(_vehicleFwd, chassis.transform.rot, _vehicleFwdLocal)-- _vehicleFwd.y = 0-- const len = Math.hypot(_vehicleFwd.x, _vehicleFwd.z) || 1-- _vehicleFwd.x /= len-- _vehicleFwd.z /= len-- const power = (part.power ?? 4000) * input.moveZ-- _vehicleForce.x = _vehicleFwd.x * power-- _vehicleForce.y = 0-- _vehicleForce.z = _vehicleFwd.z * power-- if (input.moveZ !== 0) this.world.physics.applyForce(bodyId, _vehicleForce)-- // Steering: blend yaw angular velocity toward the wheel input.-- this.world.physics.getAngularVelocity(bodyId, _vehicleAng)-- const steer = -input.moveX * 1.6 * (input.moveZ < 0 ? -1 : 1)-- _vehicleAng.y = _vehicleAng.y + (steer - _vehicleAng.y) * 0.25-- this.world.physics.setAngularVelocity(bodyId, _vehicleAng)-- // Top speed cap.-- this.world.physics.getLinearVelocity(bodyId, _vehicleVel)-- const speed = Math.hypot(_vehicleVel.x, _vehicleVel.z)-- const top = part.topSpeed ?? 9-- if (speed > top) {-- const k = top / speed-- _vehicleVel.x *= k-- _vehicleVel.z *= k-- this.world.physics.setLinearVelocity(bodyId, _vehicleVel)-- }-- }-- // Carry the rider: perch on the chassis, share its velocity.-- session.move.pos.x = chassis.transform.pos.x-- session.move.pos.y = chassis.transform.pos.y + 0.75-- session.move.pos.z = chassis.transform.pos.z-- this.world.physics.getLinearVelocity(bodyId, _vehicleVel)-- session.move.vel.x = _vehicleVel.x-- session.move.vel.y = _vehicleVel.y-- session.move.vel.z = _vehicleVel.z-- session.move.grounded = true-- session.fallVy = 0-- const body = this.playerBodies.get(session.playerId)-- if (body !== undefined) {-- _bodyPosScratch.x = session.move.pos.x-- _bodyPosScratch.y = session.move.pos.y + 0.15-- _bodyPosScratch.z = session.move.pos.z-- this.world.physics.setTransform(body, _bodyPosScratch)-- }-- const entity = this.world.entities.get(session.entityId)-- if (entity) qfromYaw(entity.transform.rot, session.yaw)-- this.world.spatial.move(session.entityId, session.move.pos.x, session.move.pos.z)-- }---- /** Death/rescue respawn: back to the city with restored vitals. */-- private respawn(session: PlayerSession, died: boolean): void {-- const spawn = worldSpawn(this.world.content.world).pos-- // Risk made real: UNSECURED valuables drop where you fell, in a bag-- // anyone can loot. Secured loot and ordinary gear stay with you.-- if (died) {-- const dropped = session.inventory.takeUnsecuredValuables(-- (defId) => this.world.content.item(defId)?.valuable !== undefined,-- )-- if (dropped.length > 0) {-- const bag = this.world.spawnProp({-- defId: 'loot_bag',-- pos: vec3(session.move.pos.x, session.move.pos.y + 0.4, session.move.pos.z),-- rot: qfromYaw(quat(), session.yaw),-- motion: 'dynamic',-- })-- if (bag.prop?.container) {-- for (let i = 0; i < dropped.length && i < bag.prop.container.length; i++) {-- bag.prop.container[i] = { defId: dropped[i]!.defId, count: dropped[i]!.count }-- }-- }-- this.broadcastSpawn(bag)-- this.send(session, { t: 'announce', text: '💀 Your unsecured loot hit the dirt.' })-- }-- }-- session.move.pos.x = spawn[0]-- session.move.pos.y = spawn[1]-- session.move.pos.z = spawn[2]-- session.move.vel.x = 0-- session.move.vel.y = 0-- session.move.vel.z = 0-- if (died) {-- session.stats.health = 60-- session.stats.hunger = Math.max(session.stats.hunger, 40)-- session.stats.thirst = Math.max(session.stats.thirst, 40)-- session.statsDirty = true-- this.send(session, { t: 'stats', ...this.statsWire(session), died: true })-- session.statsDirty = false-- }-- session.dirty = true-- }---- private statsWire(session: PlayerSession) {-- return {-- hp: Math.round(session.stats.health),-- hunger: Math.round(session.stats.hunger),-- thirst: Math.round(session.stats.thirst),-- stamina: Math.round(session.stats.stamina),-- temp: Math.round(session.stats.bodyTemp * 10) / 10,-- statuses: activeStatuses(session.stats, Date.now()) as string[],-- }-- }---- // ── Simulation tick ────────────────────────────────────────────────--step(): void {++ const ctx = this.ctx++ const s = this.systemsconst tickStart = performance.now()-- this.tick++++ ctx.clock.tick++++ const dt = 1 / ctx.config.tickRate// 1. Movement from queued inputs (server-simulated, never client positions).-- for (const session of this.sessions.values()) {-- this.stepSessionMovement(session)-- }++ s.movement.tick(ctx, dt)// 2. Physgun: retry unlatched beams (sweep-to-grab), drive held bodies.-- for (const session of this.sessions.values()) {-- if (session.grabbing && !session.held && equippedTool(session)?.kind === 'physgun') {-- this.attemptGrab(session)-- }-- if (session.held) driveHeld(session, this.world)-- }++ s.manipulation.tick(ctx, dt)// 3. Fixed-step physics.-- const physStart = performance.now()-- this.world.physics.step(1 / this.config.tickRate)-- const physMs = performance.now() - physStart--// 4. Sync awake props; announce settles so clients pin final transforms.-- const { awake, settledCount } = this.world.syncFromPhysics({-- onSettle: (entity) => {-- this.broadcastToKnowing(entity.id, {-- t: 'entity',-- id: entity.id,-- pos: [entity.transform.pos.x, entity.transform.pos.y, entity.transform.pos.z],-- rot: [-- entity.transform.rot.x,-- entity.transform.rot.y,-- entity.transform.rot.z,-- entity.transform.rot.w,-- ],-- })-- },-- })-- this.metrics.awakeBodies = awake-- this.metrics.settledBodies = settledCount---- // 4b. NPC simulation (LOD-aware; abstract NPCs cost nothing here).-- this.npcs.step(Date.now(), this.tick, this.config.tickRate)-- if (this.tick % this.config.tickRate === 0) this.sounds = []---- // 5. Crafting queues (completions grant crafting/construction XP).-- for (const session of this.sessions.values()) {-- const completed = session.craftQueue.update(this.tick, this.world.content, session.inventory)-- if (completed.length > 0) {-- session.dirty = true-- this.sendInventory(session)-- this.sendCraftState(session)-- for (const recipe of completed) {-- const ups = session.skills.addXp(recipeSkill(recipe.category), recipeXp(recipe))-- for (const up of ups) {-- this.send(session, { t: 'levelup', skill: up.skill, level: up.level })-- }-- }-- this.sendSkills(session)-- }-- }---- // 6. Once a second: environment, survival vitals, void rescue, clock.-- if (this.tick % this.config.tickRate === 0) {-- const weatherBefore = this.env.weather-- stepEnvironment(this.env, 1, Math.random)-- if (this.env.weather !== weatherBefore) {-- this.broadcastAll(this.timeWire())-- this.log.info('weather changed', { from: weatherBefore, to: this.env.weather })-- }-- const ambientC = ambientTemperature(this.env)-- const raining = precipitation(this.env) > 0-- const nowMs = Date.now()-- // Region activation follows players (coarse, 1 Hz).-- this.regions.update(-- [...this.sessions.values()].map((s) => ({ x: s.move.pos.x, z: s.move.pos.z })),-- )-- for (const session of this.sessions.values()) {-- const sprinting =-- (session.buttons & Buttons.Sprint) !== 0 &&-- Math.hypot(session.move.vel.x, session.move.vel.z) > 1-- // Wet: wading/swimming, or out in the rain (no roof detection yet —-- // "indoors" arrives with the shelter model).-- const inWater =-- terrainHeight(this.world.content.world, session.move.pos.x, session.move.pos.z) <-- WATER_LEVEL - 0.03-- // Warmth: standing near a lit burn barrel (workstation scan; the-- // spatial index will replace this walk).-- const nearHeat = nearbyWorkstationKinds(session, this.world).has('campfire')-- const before = { ...session.stats }-- const statusesBefore = activeStatuses(session.stats, nowMs).join(',')-- const died = tickSurvival(session.stats, 1, {-- sprinting,-- ambientC,-- wet: inWater || raining,-- nearHeat,-- nowMs,-- })-- if (-- Math.round(before.health) !== Math.round(session.stats.health) ||-- Math.round(before.hunger) !== Math.round(session.stats.hunger) ||-- Math.round(before.thirst) !== Math.round(session.stats.thirst) ||-- Math.round(before.stamina) !== Math.round(session.stats.stamina) ||-- Math.round(before.bodyTemp * 2) !== Math.round(session.stats.bodyTemp * 2) ||-- activeStatuses(session.stats, nowMs).join(',') !== statusesBefore-- ) {-- session.statsDirty = true-- }-- if (died) {-- this.log.info('player died of exposure', { playerId: session.playerId })-- this.respawn(session, true)-- } else if (session.statsDirty) {-- this.send(session, { t: 'stats', ...this.statsWire(session) })-- session.statsDirty = false-- session.dirty = true-- }-- if (session.move.pos.y < -25) {-- this.respawn(session, false)-- this.log.info('void rescue', { playerId: session.playerId })-- }-- }-- if (this.tick % (this.config.tickRate * 10) === 0) {-- this.broadcastAll(this.timeWire())-- }-- this.events.tick(nowMs, this.sessions.size)-- this.tickProduction(nowMs, raining, ambientC)-- }---- // 7. Resource respawn sweep (once a second).-- if (this.tick % this.config.tickRate === 0) {-- for (const entity of this.world.respawnDueResources(Date.now())) {-- if (entity.resource) {-- this.broadcastToKnowing(entity.id, {-- t: 'entity',-- id: entity.id,-- remaining: entity.resource.remaining,-- })-- }-- }-- }---- // 7. Replication.-- if (this.tick % this.config.snapshotEvery === 0) {-- this.replicate()-- }---- // 8. Mods: reconcile installs (a revoked or disabled mod's effects are-- // retracted on the first tick after the change).-- this.integrations.mods?.tick(this.tick, this.modHost)---- // 9. Periodic persistence checkpoint (write-behind: returns immediately).-- if (this.tick - this.lastFlushTick >= this.config.persistFlushSeconds * this.config.tickRate) {-- this.lastFlushTick = this.tick-- this.checkpoint()-- }---- this.metrics.tick = this.tick-- this.metrics.lastTickAt = Date.now()-- this.metrics.entities = this.world.entities.size-- this.metrics.constraints = this.world.constraintCount-- this.metrics.constraintIslands = this.world.islands.islandCount()-- this.metrics.activeRegions = this.regions.activeRegionCount-- this.metrics.occupiedRegions = this.regions.occupiedRegionCount-- const [npcFull, npcAbstract] = this.npcs.counts()-- this.metrics.npcsFull = npcFull-- this.metrics.npcsAbstract = npcAbstract-- this.metrics.mapStatics = this.world.mapStaticCount()-- this.metrics.mapTerrains = this.world.mapTerrainCount()-- this.metrics.mapZones = this.world.mapZoneCount()-- this.metrics.mapRebuilds = this.world.mapRebuildCount()-- this.metrics.recordTick(performance.now() - tickStart, physMs)-- }---- private stepSessionMovement(session: PlayerSession): void {-- if (session.driving) {-- this.driveVehicle(session)-- return-- }-- this.sweepSelf = this.playerBodies.get(session.playerId)-- // Process at most 2 queued inputs per tick (catch-up), else repeat the-- // last input — the queue bound caps client-driven speedup.-- const budget = session.inputQueue.length > 2 ? 2 : 1-- let simulated = 0-- for (let i = 0; i < budget; i++) {-- const input = session.inputQueue.shift()-- if (!input) break-- session.lastInput = input-- session.starvedTicks = 0-- session.lastProcessedSeq = input.seq-- session.yaw = input.yaw-- session.pitch = input.pitch-- session.buttons = input.buttons-- stepMovement(session.move, input, MOVE, this.moveQueries, 1 / this.config.tickRate)-- simulated++-- }-- if (simulated === 0 && session.lastInput) {-- // Bridge short network jitter by repeating the last command, but only-- // briefly — a silent client must coast to a stop, not walk forever.-- session.starvedTicks++-- // Zero MOVEMENT only — buttons stay held. Zeroing buttons fabricates-- // release edges for toggle keys (prone/crouch), so any client hitch-- // longer than 3 ticks made the server flap stances endlessly.-- const input =-- session.starvedTicks <= 3 ? session.lastInput : { ...session.lastInput, moveX: 0, moveZ: 0 }-- stepMovement(session.move, input, MOVE, this.moveQueries, 1 / this.config.tickRate)-- }-- let bodyId = this.playerBodies.get(session.playerId)-- if (bodyId !== undefined && session.bodyStance !== session.move.stance) {-- // Stance changed: swap the kinematic hull to match the new posture.-- this.world.physics.removeBody(bodyId)-- const hull = hullHeightFor(session.move.stance)-- bodyId = this.world.physics.addBody({-- shape: {-- type: 'capsule',-- radius: MOVE.capsuleRadius,-- height: Math.max(hull - 0.3, 0.4),-- },-- motion: 'kinematic',-- pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),-- layer: CollisionLayer.Player,-- collidesWith: CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,-- })-- this.playerBodies.set(session.playerId, bodyId)-- session.bodyStance = session.move.stance-- }-- if (bodyId !== undefined) {-- _bodyPosScratch.x = session.move.pos.x-- _bodyPosScratch.y = session.move.pos.y + 0.15-- _bodyPosScratch.z = session.move.pos.z-- this.world.physics.setTransform(bodyId, _bodyPosScratch)-- }-- // Fall damage: track the hardest downward velocity while airborne and-- // cash it in on landing. ~13 m/s (≈2.5 story drop) is the free threshold.-- if (!session.move.grounded) {-- session.fallVy = Math.min(session.fallVy, session.move.vel.y)-- } else if (session.fallVy < -13) {-- const dmg = Math.round((-session.fallVy - 13) * 3.5)-- session.fallVy = 0-- if (applyDamage(session.stats, dmg)) {-- this.log.info('fall death', { playerId: session.playerId })-- this.respawn(session, true)-- } else {-- session.statsDirty = true-- }-- } else {-- session.fallVy = 0-- }-- const entity = this.world.entities.get(session.entityId)-- if (entity) qfromYaw(entity.transform.rot, session.yaw)-- this.world.spatial.move(session.entityId, session.move.pos.x, session.move.pos.z)-- }---- private replicate(): void {-- for (const session of this.sessions.values()) {-- const diff = updateInterest(session, this.world, this.config.interestRadius)-- if (diff.entered.length > 0) {-- this.send(session, {-- t: 'spawn',-- entities: diff.entered.map((e) => {-- const wire = wireEntityFor(this.world, e)-- const owner = this.sessionsByEntity.get(e.id)-- if (owner) {-- wire.name = owner.name-- wire.player = owner.playerId as string-- wire.appearance = owner.appearance-- }-- return wire-- }),-- })-- }-- if (diff.left.length > 0) {-- this.send(session, { t: 'despawn', ids: diff.left as string[] })-- }-- // Constraints touching newly-known props (visuals need endpoints).-- if (diff.entered.length > 0) {-- const sent = new Set<string>()-- for (const entity of diff.entered) {-- for (const rec of this.world.constraintsFor(entity.id)) {-- if (sent.has(rec.id)) continue-- sent.add(rec.id)-- this.send(session, constraintStateWire(rec, true))-- }-- }-- }-- const snapshot = buildSnapshot(session, this.world, this.sessions.values(), this.tick)-- const encoded = encodeServerMessage(snapshot)-- this.metrics.snapshotBytes = encoded.length-- this.sendRaw(session, encoded)-- }-- }---- // ── Persistence ────────────────────────────────────────────────────---- /**-- * The tick's persistence call: builds the snapshot synchronously and hands it to the write-behind-- * flusher. It returns immediately; a checkpoint already in flight coalesces this one.-- */-- private checkpoint(reason: 'checkpoint' | 'shutdown' = 'checkpoint'): void {-- this.flusher.checkpoint(() => this.buildFlushPayload(reason))-- }---- /**-- * Copy-on-write snapshot of everything dirty (clearing the flags as it copies). One transaction-- * writes meta, NPC/entity upserts and deletes, constraints, characters and the outbox events-- * describing them; on failure the snapshot's dirtiness is restored.-- */-- private buildFlushPayload(reason: 'checkpoint' | 'shutdown'): FlushPayload {-- const now = Date.now()-- const world = this.world.takeDirty()-- const npcs = this.npcs.takeDirty(now)-- const characters: FlushCharacter[] = []-- for (const session of this.sessions.values()) {-- if (!session.dirty) continue-- session.dirty = false-- characters.push(this.flushCharacter(session))-- }-- // World clock + weather and the market blobs ride along with every checkpoint.-- const meta: Record<string, unknown> = {-- env_time: this.env.timeOfDay,-- env_weather: this.env.weather,-- }-- for (const [id, market] of this.marketStock) meta[`market_${id}`] = Object.fromEntries(market)-- if (world.entityUpserts.length > 0 || characters.length > 0) {-- this.log.debug('persistence checkpoint', {-- entities: world.entityUpserts.length,-- npcs: npcs.length,-- players: characters.length,-- })-- }-- return {-- reason,-- leaving: false,-- worldSaved: true,-- meta,-- entityUpserts: world.entityUpserts,-- entityDeletes: world.entityDeletes,-- npcUpserts: npcs,-- constraintUpserts: world.constraintUpserts,-- constraintDeletes: world.constraintDeletes,-- characters,-- worldSavedCount: world.entityUpserts.length,-- restore: () => {-- this.world.restoreDirty(world)-- this.npcs.restoreDirty(npcs.map((n) => n.id))-- // A disconnected session was saved by its own queued save, which runs after this one.-- for (const c of characters) {-- if (this.sessions.has(c.session.playerId)) c.session.dirty = true-- }-- },-- }-- }---- private flushCharacter(session: PlayerSession): FlushCharacter {-- return {-- session,-- dto: this.playerToDto(session),-- player: eventPlayer(session),-- progress: sessionProgress(session),-- }-- }---- /** Full save on shutdown; main.ts awaits drain() after this. */-- shutdown(): void {-- for (const session of this.sessions.values()) session.dirty = true-- // Never coalesced: behind a checkpoint still in flight this one is queued, not dropped.-- this.flusher.checkpoint(() => this.buildFlushPayload('shutdown'), { final: true })-- this.log.info('world saved on shutdown', {})-- }---- /** Resolves when the write-behind queue is empty (shutdown). */-- async drain(timeoutMs = 25_000): Promise<boolean> {-- return this.flusher.drain(timeoutMs)-- }---- /**-- * Saves one character; `leaving` also records games.player.left in the same transaction. The DTO is-- * built synchronously and the transaction is queued on the same queue as checkpoints, so a-- * disconnect save always lands after an earlier checkpoint of that character. The caller does not-- * await it.-- */-- private savePlayer(session: PlayerSession, leaving = false): void {-- session.dirty = false-- this.flusher.savePlayer(-- {-- reason: 'checkpoint',-- leaving,-- worldSaved: false,-- meta: {},-- entityUpserts: [],-- entityDeletes: [],-- npcUpserts: [],-- constraintUpserts: [],-- constraintDeletes: [],-- characters: [this.flushCharacter(session)],-- worldSavedCount: 0,-- restore: () => {-- if (this.sessions.has(session.playerId)) session.dirty = true-- },-- },-- () => this.sessions.has(session.playerId),-- )-- }---- /**-- * What mods may do to the world, lent to the mod runtime only. Mod props-- * are owned by the mod id, so prop protection keeps players' hands off.-- */-- private readonly modHost: ModHost = {-- announce: (text) => this.broadcastAll({ t: 'announce', text }),-- placeProp: ({ item, pos, yaw, owner }) => {-- const entity = this.world.spawnProp({-- defId: item,-- pos: vec3(pos[0], pos[1] + terrainHeight(this.world.content.world, pos[0], pos[2]), pos[2]),-- rot: qfromYaw(quat(), yaw),-- motion: 'frozen',-- owner: owner as PlayerId,-- })-- this.broadcastSpawn(entity)-- return entity.id as string-- },-- removeEntity: (id) => {-- const entityId = id as EntityId-- if (!this.world.entities.get(entityId)) return false-- this.world.despawn(entityId)-- this.broadcastDespawn(entityId)-- return true-- },-- entityExists: (id) => this.world.entities.get(id as EntityId) !== undefined,-- }---- private playerToDto(session: PlayerSession): PlayerDto {-- const { pos } = session.move-- return {-- id: session.playerId as string,-- token: session.token,-- ...(session.subjectId ? { subjectId: session.subjectId } : {}),-- charSlot: session.charSlot,-- name: session.name,-- pos: [pos.x, pos.y, pos.z],-- yaw: session.yaw,-- inventory: session.inventory.toDto(),-- skills: session.skills.toDto(),-- friends: [...session.friends],-- appearance: session.appearance,-- stats: session.stats,-- armor: session.armor,-- reputation: session.reputation,-- unlocks: [...session.unlocks],-- activeJob: session.activeJob,-- updatedAt: Date.now(),-- }-- }---- // ── Messaging helpers ──────────────────────────────────────────────---- private send(session: PlayerSession, msg: ServerMessage): void {-- this.sendRaw(session, encodeServerMessage(msg))-- }---- private sendRaw(session: PlayerSession, encoded: string): void {-- session.send(encoded)-- this.metrics.bytesOut += encoded.length-- this.metrics.messagesOut++-- }---- private sendInventory(session: PlayerSession): void {-- const dto = session.inventory.toDto()-- this.send(session, {-- t: 'inventory',-- inv: {-- size: dto.size,-- hotbar: dto.hotbar,-- slots: dto.slots.map(({ i, stack }) => ({-- i,-- stack: {-- def: stack.defId,-- count: stack.count,-- ...(stack.meta !== undefined ? { meta: stack.meta } : {}),-- },-- })),-- },-- activeHotbar: session.activeHotbar,-- armor: session.armor-- ? {-- def: session.armor.defId,-- count: session.armor.count,-- ...(session.armor.meta !== undefined ? { meta: session.armor.meta } : {}),-- }-- : null,-- })-- }---- private sendSkills(session: PlayerSession): void {-- this.send(session, {-- t: 'skills',-- skills: session.skills.all(),-- unlocks: [...session.unlocks],-- })-- }---- private sendCraftState(session: PlayerSession): void {-- this.send(session, {-- t: 'craft_state',-- jobs: session.craftQueue.pending.map((j) => ({ recipe: j.recipeId, readyTick: j.readyTick })),-- })-- }---- private broadcastAll(msg: ServerMessage): void {-- const encoded = encodeServerMessage(msg)-- for (const session of this.sessions.values()) this.sendRaw(session, encoded)-- }---- private broadcastSpawn(entity: GameEntity): void {-- // Deliver immediately to sessions in range; interest diff would send it-- // next snapshot anyway, but placement feedback should be instant.-- const wire = wireEntityFor(this.world, entity)-- const encoded = encodeServerMessage({ t: 'spawn', entities: [wire] })-- const radiusSq = this.config.interestRadius ** 2-- for (const session of this.sessions.values()) {-- const d2 =-- (entity.transform.pos.x - session.move.pos.x) ** 2 +-- (entity.transform.pos.y - session.move.pos.y) ** 2 +-- (entity.transform.pos.z - session.move.pos.z) ** 2-- if (d2 <= radiusSq) {-- session.known.add(entity.id)-- this.sendRaw(session, encoded)-- }-- }-- }---- /** Constraint create/remove: tell every client that knows either prop. */-- private broadcastConstraintState(rec: ConstraintRecord, active: boolean): void {-- const msg = constraintStateWire(rec, active)-- const encoded = encodeServerMessage(msg)-- for (const session of this.sessions.values()) {-- if (session.known.has(rec.a) || session.known.has(rec.b)) {-- this.sendRaw(session, encoded)-- }-- }-- }---- private broadcastDespawn(id: string): void {-- for (const session of this.sessions.values()) {-- if (session.known.delete(id as EntityId)) {-- this.send(session, { t: 'despawn', ids: [id] })-- }-- }-- }---- private broadcastToKnowing(id: EntityId, msg: ServerMessage): void {-- const encoded = encodeServerMessage(msg)-- for (const session of this.sessions.values()) {-- if (session.known.has(id)) this.sendRaw(session, encoded)-- }-- }---- /** Live map edit: every client refetches and rebuilds its terrain. */-- /**-- * Network moved this person's token cutoff (sign out everywhere, password changed, banned): close-- * every session they opened with an older Network sign-in (4011 signed_out). The client's reconnect-- * then fails its /api/auth/me check. Returns how many closed.-- */-- revokeSubject(subjectId: string, validAfterMs: number): number {-- if (!subjectId || !Number.isFinite(validAfterMs)) return 0-- let closed = 0-- for (const session of [...this.sessions.values()]) {-- if (session.subjectId !== subjectId) continue-- if (session.authIat !== null && session.authIat * 1000 >= validAfterMs) continue-- try {-- session.send(encodeServerMessage({ t: 'reject', reason: 'signed_out' }))-- } catch {-- // closing anyway-- }-- session.closeConnection(4011, 'signed_out')-- closed++-- }-- return closed++ s.physics.tick(ctx, dt)++++ // 5. NPC simulation (LOD-aware; abstract NPCs cost nothing here).++ // The sound buffer resets once a second inside the same step.++ s.npcs.tick(ctx, dt)++++ // 6. Crafting queues (completions grant crafting/construction XP).++ s.production.tickCraftQueues()++++ // 7. Once a second: environment (clock, weather, region activation), survival vitals,++ // death by exposure, void rescue and the periodic clock broadcast.++ s.environment.tick(ctx, dt)++++ // 8. Once a second: world events (supply drops, extraction).++ s.worldEvents.tick(ctx, dt)++++ // 9. Once a second: production sweep (generators, machines, tanks, crops).++ s.production.tickProduction()++++ // 10. Once a second: resource respawn sweep.++ s.interaction.respawnResources()++++ // 11. Replication: interest diff + snapshot (every `snapshotEvery` ticks → 15 Hz).++ s.replication.tick(ctx, dt)++++ // 12. Mods: reconcile installs (a revoked or disabled mod's effects are++ // retracted on the first tick after the change).++ s.mods.tick(ctx, dt)++++ // 13. Periodic persistence checkpoint (write-behind: returns immediately).++ s.persistence.tick(ctx, dt)++++ // 14. Metrics sampling.++ ctx.metrics.tick = ctx.clock.tick++ ctx.metrics.lastTickAt = Date.now()++ ctx.metrics.entities = ctx.world.entities.size++ ctx.metrics.constraints = ctx.world.constraintCount++ ctx.metrics.constraintIslands = ctx.world.islands.islandCount()++ ctx.metrics.activeRegions = ctx.regions.activeRegionCount++ ctx.metrics.occupiedRegions = ctx.regions.occupiedRegionCount++ const [npcFull, npcAbstract] = s.npcs.counts()++ ctx.metrics.npcsFull = npcFull++ ctx.metrics.npcsAbstract = npcAbstract++ ctx.metrics.mapStatics = ctx.world.mapStaticCount()++ ctx.metrics.mapTerrains = ctx.world.mapTerrainCount()++ ctx.metrics.mapZones = ctx.world.mapZoneCount()++ ctx.metrics.mapRebuilds = ctx.world.mapRebuildCount()++ ctx.metrics.recordTick(performance.now() - tickStart, s.physics.physicsMs)}---- /**-- * Graceful stop (WS-P lifecycle): everyone in the world is told the server is restarting. main.ts-- * then closes every socket with 1012 (service restart); each close saves that character and records-- * games.player.left, as when a player leaves, and the client reconnects on its own.-- */-- announceRestart(): void {-- this.broadcastAll({-- t: 'announce',-- text: '🔄 The server is restarting: you will reconnect in a moment…',-- })-- }---- broadcastMapReload(): void {-- this.broadcastAll({ t: 'map_reload' })-- this.broadcastAll({ t: 'announce', text: '🗺 The world was reshaped by the map editors…' })-- }---- /** Exposes crafting context for the client-facing recipe availability (welcome-time). */-- workstationsNear(session: PlayerSession): ReadonlySet<string> {-- return nearbyWorkstationKinds(session, this.world)-- }--}----const _eyeScratch = vec3()--const _relVel = vec3()--const _bodyPosScratch = vec3()--const _fireDir = vec3()--const _fireFrom = vec3()--const _fireTo = vec3()--const _vehicleFwdLocal = vec3(0, 0, 1)--const _vehicleFwd = vec3()--const _vehicleForce = vec3()--const _vehicleAng = vec3()--const _vehicleVel = vec3()----function constraintStateWire(rec: ConstraintRecord, active: boolean): ServerMessage {-- return {-- t: 'constraint_state',-- id: rec.id,-- kind: rec.type,-- a: rec.a as string,-- b: rec.b as string,-- anchorA: rec.params.anchorA ?? [0, 0, 0],-- anchorB: rec.params.anchorB ?? [0, 0, 0],-- ...(rec.params.length !== undefined ? { length: rec.params.length } : {}),-- active,-- }--}----/** A session as the platform event recorder sees it. */--function eventPlayer(session: PlayerSession): EventPlayer {-- return {-- playerId: session.playerId as string,-- slot: session.charSlot,-- name: session.name,-- subjectId: session.subjectId,-- }--}----function progressOf(skills: SkillSet, unlocks: Iterable<string>): ProgressSnapshot {-- const levels: Record<string, number> = {}-- for (const skill of skills.all()) levels[skill.id] = skill.level-- return { levels, unlocks: [...unlocks] }--}----function sessionProgress(session: PlayerSession): ProgressSnapshot {-- return progressOf(session.skills, session.unlocks)}nnew file mode 100644@@@ -0,0 +1,83 @@++/**++ * The message handler table (ADR-0007 M1). The split moved 27 dispatch cases out of one switch and++ * into the systems; these tests are what stops that table from rotting:++ *++ * - a duplicate message type is a startup error, not a silent shadowing;++ * - every client message the protocol defines has exactly one handler.++ *++ * The GameServer is constructed with stub world/store objects: nothing here ticks or sends, so++ * nothing may dereference them.++ */++import { mkdtempSync } from 'node:fs'++import { tmpdir } from 'node:os'++import { join } from 'node:path'++import { createContent } from '@openvibe/content'++import type { PersistenceStore } from '@openvibe/persistence'++import { ClientMessageSchema } from '@openvibe/protocol'++import { createConsoleLogger } from '@openvibe/shared'++import { describe, expect, it } from 'vitest'++import { loadConfig } from '../config.js'++import { ServerMetrics } from '../observability/metrics.js'++import { GameServer } from './gameServer.js'++import type { GameWorld } from './gameWorld.js'++import { buildHandlerTable, type System } from './systems/index.js'++++const dir = mkdtempSync(join(tmpdir(), 'openvibe-handlers-'))++const config = loadConfig({ MAP_PATH: join(dir, 'map.json') })++const log = createConsoleLogger({ app: 'test' }, 'error')++++/**++ * Every message type the protocol's closed client union defines. The union nests — `physgun` is++ * itself a discriminated union of six actions — so walk `options` recursively and read the `t`++ * literal off each leaf object.++ */++function walkMessageTypes(schema: unknown): string[] {++ const options = (schema as { options?: readonly unknown[] }).options++ if (options) return options.flatMap(walkMessageTypes)++ const shape = (schema as { shape?: Record<string, unknown> }).shape++ const literal = shape?.t as { value?: unknown } | undefined++ return typeof literal?.value === 'string' ? [literal.value] : []++}++++/** The distinct message types — the six physgun actions are one type. */++function protocolMessageTypes(schema: unknown): string[] {++ return [...new Set(walkMessageTypes(schema))]++}++++describe('the message handler table', () => {++ it('rejects two systems claiming the same message type', () => {++ const first: System = { name: 'first', handlers: { use: () => {} } }++ const second: System = { name: 'second', handlers: { use: () => {} } }++ expect(() => buildHandlerTable([first, second])).toThrow(++ 'duplicate message handler for "use" (second)',++ )++ })++++ it('accepts systems that each claim distinct types', () => {++ const first: System = { name: 'first', handlers: { use: () => {} } }++ const second: System = { name: 'second', handlers: { attack: () => {} } }++ expect([...buildHandlerTable([first, second]).keys()]).toEqual(['use', 'attack'])++ })++++ it('has exactly one handler for every message type the protocol defines', () => {++ // Constructing the real server also proves its own table has no duplicate type.++ const game = new GameServer(++ config,++ { content: createContent() } as unknown as GameWorld,++ {} as PersistenceStore,++ new ServerMetrics(),++ log,++ )++ const handled = game.messageTypes++ const defined = protocolMessageTypes(ClientMessageSchema)++ expect(defined.length).toBeGreaterThan(0)++ for (const type of defined) {++ expect(++ handled.filter((t) => t === type),++ `handlers for "${type}"`,++ ).toHaveLength(1)++ }++ // …and that no handler is left over for a type the protocol does not have.++ expect([...handled].sort()).toEqual([...defined].sort())++ })++})nnew file mode 100644@@@ -0,0 +1,526 @@++import {++ BLEED_SECONDS,++ REP_DELTAS,++ addReputation,++ applyDamage,++ applyStatus,++ canFire,++ causesBleeding,++ mitigate,++ recordShot,++ startReload,++ weaponStateFromMeta,++ weaponStateToMeta,++ type DamageEvent,++ type DamageType,++ type GameEntity,++} from '@openvibe/gameplay'++import { worldSpawn } from '@openvibe/content'++import { CollisionLayer } from '@openvibe/physics'++import {++ encodeServerMessage,++ type ClientAttack,++ type ClientFire,++ type ClientReload,++ type ServerMessage,++} from '@openvibe/protocol'++import { qfromYaw, quat, v3dist, vec3, type EntityId } from '@openvibe/shared'++import { equippedTool } from '../interactions.js'++import { eyePosition, viewDirection, type PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import type { HandlerMap, System } from './system.js'++++const _eyeScratch = vec3()++const _fireDir = vec3()++const _fireFrom = vec3()++const _fireTo = vec3()++++/**++ * Combat: melee, ranged fire, reload, the single damage sink, prop destruction and respawn. Every++ * death path (NPC melee, bullets, falls, exposure, the void) converges on `damagePlayer`/`respawn`.++ */++export class CombatSystem implements System {++ readonly name = 'combat'++++ readonly handlers: HandlerMap = {++ attack: (session, msg: ClientAttack, _conn) => {++ // Swing cooldown: silently drop spam faster than ~5 swings/sec.++ if (this.ctx.clock.tick - session.lastUseTick < 6) return++ const victimSession = this.ctx.sessions.getByEntity(msg.target)++ if (victimSession) {++ this.melee(session, victimSession)++ return++ }++ const propTarget = this.ctx.world.entities.get(msg.target as EntityId)++ if (propTarget?.prop) {++ this.handlePropAttack(session, propTarget)++ return++ }++ if (propTarget?.kind === 'npc') {++ this.handleNpcAttack(session, propTarget)++ return++ }++ this.ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: 'no_target' })++ },++ fire: (session, _msg: ClientFire, _conn) => {++ this.handleFire(session)++ },++ reload: (session, _msg: ClientReload, _conn) => {++ const ctx = this.ctx++ const held = session.holstered ? null : session.inventory.get(session.activeHotbar)++ const spec = held ? ctx.world.content.item(held.defId)?.rangedWeapon : undefined++ if (!held || !spec) {++ ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })++ return++ }++ const state = weaponStateFromMeta(held.meta)++ const available = session.inventory.countOf(spec.ammoItem)++ const took = startReload(state, spec, available, Date.now())++ if (took <= 0) {++ ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: 'no_ammo' })++ return++ }++ session.inventory.consume([{ item: spec.ammoItem, count: took }])++ held.meta = weaponStateToMeta(state, held.meta)++ session.dirty = true++ ctx.net.send(session, { t: 'result', action: 'attack', ok: true })++ ctx.net.sendInventory(session)++ },++ }++++ constructor(private readonly ctx: ServerContext) {}++++ /** Melee swing on another player: range + zone PvP rules + tool damage. */++ melee(attacker: PlayerSession, victim: PlayerSession): void {++ const ctx = this.ctx++ const d = Math.hypot(++ victim.move.pos.x - attacker.move.pos.x,++ victim.move.pos.y - attacker.move.pos.y,++ victim.move.pos.z - attacker.move.pos.z,++ )++ const tool = equippedTool(attacker)++ if (tool?.kind === 'physgun') {++ ctx.net.send(attacker, { t: 'result', action: 'use', ok: false, error: 'not_a_weapon' })++ return++ }++ // ANY held item swings; weapon capability > tool power > improvised.++ const heldDef = attacker.holstered++ ? undefined++ : ctx.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')++ const weapon = heldDef?.weapon++ const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)++ if (d > range) {++ ctx.net.send(attacker, { t: 'result', action: 'use', ok: false, error: 'out_of_range' })++ return++ }++ // PvP must be legal where BOTH players stand (no shooting into the city).++ if (++ !ctx.world.zones.rulesAt(attacker.move.pos).pvp ||++ !ctx.world.zones.rulesAt(victim.move.pos).pvp++ ) {++ ctx.net.send(attacker, { t: 'result', action: 'use', ok: false, error: 'safe_zone' })++ return++ }++ attacker.lastUseTick = ctx.clock.tick++ // Swinging costs stamina; an exhausted swing lands soft.++ const exhausted = attacker.stats.stamina < 10++ attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)++ attacker.statsDirty = true++ const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)++ // Edged tools cut (can open wounds); everything else is blunt.++ const type: DamageType =++ weapon || tool?.kind === 'axe' || tool?.kind === 'pickaxe' ? 'cutting' : 'blunt'++ // Knockback: shove the victim away (replicates through prediction).++ const kx = victim.move.pos.x - attacker.move.pos.x++ const kz = victim.move.pos.z - attacker.move.pos.z++ const kl = Math.hypot(kx, kz) || 1++ victim.move.vel.x += (kx / kl) * 4.5++ victim.move.vel.z += (kz / kl) * 4.5++ victim.move.vel.y += 2.2++ ctx.net.send(attacker, { t: 'result', action: 'use', ok: true })++ this.damagePlayer(attacker, victim, { type, amount: base * (exhausted ? 0.5 : 1) })++ }++++ /**++ * Ranged fire: the client sent pure intent — the shot leaves the++ * player's AUTHORITATIVE eye along their AUTHORITATIVE view (from++ * movement inputs) plus server-rolled spread. Magazine, cadence and++ * reload state live in the weapon stack's meta and are validated here.++ */++ private handleFire(session: PlayerSession): void {++ const ctx = this.ctx++ const held = session.holstered ? null : session.inventory.get(session.activeHotbar)++ const spec = held ? ctx.world.content.item(held.defId)?.rangedWeapon : undefined++ if (!held || !spec) {++ ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })++ return++ }++ if (!ctx.world.zones.rulesAt(session.move.pos).pvp) {++ ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: 'safe_zone' })++ return++ }++ const nowMs = Date.now()++ const state = weaponStateFromMeta(held.meta)++ const verdict = canFire(state, spec, nowMs)++ if (verdict !== 'ok') {++ ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: verdict })++ return++ }++ recordShot(state, nowMs)++ held.meta = weaponStateToMeta(state, held.meta)++ session.dirty = true++++ eyePosition(session, _eyeScratch)++ viewDirection(session, _fireDir)++ // Server-rolled spread: deflect the view ray inside the cone.++ const spread = (spec.spreadDeg * Math.PI) / 180++ const dyaw = (Math.random() * 2 - 1) * spread++ const dpitch = (Math.random() * 2 - 1) * spread++ const cosP = Math.cos(dpitch)++ const yaw = Math.atan2(_fireDir.x, _fireDir.z) + dyaw++ const pitch = Math.asin(Math.max(-1, Math.min(1, _fireDir.y))) + dpitch++ _fireDir.x = Math.sin(yaw) * Math.cos(pitch)++ _fireDir.y = Math.sin(pitch)++ _fireDir.z = Math.cos(yaw) * Math.cos(pitch)++ void cosP++ // Start past the shooter's own capsule (raycast cannot exclude bodies).++ _fireFrom.x = _eyeScratch.x + _fireDir.x * 0.6++ _fireFrom.y = _eyeScratch.y + _fireDir.y * 0.6++ _fireFrom.z = _eyeScratch.z + _fireDir.z * 0.6++ _fireTo.x = _fireFrom.x + _fireDir.x * spec.range++ _fireTo.y = _fireFrom.y + _fireDir.y * spec.range++ _fireTo.z = _fireFrom.z + _fireDir.z * spec.range++ const hit = ctx.world.physics.raycast(++ _fireFrom,++ _fireTo,++ CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,++ )++ let end = _fireTo++ let landed = false++ if (hit) {++ end = hit.point++ // Player hit?++ let victim: PlayerSession | null = null++ for (const other of ctx.sessions.values()) {++ if (ctx.sessions.bodyOf(other.playerId) === hit.bodyId) {++ victim = other++ break++ }++ }++ if (victim && victim !== session) {++ if (ctx.world.zones.rulesAt(victim.move.pos).pvp) {++ landed = true++ this.damagePlayer(session, victim, {++ type: 'projectile',++ amount: spec.damage,++ sourceId: session.entityId as string,++ })++ }++ } else if (!victim) {++ const entity = ctx.world.entityOfBody(hit.bodyId)++ if (entity?.kind === 'npc') {++ landed = this.applyNpcDamage(session, entity, spec.damage)++ } else if (entity?.prop && ctx.world.content.item(entity.prop.defId)?.health) {++ landed = this.applyPropDamage(entity, spec.damage, 'projectile')++ }++ }++ }++ ctx.systems.npcs.recordSound(session.move.pos.x, session.move.pos.z)++ ctx.net.send(session, { t: 'result', action: 'attack', ok: true })++ const tracer: ServerMessage = {++ t: 'tracer',++ shooter: session.entityId as string,++ from: [_fireFrom.x, _fireFrom.y, _fireFrom.z],++ to: [end.x, end.y, end.z],++ hit: landed,++ }++ const encoded = encodeServerMessage(tracer)++ for (const other of ctx.sessions.values()) {++ if (other === session || other.known.has(session.entityId)) ctx.net.sendRaw(other, encoded)++ }++ ctx.net.sendInventory(session)++ }++++ /**++ * The one place player damage lands: armor mitigation (with durability++ * wear), wound statuses, death handling, and hit feedback. Melee, bullets++ * and future explosions all converge here.++ */++ damagePlayer(attacker: PlayerSession | null, victim: PlayerSession, event: DamageEvent): void {++ const ctx = this.ctx++ const armorDef = victim.armor ? ctx.world.content.item(victim.armor.defId)?.armor : undefined++ const damage = mitigate(event, armorDef ? { reduction: armorDef.reduction } : null)++ // Armor wears: every mitigated hit costs a point of durability.++ if (victim.armor && armorDef && damage < event.amount) {++ const dur = Number(victim.armor.meta?.dur ?? armorDef.durability) - 1++ if (dur <= 0) {++ victim.armor = null++ ctx.net.send(victim, { t: 'announce', text: '🧥 Your armor fell apart!' })++ } else {++ victim.armor.meta = { ...victim.armor.meta, dur }++ }++ ctx.net.sendInventory(victim)++ }++ if (causesBleeding(event.type, damage)) {++ applyStatus(victim.stats, 'bleeding', BLEED_SECONDS, Date.now())++ }++ const died = applyDamage(victim.stats, damage)++ victim.statsDirty = true++ victim.dirty = true++ ctx.net.send(victim, {++ t: 'stats',++ ...ctx.net.statsWire(victim),++ ...(died ? { died: true } : {}),++ })++ victim.statsDirty = false++ ctx.net.broadcastToKnowing(victim.entityId, {++ t: 'fx',++ kind: died ? 'death' : 'hurt',++ id: victim.entityId as string,++ })++ if (died) {++ ctx.log.info('player killed', {++ victim: victim.playerId,++ attacker: attacker?.playerId ?? 'world',++ type: event.type,++ })++ this.respawn(victim, true)++ }++ }++++ /**++ * Melee swing on a damageable prop: range + zone build rules + the same++ * stamina/cooldown economics as PvP. Structures are only destructible++ * where building is legal (safe city props are untouchable).++ */++ private handlePropAttack(attacker: PlayerSession, entity: GameEntity): void {++ const ctx = this.ctx++ const def = entity.prop ? ctx.world.content.item(entity.prop.defId) : undefined++ const cap = def?.health++ if (!entity.prop || !cap) {++ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_damageable' })++ return++ }++ const tool = equippedTool(attacker)++ if (tool?.kind === 'physgun') {++ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })++ return++ }++ const heldDef = attacker.holstered++ ? undefined++ : ctx.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')++ const weapon = heldDef?.weapon++ const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)++ eyePosition(attacker, _eyeScratch)++ if (v3dist(_eyeScratch, entity.transform.pos) > range + 1) {++ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'out_of_range' })++ return++ }++ if (!ctx.world.zones.rulesAt(entity.transform.pos).build) {++ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'safe_zone' })++ return++ }++ attacker.lastUseTick = ctx.clock.tick++ const exhausted = attacker.stats.stamina < 10++ attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)++ attacker.statsDirty = true++ const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)++ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: true })++ this.applyPropDamage(entity, base * (exhausted ? 0.5 : 1), 'blunt')++ }++++ /** Melee swing on an NPC: reach + the shared stamina economics. */++ private handleNpcAttack(attacker: PlayerSession, entity: GameEntity): void {++ const ctx = this.ctx++ const tool = equippedTool(attacker)++ if (tool?.kind === 'physgun') {++ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })++ return++ }++ const heldDef = attacker.holstered++ ? undefined++ : ctx.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')++ const weapon = heldDef?.weapon++ const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)++ eyePosition(attacker, _eyeScratch)++ if (v3dist(_eyeScratch, entity.transform.pos) > range + 1) {++ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'out_of_range' })++ return++ }++ attacker.lastUseTick = ctx.clock.tick++ const exhausted = attacker.stats.stamina < 10++ attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)++ attacker.statsDirty = true++ const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)++ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: true })++ this.applyNpcDamage(attacker, entity, base * (exhausted ? 0.5 : 1))++ }++++ /** NPC damage sink: aggro marking, death, loot scatter, feedback. */++ private applyNpcDamage(++ attacker: PlayerSession | null,++ entity: GameEntity,++ amount: number,++ ): boolean {++ const ctx = this.ctx++ const nowMs = Date.now()++ const result = ctx.systems.npcs.damage(entity.id, amount, nowMs)++ if (result === null) return false++ // Defensive NPCs (wardens) now treat this player as hostile for a while.++ if (attacker) ctx.systems.npcs.markAggro(attacker.entityId as string, nowMs + 45_000)++ if (result === 'hurt') {++ ctx.net.broadcastToKnowing(entity.id, { t: 'fx', kind: 'hurt', id: entity.id as string })++ return true++ }++ // Death: scatter loot rolls as physical props. The entity was already++ // dematerialized; interest diffs despawn it on the next snapshot.++ const state = ctx.systems.npcs.npcStateOf(entity.id)++ const arch = state ? ctx.world.content.npc(state.archetype) : undefined++ const around = entity.transform.pos++ ctx.net.broadcastToKnowing(entity.id, { t: 'fx', kind: 'death', id: entity.id as string })++ let slot = 0++ for (const loot of arch?.loot ?? []) {++ if (Math.random() > loot.chance) continue++ const angle = slot * 1.6++ slot++++ const spawned = ctx.world.spawnProp({++ defId: loot.item,++ pos: vec3(++ around.x + Math.cos(angle) * 0.4,++ around.y + 0.3,++ around.z + Math.sin(angle) * 0.4,++ ),++ rot: qfromYaw(quat(), angle),++ motion: 'dynamic',++ lootCount: loot.count,++ velocity: vec3(Math.cos(angle) * 1.2, 1.5, Math.sin(angle) * 1.2),++ })++ ctx.net.broadcastSpawn(spawned)++ }++ if (attacker && arch) {++ addReputation(attacker.reputation, arch.faction, REP_DELTAS.killMember)++ // Kill contracts count matching archetypes.++ const job = attacker.activeJob ? ctx.world.content.job(attacker.activeJob.job) : undefined++ if (++ attacker.activeJob &&++ job?.objective.kind === 'kill' &&++ job.objective.archetype === arch.id &&++ attacker.activeJob.progress < job.objective.count++ ) {++ attacker.activeJob.progress++++ ctx.net.send(attacker, {++ t: 'announce',++ text: `📋 ${job.name}: ${attacker.activeJob.progress}/${job.objective.count}`,++ })++ ctx.systems.economy.sendJobs(attacker, job.market)++ }++ attacker.dirty = true++ ctx.systems.economy.sendReputation(attacker)++ }++ ctx.log.info('npc killed', {++ archetype: arch?.id ?? 'unknown',++ by: attacker?.playerId ?? 'world',++ })++ return true++ }++++ /** Damage application for props: resistance, destruction, feedback.++ * Returns false when the prop has no health capability. */++ private applyPropDamage(entity: GameEntity, rawAmount: number, _type: DamageType): boolean {++ const ctx = this.ctx++ const cap = entity.prop ? ctx.world.content.item(entity.prop.defId)?.health : undefined++ if (!entity.prop || !cap) return false++ if (!ctx.world.zones.rulesAt(entity.transform.pos).build) return false++ entity.prop.health = (entity.prop.health ?? cap.max) - rawAmount * cap.resistance++ entity.dirty = true++ if (entity.prop.health <= 0) {++ this.destroyProp(entity)++ return true++ }++ ctx.net.broadcastToKnowing(entity.id, {++ t: 'entity',++ id: entity.id,++ health: Math.round(entity.prop.health),++ })++ ctx.net.broadcastToKnowing(entity.id, { t: 'fx', kind: 'hurt', id: entity.id as string })++ return true++ }++++ /** Destruction: scatter salvage + stored contents as physical props. */++ private destroyProp(entity: GameEntity): void {++ const ctx = this.ctx++ const def = entity.prop ? ctx.world.content.item(entity.prop.defId) : undefined++ const drops: { defId: string; count: number }[] = []++ for (const loot of def?.health?.destroyLoot ?? []) {++ drops.push({ defId: loot.item, count: loot.count })++ }++ // A destroyed container spills everything it held.++ for (const slot of entity.prop?.container ?? []) {++ if (slot) drops.push({ defId: slot.defId, count: slot.count })++ }++ const around = entity.transform.pos++ ctx.world.despawn(entity.id)++ ctx.net.broadcastDespawn(entity.id)++ for (const [i, drop] of drops.entries()) {++ if (!ctx.world.content.item(drop.defId)) continue++ const angle = (i / Math.max(drops.length, 1)) * Math.PI * 2++ const spawned = ctx.world.spawnProp({++ defId: drop.defId,++ pos: vec3(++ around.x + Math.cos(angle) * 0.4,++ around.y + 0.4,++ around.z + Math.sin(angle) * 0.4,++ ),++ rot: qfromYaw(quat(), angle),++ motion: 'dynamic',++ lootCount: drop.count,++ velocity: vec3(Math.cos(angle) * 1.5, 2, Math.sin(angle) * 1.5),++ })++ ctx.net.broadcastSpawn(spawned)++ }++ ctx.log.info('prop destroyed', { def: def?.id ?? 'unknown', drops: drops.length })++ }++++ /** Death/rescue respawn: back to the city with restored vitals. */++ respawn(session: PlayerSession, died: boolean): void {++ const ctx = this.ctx++ const spawn = worldSpawn(ctx.world.content.world).pos++ // Risk made real: UNSECURED valuables drop where you fell, in a bag++ // anyone can loot. Secured loot and ordinary gear stay with you.++ if (died) {++ const dropped = session.inventory.takeUnsecuredValuables(++ (defId) => ctx.world.content.item(defId)?.valuable !== undefined,++ )++ if (dropped.length > 0) {++ const bag = ctx.world.spawnProp({++ defId: 'loot_bag',++ pos: vec3(session.move.pos.x, session.move.pos.y + 0.4, session.move.pos.z),++ rot: qfromYaw(quat(), session.yaw),++ motion: 'dynamic',++ })++ if (bag.prop?.container) {++ for (let i = 0; i < dropped.length && i < bag.prop.container.length; i++) {++ bag.prop.container[i] = { defId: dropped[i]!.defId, count: dropped[i]!.count }++ }++ }++ ctx.net.broadcastSpawn(bag)++ ctx.net.send(session, { t: 'announce', text: '💀 Your unsecured loot hit the dirt.' })++ }++ }++ session.move.pos.x = spawn[0]++ session.move.pos.y = spawn[1]++ session.move.pos.z = spawn[2]++ session.move.vel.x = 0++ session.move.vel.y = 0++ session.move.vel.z = 0++ if (died) {++ session.stats.health = 60++ session.stats.hunger = Math.max(session.stats.hunger, 40)++ session.stats.thirst = Math.max(session.stats.thirst, 40)++ session.statsDirty = true++ ctx.net.send(session, { t: 'stats', ...ctx.net.statsWire(session), died: true })++ session.statsDirty = false++ }++ session.dirty = true++ }++}nnew file mode 100644@@@ -0,0 +1,212 @@++import { RegionTracker, type EnvironmentState, type GameEntity } from '@openvibe/gameplay'++import type { PersistenceStore } from '@openvibe/persistence'++import type { BodyId } from '@openvibe/physics'++import type { ServerMessage, ServerStats } from '@openvibe/protocol'++import type { EntityId, Logger, PlayerId } from '@openvibe/shared'++import type { ServerConfig } from '../../config.js'++import type { ModRuntime } from '../../mods/runtime.js'++import type { ServerMetrics } from '../../observability/metrics.js'++import type { GameEventRecorder } from '../../platform/gameEvents.js'++import type { ProgressSummaryWriter } from '../../platform/progressSummary.js'++import type { ConstraintRecord, GameWorld } from '../gameWorld.js'++import type { PlayerSession } from '../playerSession.js'++import type { CombatSystem } from './combat.js'++import type { EconomySystem } from './economy.js'++import type { EditorSystem } from './editor.js'++import type { EnvironmentSystem } from './environment.js'++import type { InteractionSystem } from './interaction.js'++import type { InventorySystem } from './inventory.js'++import type { ManipulationSystem } from './manipulation.js'++import type { ModSystem } from './mods.js'++import type { MovementSystem } from './movement.js'++import type { NpcSystem } from './npcs.js'++import type { PersistenceSystem } from './persistence.js'++import type { PhysicsSystem } from './physics.js'++import type { ProductionSystem } from './production.js'++import type { ReplicationSystem } from './replication.js'++import type { SessionsSystem } from './sessions.js'++import type { SocialSystem } from './social.js'++import type { VehicleSystem } from './vehicles.js'++import type { WorldEventSystem } from './worldEvents.js'++++/** A network connection as the game sees it — transport-agnostic. */++export interface GameConnection {++ send(text: string): void++ close(code: number, reason: string): void++}++++/**++ * Platform adapters the server drives (roadmap Wave 12). Both optional: the++ * game runs exactly as before without them.++ */++export interface GameIntegrations {++ /** Durable lifecycle/progression events through the outbox. */++ events?: GameEventRecorder++ /** Installed mods (content packs), reconciled every tick. */++ mods?: ModRuntime++ /** The person's games.progress.summary user module on Network, written when a character leaves. */++ progressSummary?: ProgressSummaryWriter++}++++/** The mutable tick counter, shared by every system that reads cadence or cooldowns. */++export interface Clock {++ tick: number++}++++/** The wire helpers every system sends through (implemented by ReplicationSystem). */++export interface Net {++ send(session: PlayerSession, msg: ServerMessage): void++ sendRaw(session: PlayerSession, encoded: string): void++ broadcastAll(msg: ServerMessage): void++ /** Sends to every session whose interest set currently contains `id`. */++ broadcastToKnowing(id: EntityId, msg: ServerMessage): void++ /** Immediate spawn to in-range sessions, adding the id to their interest sets. */++ broadcastSpawn(entity: GameEntity): void++ /** Despawn to every session that knew it, removing it from their interest sets. */++ broadcastDespawn(id: string): void++ /** Constraint create/remove to every client that knows either end. */++ broadcastConstraintState(rec: ConstraintRecord, active: boolean): void++ timeWire(): ServerMessage++ sendInventory(session: PlayerSession): void++ sendSkills(session: PlayerSession): void++ sendCraftState(session: PlayerSession): void++ sendContainer(session: PlayerSession, entity: GameEntity): void++ /** The vitals payload shared by every stats message (without `t`/`died`). */++ statsWire(session: PlayerSession): Omit<ServerStats, 't' | 'died'>++ /** Interest diff + snapshot for every session (every `snapshotEvery` ticks). */++ replicate(): void++}++++/**++ * Every live session and the handles the server keeps beside them. This is the one piece of state++ * more than half the systems touch, so it lives behind the context rather than inside a system.++ */++export class SessionRegistry {++ private readonly byPlayer = new Map<PlayerId, PlayerSession>()++ private readonly byConn = new Map<GameConnection, PlayerSession>()++ private readonly byEntity = new Map<EntityId, PlayerSession>()++ /** Kinematic capsule handle per player (movement, vehicles and combat all need it). */++ private readonly bodies = new Map<PlayerId, BodyId>()++++ get size(): number {++ return this.byPlayer.size++ }++++ get(playerId: PlayerId | string): PlayerSession | undefined {++ return this.byPlayer.get(playerId as PlayerId)++ }++++ getByConn(conn: GameConnection): PlayerSession | undefined {++ return this.byConn.get(conn)++ }++++ getByEntity(entityId: EntityId | string): PlayerSession | undefined {++ return this.byEntity.get(entityId as EntityId)++ }++++ values(): IterableIterator<PlayerSession> {++ return this.byPlayer.values()++ }++++ /** A snapshot, for loops that may remove a session. */++ all(): PlayerSession[] {++ return [...this.byPlayer.values()]++ }++++ add(conn: GameConnection, session: PlayerSession): void {++ this.byPlayer.set(session.playerId, session)++ this.byConn.set(conn, session)++ this.byEntity.set(session.entityId, session)++ }++++ /** Removes the session from all three maps; returns it (undefined when the conn is unknown). */++ remove(conn: GameConnection): PlayerSession | undefined {++ const session = this.byConn.get(conn)++ if (!session) return undefined++ this.byConn.delete(conn)++ this.byPlayer.delete(session.playerId)++ this.byEntity.delete(session.entityId)++ return session++ }++++ bodyOf(playerId: PlayerId): BodyId | undefined {++ return this.bodies.get(playerId)++ }++++ setBody(playerId: PlayerId, bodyId: BodyId): void {++ this.bodies.set(playerId, bodyId)++ }++++ deleteBody(playerId: PlayerId): BodyId | undefined {++ const bodyId = this.bodies.get(playerId)++ this.bodies.delete(playerId)++ return bodyId++ }++}++++/**++ * The systems, late-bound so a system can reach its peers. Assigned once, after every system is++ * constructed; no system may touch it before the first message or tick.++ */++export interface ServerSystems {++ sessions: SessionsSystem++ movement: MovementSystem++ replication: ReplicationSystem++ manipulation: ManipulationSystem++ combat: CombatSystem++ vehicles: VehicleSystem++ inventory: InventorySystem++ interaction: InteractionSystem++ economy: EconomySystem++ production: ProductionSystem++ npcs: NpcSystem++ social: SocialSystem++ worldEvents: WorldEventSystem++ persistence: PersistenceSystem++ mods: ModSystem++ editor: EditorSystem++ environment: EnvironmentSystem++ physics: PhysicsSystem++}++++/**++ * Drives the systems' `onJoin`/`onLeave` hooks in construction order. GameServer binds this to its++ * ordered system list, so the session system never has to know which systems keep session state.++ */++export interface Lifecycle {++ joined(session: PlayerSession): void++ left(session: PlayerSession): void++}++++/**++ * What every system shares (ADR-0007 M1): the world, the sessions registry, content, config, log,++ * metrics, the store, the wire helpers and the clock. Anything a system owns alone stays inside it.++ */++export class ServerContext {++ readonly sessions = new SessionRegistry()++ readonly clock: Clock = { tick: 0 }++ /** Coarse activation regions: the environment system moves them, NPC LOD and metrics read them. */++ readonly regions = new RegionTracker(32)++ /** The wire helpers; assigned right after this context is constructed. */++ net!: Net++ /** Every system; assigned once they all exist. */++ systems!: ServerSystems++ /** The join/leave hooks; assigned with the systems. */++ lifecycle!: Lifecycle++++ constructor(++ readonly config: ServerConfig,++ readonly world: GameWorld,++ readonly store: PersistenceStore,++ readonly metrics: ServerMetrics,++ readonly log: Logger,++ readonly integrations: GameIntegrations,++ /** Authoritative world environment: clock, weather, temperature. */++ readonly env: EnvironmentState,++ ) {}++++ /** The zod content registry the world was built with. */++ get content(): GameWorld['content'] {++ return this.world.content++ }++}nnew file mode 100644@@@ -0,0 +1,258 @@++import { REP_DELTAS, addReputation, stanceToward } from '@openvibe/gameplay'++import type {++ ClientJobAccept,++ ClientJobTurnIn,++ ClientMarketBuy,++ ClientMarketOpen,++ ClientMarketSell,++} from '@openvibe/protocol'++import type { EntityId } from '@openvibe/shared'++import type { PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import type { HandlerMap, System } from './system.js'++++export class EconomySystem implements System {++ readonly name = 'economy'++++ readonly handlers: HandlerMap = {++ market_open: (session, msg: ClientMarketOpen, _conn) => this.handleMarket(session, msg),++ market_buy: (session, msg: ClientMarketBuy, _conn) => this.handleMarket(session, msg),++ market_sell: (session, msg: ClientMarketSell, _conn) => this.handleMarket(session, msg),++ job_accept: (session, msg: ClientJobAccept, _conn) => this.handleJob(session, msg),++ job_turnin: (session, msg: ClientJobTurnIn, _conn) => this.handleJob(session, msg),++ }++++ constructor(private readonly ctx: ServerContext) {}++++ /** Live sell-stock per market: item -> remaining + next restock time. */++ private readonly marketStock = new Map<string, Map<string, { stock: number; at: number }>>()++++ /**++ * Reads the persisted market blobs. main.ts awaits this before starting the tick loop++ * (alongside the rest of the boot load).++ */++ async load(): Promise<void> {++ for (const { key, value } of await this.ctx.store.meta.list('market_')) {++ const market = new Map<string, { stock: number; at: number }>()++ if (value && typeof value === 'object') {++ for (const [item, state] of Object.entries(++ value as Record<string, { stock: number; at: number }>,++ )) {++ market.set(item, state)++ }++ }++ this.marketStock.set(key.slice('market_'.length), market)++ }++ }++++ /** The market blobs a checkpoint rides along with (one `market_<id>` per live market). */++ marketMeta(): Record<string, unknown> {++ const meta: Record<string, unknown> = {}++ for (const [id, market] of this.marketStock) meta[`market_${id}`] = Object.fromEntries(market)++ return meta++ }++++ /** Lazily restocked stock entry for one market sell line. */++ private stockOf(++ marketId: string,++ entry: { item: string; stock: number; restockSeconds: number },++ nowMs: number,++ ): { stock: number; at: number } {++ let market = this.marketStock.get(marketId)++ if (!market) {++ // Persisted market blobs are loaded at boot (GameServer.load); an unknown market starts fresh.++ market = new Map()++ this.marketStock.set(marketId, market)++ }++ let state = market.get(entry.item)++ if (!state) {++ state = { stock: entry.stock, at: nowMs + entry.restockSeconds * 1000 }++ market.set(entry.item, state)++ }++ if (nowMs >= state.at) {++ state.stock = entry.stock++ state.at = nowMs + entry.restockSeconds * 1000++ }++ return state++ }++++ /**++ * Market interactions: open/buy/sell against the market a shop prop++ * references. Everything server-atomic: proximity, faction stance,++ * live stock, coins and space all validated here.++ */++ private handleMarket(++ session: PlayerSession,++ msg: ClientMarketOpen | ClientMarketBuy | ClientMarketSell,++ ): void {++ const deny = (error: string) =>++ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: false, error })++ const entity = this.ctx.world.entities.get(msg.target as EntityId)++ const marketId = entity?.prop++ ? this.ctx.world.content.item(entity.prop.defId)?.shop?.market++ : undefined++ const market = marketId ? this.ctx.world.content.market(marketId) : undefined++ if (!entity || !market) return deny('no_merchant')++ const d = Math.hypot(++ entity.transform.pos.x - session.move.pos.x,++ entity.transform.pos.z - session.move.pos.z,++ )++ if (d > 5) return deny('out_of_range')++ const faction = this.ctx.world.content.faction(market.faction)++ const stance = faction ? stanceToward(faction, session.reputation) : 'neutral'++ if (stance === 'hostile') return deny('they_hate_you')++ const nowMs = Date.now()++++ if (msg.t === 'market_buy' && msg.item) {++ const entry = market.sells.find((s) => s.item === msg.item)++ if (!entry) return deny('no_such_trade')++ const state = this.stockOf(market.id, entry, nowMs)++ if (state.stock <= 0) return deny('out_of_stock')++ if (session.inventory.countOf('coin') < entry.price) return deny('missing_coins')++ if (!session.inventory.canFit(entry.item, entry.count)) return deny('inventory_full')++ const paid = session.inventory.consume([{ item: 'coin', count: entry.price }])++ if (!paid.ok) return deny('missing_coins')++ session.inventory.add(entry.item, entry.count)++ state.stock -= 1++ addReputation(session.reputation, market.faction, REP_DELTAS.trade)++ session.dirty = true++ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })++ this.ctx.net.sendInventory(session)++ this.sendReputation(session)++ } else if (msg.t === 'market_sell' && msg.item) {++ const entry = market.buys.find((b) => b.item === msg.item)++ if (!entry) return deny('no_such_trade')++ if (session.inventory.countOf(entry.item) < entry.count) return deny('missing_items')++ if (!session.inventory.canFit('coin', entry.price)) return deny('inventory_full')++ const taken = session.inventory.consume([{ item: entry.item, count: entry.count }])++ if (!taken.ok) return deny('missing_items')++ session.inventory.add('coin', entry.price)++ addReputation(session.reputation, market.faction, REP_DELTAS.trade)++ session.dirty = true++ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })++ this.ctx.net.sendInventory(session)++ this.sendReputation(session)++ }++ // Every path (including plain open) refreshes the market + contracts.++ this.sendJobs(session, market.id)++ this.ctx.net.send(session, {++ t: 'market',++ id: market.id,++ name: market.name,++ stance,++ sells: market.sells.map((s) => ({++ item: s.item,++ count: s.count,++ price: s.price,++ stock: this.stockOf(market.id, s, nowMs).stock,++ })),++ buys: market.buys.map((b) => ({ item: b.item, count: b.count, price: b.price })),++ })++ }++++ /** Contract accept/turn-in at a trading post. */++ private handleJob(session: PlayerSession, msg: ClientJobAccept | ClientJobTurnIn): void {++ const deny = (error: string) =>++ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: false, error })++ const entity = this.ctx.world.entities.get(msg.target as EntityId)++ const marketId = entity?.prop++ ? this.ctx.world.content.item(entity.prop.defId)?.shop?.market++ : undefined++ if (!entity || !marketId) return deny('no_merchant')++ const d = Math.hypot(++ entity.transform.pos.x - session.move.pos.x,++ entity.transform.pos.z - session.move.pos.z,++ )++ if (d > 5) return deny('out_of_range')++++ if (msg.t === 'job_accept' && msg.job) {++ const job = this.ctx.world.content.job(msg.job)++ if (!job || job.market !== marketId) return deny('no_such_job')++ if (session.activeJob) return deny('job_in_progress')++ session.activeJob = { job: job.id, progress: 0 }++ session.dirty = true++ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })++ this.ctx.net.send(session, { t: 'announce', text: `📋 Contract accepted: ${job.name}` })++ } else if (msg.t === 'job_turnin') {++ const job = session.activeJob ? this.ctx.world.content.job(session.activeJob.job) : undefined++ if (!session.activeJob || !job) return deny('no_active_job')++ if (job.market !== marketId) return deny('wrong_merchant')++ if (job.objective.kind === 'deliver') {++ const need = { item: job.objective.item, count: job.objective.count }++ if (session.inventory.countOf(need.item) < need.count) return deny('missing_items')++ const taken = session.inventory.consume([need])++ if (!taken.ok) return deny('missing_items')++ } else if (session.activeJob.progress < job.objective.count) {++ return deny('not_finished')++ }++ // Rewards: coins, reputation, items, XP — all atomic-enough (coins++ // and items overflow to the floor is prevented by canFit pre-check).++ if (job.reward.coins > 0 && !session.inventory.canFit('coin', job.reward.coins)) {++ return deny('inventory_full')++ }++ if (job.reward.coins > 0) session.inventory.add('coin', job.reward.coins)++ for (const it of job.reward.items) session.inventory.add(it.item, it.count)++ const market = this.ctx.world.content.market(marketId)++ if (job.reward.reputation !== 0 && market) {++ addReputation(session.reputation, market.faction, job.reward.reputation)++ this.sendReputation(session)++ }++ if (job.reward.xp) {++ const ups = session.skills.addXp(job.reward.xp.skill, job.reward.xp.amount)++ for (const up of ups)++ this.ctx.net.send(session, { t: 'levelup', skill: up.skill, level: up.level })++ this.ctx.net.sendSkills(session)++ }++ session.activeJob = null++ session.dirty = true++ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })++ this.ctx.net.send(session, { t: 'announce', text: `✅ Contract complete: ${job.name}` })++ this.ctx.net.sendInventory(session)++ }++ this.sendJobs(session, marketId)++ }++++ sendJobs(session: PlayerSession, marketId: string): void {++ const active = session.activeJob++ ? (() => {++ const job = this.ctx.world.content.job(session.activeJob!.job)++ if (!job) return null++ const goal = job.objective.count++ const progress =++ job.objective.kind === 'deliver'++ ? Math.min(goal, session.inventory.countOf(job.objective.item))++ : session.activeJob!.progress++ return {++ job: job.id,++ name: job.name,++ progress,++ goal,++ ready: progress >= goal,++ }++ })()++ : null++ this.ctx.net.send(session, {++ t: 'jobs',++ market: marketId,++ available: this.ctx.world.content.jobsForMarket(marketId).map((j) => ({++ id: j.id,++ name: j.name,++ description: j.description,++ done: false,++ })),++ active,++ })++ }++++ sendReputation(session: PlayerSession): void {++ this.ctx.net.send(session, {++ t: 'reputation',++ factions: this.ctx.world.content.allFactions().map((f) => ({++ id: f.id,++ name: f.name,++ value: Math.round(session.reputation[f.id] ?? 0),++ stance: stanceToward(f, session.reputation),++ })),++ })++ }++}nnew file mode 100644@@@ -0,0 +1,20 @@++import type { ServerContext } from './context.js'++import type { System } from './system.js'++++/**++ * The editor's one wire effect: tell every client the map changed. The reconcile itself is main.ts's++ * (it owns the map document) and stays there.++ */++export class EditorSystem implements System {++ readonly name = 'editor'++++ constructor(private readonly ctx: ServerContext) {}++++ broadcastMapReload(): void {++ this.ctx.net.broadcastAll({ t: 'map_reload' })++ this.ctx.net.broadcastAll({++ t: 'announce',++ text: '🗺 The world was reshaped by the map editors…',++ })++ }++}nnew file mode 100644@@@ -0,0 +1,100 @@++import {++ Buttons,++ activeStatuses,++ ambientTemperature,++ precipitation,++ stepEnvironment,++ tickSurvival,++ type WeatherKind,++} from '@openvibe/gameplay'++import { WATER_LEVEL, terrainHeight } from '@openvibe/content'++import type { ServerContext } from './context.js'++import type { System } from './system.js'++import { nearbyWorkstationKinds } from '../interactions.js'++++/** The five weather kinds a persisted env_weather may name. */++const WEATHERS = new Set<WeatherKind>(['clear', 'cloudy', 'rain', 'storm', 'fog'])++++export class EnvironmentSystem implements System {++ readonly name = 'environment'++++ constructor(private readonly ctx: ServerContext) {}++++ /**++ * Async boot: the world clock and weather are read from PostgreSQL.++ * main.ts awaits this before starting the tick loop.++ */++ async load(): Promise<void> {++ const rawTime = await this.ctx.store.meta.get('env_time')++ const savedTime = Number(rawTime ?? Number.NaN)++ if (Number.isFinite(savedTime)) this.ctx.env.timeOfDay = savedTime++ const savedWeather = await this.ctx.store.meta.get('env_weather')++ if (typeof savedWeather === 'string' && WEATHERS.has(savedWeather as WeatherKind)) {++ this.ctx.env.weather = savedWeather as WeatherKind++ }++ }++++ tick(ctx: ServerContext, _dt: number): void {++ if (ctx.clock.tick % ctx.config.tickRate !== 0) return++ const weatherBefore = ctx.env.weather++ stepEnvironment(ctx.env, 1, Math.random)++ if (ctx.env.weather !== weatherBefore) {++ ctx.net.broadcastAll(ctx.net.timeWire())++ ctx.log.info('weather changed', { from: weatherBefore, to: ctx.env.weather })++ }++ const ambientC = ambientTemperature(ctx.env)++ const raining = precipitation(ctx.env) > 0++ const nowMs = Date.now()++ // Region activation follows players (coarse, 1 Hz).++ ctx.regions.update(++ [...ctx.sessions.values()].map((s) => ({ x: s.move.pos.x, z: s.move.pos.z })),++ )++ for (const session of ctx.sessions.values()) {++ const sprinting =++ (session.buttons & Buttons.Sprint) !== 0 &&++ Math.hypot(session.move.vel.x, session.move.vel.z) > 1++ // Wet: wading/swimming, or out in the rain (no roof detection yet —++ // "indoors" arrives with the shelter model).++ const inWater =++ terrainHeight(ctx.world.content.world, session.move.pos.x, session.move.pos.z) <++ WATER_LEVEL - 0.03++ // Warmth: standing near a lit burn barrel (workstation scan; the++ // spatial index will replace this walk).++ const nearHeat = nearbyWorkstationKinds(session, ctx.world).has('campfire')++ const before = { ...session.stats }++ const statusesBefore = activeStatuses(session.stats, nowMs).join(',')++ const died = tickSurvival(session.stats, 1, {++ sprinting,++ ambientC,++ wet: inWater || raining,++ nearHeat,++ nowMs,++ })++ if (++ Math.round(before.health) !== Math.round(session.stats.health) ||++ Math.round(before.hunger) !== Math.round(session.stats.hunger) ||++ Math.round(before.thirst) !== Math.round(session.stats.thirst) ||++ Math.round(before.stamina) !== Math.round(session.stats.stamina) ||++ Math.round(before.bodyTemp * 2) !== Math.round(session.stats.bodyTemp * 2) ||++ activeStatuses(session.stats, nowMs).join(',') !== statusesBefore++ ) {++ session.statsDirty = true++ }++ if (died) {++ ctx.log.info('player died of exposure', { playerId: session.playerId })++ ctx.systems.combat.respawn(session, true)++ } else if (session.statsDirty) {++ ctx.net.send(session, { t: 'stats', ...ctx.net.statsWire(session) })++ session.statsDirty = false++ session.dirty = true++ }++ if (session.move.pos.y < -25) {++ ctx.systems.combat.respawn(session, false)++ ctx.log.info('void rescue', { playerId: session.playerId })++ }++ }++ if (ctx.clock.tick % (ctx.config.tickRate * 10) === 0) {++ ctx.net.broadcastAll(ctx.net.timeWire())++ }++ }++}nnew file mode 100644@@@ -0,0 +1,35 @@++/**++ * The gameplay systems (ADR-0007 M1). `gameServer.ts` is the coordinator: it constructs these,++ * assembles their message handlers in one table, and calls their ticks in the documented order.++ * Everything else lives in the file named after the system.++ */++export { ServerContext, SessionRegistry } from './context.js'++export type {++ Clock,++ GameConnection,++ GameIntegrations,++ Lifecycle,++ Net,++ ServerSystems,++} from './context.js'++export { buildHandlerTable } from './system.js'++export type { HandlerMap, MessageHandler, System } from './system.js'++++export { SessionsSystem } from './sessions.js'++export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'++export { ReplicationSystem } from './replication.js'++export { ManipulationSystem } from './manipulation.js'++export { CombatSystem } from './combat.js'++export { VehicleSystem } from './vehicles.js'++export { InventorySystem } from './inventory.js'++export { InteractionSystem } from './interaction.js'++export { EconomySystem } from './economy.js'++export { ProductionSystem } from './production.js'++export { NpcSystem } from './npcs.js'++export { SocialSystem } from './social.js'++export { WorldEventSystem } from './worldEvents.js'++export { PersistenceSystem } from './persistence.js'++export { ModSystem } from './mods.js'++export { EditorSystem } from './editor.js'++export { EnvironmentSystem } from './environment.js'++export { PhysicsSystem } from './physics.js'nnew file mode 100644@@@ -0,0 +1,105 @@++import { ambientTemperature, plantProgress, precipitation } from '@openvibe/gameplay'++import type { ClientUse } from '@openvibe/protocol'++import type { EntityId } from '@openvibe/shared'++import { handleUse } from '../interactions.js'++import type { PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import type { HandlerMap, System } from './system.js'++++/**++ * Using a tool or bare hand against the world (ADR-0007 M1): gathering resources, doors, plants,++ * repairing and disassembling — plus the world's resource-respawn sweep.++ */++export class InteractionSystem implements System {++ readonly name = 'interaction'++++ readonly handlers: HandlerMap = {++ use: (session: PlayerSession, msg: ClientUse, _conn) => {++ // Swing cooldown: silently drop spam faster than ~5 swings/sec.++ if (this.ctx.clock.tick - session.lastUseTick < 6) return++ const targetSession = this.ctx.sessions.getByEntity(msg.target as EntityId)++ if (targetSession) {++ // Legacy path: melee also arrives as 'use' from older flows.++ this.ctx.systems.combat.melee(session, targetSession)++ return++ }++ const vehicleTarget = this.ctx.world.entities.get(msg.target as EntityId)++ if (++ vehicleTarget?.prop &&++ this.ctx.world.content.item(vehicleTarget.prop.defId)?.vehiclePart?.part === 'chassis'++ ) {++ this.ctx.systems.vehicles.useVehicle(session, vehicleTarget)++ return++ }++ const gather = handleUse(++ session,++ this.ctx.world,++ msg,++ Date.now(),++ (e) => this.ctx.systems.manipulation.canManipulate(session, e),++ {++ nowMs: Date.now(),++ raining: precipitation(this.ctx.env) > 0,++ ambientC: ambientTemperature(this.ctx.env),++ },++ )++ this.ctx.net.send(session, gather.outcome)++ if (!gather.outcome.ok) return++ session.lastUseTick = this.ctx.clock.tick++ this.ctx.net.sendInventory(session)++ if (gather.xpChanged) this.ctx.net.sendSkills(session)++ for (const up of gather.levelUps) {++ this.ctx.net.send(session, { t: 'levelup', skill: up.skill, level: up.level })++ }++ if (gather.pickedUp) {++ if (session.held?.entityId === gather.pickedUp.id) {++ this.ctx.systems.manipulation.releaseHeld(session)++ }++ this.ctx.net.broadcastDespawn(gather.pickedUp.id)++ }++ if (gather.spawned) this.ctx.net.broadcastSpawn(gather.spawned)++ if (gather.changed?.prop) {++ // Door swing / plant change / repair: pin authoritative state.++ const e = gather.changed++ const plant = e.prop?.plant++ const max = this.ctx.world.content.item(e.prop!.defId)?.health?.max++ this.ctx.net.broadcastToKnowing(e.id, {++ t: 'entity',++ id: e.id,++ pos: [e.transform.pos.x, e.transform.pos.y, e.transform.pos.z],++ rot: [e.transform.rot.x, e.transform.rot.y, e.transform.rot.z, e.transform.rot.w],++ plant: (() => {++ const crop = plant ? this.ctx.world.content.crop(plant.crop) : undefined++ return plant && crop++ ? { crop: plant.crop, t: plantProgress(plant, crop), water: plant.water }++ : null++ })(),++ ...(max !== undefined ? { health: Math.round(e.prop!.health ?? max) } : {}),++ })++ }++ if (gather.changed?.resource) {++ this.ctx.net.broadcastToKnowing(gather.changed.id, {++ t: 'entity',++ id: gather.changed.id,++ remaining: gather.changed.resource.remaining,++ })++ }++ },++ }++++ constructor(private readonly ctx: ServerContext) {}++++ /** Resource respawn sweep (once a second). */++ respawnResources(): void {++ if (this.ctx.clock.tick % this.ctx.config.tickRate !== 0) return++ for (const entity of this.ctx.world.respawnDueResources(Date.now())) {++ if (entity.resource) {++ this.ctx.net.broadcastToKnowing(entity.id, {++ t: 'entity',++ id: entity.id,++ remaining: entity.resource.remaining,++ })++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,288 @@++import { WATER_LEVEL, terrainHeight } from '@openvibe/content'++import { clearStatus, containerAdd, containerSort, eat, type GameEntity } from '@openvibe/gameplay'++import type {++ ClientConsume,++ ClientContainerMove,++ ClientContainerOpen,++ ClientContainerSort,++ ClientDrink,++ ClientDrop,++ ClientEquipArmor,++ ClientHotbarSelect,++ ClientInvMove,++ ClientPlace,++} from '@openvibe/protocol'++import type { EntityId } from '@openvibe/shared'++import { equippedTool, handleDrop, handleInvMove, handlePlace } from '../interactions.js'++import { HOTBAR_SIZE, type PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import type { HandlerMap, System } from './system.js'++++/**++ * The player's items: worn armor, the hotbar, consumables, dropped and placed props, and the++ * containers they open and move things through (ADR-0007 M1).++ */++export class InventorySystem implements System {++ readonly name = 'inventory'++ readonly handlers: HandlerMap++++ constructor(private readonly ctx: ServerContext) {++ this.handlers = {++ equip_armor: (session, msg: ClientEquipArmor, _conn) => {++ if (msg.slot === undefined) {++ // Unequip into the first free inventory slot.++ if (!session.armor) {++ ctx.net.send(session, { t: 'result', action: 'inv_move', ok: false, error: 'no_armor' })++ return++ }++ if (!session.inventory.addStack(session.armor)) {++ ctx.net.send(session, {++ t: 'result',++ action: 'inv_move',++ ok: false,++ error: 'inventory_full',++ })++ return++ }++ session.armor = null++ session.dirty = true++ ctx.net.send(session, { t: 'result', action: 'inv_move', ok: true })++ ctx.net.sendInventory(session)++ return++ }++ const stack = session.inventory.get(msg.slot)++ const armorDef = stack ? ctx.world.content.item(stack.defId)?.armor : undefined++ if (!stack || !armorDef) {++ ctx.net.send(session, { t: 'result', action: 'inv_move', ok: false, error: 'not_armor' })++ return++ }++ const removed = session.inventory.removeFromSlot(msg.slot, 1)++ if (!removed) return++ // Fresh pieces get their full durability stamped into the stack.++ if (removed.meta?.dur === undefined) {++ removed.meta = { ...removed.meta, dur: armorDef.durability }++ }++ const previous = session.armor++ session.armor = removed++ if (previous) session.inventory.addStack(previous)++ session.dirty = true++ ctx.net.send(session, { t: 'result', action: 'inv_move', ok: true })++ ctx.net.sendInventory(session)++ },++ drop: (session, msg: ClientDrop, _conn) => {++ const { outcome, droppedId } = handleDrop(session, ctx.world, msg)++ ctx.net.send(session, outcome)++ if (outcome.ok) {++ ctx.net.sendInventory(session)++ if (droppedId) {++ const entity = ctx.world.entities.get(droppedId as EntityId)++ if (entity) ctx.net.broadcastSpawn(entity)++ }++ }++ },++ place: (session, msg: ClientPlace, _conn) => {++ const { outcome, placedId } = handlePlace(session, ctx.world, msg)++ ctx.net.send(session, outcome)++ if (outcome.ok) {++ ctx.net.sendInventory(session)++ if (placedId) {++ const entity = ctx.world.entities.get(placedId as EntityId)++ if (entity) ctx.net.broadcastSpawn(entity)++ }++ }++ },++ inv_move: (session, msg: ClientInvMove, _conn) => {++ const outcome = handleInvMove(session, msg)++ ctx.net.send(session, outcome)++ ctx.net.sendInventory(session)++ },++ hotbar: (session, msg: ClientHotbarSelect, _conn) => {++ if (msg.slot < HOTBAR_SIZE) {++ // Re-pressing the active slot holsters/unholsters (empty hands).++ if (msg.slot === session.activeHotbar) {++ session.holstered = !session.holstered++ } else {++ session.activeHotbar = msg.slot++ session.holstered = false++ }++ // Switching away from the physgun drops beam and anything held.++ if (equippedTool(session)?.kind !== 'physgun') {++ session.grabbing = false++ if (session.held) ctx.systems.manipulation.releaseHeld(session)++ }++ }++ },++ consume: (session, msg: ClientConsume, _conn) => {++ const stack = session.inventory.get(msg.slot)++ const def = stack ? ctx.world.content.item(stack.defId) : undefined++ // Blueprints: using one permanently unlocks its recipe.++ if (stack && def?.blueprint) {++ if (session.unlocks.has(def.blueprint.recipe)) {++ ctx.net.send(session, {++ t: 'result',++ action: 'consume',++ ok: false,++ error: 'already_known',++ })++ return++ }++ session.inventory.removeFromSlot(msg.slot, 1)++ session.unlocks.add(def.blueprint.recipe)++ session.dirty = true++ const recipeName = ctx.world.content.recipe(def.blueprint.recipe)?.name ?? 'recipe'++ ctx.net.send(session, { t: 'result', action: 'consume', ok: true })++ ctx.net.send(session, { t: 'announce', text: `📜 Blueprint learned: ${recipeName}!` })++ ctx.net.sendInventory(session)++ ctx.net.sendSkills(session)++ return++ }++ if (!stack || (!def?.food && !def?.medical)) {++ ctx.net.send(session, { t: 'result', action: 'consume', ok: false, error: 'not_food' })++ return++ }++ session.inventory.removeFromSlot(msg.slot, 1)++ if (def.food) eat(session.stats, def.food, Date.now())++ if (def.medical) {++ session.stats.health = Math.min(100, session.stats.health + def.medical.heal)++ if (def.medical.curesBleeding) clearStatus(session.stats, 'bleeding')++ }++ session.statsDirty = true++ session.dirty = true++ ctx.net.send(session, { t: 'result', action: 'consume', ok: true })++ ctx.net.send(session, { t: 'stats', ...ctx.net.statsWire(session) })++ session.statsDirty = false++ ctx.net.sendInventory(session)++ },++ drink: (session, _msg: ClientDrink, _conn) => {++ if (ctx.clock.tick - session.lastUseTick < 15) return++ const ground = terrainHeight(++ ctx.world.content.world,++ session.move.pos.x,++ session.move.pos.z,++ )++ if (ground > WATER_LEVEL - 0.03) {++ ctx.net.send(session, { t: 'result', action: 'consume', ok: false, error: 'no_water' })++ return++ }++ session.lastUseTick = ctx.clock.tick++ // A held watering can fills instead of drinking.++ const held = session.holstered ? null : session.inventory.get(session.activeHotbar)++ const fluidCap = held ? ctx.world.content.item(held.defId)?.fluidContainer : undefined++ if (held && fluidCap) {++ held.meta = { ...held.meta, fluid: fluidCap.capacity }++ session.dirty = true++ ctx.net.send(session, { t: 'result', action: 'consume', ok: true })++ ctx.net.sendInventory(session)++ return++ }++ session.stats.thirst = Math.min(100, session.stats.thirst + 30)++ session.statsDirty = true++ session.dirty = true++ ctx.net.send(session, { t: 'result', action: 'consume', ok: true })++ ctx.net.send(session, { t: 'stats', ...ctx.net.statsWire(session) })++ session.statsDirty = false++ },++ container_open: (session, msg: ClientContainerOpen, _conn) => {++ const entity = ctx.world.entities.get(msg.target as EntityId)++ const denied = this.containerAccessDenied(session, entity)++ if (denied || !entity?.prop?.container) {++ ctx.net.send(session, {++ t: 'result',++ action: 'container',++ ok: false,++ error: denied ?? 'no_container',++ })++ return++ }++ session.openContainer = entity.id++ ctx.net.sendContainer(session, entity)++ },++ container_move: (session, msg: ClientContainerMove, _conn) => {++ const entity = ctx.world.entities.get(msg.target as EntityId)++ const denied = this.containerAccessDenied(session, entity)++ const box = entity?.prop?.container++ if (denied || !entity || !box) {++ ctx.net.send(session, {++ t: 'result',++ action: 'container',++ ok: false,++ error: denied ?? 'no_container',++ })++ return++ }++ const maxStackOf = (defId: string) => ctx.world.content.item(defId)?.maxStack ?? 1++ let ok = false++ if (msg.dir === 'in') {++ const stack = session.inventory.get(msg.slot)++ if (stack) {++ const want = msg.count !== undefined ? Math.min(msg.count, stack.count) : stack.count++ const moved = containerAdd(box, stack.defId, want, maxStackOf)++ if (moved > 0) {++ session.inventory.removeFromSlot(msg.slot, moved)++ ok = true++ }++ }++ } else {++ const slot = box[msg.slot]++ if (slot) {++ const want = msg.count !== undefined ? Math.min(msg.count, slot.count) : slot.count++ const leftover = session.inventory.add(slot.defId, want)++ const moved = want - leftover++ if (moved > 0) {++ slot.count -= moved++ if (slot.count <= 0) box[msg.slot] = null++ ok = true++ }++ }++ }++ if (ok) {++ entity.dirty = true++ session.dirty = true++ ctx.net.sendInventory(session)++ // Push fresh contents to EVERYONE with this container open.++ for (const other of ctx.sessions.values()) {++ if (other.openContainer === entity.id) ctx.net.sendContainer(other, entity)++ }++ } else {++ ctx.net.send(session, { t: 'result', action: 'container', ok: false, error: 'no_space' })++ }++ },++ container_sort: (session, msg: ClientContainerSort, _conn) => {++ const entity = ctx.world.entities.get(msg.target as EntityId)++ const denied = this.containerAccessDenied(session, entity)++ const box = entity?.prop?.container++ if (denied || !entity || !box) {++ ctx.net.send(session, {++ t: 'result',++ action: 'container',++ ok: false,++ error: denied ?? 'no_container',++ })++ return++ }++ containerSort(box, (defId) => ctx.world.content.item(defId)?.maxStack ?? 1)++ entity.dirty = true++ ctx.net.send(session, { t: 'result', action: 'container', ok: true })++ for (const other of ctx.sessions.values()) {++ if (other.openContainer === entity.id) ctx.net.sendContainer(other, entity)++ }++ },++ }++ }++++ /** Range + prop-protection gate shared by all container operations. */++ private containerAccessDenied(++ session: PlayerSession,++ entity: GameEntity | undefined,++ ): string | null {++ if (!entity?.prop) return 'no_target'++ const d = Math.hypot(++ entity.transform.pos.x - session.move.pos.x,++ entity.transform.pos.y - session.move.pos.y,++ entity.transform.pos.z - session.move.pos.z,++ )++ if (d > 4.5) return 'out_of_range'++ if (!this.ctx.systems.manipulation.canManipulate(session, entity)) return 'not_owner'++ return null++ }++}nnew file mode 100644@@@ -0,0 +1,280 @@++import type { GameEntity } from '@openvibe/gameplay'++import type { ClientConstraint, ClientConstraintRemove, ClientPhysgun } from '@openvibe/protocol'++import { v3dist, vec3, type EntityId } from '@openvibe/shared'++import { equippedTool, handleConstraint } from '../interactions.js'++import { adjustDistance, driveHeld, freezeHeld, release, rotateHeld, tryGrab } from '../physgun.js'++import { eyePosition, type PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import type { HandlerMap, System } from './system.js'++++const _eyeScratch = vec3()++const _relVel = vec3()++++/**++ * Manipulation: the physgun beam and the rigging tool — everything gated by prop protection. It++ * owns who is holding what (the exclusivity latch) and the offline-owner friend cache that++ * `canManipulate` consults, so no other system ever has to look up an offline owner.++ */++export class ManipulationSystem implements System {++ readonly name = 'manipulation'++++ /** Exclusivity set for physgun grabs (one beam per prop). */++ private readonly heldEntityIds = new Set<string>()++ /** Short-lived cache of OFFLINE owners' friend lists (prop protection). */++ private readonly offlineFriendsCache = new Map<string, { friends: Set<string>; at: number }>()++ /** Offline-owner lookups in flight, so the tick never issues the same read twice. */++ private readonly friendsLoadInFlight = new Set<string>()++++ readonly handlers: HandlerMap = {++ physgun: (session, msg: ClientPhysgun, _conn) => {++ this.handlePhysgun(session, msg)++ },++ constraint: (session, msg: ClientConstraint, _conn) => {++ const { outcome, created } = handleConstraint(session, this.ctx.world, msg, (e) =>++ this.canManipulate(session, e),++ )++ this.ctx.net.send(session, outcome)++ if (created) {++ this.ctx.net.broadcastConstraintState(created, true)++ this.ctx.net.sendSkills(session)++ // Rope/spring/motor constraints consume materials.++ this.ctx.net.sendInventory(session)++ }++ },++ constraint_remove: (session, msg: ClientConstraintRemove, _conn) => {++ const ctx = this.ctx++ const tool = equippedTool(session)++ if (tool?.kind !== 'rigging') {++ ctx.net.send(session, {++ t: 'result',++ action: 'constraint',++ ok: false,++ error: 'requires_rigging_tool',++ })++ return++ }++ const target = ctx.world.entities.get(msg.target as EntityId)++ if (!target?.prop) {++ ctx.net.send(session, { t: 'result', action: 'constraint', ok: false, error: 'no_target' })++ return++ }++ if (!this.canManipulate(session, target)) {++ ctx.net.send(session, { t: 'result', action: 'constraint', ok: false, error: 'not_owner' })++ return++ }++ eyePosition(session, _eyeScratch)++ if (v3dist(_eyeScratch, target.transform.pos) > (tool.range ?? 6) + 1.5) {++ ctx.net.send(session, {++ t: 'result',++ action: 'constraint',++ ok: false,++ error: 'out_of_range',++ })++ return++ }++ const removed = ctx.world.removeConstraintsFor(target.id)++ ctx.net.send(session, {++ t: 'result',++ action: 'constraint',++ ok: removed.length > 0,++ ...(removed.length === 0 ? { error: 'no_constraints' } : {}),++ })++ for (const rec of removed) ctx.net.broadcastConstraintState(rec, false)++ },++ }++++ constructor(private readonly ctx: ServerContext) {}++++ /** A player joined: their own offline-friend entry is stale. */++ onJoin(session: PlayerSession): void {++ this.offlineFriendsCache.delete(session.playerId as string)++ }++++ /** A player left: keep protection checks fresh, and drop the beam latch (no broadcast). */++ onLeave(session: PlayerSession): void {++ this.offlineFriendsCache.set(session.playerId as string, {++ friends: new Set(session.friends),++ at: Date.now(),++ })++ if (session.held) {++ this.heldEntityIds.delete(session.held.entityId)++ session.held = null++ }++ }++++ /**++ * Prop protection: world props (no owner) are free; otherwise the owner++ * or anyone the OWNER trusts may manipulate. Works for offline owners via++ * a TTL-cached repository lookup.++ */++ canManipulate(session: PlayerSession, entity: GameEntity): boolean {++ if (entity.owner === undefined) return true++ if (entity.owner === session.playerId) return true++ const ownerSession = this.ctx.sessions.get(entity.owner)++ if (ownerSession) return ownerSession.friends.has(session.playerId)++ const cached = this.offlineFriendsCache.get(entity.owner)++ if (cached && Date.now() - cached.at < 30_000) {++ return cached.friends.has(session.playerId)++ }++ // Offline owner, no cached answer: start an async load and DENY this tick. The tick never awaits++ // I/O; the cached answer is used from the next tick on (the same 30 s TTL).++ const owner = entity.owner++ if (!this.friendsLoadInFlight.has(owner)) {++ this.friendsLoadInFlight.add(owner)++ void this.ctx.store.players++ .findById(owner)++ .then((found) => {++ this.offlineFriendsCache.set(owner, {++ friends: new Set(found?.friends ?? []),++ at: Date.now(),++ })++ })++ .catch((err: unknown) => {++ this.ctx.log.warn('offline owner lookup failed', { owner, error: String(err) })++ })++ .finally(() => this.friendsLoadInFlight.delete(owner))++ }++ return false++ }++++ /** Retry unlatched beams (sweep-to-grab) and drive held bodies. */++ tick(ctx: ServerContext, _dt: number): void {++ for (const session of ctx.sessions.values()) {++ if (session.grabbing && !session.held && equippedTool(session)?.kind === 'physgun') {++ this.attemptGrab(session)++ }++ if (session.held) driveHeld(session, ctx.world)++ }++ }++++ private handlePhysgun(session: PlayerSession, msg: ClientPhysgun): void {++ const ctx = this.ctx++ // Physgun actions require the physgun in the active hotbar slot.++ if ((msg.a === 'grab' || msg.a === 'unfreeze') && equippedTool(session)?.kind !== 'physgun') {++ ctx.net.send(session, {++ t: 'result',++ action: 'physgun',++ ok: false,++ error: 'no_physgun_equipped',++ })++ return++ }++ if (msg.a === 'grab') {++ if (session.held) return++ // Beam on: even with nothing under the crosshair, keep trying each++ // tick — sweeping the beam onto a prop picks it up (GMod behavior).++ session.grabbing = true++ const denied = this.attemptGrab(session)++ // Only meaningful denials are surfaced; an empty beam is not an error.++ if (denied && denied !== 'no_target') {++ ctx.net.send(session, { t: 'result', action: 'physgun', ok: false, error: denied })++ }++ } else if (msg.a === 'release') {++ session.grabbing = false++ this.releaseHeld(session)++ } else if (msg.a === 'adjust') {++ adjustDistance(session, msg.dist)++ } else if (msg.a === 'rotate') {++ rotateHeld(session, msg.dyaw, msg.dpitch, msg.snap ?? false, msg.snapStep)++ } else if (msg.a === 'grid') {++ if (session.held) {++ session.held.grid = msg.on++ if (msg.size !== undefined) session.held.gridSize = msg.size++ }++ } else if (msg.a === 'freeze') {++ // Freezing ends the beam — otherwise the sweep-to-grab retry would++ // immediately unfreeze what was just frozen.++ session.grabbing = false++ const frozen = freezeHeld(session, ctx.world)++ if (frozen) {++ this.heldEntityIds.delete(frozen.id)++ ctx.net.broadcastToKnowing(frozen.id, {++ t: 'entity',++ id: frozen.id,++ motion: 'frozen',++ pos: [frozen.transform.pos.x, frozen.transform.pos.y, frozen.transform.pos.z],++ rot: [++ frozen.transform.rot.x,++ frozen.transform.rot.y,++ frozen.transform.rot.z,++ frozen.transform.rot.w,++ ],++ })++ ctx.net.broadcastAll({ t: 'physgun_state', player: session.entityId, target: null })++ }++ } else if (msg.a === 'unfreeze') {++ const entity = ctx.world.entities.get(msg.target as EntityId)++ if (!entity?.prop || entity.prop.motion !== 'frozen') {++ ctx.net.send(session, { t: 'result', action: 'physgun', ok: false, error: 'no_target' })++ return++ }++ if (!ctx.world.zones.rulesAt(entity.transform.pos).physgun) {++ ctx.net.send(session, { t: 'result', action: 'physgun', ok: false, error: 'zone' })++ return++ }++ if (!this.canManipulate(session, entity)) {++ ctx.net.send(session, { t: 'result', action: 'physgun', ok: false, error: 'not_owner' })++ return++ }++ eyePosition(session, _eyeScratch)++ if (++ Math.hypot(++ entity.transform.pos.x - _eyeScratch.x,++ entity.transform.pos.y - _eyeScratch.y,++ entity.transform.pos.z - _eyeScratch.z,++ ) > 10++ ) {++ ctx.net.send(session, { t: 'result', action: 'physgun', ok: false, error: 'out_of_range' })++ return++ }++ ctx.world.setPropMotion(entity, 'dynamic')++ ctx.net.broadcastToKnowing(entity.id, { t: 'entity', id: entity.id, motion: 'dynamic' })++ }++ }++++ /** One grab attempt down the view ray; latches + broadcasts on success. */++ private attemptGrab(session: PlayerSession): string | null {++ const grabbed = tryGrab(session, this.ctx.world, this.heldEntityIds, (e) =>++ this.canManipulate(session, e),++ )++ if (typeof grabbed === 'string') return grabbed++ this.heldEntityIds.add(grabbed.id)++ // Grabbing a frozen prop unfreezes it — tell clients about the motion++ // change (physics resumes; frozen visuals must clear).++ this.ctx.net.broadcastToKnowing(grabbed.id, { t: 'entity', id: grabbed.id, motion: 'dynamic' })++ const grab = session.held?.localOffset++ this.ctx.net.broadcastAll({++ t: 'physgun_state',++ player: session.entityId,++ target: grabbed.id,++ ...(grab ? { grab: [grab.x, grab.y, grab.z] as [number, number, number] } : {}),++ })++ return null++ }++++ releaseHeld(session: PlayerSession): void {++ const ctx = this.ctx++ if (!session.held) return++ // Wake the released body: if it was driven into a sleeping neighbor,++ // the depenetration solver needs it active to push them apart.++ const bodyId = ctx.world.bodyOf(session.held.entityId)++ if (bodyId !== undefined) {++ ctx.world.physics.wake(bodyId)++ // Source-feel throw cap: the drive can move props at 45 m/s, but a++ // LET-GO should toss, not rocket-launch. Clamp exit velocity.++ ctx.world.physics.getLinearVelocity(bodyId, _relVel)++ const speed = Math.hypot(_relVel.x, _relVel.y, _relVel.z)++ const cap = 9++ if (speed > cap) {++ const k = cap / speed++ _relVel.x *= k++ _relVel.y *= k++ _relVel.z *= k++ ctx.world.physics.setLinearVelocity(bodyId, _relVel)++ }++ }++ this.heldEntityIds.delete(session.held.entityId)++ release(session)++ ctx.net.broadcastAll({ t: 'physgun_state', player: session.entityId, target: null })++ }++}nnew file mode 100644@@@ -0,0 +1,51 @@++import { terrainHeight } from '@openvibe/content'++import { qfromYaw, quat, vec3, type EntityId, type PlayerId } from '@openvibe/shared'++import type { ModHost } from '../../mods/runtime.js'++import type { ServerContext } from './context.js'++import type { System } from './system.js'++++/**++ * The mod runtime's host: the tick that reconciles installs and the capability surface it is lent.++ */++export class ModSystem implements System {++ readonly name = 'mods'++++ /**++ * What mods may do to the world, lent to the mod runtime only. Mod props++ * are owned by the mod id, so prop protection keeps players' hands off.++ */++ readonly host: ModHost = {++ announce: (text) => this.ctx.net.broadcastAll({ t: 'announce', text }),++ placeProp: ({ item, pos, yaw, owner }) => {++ const entity = this.ctx.world.spawnProp({++ defId: item,++ pos: vec3(++ pos[0],++ pos[1] + terrainHeight(this.ctx.world.content.world, pos[0], pos[2]),++ pos[2],++ ),++ rot: qfromYaw(quat(), yaw),++ motion: 'frozen',++ owner: owner as PlayerId,++ })++ this.ctx.net.broadcastSpawn(entity)++ return entity.id as string++ },++ removeEntity: (id) => {++ const entityId = id as EntityId++ if (!this.ctx.world.entities.get(entityId)) return false++ this.ctx.world.despawn(entityId)++ this.ctx.net.broadcastDespawn(entityId)++ return true++ },++ entityExists: (id) => this.ctx.world.entities.get(id as EntityId) !== undefined,++ }++++ constructor(private readonly ctx: ServerContext) {}++++ tick(ctx: ServerContext, _dt: number): void {++ // Reconcile installs (a revoked or disabled mod's effects are++ // retracted on the first tick after the change).++ ctx.integrations.mods?.tick(ctx.clock.tick, this.host)++ }++}nnew file mode 100644@@@ -0,0 +1,155 @@++import {++ DEFAULT_MOVEMENT,++ applyDamage,++ hullHeightFor,++ stepMovement,++ type CollisionQueries,++} from '@openvibe/gameplay'++import { CollisionLayer, type BodyId } from '@openvibe/physics'++import type { ClientEditMode, ClientInput } from '@openvibe/protocol'++import { qfromYaw, vec3 } from '@openvibe/shared'++import { canEditMap } from '../../net/networkAuth.js'++import type { PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import type { HandlerMap, System } from './system.js'++++export const MOVE = DEFAULT_MOVEMENT++/** Bounded input queue: the cap stops a client from buying simulation speed with backlog. */++export const MAX_INPUT_QUEUE = 6++++const _bodyPosScratch = vec3()++++/**++ * Authoritative movement: queued input commands become positions, the kinematic capsule follows,++ * stance swaps rebuild the hull, and falling hurts. Vehicle driving is the one exception — it++ * lives in the vehicles system and takes over a session's movement while it is mounted.++ */++export class MovementSystem implements System {++ readonly name = 'movement'++++ private readonly moveQueries: CollisionQueries++ /** Body excluded from the current movement sweep (the moving player's own). */++ private sweepSelf: BodyId | undefined++++ readonly handlers: HandlerMap = {++ input: (session, msg: ClientInput, _conn) => {++ if (session.inputQueue.length < MAX_INPUT_QUEUE) session.inputQueue.push(msg)++ },++ editmode: (session, msg: ClientEditMode, _conn) => {++ // Rank-gated noclip build mode. The flag lives in the move state so++ // server sim and client prediction stay in lockstep via the normal++ // self-state replication path.++ if (canEditMap(session.rank)) {++ session.move.noclip = msg.on++ session.move.vel.x = 0++ session.move.vel.y = 0++ session.move.vel.z = 0++ this.ctx.net.send(session, {++ t: 'announce',++ text: msg.on ? '🛠 Edit mode ON — noclip flight' : '🛠 Edit mode OFF',++ })++ }++ },++ }++++ constructor(private readonly ctx: ServerContext) {++ // Players block players: sweeps include the Player layer, minus the++ // mover's own kinematic body.++ this.moveQueries = {++ sweepCapsule: (from, to, radius, height) =>++ ctx.world.physics.sweepCapsule(++ from,++ to,++ radius,++ height,++ CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,++ this.sweepSelf,++ ),++ }++ }++++ tick(ctx: ServerContext, dt: number): void {++ for (const session of ctx.sessions.values()) {++ this.stepSession(ctx, session, dt)++ }++ }++++ private stepSession(ctx: ServerContext, session: PlayerSession, dt: number): void {++ if (session.driving) {++ ctx.systems.vehicles.driveVehicle(session)++ return++ }++ this.sweepSelf = ctx.sessions.bodyOf(session.playerId)++ // Process at most 2 queued inputs per tick (catch-up), else repeat the++ // last input — the queue bound caps client-driven speedup.++ const budget = session.inputQueue.length > 2 ? 2 : 1++ let simulated = 0++ for (let i = 0; i < budget; i++) {++ const input = session.inputQueue.shift()++ if (!input) break++ session.lastInput = input++ session.starvedTicks = 0++ session.lastProcessedSeq = input.seq++ session.yaw = input.yaw++ session.pitch = input.pitch++ session.buttons = input.buttons++ stepMovement(session.move, input, MOVE, this.moveQueries, dt)++ simulated++++ }++ if (simulated === 0 && session.lastInput) {++ // Bridge short network jitter by repeating the last command, but only++ // briefly — a silent client must coast to a stop, not walk forever.++ session.starvedTicks++++ // Zero MOVEMENT only — buttons stay held. Zeroing buttons fabricates++ // release edges for toggle keys (prone/crouch), so any client hitch++ // longer than 3 ticks made the server flap stances endlessly.++ const input =++ session.starvedTicks <= 3 ? session.lastInput : { ...session.lastInput, moveX: 0, moveZ: 0 }++ stepMovement(session.move, input, MOVE, this.moveQueries, dt)++ }++ let bodyId = ctx.sessions.bodyOf(session.playerId)++ if (bodyId !== undefined && session.bodyStance !== session.move.stance) {++ // Stance changed: swap the kinematic hull to match the new posture.++ ctx.world.physics.removeBody(bodyId)++ const hull = hullHeightFor(session.move.stance)++ bodyId = ctx.world.physics.addBody({++ shape: {++ type: 'capsule',++ radius: MOVE.capsuleRadius,++ height: Math.max(hull - 0.3, 0.4),++ },++ motion: 'kinematic',++ pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),++ layer: CollisionLayer.Player,++ collidesWith: CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,++ })++ ctx.sessions.setBody(session.playerId, bodyId)++ session.bodyStance = session.move.stance++ }++ if (bodyId !== undefined) {++ _bodyPosScratch.x = session.move.pos.x++ _bodyPosScratch.y = session.move.pos.y + 0.15++ _bodyPosScratch.z = session.move.pos.z++ ctx.world.physics.setTransform(bodyId, _bodyPosScratch)++ }++ // Fall damage: track the hardest downward velocity while airborne and++ // cash it in on landing. ~13 m/s (≈2.5 story drop) is the free threshold.++ if (!session.move.grounded) {++ session.fallVy = Math.min(session.fallVy, session.move.vel.y)++ } else if (session.fallVy < -13) {++ const dmg = Math.round((-session.fallVy - 13) * 3.5)++ session.fallVy = 0++ if (applyDamage(session.stats, dmg)) {++ ctx.log.info('fall death', { playerId: session.playerId })++ ctx.systems.combat.respawn(session, true)++ } else {++ session.statsDirty = true++ }++ } else {++ session.fallVy = 0++ }++ const entity = ctx.world.entities.get(session.entityId)++ if (entity) qfromYaw(entity.transform.rot, session.yaw)++ ctx.world.spatial.move(session.entityId, session.move.pos.x, session.move.pos.z)++ }++}nnew file mode 100644@@@ -0,0 +1,108 @@++import type { NpcState } from '@openvibe/gameplay'++import type { WorldEntityDto } from '@openvibe/persistence'++import { v3dist, type EntityId } from '@openvibe/shared'++import { NpcManager } from '../npcManager.js'++import type { ServerContext } from './context.js'++import type { System } from './system.js'++++/**++ * The server-authoritative NPC simulation (ADR-0007 M1): an {@link NpcManager} plus the two pieces++ * of mutable state its AI reads — the per-entity aggro deadlines (a player who recently attacked++ * draws defensive NPCs) and the per-second sound-event buffer (gunshots, fights) for NPC hearing.++ */++export class NpcSystem implements System {++ readonly name = 'npcs'++ /** Players who recently attacked someone (defensive NPCs respond); entity id -> aggro-until ms. */++ private readonly aggro = new Map<string, number>()++ /** Sound events (gunshots, fights) accumulated for NPC hearing. */++ private sounds: { x: number; z: number }[] = []++ private readonly npcs: NpcManager++++ constructor(private readonly ctx: ServerContext) {++ this.npcs = new NpcManager(++ ctx.world,++ ctx.regions,++ {++ onAttackPlayer: (npcId, playerEntityId, damage) => {++ const victim = ctx.sessions.getByEntity(playerEntityId as EntityId)++ const npcEntity = ctx.world.entities.get(npcId)++ if (!victim || !npcEntity) return++ // NPC melee has reach limits too (no cross-map slaps).++ const d = v3dist(npcEntity.transform.pos, victim.move.pos)++ if (d > 3) return++ ctx.systems.combat.damagePlayer(null, victim, {++ type: 'blunt',++ amount: damage,++ sourceId: npcId,++ })++ },++ soundsThisTick: () => this.sounds,++ hostilesNear: (x, z, radius, faction) => {++ const stance = ctx.world.content.faction(faction)?.playerStance ?? 'neutral'++ const nowMs = Date.now()++ const out: { id: string; x: number; z: number; hostile: boolean }[] = []++ for (const other of ctx.sessions.values()) {++ const dx = other.move.pos.x - x++ const dz = other.move.pos.z - z++ if (dx * dx + dz * dz > radius * radius) continue++ const aggro = (this.aggro.get(other.entityId as string) ?? 0) > nowMs++ const hostile = stance === 'hostile' || aggro++ out.push({++ id: other.entityId as string,++ x: other.move.pos.x,++ z: other.move.pos.z,++ hostile,++ })++ }++ return out++ },++ },++ ctx.log.child({ system: 'npc' }),++ )++ }++++ /** Boot: restore persisted NPCs, then seed content spawns not yet known. */++ async load(): Promise<void> {++ await this.npcs.seedOrRestore(this.ctx.store)++ }++++ /** One NPC simulation step; the sound buffer is cleared once a second. */++ tick(ctx: ServerContext, _dt: number): void {++ this.npcs.step(Date.now(), ctx.clock.tick, ctx.config.tickRate)++ if (ctx.clock.tick % ctx.config.tickRate === 0) this.sounds = []++ }++++ /** Damage an NPC (melee/ranged already mitigated upstream). */++ damage(entityId: EntityId, amount: number, nowMs: number): 'dead' | 'hurt' | null {++ return this.npcs.damage(entityId, amount, nowMs)++ }++++ npcStateOf(entityId: EntityId): NpcState | undefined {++ return this.npcs.npcStateOf(entityId)++ }++++ /** Counts for metrics: [full-sim, abstract]. */++ counts(): [number, number] {++ return this.npcs.counts()++ }++++ /** A sound event (gunshot, fight) for NPC hearing this second. */++ recordSound(x: number, z: number): void {++ this.sounds.push({ x, z })++ }++++ /** A player recently attacked, so defensive NPCs respond until `untilMs`. */++ markAggro(entityId: string, untilMs: number): void {++ this.aggro.set(entityId, untilMs)++ }++++ /** Copy-on-write snapshot of the NPC rows whose abstract state changed (for the flusher). */++ takeDirty(nowMs: number): WorldEntityDto[] {++ return this.npcs.takeDirty(nowMs)++ }++++ /** A failed flush: mark these NPCs dirty again. */++ restoreDirty(ids: readonly string[]): void {++ this.npcs.restoreDirty(ids)++ }++}nnew file mode 100644@@@ -0,0 +1,179 @@++import type { PlayerDto } from '@openvibe/persistence'++import { Flusher, type FlushCharacter, type FlushPayload } from '../flusher.js'++import type { PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import { eventPlayer, sessionProgress } from './platform.js'++import type { System } from './system.js'++++/**++ * The write-behind checkpoint cadence (ADR-0007 M1): the tick builds a copy-on-write snapshot of++ * everything dirty and hands it to the {@link Flusher}, which runs one database transaction at a++ * time. The tick never awaits I/O.++ */++export class PersistenceSystem implements System {++ readonly name = 'persistence'++++ private readonly flusher: Flusher++ private lastFlushTick = 0++++ constructor(private readonly ctx: ServerContext) {++ this.flusher = new Flusher(++ ctx.store,++ ctx.metrics,++ ctx.log.child({ system: 'persist' }),++ ctx.integrations.events,++ )++ }++++ /**++ * The tick's persistence call: builds the snapshot synchronously and hands it to the write-behind++ * flusher. It returns immediately; a checkpoint already in flight coalesces this one.++ */++ checkpoint(reason: 'checkpoint' | 'shutdown' = 'checkpoint'): void {++ this.flusher.checkpoint(() => this.buildFlushPayload(reason))++ }++++ /** The periodic checkpoint gate, called once per tick by GameServer.step. */++ tick(ctx: ServerContext, _dt: number): void {++ if (++ ctx.clock.tick - this.lastFlushTick >=++ ctx.config.persistFlushSeconds * ctx.config.tickRate++ ) {++ this.lastFlushTick = ctx.clock.tick++ this.checkpoint()++ }++ }++++ /**++ * Copy-on-write snapshot of everything dirty (clearing the flags as it copies). One transaction++ * writes meta, NPC/entity upserts and deletes, constraints, characters and the outbox events++ * describing them; on failure the snapshot's dirtiness is restored.++ */++ private buildFlushPayload(reason: 'checkpoint' | 'shutdown'): FlushPayload {++ const now = Date.now()++ const world = this.ctx.world.takeDirty()++ const npcs = this.ctx.systems.npcs.takeDirty(now)++ const characters: FlushCharacter[] = []++ for (const session of this.ctx.sessions.values()) {++ if (!session.dirty) continue++ session.dirty = false++ characters.push(this.flushCharacter(session))++ }++ // World clock + weather and the market blobs ride along with every checkpoint.++ const meta: Record<string, unknown> = {++ env_time: this.ctx.env.timeOfDay,++ env_weather: this.ctx.env.weather,++ }++ Object.assign(meta, this.ctx.systems.economy.marketMeta())++ if (world.entityUpserts.length > 0 || characters.length > 0) {++ this.ctx.log.debug('persistence checkpoint', {++ entities: world.entityUpserts.length,++ npcs: npcs.length,++ players: characters.length,++ })++ }++ return {++ reason,++ leaving: false,++ worldSaved: true,++ meta,++ entityUpserts: world.entityUpserts,++ entityDeletes: world.entityDeletes,++ npcUpserts: npcs,++ constraintUpserts: world.constraintUpserts,++ constraintDeletes: world.constraintDeletes,++ characters,++ worldSavedCount: world.entityUpserts.length,++ restore: () => {++ this.ctx.world.restoreDirty(world)++ this.ctx.systems.npcs.restoreDirty(npcs.map((n) => n.id))++ // A disconnected session was saved by its own queued save, which runs after this one.++ for (const c of characters) {++ if (this.ctx.sessions.get(c.session.playerId) !== undefined) c.session.dirty = true++ }++ },++ }++ }++++ private flushCharacter(session: PlayerSession): FlushCharacter {++ return {++ session,++ dto: this.playerToDto(session),++ player: eventPlayer(session),++ progress: sessionProgress(session),++ }++ }++++ /** Full save on shutdown; main.ts awaits drain() after this. */++ shutdown(): void {++ for (const session of this.ctx.sessions.values()) session.dirty = true++ // Never coalesced: behind a checkpoint still in flight this one is queued, not dropped.++ this.flusher.checkpoint(() => this.buildFlushPayload('shutdown'), { final: true })++ this.ctx.log.info('world saved on shutdown', {})++ }++++ /** Resolves when the write-behind queue is empty (shutdown). */++ async drain(timeoutMs = 25_000): Promise<boolean> {++ return this.flusher.drain(timeoutMs)++ }++++ /**++ * Saves one character; `leaving` also records games.player.left in the same transaction. The DTO is++ * built synchronously and the transaction is queued on the same queue as checkpoints, so a++ * disconnect save always lands after an earlier checkpoint of that character. The caller does not++ * await it.++ */++ savePlayer(session: PlayerSession, leaving = false): void {++ session.dirty = false++ this.flusher.savePlayer(++ {++ reason: 'checkpoint',++ leaving,++ worldSaved: false,++ meta: {},++ entityUpserts: [],++ entityDeletes: [],++ npcUpserts: [],++ constraintUpserts: [],++ constraintDeletes: [],++ characters: [this.flushCharacter(session)],++ worldSavedCount: 0,++ restore: () => {++ if (this.ctx.sessions.get(session.playerId) !== undefined) session.dirty = true++ },++ },++ () => this.ctx.sessions.get(session.playerId) !== undefined,++ )++ }++++ /**++ * A session left the world; it has already been removed from the registry, so the save's `returned`++ * check sees it as gone. The session system calls this through `ctx.lifecycle.left(session)`.++ */++ onLeave(session: PlayerSession): void {++ this.savePlayer(session, true)++ }++++ private playerToDto(session: PlayerSession): PlayerDto {++ const { pos } = session.move++ return {++ id: session.playerId as string,++ token: session.token,++ ...(session.subjectId ? { subjectId: session.subjectId } : {}),++ charSlot: session.charSlot,++ name: session.name,++ pos: [pos.x, pos.y, pos.z],++ yaw: session.yaw,++ inventory: session.inventory.toDto(),++ skills: session.skills.toDto(),++ friends: [...session.friends],++ appearance: session.appearance,++ stats: session.stats,++ armor: session.armor,++ reputation: session.reputation,++ unlocks: [...session.unlocks],++ activeJob: session.activeJob,++ updatedAt: Date.now(),++ }++ }++}nnew file mode 100644@@@ -0,0 +1,38 @@++import type { ServerContext } from './context.js'++import type { System } from './system.js'++++/**++ * The fixed-step physics: advance the world, sync awake props and announce settles so clients pin++ * final transforms. The measured step duration is exposed for the tick metrics.++ */++export class PhysicsSystem implements System {++ readonly name = 'physics'++ /** Duration of the last physics step, for `metrics.recordTick`. */++ physicsMs = 0++++ constructor(private readonly ctx: ServerContext) {}++++ tick(ctx: ServerContext, dt: number): void {++ const physStart = performance.now()++ ctx.world.physics.step(dt)++ this.physicsMs = performance.now() - physStart++++ const { awake, settledCount } = ctx.world.syncFromPhysics({++ onSettle: (entity) => {++ ctx.net.broadcastToKnowing(entity.id, {++ t: 'entity',++ id: entity.id,++ pos: [entity.transform.pos.x, entity.transform.pos.y, entity.transform.pos.z],++ rot: [++ entity.transform.rot.x,++ entity.transform.rot.y,++ entity.transform.rot.z,++ entity.transform.rot.w,++ ],++ })++ },++ })++ ctx.metrics.awakeBodies = awake++ ctx.metrics.settledBodies = settledCount++ }++}nnew file mode 100644@@@ -0,0 +1,29 @@++import type { SkillSet } from '@openvibe/gameplay'++import type { EventPlayer, ProgressSnapshot } from '../../platform/gameEvents.js'++import type { PlayerSession } from '../playerSession.js'++++/**++ * Pure adapters between a session and the platform event/progress payloads. Both the session++ * lifecycle (join) and the persistence flusher (saves, leaves) need them, so they live here rather++ * than inside either system.++ */++++/** A session as the platform event recorder sees it. */++export function eventPlayer(session: PlayerSession): EventPlayer {++ return {++ playerId: session.playerId as string,++ slot: session.charSlot,++ name: session.name,++ subjectId: session.subjectId,++ }++}++++export function progressOf(skills: SkillSet, unlocks: Iterable<string>): ProgressSnapshot {++ const levels: Record<string, number> = {}++ for (const skill of skills.all()) levels[skill.id] = skill.level++ return { levels, unlocks: [...unlocks] }++}++++export function sessionProgress(session: PlayerSession): ProgressSnapshot {++ return progressOf(session.skills, session.unlocks)++}nnew file mode 100644@@@ -0,0 +1,228 @@++import { recipeSkill, recipeXp } from '@openvibe/content'++import {++ ambientTemperature,++ completeJob,++ type GameEntity,++ plantProgress,++ plantStage,++ precipitation,++ tryStartJob,++ updatePlant,++ waterPlant,++} from '@openvibe/gameplay'++import type { ClientCraft } from '@openvibe/protocol'++import { handleCraft } from '../interactions.js'++import type { ServerContext } from './context.js'++import type { HandlerMap, System } from './system.js'++++export class ProductionSystem implements System {++ readonly name = 'production'++++ readonly handlers: HandlerMap = {++ craft: (session, msg: ClientCraft, _conn) => {++ const outcome = handleCraft(++ session,++ this.ctx.world,++ msg,++ this.ctx.clock.tick,++ this.ctx.config.tickRate,++ )++ this.ctx.net.send(session, outcome)++ if (outcome.ok) {++ this.ctx.net.sendInventory(session)++ this.ctx.net.sendCraftState(session)++ }++ },++ }++++ constructor(private readonly ctx: ServerContext) {}++++ /**++ * Utility + production sweep, once a second over prop entities. Plants++ * stay timestamp-lazy (a slower cadence touches them); machines and++ * generators are few, and their jobs are timestamp-based too — this++ * sweep only notices completions/starts, it never simulates.++ */++ tickProduction(): void {++ const nowMs = Date.now()++ const raining = precipitation(this.ctx.env) > 0++ const ambientC = ambientTemperature(this.ctx.env)++ const content = this.ctx.world.content++ const maxStackOf = (defId: string) => content.item(defId)?.maxStack ?? 1++ // Pass 1: generators burn fuel; remember active ones for power checks.++ const activePower: { x: number; z: number; radius: number }[] = []++ for (const entity of this.ctx.world.entities.ofKind('prop')) {++ if (!entity.prop) continue++ const def = content.item(entity.prop.defId)++ if (!def?.powerProducer) continue++ if ((entity.prop.burnUntil ?? 0) <= nowMs && entity.prop.container) {++ // Reload: burn the first fuel item in the hopper.++ for (let i = 0; i < entity.prop.container.length; i++) {++ const slot = entity.prop.container[i]++ const fuel = slot ? content.item(slot.defId)?.fuel : undefined++ if (!slot || !fuel) continue++ slot.count -= 1++ if (slot.count <= 0) entity.prop.container[i] = null++ entity.prop.burnUntil =++ Math.max(nowMs, entity.prop.burnUntil ?? 0) + fuel.burnSeconds * 1000++ entity.dirty = true++ for (const other of this.ctx.sessions.values()) {++ if (other.openContainer === entity.id) this.ctx.net.sendContainer(other, entity)++ }++ break++ }++ }++ if ((entity.prop.burnUntil ?? 0) > nowMs) {++ activePower.push({++ x: entity.transform.pos.x,++ z: entity.transform.pos.z,++ radius: def.powerProducer.radius,++ })++ }++ }++ const powered = (x: number, z: number): boolean =>++ activePower.some((p) => Math.hypot(p.x - x, p.z - z) <= p.radius)++++ // Pass 2: machines complete/start jobs (timestamp-based).++ for (const entity of this.ctx.world.entities.ofKind('prop')) {++ if (!entity.prop?.container) continue++ const def = content.item(entity.prop.defId)++ const machine = def?.machine++ if (!machine) continue++ entity.prop.machine ??= {}++ const shape = { inputSlots: machine.inputSlots, outputSlots: machine.outputSlots }++ let changed = false++ if (++ completeJob(++ entity.prop.machine,++ entity.prop.container,++ shape,++ (id) => content.recipe(id),++ maxStackOf,++ nowMs,++ )++ ) {++ changed = true++ }++ const hasPower =++ !machine.needsPower || powered(entity.transform.pos.x, entity.transform.pos.z)++ if (++ hasPower &&++ tryStartJob(++ entity.prop.machine,++ entity.prop.container,++ shape,++ content.machineRecipes(machine.kind),++ nowMs,++ )++ ) {++ changed = true++ }++ if (changed) {++ entity.dirty = true++ for (const other of this.ctx.sessions.values()) {++ if (other.openContainer === entity.id) this.ctx.net.sendContainer(other, entity)++ }++ }++ }++++ // Pass 3 (every 5s): tanks catch rain; sprinklers water nearby planters.++ if (this.ctx.clock.tick % (this.ctx.config.tickRate * 5) === 0) {++ const tanks: { entity: GameEntity; capacity: number }[] = []++ for (const entity of this.ctx.world.entities.ofKind('prop')) {++ const cap = entity.prop ? content.item(entity.prop.defId)?.waterTank : undefined++ if (!entity.prop || !cap) continue++ if (raining && (entity.prop.waterAmount ?? 0) < cap.capacity) {++ entity.prop.waterAmount = Math.min(cap.capacity, (entity.prop.waterAmount ?? 0) + 2.5)++ entity.dirty = true++ }++ tanks.push({ entity, capacity: cap.capacity })++ }++ for (const entity of this.ctx.world.entities.ofKind('prop')) {++ const spr = entity.prop ? content.item(entity.prop.defId)?.sprinkler : undefined++ if (!entity.prop || !spr) continue++ const tank = tanks.find(++ (t) =>++ (t.entity.prop!.waterAmount ?? 0) >= 0.5 &&++ Math.hypot(++ t.entity.transform.pos.x - entity.transform.pos.x,++ t.entity.transform.pos.z - entity.transform.pos.z,++ ) <= spr.tankRange,++ )++ if (!tank) continue++ // Planters via the spatial hash (no all-props walk per sprinkler).++ this.ctx.world.spatial.forEachInRadius(++ entity.transform.pos.x,++ entity.transform.pos.z,++ spr.radius,++ (id) => {++ const planter = this.ctx.world.entities.get(id)++ const plant = planter?.prop?.plant++ if (!planter || !plant) return++ if (++ Math.hypot(++ planter.transform.pos.x - entity.transform.pos.x,++ planter.transform.pos.z - entity.transform.pos.z,++ ) > spr.radius++ ) {++ return++ }++ const crop = content.crop(plant.crop)++ if (!crop) return++ updatePlant(plant, crop, { nowMs, raining, ambientC })++ if ((tank.entity.prop!.waterAmount ?? 0) >= 0.5 && waterPlant(plant)) {++ tank.entity.prop!.waterAmount = (tank.entity.prop!.waterAmount ?? 0) - 0.5++ tank.entity.dirty = true++ planter.dirty = true++ }++ },++ )++ }++ }++++ // Pass 4 (every 15s): lazy plant advance + growth-stage broadcasts.++ if (this.ctx.clock.tick % (this.ctx.config.tickRate * 15) !== 0) return++ for (const entity of this.ctx.world.entities.ofKind('prop')) {++ const plant = entity.prop?.plant++ if (!plant) continue++ const crop = content.crop(plant.crop)++ if (!crop) continue++ const beforeStage = plantStage(plant, crop)++ const beforeWater = plant.water++ updatePlant(plant, crop, { nowMs, raining, ambientC })++ entity.dirty = true++ const stageChanged = plantStage(plant, crop) !== beforeStage++ const driedOut = beforeWater > 0 && plant.water <= 0++ if (stageChanged || driedOut) {++ this.ctx.net.broadcastToKnowing(entity.id, {++ t: 'entity',++ id: entity.id,++ plant: { crop: plant.crop, t: plantProgress(plant, crop), water: plant.water },++ })++ }++ }++ }++++ tickCraftQueues(): void {++ // 5. Crafting queues (completions grant crafting/construction XP).++ for (const session of this.ctx.sessions.values()) {++ const completed = session.craftQueue.update(++ this.ctx.clock.tick,++ this.ctx.world.content,++ session.inventory,++ )++ if (completed.length > 0) {++ session.dirty = true++ this.ctx.net.sendInventory(session)++ this.ctx.net.sendCraftState(session)++ for (const recipe of completed) {++ 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.net.sendSkills(session)++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,208 @@++import { activeStatuses, type GameEntity } from '@openvibe/gameplay'++import { encodeServerMessage, type ServerMessage, type ServerStats } from '@openvibe/protocol'++import type { EntityId } from '@openvibe/shared'++import type { ConstraintRecord } from '../gameWorld.js'++import type { PlayerSession } from '../playerSession.js'++import { buildSnapshot, updateInterest, wireEntityFor } from '../replication.js'++import type { Net, ServerContext } from './context.js'++import type { System } from './system.js'++++/**++ * Replication: the one place every system sends through, plus interest management and the++ * authoritative snapshot phase. It owns the byte/message counters and the geometry of who knows++ * what; it holds no gameplay state of its own.++ */++export class ReplicationSystem implements System, Net {++ readonly name = 'replication'++++ constructor(private readonly ctx: ServerContext) {}++++ tick(ctx: ServerContext, _dt: number): void {++ // Snapshots go out at `tickRate / snapshotEvery` Hz (15 with the defaults).++ if (ctx.clock.tick % ctx.config.snapshotEvery === 0) this.replicate()++ }++++ // ── The wire surface (Net) ─────────────────────────────────────────++++ send(session: PlayerSession, msg: ServerMessage): void {++ this.sendRaw(session, encodeServerMessage(msg))++ }++++ sendRaw(session: PlayerSession, encoded: string): void {++ session.send(encoded)++ this.ctx.metrics.bytesOut += encoded.length++ this.ctx.metrics.messagesOut++++ }++++ sendInventory(session: PlayerSession): void {++ const dto = session.inventory.toDto()++ this.send(session, {++ t: 'inventory',++ inv: {++ size: dto.size,++ hotbar: dto.hotbar,++ slots: dto.slots.map(({ i, stack }) => ({++ i,++ stack: {++ def: stack.defId,++ count: stack.count,++ ...(stack.meta !== undefined ? { meta: stack.meta } : {}),++ },++ })),++ },++ activeHotbar: session.activeHotbar,++ armor: session.armor++ ? {++ def: session.armor.defId,++ count: session.armor.count,++ ...(session.armor.meta !== undefined ? { meta: session.armor.meta } : {}),++ }++ : null,++ })++ }++++ sendSkills(session: PlayerSession): void {++ this.send(session, {++ t: 'skills',++ skills: session.skills.all(),++ unlocks: [...session.unlocks],++ })++ }++++ sendCraftState(session: PlayerSession): void {++ this.send(session, {++ t: 'craft_state',++ jobs: session.craftQueue.pending.map((j) => ({ recipe: j.recipeId, readyTick: j.readyTick })),++ })++ }++++ sendContainer(session: PlayerSession, entity: GameEntity): void {++ const box = entity.prop?.container ?? []++ this.send(session, {++ t: 'container',++ id: entity.id,++ size: box.length,++ slots: box.flatMap((slot, i) => (slot ? [{ i, def: slot.defId, count: slot.count }] : [])),++ })++ }++++ timeWire(): ServerMessage {++ return { t: 'time', frac: this.ctx.env.timeOfDay, weather: this.ctx.env.weather }++ }++++ statsWire(session: PlayerSession): Omit<ServerStats, 't' | 'died'> {++ return {++ hp: Math.round(session.stats.health),++ hunger: Math.round(session.stats.hunger),++ thirst: Math.round(session.stats.thirst),++ stamina: Math.round(session.stats.stamina),++ temp: Math.round(session.stats.bodyTemp * 10) / 10,++ statuses: activeStatuses(session.stats, Date.now()) as string[],++ }++ }++++ broadcastAll(msg: ServerMessage): void {++ const encoded = encodeServerMessage(msg)++ for (const session of this.ctx.sessions.values()) this.sendRaw(session, encoded)++ }++++ broadcastSpawn(entity: GameEntity): void {++ // Deliver immediately to sessions in range; interest diff would send it++ // next snapshot anyway, but placement feedback should be instant.++ const wire = wireEntityFor(this.ctx.world, entity)++ const encoded = encodeServerMessage({ t: 'spawn', entities: [wire] })++ const radiusSq = this.ctx.config.interestRadius ** 2++ for (const session of this.ctx.sessions.values()) {++ const d2 =++ (entity.transform.pos.x - session.move.pos.x) ** 2 +++ (entity.transform.pos.y - session.move.pos.y) ** 2 +++ (entity.transform.pos.z - session.move.pos.z) ** 2++ if (d2 <= radiusSq) {++ session.known.add(entity.id)++ this.sendRaw(session, encoded)++ }++ }++ }++++ /** Constraint create/remove: tell every client that knows either prop. */++ broadcastConstraintState(rec: ConstraintRecord, active: boolean): void {++ const msg = constraintStateWire(rec, active)++ const encoded = encodeServerMessage(msg)++ for (const session of this.ctx.sessions.values()) {++ if (session.known.has(rec.a) || session.known.has(rec.b)) {++ this.sendRaw(session, encoded)++ }++ }++ }++++ broadcastDespawn(id: string): void {++ for (const session of this.ctx.sessions.values()) {++ if (session.known.delete(id as EntityId)) {++ this.send(session, { t: 'despawn', ids: [id] })++ }++ }++ }++++ broadcastToKnowing(id: EntityId, msg: ServerMessage): void {++ const encoded = encodeServerMessage(msg)++ for (const session of this.ctx.sessions.values()) {++ if (session.known.has(id)) this.sendRaw(session, encoded)++ }++ }++++ // ── Interest + snapshots ───────────────────────────────────────────++++ replicate(): void {++ const ctx = this.ctx++ for (const session of ctx.sessions.values()) {++ const diff = updateInterest(session, ctx.world, ctx.config.interestRadius)++ if (diff.entered.length > 0) {++ this.send(session, {++ t: 'spawn',++ entities: diff.entered.map((e) => {++ const wire = wireEntityFor(ctx.world, e)++ const owner = ctx.sessions.getByEntity(e.id)++ if (owner) {++ wire.name = owner.name++ wire.player = owner.playerId as string++ wire.appearance = owner.appearance++ }++ return wire++ }),++ })++ }++ if (diff.left.length > 0) {++ this.send(session, { t: 'despawn', ids: diff.left as string[] })++ }++ // Constraints touching newly-known props (visuals need endpoints).++ if (diff.entered.length > 0) {++ const sent = new Set<string>()++ for (const entity of diff.entered) {++ for (const rec of ctx.world.constraintsFor(entity.id)) {++ if (sent.has(rec.id)) continue++ sent.add(rec.id)++ this.send(session, constraintStateWire(rec, true))++ }++ }++ }++ const snapshot = buildSnapshot(session, ctx.world, ctx.sessions.values(), ctx.clock.tick)++ const encoded = encodeServerMessage(snapshot)++ ctx.metrics.snapshotBytes = encoded.length++ this.sendRaw(session, encoded)++ }++ }++}++++export function constraintStateWire(rec: ConstraintRecord, active: boolean): ServerMessage {++ return {++ t: 'constraint_state',++ id: rec.id,++ kind: rec.type,++ a: rec.a as string,++ b: rec.b as string,++ anchorA: rec.params.anchorA ?? [0, 0, 0],++ anchorB: rec.params.anchorB ?? [0, 0, 0],++ ...(rec.params.length !== undefined ? { length: rec.params.length } : {}),++ active,++ }++}nnew file mode 100644@@@ -0,0 +1,300 @@++import { STARTER_ITEMS, worldSpawn } from '@openvibe/content'++import { Inventory, SkillSet, type GameEntity } from '@openvibe/gameplay'++import { CollisionLayer } from '@openvibe/physics'++import { PROTOCOL_VERSION, encodeServerMessage, type ClientHello } from '@openvibe/protocol'++import { asPlayerId, newEntityId, newPlayerId, qfromYaw, quat, vec3 } from '@openvibe/shared'++import { resolveNetworkUser } from '../../net/networkAuth.js'++import { isGuestToken } from '../../platform/accounts.js'++import type { GameConnection, ServerContext } from './context.js'++import { MOVE } from './movement.js'++import { eventPlayer, progressOf } from './platform.js'++import { createSession, HOTBAR_SIZE, INVENTORY_SIZE } from '../playerSession.js'++import type { HandlerMap, System } from './system.js'++++/** A JWT's iat (seconds), read from a token the Network has already accepted; null otherwise. */++function tokenIat(jwt: string): number | null {++ const part = jwt.split('.')[1]++ if (!part) return null++ try {++ const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as { iat?: unknown }++ return typeof claims.iat === 'number' ? claims.iat : null++ } catch {++ return null++ }++}++++/**++ * Connect, hello, disconnect, supersede and revocation close: everything about a session's++ * lifetime in the world. It owns no gameplay state — the session record itself is the state, and++ * the registry in the context is how every other system reaches it.++ */++export class SessionsSystem implements System {++ readonly name = 'sessions'++++ /** Connections whose hello is still resolving (a second one while it is refused). */++ private readonly hellosInFlight = new Set<GameConnection>()++++ readonly handlers: HandlerMap++++ constructor(private readonly ctx: ServerContext) {++ this.handlers = {++ hello: (session, msg, conn) => {++ // A session already exists: a duplicate hello is silently ignored.++ if (session) return++ // A second hello while the first is still resolving (it awaits Network) is refused, so two++ // frames cannot both create a session for one connection.++ if (this.hellosInFlight.has(conn)) {++ conn.close(4008, 'hello_in_flight')++ return++ }++ void this.handleHello(conn, msg as ClientHello)++ },++ }++ }++++ /** Players connected now (the readiness `sessions` check; a restart would disconnect them). */++ onlineCount(): number {++ return this.ctx.sessions.size++ }++++ onDisconnect(conn: GameConnection): void {++ const session = this.ctx.sessions.getByConn(conn)++ if (!session) return++ this.ctx.sessions.remove(conn)++ // Systems that hold per-session state release it: the offline friend cache and the physgun++ // latch (manipulation), and the disconnect save (persistence, which records player.left).++ this.ctx.lifecycle.left(session)++ const bodyId = this.ctx.sessions.bodyOf(session.playerId)++ if (bodyId !== undefined) {++ this.ctx.world.physics.removeBody(bodyId)++ this.ctx.sessions.deleteBody(session.playerId)++ }++ this.ctx.world.entities.remove(session.entityId)++ this.ctx.world.spatial.remove(session.entityId)++ void this.ctx.integrations.progressSummary?.playerLeft(++ session.playerId as string,++ session.subjectId,++ progressOf(session.skills, session.unlocks).levels,++ this.ctx.world.content.world.id,++ )++ this.ctx.net.broadcastDespawn(session.entityId)++ this.ctx.metrics.sessions = this.ctx.sessions.size++ this.ctx.log.info('player disconnected', { playerId: session.playerId, name: session.name })++ }++++ /** Guards one hello per connection while it resolves (it awaits Network); see the handler. */++ private async handleHello(conn: GameConnection, msg: ClientHello): Promise<void> {++ this.hellosInFlight.add(conn)++ try {++ await this.resolveHello(conn, msg)++ } finally {++ this.hellosInFlight.delete(conn)++ }++ }++++ private async resolveHello(conn: GameConnection, msg: ClientHello): Promise<void> {++ const ctx = this.ctx++ if (msg.v !== PROTOCOL_VERSION) {++ conn.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))++ conn.close(4002, 'protocol_mismatch')++ return++ }++ if (ctx.sessions.size >= ctx.config.maxPlayers) {++ conn.send(encodeServerMessage({ t: 'reject', reason: 'server_full' }))++ conn.close(4003, 'server_full')++ return++ }++++ const slot = msg.slot ?? 0++ // Account resolution. openvibe.network sign-in keys the account on the++ // canonical subject (3 slots, follows you across devices). Guests get ONE++ // character bound to their connection's browser token.++ let token = msg.token++ let subjectId: string | null = null++ let authIat: number | null = null++ let rank: 'owner' | 'admin' | 'moderator' | null = null++ if (msg.auth) {++ const user = await resolveNetworkUser(ctx.config.networkAuthUrl, msg.auth)++ // A signed-in account is keyed by its canonical subject (ADR-0006); a++ // token that carries none cannot name an account.++ if (!user || !user.subjectId) {++ conn.send(encodeServerMessage({ t: 'reject', reason: 'auth_failed' }))++ conn.close(4009, 'auth_failed')++ return++ }++ token = user.subjectId++ subjectId = user.subjectId++ authIat = tokenIat(msg.auth)++ rank = user.rank++ // Guest conversion (WS-B task 8): the character this browser played as a guest joins the++ // account in its first free slot, once.++ if (isGuestToken(msg.token)) {++ const g = await ctx.store.identity.adoptGuestCharacter(msg.token, subjectId, Date.now())++ if (g.moved > 0)++ ctx.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })++ else if (g.full)++ ctx.log.info('guest character kept: account slots full', { subject: subjectId })++ }++ } else {++ // A guest token may never look like an account key (`usr_…`): that++ // would open someone else's characters. The client mints plain++ // alphanumerics; anything key-shaped is refused.++ if (!isGuestToken(msg.token)) {++ conn.send(encodeServerMessage({ t: 'reject', reason: 'invalid_hello' }))++ conn.close(4007, 'invalid_guest_token')++ return++ }++ if (slot > 0) {++ conn.send(encodeServerMessage({ t: 'reject', reason: 'guest_one_character' }))++ conn.close(4010, 'guest_one_character')++ return++ }++ }++ // A disconnect save of this character may still be queued: read after it lands, not before.++ await ctx.systems.persistence.drain(5000)++ const existing = await ctx.store.players.findByTokenSlot(token, slot)++ // One live session per CHARACTER; other characters of the same account++ // may stay online (an account still only plays one at a time in++ // practice — same token kicks apply per slot).++ for (const s of ctx.sessions.values()) {++ if (s.token === token && s.charSlot === slot) {++ s.closeConnection(4004, 'session_superseded')++ }++ }++++ const world = ctx.world.content.world++ const playerId = existing ? asPlayerId(existing.id) : newPlayerId()++ const mapSpawn = worldSpawn(world)++ const spawn = existing++ ? vec3(existing.pos[0], existing.pos[1], existing.pos[2])++ : vec3(mapSpawn.pos[0], mapSpawn.pos[1], mapSpawn.pos[2])++ const inventory = existing++ ? Inventory.fromDto(existing.inventory, ctx.world.content)++ : new Inventory(INVENTORY_SIZE, HOTBAR_SIZE, ctx.world.content)++ const skills = existing++ ? SkillSet.fromDto(existing.skills, ctx.world.content)++ : new SkillSet(ctx.world.content)++ const friends = new Set(existing?.friends ?? [])++ // The client's customization is authoritative for looks (validated by++ // the protocol schema) — EXCEPT body size: everyone shares one hull and++ // silhouette so combat stays fair.++ const appearance = { ...msg.appearance, height: 1, build: 1 }++++ // Starter kit: every scrapper carries a physgun. Also grants it to++ // players from before the tool system existed.++ for (const grant of STARTER_ITEMS) {++ if (inventory.countOf(grant.item) === 0) {++ const leftover = inventory.add(grant.item, grant.count)++ if (leftover > 0) {++ ctx.log.warn('starter item did not fit', { item: grant.item, playerId })++ }++ }++ }++++ const session = createSession({++ playerId,++ charSlot: slot,++ entityId: newEntityId(),++ token,++ subjectId,++ authIat,++ rank,++ name: msg.name,++ spawn,++ yaw: existing?.yaw ?? mapSpawn.yaw,++ inventory,++ skills,++ friends,++ appearance,++ stats: existing?.stats ?? undefined,++ armor: existing?.armor ?? null,++ reputation: existing?.reputation ?? {},++ unlocks: existing?.unlocks ?? [],++ activeJob: existing?.activeJob ?? null,++ content: ctx.world.content,++ send: (text) => conn.send(text),++ closeConnection: (code, reason) => conn.close(code, reason),++ })++ ctx.sessions.add(conn, session)++ ctx.lifecycle.joined(session)++++ // Player entity (transient — players persist via the player repository).++ const entity: GameEntity = {++ id: session.entityId,++ kind: 'player',++ transform: { pos: session.move.pos, rot: qfromYaw(quat(), session.yaw) },++ persistent: false,++ dirty: false,++ }++ ctx.world.entities.add(entity)++ ctx.world.spatial.insert(entity.id, session.move.pos.x, session.move.pos.z)++++ // Kinematic capsule so props collide with players. Shorter than the++ // movement hull and lifted off the feet: standing ON a prop must not++ // press it down (that caused sink/jitter loops when prop-surfing).++ const bodyId = ctx.world.physics.addBody({++ shape: { type: 'capsule', radius: MOVE.capsuleRadius, height: MOVE.capsuleHeight - 0.3 },++ motion: 'kinematic',++ pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),++ layer: CollisionLayer.Player,++ collidesWith: CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,++ })++ ctx.sessions.setBody(playerId, bodyId)++++ ctx.net.send(session, {++ t: 'welcome',++ v: PROTOCOL_VERSION,++ rank,++ playerId: playerId as string,++ entityId: session.entityId as string,++ tick: ctx.clock.tick,++ tickRate: ctx.config.tickRate,++ snapshotRate: ctx.config.tickRate / ctx.config.snapshotEvery,++ })++ ctx.net.sendInventory(session)++ ctx.net.sendSkills(session)++ await ctx.systems.social.sendFriends(session)++ ctx.net.send(session, ctx.net.timeWire())++ ctx.systems.economy.sendReputation(session)++ ctx.metrics.sessions = ctx.sessions.size++ ctx.integrations.progressSummary?.playerJoined(session.playerId as string)++ const recorder = ctx.integrations.events++ if (recorder) {++ // Baseline = what the database holds for this character (nothing for a++ // new one), so the first save reports only what was earned since.++ const stored = existing++ ? progressOf(SkillSet.fromDto(existing.skills, ctx.world.content), existing.unlocks)++ : { levels: {}, unlocks: [] }++ await ctx.store.transaction(async (t) => {++ await recorder.recordJoin(t, eventPlayer(session), stored, existing !== null)++ })++ }++ ctx.log.info('player connected', {++ playerId: playerId as string,++ name: msg.name,++ restored: existing !== null,++ })++ }++++ /**++ * Network moved this person's token cutoff (sign out everywhere, password changed, banned): close++ * every session they opened with an older Network sign-in (4011 signed_out). The client's reconnect++ * then fails its /api/auth/me check. Returns how many closed.++ */++ revokeSubject(subjectId: string, validAfterMs: number): number {++ if (!subjectId || !Number.isFinite(validAfterMs)) return 0++ let closed = 0++ for (const session of this.ctx.sessions.all()) {++ if (session.subjectId !== subjectId) continue++ if (session.authIat !== null && session.authIat * 1000 >= validAfterMs) continue++ try {++ session.send(encodeServerMessage({ t: 'reject', reason: 'signed_out' }))++ } catch {++ // closing anyway++ }++ session.closeConnection(4011, 'signed_out')++ closed++++ }++ return closed++ }++}nnew file mode 100644@@@ -0,0 +1,70 @@++import type { ClientTrust } from '@openvibe/protocol'++import type { PlayerId } from '@openvibe/shared'++import type { PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import type { HandlerMap, System } from './system.js'++++/**++ * The player's trust list (who may manipulate their props) and the server-wide++ * announcements. There is no chat message in this protocol.++ */++export class SocialSystem implements System {++ readonly name = 'social'++ readonly handlers: HandlerMap = {++ trust: (session, msg: ClientTrust, _conn) => {++ void this.handleTrust(session, msg.player, msg.trusted)++ },++ }++++ constructor(private readonly ctx: ServerContext) {}++++ async sendFriends(session: PlayerSession): Promise<void> {++ const friends: { id: string; name: string }[] = []++ for (const id of session.friends) {++ const online = this.ctx.sessions.get(id as PlayerId)++ const name = online?.name ?? (await this.ctx.store.players.findById(id))?.name ?? 'unknown'++ friends.push({ id, name })++ }++ this.ctx.net.send(session, { t: 'friends', friends })++ }++++ private async handleTrust(++ session: PlayerSession,++ targetId: string,++ trusted: boolean,++ ): Promise<void> {++ if (targetId === (session.playerId as string)) {++ this.ctx.net.send(session, { t: 'result', action: 'trust', ok: false, error: 'self' })++ return++ }++ // Target must be a real player (online or persisted).++ const online = this.ctx.sessions.get(targetId as PlayerId)++ const known = online ?? (await this.ctx.store.players.findById(targetId))++ if (!known) {++ this.ctx.net.send(session, {++ t: 'result',++ action: 'trust',++ ok: false,++ error: 'unknown_player',++ })++ return++ }++ if (trusted) session.friends.add(targetId)++ else session.friends.delete(targetId)++ session.dirty = true++ this.ctx.net.send(session, { t: 'result', action: 'trust', ok: true })++ await this.sendFriends(session)++ }++++ /**++ * Graceful stop (WS-P lifecycle): everyone in the world is told the server is restarting. main.ts++ * then closes every socket with 1012 (service restart); each close saves that character and records++ * games.player.left, as when a player leaves, and the client reconnects on its own.++ */++ announceRestart(): void {++ this.ctx.net.broadcastAll({++ t: 'announce',++ text: '🔄 The server is restarting: you will reconnect in a moment…',++ })++ }++}nnew file mode 100644@@@ -0,0 +1,55 @@++import type { ClientMessage } from '@openvibe/protocol'++import type { PlayerSession } from '../playerSession.js'++import type { GameConnection, ServerContext } from './context.js'++++/**++ * A message handler for ONE client message type. The handler table is keyed by `msg.t`, so a++ * handler registered under `physgun` only ever sees `ClientPhysgun`; it declares that type and the++ * table stores it as {@link MessageHandler}`<never>`. A function taking `never` accepts every++ * narrower parameter type, which is exactly the guarantee the key gives us.++ *++ * `hello` is the one message a connection sends before it has a session, so its handler declares++ * `PlayerSession | undefined` — still assignable here, because the parameter widens.++ */++export type MessageHandler<M extends ClientMessage = ClientMessage> = (++ session: PlayerSession,++ msg: M,++ conn: GameConnection,++) => void++++/** What a system registers: message type -> handler. */++export type HandlerMap = Readonly<Record<string, MessageHandler<never>>>++++/**++ * One gameplay system (ADR-0007 M1). Systems own their state and reach shared state, other systems++ * and the wire through the ServerContext; no system reads another's private fields.++ */++export interface System {++ /** Stable name, used in duplicate-handler errors and logging. */++ readonly name: string++ /** Message types this system handles; assembled into ONE table by {@link buildHandlerTable}. */++ readonly handlers?: HandlerMap++ /** Per-tick work, called by GameServer.step in the documented order. */++ tick?(ctx: ServerContext, dt: number): void++ /** A session joined the world (after it is registered, before the welcome). */++ onJoin?(session: PlayerSession): void++ /** A session left the world (after it is unregistered, before its entity is despawned). */++ onLeave?(session: PlayerSession): void++}++++/**++ * The one place the message handler table is assembled (ADR-0007 M1). A duplicate message type is++ * a startup error: two systems claiming `use` would otherwise silently shadow each other.++ */++export function buildHandlerTable(systems: readonly System[]): Map<string, MessageHandler<never>> {++ const table = new Map<string, MessageHandler<never>>()++ for (const system of systems) {++ for (const [type, handler] of Object.entries(system.handlers ?? {})) {++ if (table.has(type)) {++ throw new Error(`duplicate message handler for "${type}" (${system.name})`)++ }++ table.set(type, handler)++ }++ }++ return table++}nnew file mode 100644@@@ -0,0 +1,186 @@++import type { GameEntity } from '@openvibe/gameplay'++import { qfromYaw, qrotateVec, v3dist, vec3 } from '@openvibe/shared'++import type { PlayerSession } from '../playerSession.js'++import type { ServerContext } from './context.js'++import type { System } from './system.js'++++const _bodyPosScratch = vec3()++const _vehicleFwdLocal = vec3(0, 0, 1)++const _vehicleFwd = vec3()++const _vehicleForce = vec3()++const _vehicleAng = vec3()++const _vehicleVel = vec3()++++export class VehicleSystem implements System {++ readonly name = 'vehicles'++++ constructor(private readonly ctx: ServerContext) {}++++ /** E on a chassis: mount a valid assembly, or dismount if driving it. */++ useVehicle(session: PlayerSession, chassis: GameEntity): void {++ if (session.driving === chassis.id) {++ this.dismount(session, chassis)++ this.ctx.net.send(session, { t: 'result', action: 'use', ok: true })++ return++ }++ if (session.driving) {++ this.ctx.net.send(session, {++ t: 'result',++ action: 'use',++ ok: false,++ error: 'already_driving',++ })++ return++ }++ const d = v3dist(chassis.transform.pos, session.move.pos)++ if (d > 4) {++ this.ctx.net.send(session, { t: 'result', action: 'use', ok: false, error: 'out_of_range' })++ return++ }++ if (!this.ctx.systems.manipulation.canManipulate(session, chassis)) {++ this.ctx.net.send(session, { t: 'result', action: 'use', ok: false, error: 'not_owner' })++ return++ }++ // Somebody else at the wheel?++ for (const other of this.ctx.sessions.values()) {++ if (other.driving === chassis.id) {++ this.ctx.net.send(session, { t: 'result', action: 'use', ok: false, error: 'seat_taken' })++ return++ }++ }++ // A drivable assembly: >= 2 wheels rigged with axis/motor links.++ const wheels = this.ctx.world.constraintsFor(chassis.id).filter((rec) => {++ if (rec.type !== 'axis' && rec.type !== 'motor') return false++ const otherId = rec.a === chassis.id ? rec.b : rec.a++ const other = this.ctx.world.entities.get(otherId)++ return (++ other?.prop !== undefined &&++ this.ctx.world.content.item(other.prop.defId)?.vehiclePart?.part === 'wheel'++ )++ })++ if (wheels.length < 2) {++ this.ctx.net.send(session, { t: 'result', action: 'use', ok: false, error: 'needs_wheels' })++ return++ }++ // The wheel must turn: frozen carts wake up when mounted.++ if (chassis.prop!.motion !== 'dynamic') this.ctx.world.setPropMotion(chassis, 'dynamic')++ session.driving = chassis.id++ if (session.held) this.ctx.systems.manipulation.releaseHeld(session)++ this.ctx.net.send(session, { t: 'result', action: 'use', ok: true })++ this.ctx.net.send(session, { t: 'announce', text: '🛻 Driving — WASD steers, E dismounts.' })++ }++++ dismount(session: PlayerSession, chassis: GameEntity | undefined): void {++ session.driving = null++ if (chassis) {++ // Step off beside the cart.++ const rot = chassis.transform.rot++ const side = qrotateVec(vec3(), rot, vec3(1.4, 0, 0))++ session.move.pos.x = chassis.transform.pos.x + side.x++ session.move.pos.y = chassis.transform.pos.y + 0.8++ session.move.pos.z = chassis.transform.pos.z + side.z++ session.move.vel.x = 0++ session.move.vel.y = 0++ session.move.vel.z = 0++ }++ }++++ /**++ * Vehicle drive: intent (WASD) becomes thrust force + steering on the++ * chassis body; wheels roll on their rigged bearings; fuel burns from++ * the trunk like a generator. The rider is carried kinematically.++ */++ driveVehicle(session: PlayerSession): void {++ const chassis = session.driving ? this.ctx.world.entities.get(session.driving) : undefined++ const def = chassis?.prop ? this.ctx.world.content.item(chassis.prop.defId) : undefined++ const part = def?.vehiclePart++ const bodyId = chassis ? this.ctx.world.bodyOf(chassis.id) : undefined++ if (!chassis || !part || bodyId === undefined || chassis.prop!.motion !== 'dynamic') {++ this.dismount(session, chassis)++ return++ }++ // Drain inputs (ack them; the client does not predict while driving).++ let input = session.lastInput++ while (session.inputQueue.length > 0) {++ input = session.inputQueue.shift()!++ session.lastInput = input++ session.lastProcessedSeq = input.seq++ session.yaw = input.yaw++ session.pitch = input.pitch++ session.buttons = input.buttons++ }++ const nowMs = Date.now()++ // Fuel: burn items from the trunk while driving.++ if ((chassis.prop!.burnUntil ?? 0) <= nowMs && chassis.prop!.container) {++ let reloaded = false++ for (let i = 0; i < chassis.prop!.container.length; i++) {++ const slot = chassis.prop!.container[i]++ const fuel = slot ? this.ctx.world.content.item(slot.defId)?.fuel : undefined++ if (!slot || !fuel) continue++ slot.count -= 1++ if (slot.count <= 0) chassis.prop!.container[i] = null++ chassis.prop!.burnUntil = nowMs + fuel.burnSeconds * 1000++ chassis.dirty = true++ reloaded = true++ for (const other of this.ctx.sessions.values()) {++ if (other.openContainer === chassis.id) this.ctx.net.sendContainer(other, chassis)++ }++ break++ }++ if (!reloaded && input && (input.moveZ !== 0 || input.moveX !== 0)) {++ // Out of fuel: coast only. (A once-per-mount nag would need state;++ // the HUD prompt shows the tank.)++ }++ }++ const fueled = (chassis.prop!.burnUntil ?? 0) > nowMs++ if (input && fueled) {++ this.ctx.world.physics.wake(bodyId)++ // Thrust along the chassis' flat forward.++ qrotateVec(_vehicleFwd, chassis.transform.rot, _vehicleFwdLocal)++ _vehicleFwd.y = 0++ const len = Math.hypot(_vehicleFwd.x, _vehicleFwd.z) || 1++ _vehicleFwd.x /= len++ _vehicleFwd.z /= len++ const power = (part.power ?? 4000) * input.moveZ++ _vehicleForce.x = _vehicleFwd.x * power++ _vehicleForce.y = 0++ _vehicleForce.z = _vehicleFwd.z * power++ if (input.moveZ !== 0) this.ctx.world.physics.applyForce(bodyId, _vehicleForce)++ // Steering: blend yaw angular velocity toward the wheel input.++ this.ctx.world.physics.getAngularVelocity(bodyId, _vehicleAng)++ const steer = -input.moveX * 1.6 * (input.moveZ < 0 ? -1 : 1)++ _vehicleAng.y = _vehicleAng.y + (steer - _vehicleAng.y) * 0.25++ this.ctx.world.physics.setAngularVelocity(bodyId, _vehicleAng)++ // Top speed cap.++ this.ctx.world.physics.getLinearVelocity(bodyId, _vehicleVel)++ const speed = Math.hypot(_vehicleVel.x, _vehicleVel.z)++ const top = part.topSpeed ?? 9++ if (speed > top) {++ const k = top / speed++ _vehicleVel.x *= k++ _vehicleVel.z *= k++ this.ctx.world.physics.setLinearVelocity(bodyId, _vehicleVel)++ }++ }++ // Carry the rider: perch on the chassis, share its velocity.++ session.move.pos.x = chassis.transform.pos.x++ session.move.pos.y = chassis.transform.pos.y + 0.75++ session.move.pos.z = chassis.transform.pos.z++ this.ctx.world.physics.getLinearVelocity(bodyId, _vehicleVel)++ session.move.vel.x = _vehicleVel.x++ session.move.vel.y = _vehicleVel.y++ session.move.vel.z = _vehicleVel.z++ session.move.grounded = true++ session.fallVy = 0++ const body = this.ctx.sessions.bodyOf(session.playerId)++ if (body !== undefined) {++ _bodyPosScratch.x = session.move.pos.x++ _bodyPosScratch.y = session.move.pos.y + 0.15++ _bodyPosScratch.z = session.move.pos.z++ this.ctx.world.physics.setTransform(body, _bodyPosScratch)++ }++ const entity = this.ctx.world.entities.get(session.entityId)++ if (entity) qfromYaw(entity.transform.rot, session.yaw)++ this.ctx.world.spatial.move(session.entityId, session.move.pos.x, session.move.pos.z)++ }++}nnew file mode 100644@@@ -0,0 +1,75 @@++import { worldSpawn } from '@openvibe/content'++import type { EntityId } from '@openvibe/shared'++import { EventManager } from '../eventManager.js'++import type { ServerContext } from './context.js'++import type { System } from './system.js'++++/**++ * Generic world events (supply drops, extraction): the manager's hooks are bound to the wire here,++ * and the 1 Hz driver lives in `tick`.++ */++export class WorldEventSystem implements System {++ readonly name = 'worldEvents'++ private readonly events: EventManager++++ constructor(private readonly ctx: ServerContext) {++ this.events = new EventManager(++ ctx.world,++ {++ announce: (text) => ctx.net.broadcastAll({ t: 'announce', text }),++ announceTo: (entityId, text) => {++ const target = ctx.sessions.getByEntity(entityId as EntityId)++ if (target) ctx.net.send(target, { t: 'announce', text })++ },++ playersIn: (x, z, radius) => {++ const out: { entityId: string; alive: boolean }[] = []++ for (const s of ctx.sessions.values()) {++ if (Math.hypot(s.move.pos.x - x, s.move.pos.z - z) <= radius) {++ out.push({ entityId: s.entityId as string, alive: s.stats.health > 0 })++ }++ }++ return out++ },++ extractPlayer: (entityId) => this.extractPlayer(entityId),++ broadcastSpawn: (entity) => ctx.net.broadcastSpawn(entity),++ broadcastDespawn: (id) => ctx.net.broadcastDespawn(id),++ },++ ctx.config.eventIntervalScale,++ ctx.log.child({ system: 'events' }),++ )++ }++++ tick(ctx: ServerContext, _dt: number): void {++ if (ctx.clock.tick % ctx.config.tickRate !== 0) return++ this.events.tick(Date.now(), ctx.sessions.size)++ }++++ /**++ * 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 {++ const session = this.ctx.sessions.getByEntity(entityId as EntityId)++ if (!session) return++ const secured = session.inventory.secureValuables(++ (defId) => this.ctx.world.content.item(defId)?.valuable !== undefined,++ )++ const spawn = worldSpawn(this.ctx.world.content.world).pos++ session.move.pos.x = spawn[0]++ session.move.pos.y = spawn[1]++ session.move.pos.z = spawn[2]++ session.move.vel.x = 0++ session.move.vel.y = 0++ session.move.vel.z = 0++ session.dirty = true++ this.ctx.net.send(session, {++ t: 'announce',++ text:++ secured > 0++ ? `🚁 Extracted! ${secured} stack${secured === 1 ? '' : 's'} of loot secured.`++ : '🚁 Extracted safely back to Scrap City.',++ })++ this.ctx.net.sendInventory(session)++ this.ctx.log.info('player extracted', { playerId: session.playerId, secured })++ }++}@@@ -302,6 +302,7 @@ export const ClientMessageSchema = z.union([])export type ClientHello = z.infer<typeof ClientHelloSchema>++export type ClientEditMode = z.infer<typeof ClientEditModeSchema>export type ClientInput = z.infer<typeof ClientInputSchema>export type ClientUse = z.infer<typeof ClientUseSchema>export type ClientAttack = z.infer<typeof ClientAttackSchema>
@@ -1,271 +1,146 @@
@@ -1,271 +1,146 @@
-import {
- BLEED_SECONDS,
- Buttons,
- DEFAULT_MOVEMENT,
- Inventory,
- SkillSet,
- activeStatuses,
- ambientTemperature,
- applyDamage,
- applyStatus,
- canFire,
- causesBleeding,
- clearStatus,
- completeJob,
- containerAdd,
- containerSort,
- createEnvironment,
- eat,
- hullHeightFor,
- mitigate,
- plantProgress,
- plantStage,
- precipitation,
- recordShot,
- REP_DELTAS,
- RegionTracker,
- addReputation,
- stanceToward,
- startReload,
- stepEnvironment,
- stepMovement,
- tickSurvival,
- tryStartJob,
- updatePlant,
- waterPlant,
- weaponStateFromMeta,
- weaponStateToMeta,
- type CollisionQueries,
- type DamageEvent,
- type DamageType,
- type EnvironmentState,
- type GameEntity,
- type WeatherKind,
-} from '@openvibe/gameplay'
-import { STARTER_ITEMS, WATER_LEVEL, recipeSkill, recipeXp, terrainHeight } from '@openvibe/content'
-import type { PersistenceStore, PlayerDto } from '@openvibe/persistence'
-import { CollisionLayer, type BodyId } from '@openvibe/physics'
-import {
- PROTOCOL_VERSION,
- encodeServerMessage,
- type ClientHello,
- type ClientMessage,
- type ClientPhysgun,
- type ServerMessage,
-} from '@openvibe/protocol'
-import {
- asPlayerId,
- newEntityId,
- newPlayerId,
- qfromYaw,
- qrotateVec,
- quat,
- v3dist,
- vec3,
- type EntityId,
- type Logger,
- type PlayerId,
-} from '@openvibe/shared'
+import { createEnvironment } from '@openvibe/gameplay'
+import type { PersistenceStore } from '@openvibe/persistence'
+import type { ClientMessage } from '@openvibe/protocol'
+import type { Logger } from '@openvibe/shared'
import type { ServerConfig } from '../config.js'
import type { ServerConfig } from '../config.js'
-import { canEditMap, resolveNetworkUser } from '../net/networkAuth.js'
-import { isGuestToken } from '../platform/accounts.js'
-import type { EventPlayer, GameEventRecorder, ProgressSnapshot } from '../platform/gameEvents.js'
-import type { ProgressSummaryWriter } from '../platform/progressSummary.js'
-import type { ModHost, ModRuntime } from '../mods/runtime.js'
-import { worldSpawn } from '@openvibe/content'
+import { nearbyWorkstationKinds } from './interactions.js'
+import type { PlayerSession } from './playerSession.js'
+import { ServerContext } from './systems/context.js'
+import type { GameConnection, GameIntegrations, ServerSystems } from './systems/context.js'
+import {
+ CombatSystem,
+ EconomySystem,
+ EditorSystem,
+ EnvironmentSystem,
+ InteractionSystem,
+ InventorySystem,
+ ManipulationSystem,
+ ModSystem,
+ MovementSystem,
+ NpcSystem,
+ PersistenceSystem,
+ PhysicsSystem,
+ ProductionSystem,
+ ReplicationSystem,
+ SessionsSystem,
+ SocialSystem,
+ VehicleSystem,
+ WorldEventSystem,
+ buildHandlerTable,
+} from './systems/index.js'
+import type { MessageHandler, System } from './systems/system.js'
import type { ServerMetrics } from '../observability/metrics.js'
import type { ServerMetrics } from '../observability/metrics.js'
import type { GameWorld } from './gameWorld.js'
import type { GameWorld } from './gameWorld.js'
-import {
- equippedTool,
- handleConstraint,
- handleCraft,
- handleDrop,
- handleInvMove,
- handlePlace,
- handleUse,
- nearbyWorkstationKinds,
-} from './interactions.js'
-import type { ConstraintRecord } from './gameWorld.js'
-import { adjustDistance, driveHeld, freezeHeld, release, rotateHeld, tryGrab } from './physgun.js'
-import {
- createSession,
- eyePosition,
- HOTBAR_SIZE,
- INVENTORY_SIZE,
- viewDirection,
- type PlayerSession,
-} from './playerSession.js'
-import { buildSnapshot, updateInterest, wireEntityFor } from './replication.js'
-import { NpcManager } from './npcManager.js'
-import { EventManager } from './eventManager.js'
-import { Flusher, type FlushCharacter, type FlushPayload } from './flusher.js'
-/** A network connection as the game sees it — transport-agnostic. */
-export interface GameConnection {
- send(text: string): void
- close(code: number, reason: string): void
-}
-
-const MOVE = DEFAULT_MOVEMENT
-const MAX_INPUT_QUEUE = 6
-/** The five weather kinds a persisted env_weather may name. */
-const WEATHERS = new Set<WeatherKind>(['clear', 'cloudy', 'rain', 'storm', 'fog'])
+export type { GameConnection, GameIntegrations }
/**
/**
- * Platform adapters the server drives (roadmap Wave 12). Both optional: the
- * game runs exactly as before without them.
+ * The coordinator (ADR-0007 M1). It owns the shared context, constructs the systems, assembles
+ * their message handlers into one table (a duplicate type is a startup error), and drives the tick:
+ * `step()` below is the one place the simulation order is written down.
+ *
+ * Gameplay itself lives in `./systems/`; this class holds no game state beyond wiring.
*/
*/
-export interface GameIntegrations {
- /** Durable lifecycle/progression events through the outbox. */
- events?: GameEventRecorder
- /** Installed mods (content packs), reconciled every tick. */
- mods?: ModRuntime
- /** The person's games.progress.summary user module on Network, written when a character leaves. */
- progressSummary?: ProgressSummaryWriter
-}
-
-/** A JWT's iat (seconds), read from a token the Network has already accepted; null otherwise. */
-function tokenIat(jwt: string): number | null {
- const part = jwt.split('.')[1]
- if (!part) return null
- try {
- const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as { iat?: unknown }
- return typeof claims.iat === 'number' ? claims.iat : null
- } catch {
- return null
- }
-}
-
export class GameServer {
export class GameServer {
- private readonly sessions = new Map<PlayerId, PlayerSession>()
- private readonly sessionsByConn = new Map<GameConnection, PlayerSession>()
- private readonly sessionsByEntity = new Map<EntityId, PlayerSession>()
- private readonly playerBodies = new Map<PlayerId, BodyId>()
- private readonly heldEntityIds = new Set<string>()
- /** Short-lived cache of OFFLINE owners' friend lists (prop protection). */
- private readonly offlineFriendsCache = new Map<string, { friends: Set<string>; at: number }>()
- /** Offline-owner lookups in flight, so the tick never issues the same read twice. */
- private readonly friendsLoadInFlight = new Set<string>()
- /** Connections whose hello is still resolving (a second one while it is refused). */
- private readonly hellosInFlight = new Set<GameConnection>()
- /** Write-behind persistence: one transaction in flight, the tick never awaits it. */
- private readonly flusher: Flusher
- private tick = 0
- private readonly moveQueries: CollisionQueries
- /** Body excluded from the current movement sweep (the moving player's own). */
- private sweepSelf: BodyId | undefined
- private lastFlushTick = 0
- /** Authoritative world environment: clock, weather, temperature. */
- private readonly env: EnvironmentState
- /** Coarse activation regions (NPC LOD and event relevance hang off this). */
- readonly regions = new RegionTracker(32)
- /** Server-authoritative NPC simulation (LOD-aware). */
- readonly npcs: NpcManager
- /** Generic world events (supply drops, extraction). */
- readonly events: EventManager
- /** Players who recently attacked someone (defensive NPCs respond). */
- private readonly aggro = new Map<string, number>()
- /** Sound events (gunshots, fights) accumulated for NPC hearing. */
- private sounds: { x: number; z: number }[] = []
-
- /** Players connected now (the readiness `sessions` check; a restart would disconnect them). */
- onlineCount(): number {
- return this.sessions.size
- }
+ private readonly ctx: ServerContext
+ private readonly systems: ServerSystems
+ /** The systems in construction order: the handler table and the join/leave hooks both use it. */
+ private readonly ordered: System[]
+ private readonly handlers: Map<string, MessageHandler<never>>
constructor(
constructor(
- private readonly config: ServerConfig,
- private readonly world: GameWorld,
- private readonly store: PersistenceStore,
- private readonly metrics: ServerMetrics,
- private readonly log: Logger,
- private readonly integrations: GameIntegrations = {},
+ config: ServerConfig,
+ world: GameWorld,
+ store: PersistenceStore,
+ metrics: ServerMetrics,
+ log: Logger,
+ integrations: GameIntegrations = {},
) {
) {
// The world clock and weather survive restarts (a rainy dusk stays a rainy dusk); they are
// The world clock and weather survive restarts (a rainy dusk stays a rainy dusk); they are
// loaded asynchronously in load(), before the tick loop starts.
// loaded asynchronously in load(), before the tick loop starts.
- this.env = createEnvironment(0.34)
- this.npcs = new NpcManager(
+ this.ctx = new ServerContext(
+ config,
world,
world,
- this.regions,
- {
- onAttackPlayer: (npcId, playerEntityId, damage) => {
- const victim = this.sessionsByEntity.get(playerEntityId as EntityId)
- const npcEntity = this.world.entities.get(npcId)
- if (!victim || !npcEntity) return
- // NPC melee has reach limits too (no cross-map slaps).
- const d = v3dist(npcEntity.transform.pos, victim.move.pos)
- if (d > 3) return
- this.damagePlayer(null, victim, { type: 'blunt', amount: damage, sourceId: npcId })
- },
- soundsThisTick: () => this.sounds,
- hostilesNear: (x, z, radius, faction) => {
- const stance = this.world.content.faction(faction)?.playerStance ?? 'neutral'
- const nowMs = Date.now()
- const out: { id: string; x: number; z: number; hostile: boolean }[] = []
- for (const other of this.sessions.values()) {
- const dx = other.move.pos.x - x
- const dz = other.move.pos.z - z
- if (dx * dx + dz * dz > radius * radius) continue
- const aggro = (this.aggro.get(other.entityId as string) ?? 0) > nowMs
- const hostile = stance === 'hostile' || aggro
- out.push({
- id: other.entityId as string,
- x: other.move.pos.x,
- z: other.move.pos.z,
- hostile,
- })
- }
- return out
- },
- },
- log.child({ system: 'npc' }),
- )
-
- this.events = new EventManager(
- world,
- {
- announce: (text) => this.broadcastAll({ t: 'announce', text }),
- announceTo: (entityId, text) => {
- const target = this.sessionsByEntity.get(entityId as EntityId)
- if (target) this.send(target, { t: 'announce', text })
- },
- playersIn: (x, z, radius) => {
- const out: { entityId: string; alive: boolean }[] = []
- for (const s of this.sessions.values()) {
- if (Math.hypot(s.move.pos.x - x, s.move.pos.z - z) <= radius) {
- out.push({ entityId: s.entityId as string, alive: s.stats.health > 0 })
- }
- }
- return out
- },
- extractPlayer: (entityId) => this.extractPlayer(entityId),
- broadcastSpawn: (entity) => this.broadcastSpawn(entity),
- broadcastDespawn: (id) => this.broadcastDespawn(id),
- },
- config.eventIntervalScale,
- log.child({ system: 'events' }),
- )
-
- this.flusher = new Flusher(
store,
store,
metrics,
metrics,
- log.child({ system: 'persist' }),
- integrations.events,
+ log,
+ integrations,
+ createEnvironment(0.34),
)
)
-
- // Players block players: sweeps include the Player layer, minus the
- // mover's own kinematic body.
- this.moveQueries = {
- sweepCapsule: (from, to, radius, height) =>
- world.physics.sweepCapsule(
- from,
- to,
- radius,
- height,
- CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,
- this.sweepSelf,
- ),
+ const ctx = this.ctx
+ const sessions = new SessionsSystem(ctx)
+ const movement = new MovementSystem(ctx)
+ // The wire surface every system sends through.
+ const replication = new ReplicationSystem(ctx)
+ ctx.net = replication
+ const manipulation = new ManipulationSystem(ctx)
+ const combat = new CombatSystem(ctx)
+ const vehicles = new VehicleSystem(ctx)
+ const inventory = new InventorySystem(ctx)
+ const interaction = new InteractionSystem(ctx)
+ const economy = new EconomySystem(ctx)
+ const production = new ProductionSystem(ctx)
+ const npcs = new NpcSystem(ctx)
+ const social = new SocialSystem(ctx)
+ const worldEvents = new WorldEventSystem(ctx)
+ const persistence = new PersistenceSystem(ctx)
+ const mods = new ModSystem(ctx)
+ const editor = new EditorSystem(ctx)
+ const environment = new EnvironmentSystem(ctx)
+ const physics = new PhysicsSystem(ctx)
+
+ this.systems = {
+ sessions,
+ movement,
+ replication,
+ manipulation,
+ combat,
+ vehicles,
+ inventory,
+ interaction,
+ economy,
+ production,
+ npcs,
+ social,
+ worldEvents,
+ persistence,
+ mods,
+ editor,
+ environment,
+ physics,
+ }
+ // Bound together once, here: a system may reach its peers only from a handler, a tick or a
+ // lifecycle hook, never during construction.
+ ctx.systems = this.systems
+ this.ordered = [
+ sessions,
+ movement,
+ replication,
+ manipulation,
+ combat,
+ vehicles,
+ inventory,
+ interaction,
+ economy,
+ production,
+ npcs,
+ social,
+ worldEvents,
+ persistence,
+ mods,
+ editor,
+ environment,
+ physics,
+ ]
+ ctx.lifecycle = {
+ joined: (session) => {
+ for (const system of this.ordered) system.onJoin?.(session)
+ },
+ left: (session) => {
+ for (const system of this.ordered) system.onLeave?.(session)
+ },
}
}
+ this.handlers = buildHandlerTable(this.ordered)
}
}
/**
/**
@@ -273,2620 +148,152 @@ export class GameServer {
@@ -273,2620 +148,152 @@ export class GameServer {
* main.ts awaits this before starting the tick loop.
* main.ts awaits this before starting the tick loop.
*/
*/
async load(): Promise<void> {
async load(): Promise<void> {
- const rawTime = await this.store.meta.get('env_time')
- const savedTime = Number(rawTime ?? Number.NaN)
- if (Number.isFinite(savedTime)) this.env.timeOfDay = savedTime
- const savedWeather = await this.store.meta.get('env_weather')
- if (typeof savedWeather === 'string' && WEATHERS.has(savedWeather as WeatherKind)) {
- this.env.weather = savedWeather as WeatherKind
- }
- for (const { key, value } of await this.store.meta.list('market_')) {
- const market = new Map<string, { stock: number; at: number }>()
- if (value && typeof value === 'object') {
- for (const [item, state] of Object.entries(
- value as Record<string, { stock: number; at: number }>,
- )) {
- market.set(item, state)
- }
- }
- this.marketStock.set(key.slice('market_'.length), market)
- }
- await this.npcs.seedOrRestore(this.store)
+ await this.systems.environment.load()
+ await this.systems.economy.load()
+ await this.systems.npcs.load()
}
}
get currentTick(): number {
get currentTick(): number {
- return this.tick
- }
-
- /**
- * Prop protection: world props (no owner) are free; otherwise the owner
- * or anyone the OWNER trusts may manipulate. Works for offline owners via
- * a TTL-cached repository lookup.
- */
- private canManipulate(session: PlayerSession, entity: GameEntity): boolean {
- if (entity.owner === undefined) return true
- if (entity.owner === session.playerId) return true
- const ownerSession = this.sessions.get(entity.owner)
- if (ownerSession) return ownerSession.friends.has(session.playerId)
- const cached = this.offlineFriendsCache.get(entity.owner)
- if (cached && Date.now() - cached.at < 30_000) {
- return cached.friends.has(session.playerId)
- }
- // Offline owner, no cached answer: start an async load and DENY this tick. The tick never awaits
- // I/O; the cached answer is used from the next tick on (the same 30 s TTL).
- const owner = entity.owner
- if (!this.friendsLoadInFlight.has(owner)) {
- this.friendsLoadInFlight.add(owner)
- void this.store.players
- .findById(owner)
- .then((found) => {
- this.offlineFriendsCache.set(owner, {
- friends: new Set(found?.friends ?? []),
- at: Date.now(),
- })
- })
- .catch((err: unknown) => {
- this.log.warn('offline owner lookup failed', { owner, error: String(err) })
- })
- .finally(() => this.friendsLoadInFlight.delete(owner))
- }
- return false
- }
-
- // ── Connection lifecycle ───────────────────────────────────────────
-
- onMessage(conn: GameConnection, msg: ClientMessage): void {
- const session = this.sessionsByConn.get(conn)
- if (!session) {
- if (msg.t === 'hello') {
- // A second hello while the first is still resolving (it awaits Network) is refused, so two
- // frames cannot both create a session for one connection.
- if (this.hellosInFlight.has(conn)) {
- conn.close(4008, 'hello_in_flight')
- return
- }
- void this.handleHello(conn, msg)
- } else conn.close(4001, 'hello_first')
- return
- }
- switch (msg.t) {
- case 'hello':
- break // duplicate hello ignored
- case 'input':
- if (session.inputQueue.length < MAX_INPUT_QUEUE) session.inputQueue.push(msg)
- break
- case 'attack': {
- // Swing cooldown: silently drop spam faster than ~5 swings/sec.
- if (this.tick - session.lastUseTick < 6) break
- const victimSession = this.sessionsByEntity.get(msg.target as EntityId)
- if (victimSession) {
- this.handleMelee(session, victimSession)
- break
- }
- const propTarget = this.world.entities.get(msg.target as EntityId)
- if (propTarget?.prop) {
- this.handlePropAttack(session, propTarget)
- break
- }
- if (propTarget?.kind === 'npc') {
- this.handleNpcAttack(session, propTarget)
- break
- }
- this.send(session, { t: 'result', action: 'attack', ok: false, error: 'no_target' })
- break
- }
- case 'fire': {
- this.handleFire(session)
- break
- }
- case 'reload': {
- const held = session.holstered ? null : session.inventory.get(session.activeHotbar)
- const spec = held ? this.world.content.item(held.defId)?.rangedWeapon : undefined
- if (!held || !spec) {
- this.send(session, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })
- break
- }
- const state = weaponStateFromMeta(held.meta)
- const available = session.inventory.countOf(spec.ammoItem)
- const took = startReload(state, spec, available, Date.now())
- if (took <= 0) {
- this.send(session, { t: 'result', action: 'attack', ok: false, error: 'no_ammo' })
- break
- }
- session.inventory.consume([{ item: spec.ammoItem, count: took }])
- held.meta = weaponStateToMeta(state, held.meta)
- session.dirty = true
- this.send(session, { t: 'result', action: 'attack', ok: true })
- this.sendInventory(session)
- break
- }
- case 'equip_armor': {
- if (msg.slot === undefined) {
- // Unequip into the first free inventory slot.
- if (!session.armor) {
- this.send(session, { t: 'result', action: 'inv_move', ok: false, error: 'no_armor' })
- break
- }
- if (!session.inventory.addStack(session.armor)) {
- this.send(session, {
- t: 'result',
- action: 'inv_move',
- ok: false,
- error: 'inventory_full',
- })
- break
- }
- session.armor = null
- session.dirty = true
- this.send(session, { t: 'result', action: 'inv_move', ok: true })
- this.sendInventory(session)
- break
- }
- const stack = session.inventory.get(msg.slot)
- const armorDef = stack ? this.world.content.item(stack.defId)?.armor : undefined
- if (!stack || !armorDef) {
- this.send(session, { t: 'result', action: 'inv_move', ok: false, error: 'not_armor' })
- break
- }
- const removed = session.inventory.removeFromSlot(msg.slot, 1)
- if (!removed) break
- // Fresh pieces get their full durability stamped into the stack.
- if (removed.meta?.dur === undefined) {
- removed.meta = { ...removed.meta, dur: armorDef.durability }
- }
- const previous = session.armor
- session.armor = removed
- if (previous) session.inventory.addStack(previous)
- session.dirty = true
- this.send(session, { t: 'result', action: 'inv_move', ok: true })
- this.sendInventory(session)
- break
- }
- case 'use': {
- // Swing cooldown: silently drop spam faster than ~5 swings/sec.
- if (this.tick - session.lastUseTick < 6) break
- const targetSession = this.sessionsByEntity.get(msg.target as EntityId)
- if (targetSession) {
- // Legacy path: melee also arrives as 'use' from older flows.
- this.handleMelee(session, targetSession)
- break
- }
- const vehicleTarget = this.world.entities.get(msg.target as EntityId)
- if (
- vehicleTarget?.prop &&
- this.world.content.item(vehicleTarget.prop.defId)?.vehiclePart?.part === 'chassis'
- ) {
- this.handleVehicleUse(session, vehicleTarget)
- break
- }
- const gather = handleUse(
- session,
- this.world,
- msg,
- Date.now(),
- (e) => this.canManipulate(session, e),
- {
- nowMs: Date.now(),
- raining: precipitation(this.env) > 0,
- ambientC: ambientTemperature(this.env),
- },
- )
- this.send(session, gather.outcome)
- if (!gather.outcome.ok) break
- session.lastUseTick = this.tick
- this.sendInventory(session)
- if (gather.xpChanged) this.sendSkills(session)
- for (const up of gather.levelUps) {
- this.send(session, { t: 'levelup', skill: up.skill, level: up.level })
- }
- if (gather.pickedUp) {
- if (session.held?.entityId === gather.pickedUp.id) this.releaseHeld(session)
- this.broadcastDespawn(gather.pickedUp.id)
- }
- if (gather.spawned) this.broadcastSpawn(gather.spawned)
- if (gather.changed?.prop) {
- // Door swing / plant change / repair: pin authoritative state.
- const e = gather.changed
- const plant = e.prop?.plant
- const max = this.world.content.item(e.prop!.defId)?.health?.max
- this.broadcastToKnowing(e.id, {
- t: 'entity',
- id: e.id,
- pos: [e.transform.pos.x, e.transform.pos.y, e.transform.pos.z],
- rot: [e.transform.rot.x, e.transform.rot.y, e.transform.rot.z, e.transform.rot.w],
- plant: (() => {
- const crop = plant ? this.world.content.crop(plant.crop) : undefined
- return plant && crop
- ? { crop: plant.crop, t: plantProgress(plant, crop), water: plant.water }
- : null
- })(),
- ...(max !== undefined ? { health: Math.round(e.prop!.health ?? max) } : {}),
- })
- }
- if (gather.changed?.resource) {
- this.broadcastToKnowing(gather.changed.id, {
- t: 'entity',
- id: gather.changed.id,
- remaining: gather.changed.resource.remaining,
- })
- }
- break
- }
- case 'trust': {
- void this.handleTrust(session, msg.player, msg.trusted)
- break
- }
- case 'constraint': {
- const { outcome, created } = handleConstraint(session, this.world, msg, (e) =>
- this.canManipulate(session, e),
- )
- this.send(session, outcome)
- if (created) {
- this.broadcastConstraintState(created, true)
- this.sendSkills(session)
- // Rope/spring/motor constraints consume materials.
- this.sendInventory(session)
- }
- break
- }
- case 'constraint_remove': {
- const tool = equippedTool(session)
- if (tool?.kind !== 'rigging') {
- this.send(session, {
- t: 'result',
- action: 'constraint',
- ok: false,
- error: 'requires_rigging_tool',
- })
- break
- }
- const target = this.world.entities.get(msg.target as EntityId)
- if (!target?.prop) {
- this.send(session, { t: 'result', action: 'constraint', ok: false, error: 'no_target' })
- break
- }
- if (!this.canManipulate(session, target)) {
- this.send(session, { t: 'result', action: 'constraint', ok: false, error: 'not_owner' })
- break
- }
- eyePosition(session, _eyeScratch)
- if (v3dist(_eyeScratch, target.transform.pos) > (tool.range ?? 6) + 1.5) {
- this.send(session, {
- t: 'result',
- action: 'constraint',
- ok: false,
- error: 'out_of_range',
- })
- break
- }
- const removed = this.world.removeConstraintsFor(target.id)
- this.send(session, {
- t: 'result',
- action: 'constraint',
- ok: removed.length > 0,
- ...(removed.length === 0 ? { error: 'no_constraints' } : {}),
- })
- for (const rec of removed) this.broadcastConstraintState(rec, false)
- break
- }
- case 'craft': {
- const outcome = handleCraft(session, this.world, msg, this.tick, this.config.tickRate)
- this.send(session, outcome)
- if (outcome.ok) {
- this.sendInventory(session)
- this.sendCraftState(session)
- }
- break
- }
- case 'drop': {
- const { outcome, droppedId } = handleDrop(session, this.world, msg)
- this.send(session, outcome)
- if (outcome.ok) {
- this.sendInventory(session)
- if (droppedId) {
- const entity = this.world.entities.get(droppedId as EntityId)
- if (entity) this.broadcastSpawn(entity)
- }
- }
- break
- }
- case 'place': {
- const { outcome, placedId } = handlePlace(session, this.world, msg)
- this.send(session, outcome)
- if (outcome.ok) {
- this.sendInventory(session)
- if (placedId) {
- const entity = this.world.entities.get(placedId as EntityId)
- if (entity) this.broadcastSpawn(entity)
- }
- }
- break
- }
- case 'inv_move': {
- const outcome = handleInvMove(session, msg)
- this.send(session, outcome)
- this.sendInventory(session)
- break
- }
- case 'hotbar':
- if (msg.slot < HOTBAR_SIZE) {
- // Re-pressing the active slot holsters/unholsters (empty hands).
- if (msg.slot === session.activeHotbar) {
- session.holstered = !session.holstered
- } else {
- session.activeHotbar = msg.slot
- session.holstered = false
- }
- // Switching away from the physgun drops beam and anything held.
- if (equippedTool(session)?.kind !== 'physgun') {
- session.grabbing = false
- if (session.held) this.releaseHeld(session)
- }
- }
- break
- case 'editmode': {
- // Rank-gated noclip build mode. The flag lives in the move state so
- // server sim and client prediction stay in lockstep via the normal
- // self-state replication path.
- if (canEditMap(session.rank)) {
- session.move.noclip = msg.on
- session.move.vel.x = 0
- session.move.vel.y = 0
- session.move.vel.z = 0
- this.send(session, {
- t: 'announce',
- text: msg.on ? '🛠 Edit mode ON — noclip flight' : '🛠 Edit mode OFF',
- })
- }
- break
- }
- case 'physgun':
- this.handlePhysgun(session, msg)
- break
- case 'drink': {
- if (this.tick - session.lastUseTick < 15) break
- const ground = terrainHeight(
- this.world.content.world,
- session.move.pos.x,
- session.move.pos.z,
- )
- if (ground > WATER_LEVEL - 0.03) {
- this.send(session, { t: 'result', action: 'consume', ok: false, error: 'no_water' })
- break
- }
- session.lastUseTick = this.tick
- // A held watering can fills instead of drinking.
- const held = session.holstered ? null : session.inventory.get(session.activeHotbar)
- const fluidCap = held ? this.world.content.item(held.defId)?.fluidContainer : undefined
- if (held && fluidCap) {
- held.meta = { ...held.meta, fluid: fluidCap.capacity }
- session.dirty = true
- this.send(session, { t: 'result', action: 'consume', ok: true })
- this.sendInventory(session)
- break
- }
- session.stats.thirst = Math.min(100, session.stats.thirst + 30)
- session.statsDirty = true
- session.dirty = true
- this.send(session, { t: 'result', action: 'consume', ok: true })
- this.send(session, { t: 'stats', ...this.statsWire(session) })
- session.statsDirty = false
- break
- }
- case 'consume': {
- const stack = session.inventory.get(msg.slot)
- const def = stack ? this.world.content.item(stack.defId) : undefined
- // Blueprints: using one permanently unlocks its recipe.
- if (stack && def?.blueprint) {
- if (session.unlocks.has(def.blueprint.recipe)) {
- this.send(session, {
- t: 'result',
- action: 'consume',
- ok: false,
- error: 'already_known',
- })
- break
- }
- session.inventory.removeFromSlot(msg.slot, 1)
- session.unlocks.add(def.blueprint.recipe)
- session.dirty = true
- const recipeName = this.world.content.recipe(def.blueprint.recipe)?.name ?? 'recipe'
- this.send(session, { t: 'result', action: 'consume', ok: true })
- this.send(session, { t: 'announce', text: `📜 Blueprint learned: ${recipeName}!` })
- this.sendInventory(session)
- this.sendSkills(session)
- break
- }
- if (!stack || (!def?.food && !def?.medical)) {
- this.send(session, { t: 'result', action: 'consume', ok: false, error: 'not_food' })
- break
- }
- session.inventory.removeFromSlot(msg.slot, 1)
- if (def.food) eat(session.stats, def.food, Date.now())
- if (def.medical) {
- session.stats.health = Math.min(100, session.stats.health + def.medical.heal)
- if (def.medical.curesBleeding) clearStatus(session.stats, 'bleeding')
- }
- session.statsDirty = true
- session.dirty = true
- this.send(session, { t: 'result', action: 'consume', ok: true })
- this.send(session, { t: 'stats', ...this.statsWire(session) })
- session.statsDirty = false
- this.sendInventory(session)
- break
- }
- case 'market_open':
- case 'market_buy':
- case 'market_sell': {
- this.handleMarket(session, msg)
- break
- }
- case 'job_accept':
- case 'job_turnin': {
- this.handleJob(session, msg)
- break
- }
- case 'container_open': {
- const entity = this.world.entities.get(msg.target as EntityId)
- const denied = this.containerAccessDenied(session, entity)
- if (denied || !entity?.prop?.container) {
- this.send(session, {
- t: 'result',
- action: 'container',
- ok: false,
- error: denied ?? 'no_container',
- })
- break
- }
- session.openContainer = entity.id
- this.sendContainer(session, entity)
- break
- }
- case 'container_move': {
- const entity = this.world.entities.get(msg.target as EntityId)
- const denied = this.containerAccessDenied(session, entity)
- const box = entity?.prop?.container
- if (denied || !entity || !box) {
- this.send(session, {
- t: 'result',
- action: 'container',
- ok: false,
- error: denied ?? 'no_container',
- })
- break
- }
- const maxStackOf = (defId: string) => this.world.content.item(defId)?.maxStack ?? 1
- let ok = false
- if (msg.dir === 'in') {
- const stack = session.inventory.get(msg.slot)
- if (stack) {
- const want = msg.count !== undefined ? Math.min(msg.count, stack.count) : stack.count
- const moved = containerAdd(box, stack.defId, want, maxStackOf)
- if (moved > 0) {
- session.inventory.removeFromSlot(msg.slot, moved)
- ok = true
- }
- }
- } else {
- const slot = box[msg.slot]
- if (slot) {
- const want = msg.count !== undefined ? Math.min(msg.count, slot.count) : slot.count
- const leftover = session.inventory.add(slot.defId, want)
- const moved = want - leftover
- if (moved > 0) {
- slot.count -= moved
- if (slot.count <= 0) box[msg.slot] = null
- ok = true
- }
- }
- }
- if (ok) {
- entity.dirty = true
- session.dirty = true
- this.sendInventory(session)
- // Push fresh contents to EVERYONE with this container open.
- for (const other of this.sessions.values()) {
- if (other.openContainer === entity.id) this.sendContainer(other, entity)
- }
- } else {
- this.send(session, { t: 'result', action: 'container', ok: false, error: 'no_space' })
- }
- break
- }
- case 'container_sort': {
- const entity = this.world.entities.get(msg.target as EntityId)
- const denied = this.containerAccessDenied(session, entity)
- const box = entity?.prop?.container
- if (denied || !entity || !box) {
- this.send(session, {
- t: 'result',
- action: 'container',
- ok: false,
- error: denied ?? 'no_container',
- })
- break
- }
- containerSort(box, (defId) => this.world.content.item(defId)?.maxStack ?? 1)
- entity.dirty = true
- this.send(session, { t: 'result', action: 'container', ok: true })
- for (const other of this.sessions.values()) {
- if (other.openContainer === entity.id) this.sendContainer(other, entity)
- }
- break
- }
- }
+ return this.ctx.clock.tick
}
}
- // ── Markets ────────────────────────────────────────────────────────
-
- /** Live sell-stock per market: item -> remaining + next restock time. */
- private readonly marketStock = new Map<string, Map<string, { stock: number; at: number }>>()
-
- /** Lazily restocked stock entry for one market sell line. */
- private stockOf(
- marketId: string,
- entry: { item: string; stock: number; restockSeconds: number },
- nowMs: number,
- ): { stock: number; at: number } {
- let market = this.marketStock.get(marketId)
- if (!market) {
- // Persisted market blobs are loaded at boot (GameServer.load); an unknown market starts fresh.
- market = new Map()
- this.marketStock.set(marketId, market)
- }
- let state = market.get(entry.item)
- if (!state) {
- state = { stock: entry.stock, at: nowMs + entry.restockSeconds * 1000 }
- market.set(entry.item, state)
- }
- if (nowMs >= state.at) {
- state.stock = entry.stock
- state.at = nowMs + entry.restockSeconds * 1000
- }
- return state
- }
-
- /**
- * Market interactions: open/buy/sell against the market a shop prop
- * references. Everything server-atomic: proximity, faction stance,
- * live stock, coins and space all validated here.
- */
- private handleMarket(
- session: PlayerSession,
- msg: { t: string; target: string; item?: string },
- ): void {
- const deny = (error: string) =>
- this.send(session, { t: 'result', action: 'trade', ok: false, error })
- const entity = this.world.entities.get(msg.target as EntityId)
- const marketId = entity?.prop
- ? this.world.content.item(entity.prop.defId)?.shop?.market
- : undefined
- const market = marketId ? this.world.content.market(marketId) : undefined
- if (!entity || !market) return deny('no_merchant')
- const d = Math.hypot(
- entity.transform.pos.x - session.move.pos.x,
- entity.transform.pos.z - session.move.pos.z,
- )
- if (d > 5) return deny('out_of_range')
- const faction = this.world.content.faction(market.faction)
- const stance = faction ? stanceToward(faction, session.reputation) : 'neutral'
- if (stance === 'hostile') return deny('they_hate_you')
- const nowMs = Date.now()
-
- if (msg.t === 'market_buy' && msg.item) {
- const entry = market.sells.find((s) => s.item === msg.item)
- if (!entry) return deny('no_such_trade')
- const state = this.stockOf(market.id, entry, nowMs)
- if (state.stock <= 0) return deny('out_of_stock')
- if (session.inventory.countOf('coin') < entry.price) return deny('missing_coins')
- if (!session.inventory.canFit(entry.item, entry.count)) return deny('inventory_full')
- const paid = session.inventory.consume([{ item: 'coin', count: entry.price }])
- if (!paid.ok) return deny('missing_coins')
- session.inventory.add(entry.item, entry.count)
- state.stock -= 1
- addReputation(session.reputation, market.faction, REP_DELTAS.trade)
- session.dirty = true
- this.send(session, { t: 'result', action: 'trade', ok: true })
- this.sendInventory(session)
- this.sendReputation(session)
- } else if (msg.t === 'market_sell' && msg.item) {
- const entry = market.buys.find((b) => b.item === msg.item)
- if (!entry) return deny('no_such_trade')
- if (session.inventory.countOf(entry.item) < entry.count) return deny('missing_items')
- if (!session.inventory.canFit('coin', entry.price)) return deny('inventory_full')
- const taken = session.inventory.consume([{ item: entry.item, count: entry.count }])
- if (!taken.ok) return deny('missing_items')
- session.inventory.add('coin', entry.price)
- addReputation(session.reputation, market.faction, REP_DELTAS.trade)
- session.dirty = true
- this.send(session, { t: 'result', action: 'trade', ok: true })
- this.sendInventory(session)
- this.sendReputation(session)
- }
- // Every path (including plain open) refreshes the market + contracts.
- this.sendJobs(session, market.id)
- this.send(session, {
- t: 'market',
- id: market.id,
- name: market.name,
- stance,
- sells: market.sells.map((s) => ({
- item: s.item,
- count: s.count,
- price: s.price,
- stock: this.stockOf(market.id, s, nowMs).stock,
- })),
- buys: market.buys.map((b) => ({ item: b.item, count: b.count, price: b.price })),
- })
- }
-
- /** Contract accept/turn-in at a trading post. */
- private handleJob(
- session: PlayerSession,
- msg: { t: string; target: string; job?: string },
- ): void {
- const deny = (error: string) =>
- this.send(session, { t: 'result', action: 'trade', ok: false, error })
- const entity = this.world.entities.get(msg.target as EntityId)
- const marketId = entity?.prop
- ? this.world.content.item(entity.prop.defId)?.shop?.market
- : undefined
- if (!entity || !marketId) return deny('no_merchant')
- const d = Math.hypot(
- entity.transform.pos.x - session.move.pos.x,
- entity.transform.pos.z - session.move.pos.z,
- )
- if (d > 5) return deny('out_of_range')
-
- if (msg.t === 'job_accept' && msg.job) {
- const job = this.world.content.job(msg.job)
- if (!job || job.market !== marketId) return deny('no_such_job')
- if (session.activeJob) return deny('job_in_progress')
- session.activeJob = { job: job.id, progress: 0 }
- session.dirty = true
- this.send(session, { t: 'result', action: 'trade', ok: true })
- this.send(session, { t: 'announce', text: `📋 Contract accepted: ${job.name}` })
- } else if (msg.t === 'job_turnin') {
- const job = session.activeJob ? this.world.content.job(session.activeJob.job) : undefined
- if (!session.activeJob || !job) return deny('no_active_job')
- if (job.market !== marketId) return deny('wrong_merchant')
- if (job.objective.kind === 'deliver') {
- const need = { item: job.objective.item, count: job.objective.count }
- if (session.inventory.countOf(need.item) < need.count) return deny('missing_items')
- const taken = session.inventory.consume([need])
- if (!taken.ok) return deny('missing_items')
- } else if (session.activeJob.progress < job.objective.count) {
- return deny('not_finished')
- }
- // Rewards: coins, reputation, items, XP — all atomic-enough (coins
- // and items overflow to the floor is prevented by canFit pre-check).
- if (job.reward.coins > 0 && !session.inventory.canFit('coin', job.reward.coins)) {
- return deny('inventory_full')
- }
- if (job.reward.coins > 0) session.inventory.add('coin', job.reward.coins)
- for (const it of job.reward.items) session.inventory.add(it.item, it.count)
- const market = this.world.content.market(marketId)
- if (job.reward.reputation !== 0 && market) {
- addReputation(session.reputation, market.faction, job.reward.reputation)
- this.sendReputation(session)
- }
- if (job.reward.xp) {
- const ups = session.skills.addXp(job.reward.xp.skill, job.reward.xp.amount)
- for (const up of ups) this.send(session, { t: 'levelup', skill: up.skill, level: up.level })
- this.sendSkills(session)
- }
- session.activeJob = null
- session.dirty = true
- this.send(session, { t: 'result', action: 'trade', ok: true })
- this.send(session, { t: 'announce', text: `✅ Contract complete: ${job.name}` })
- this.sendInventory(session)
- }
- this.sendJobs(session, marketId)
- }
-
- private sendJobs(session: PlayerSession, marketId: string): void {
- const active = session.activeJob
- ? (() => {
- const job = this.world.content.job(session.activeJob!.job)
- if (!job) return null
- const goal = job.objective.count
- const progress =
- job.objective.kind === 'deliver'
- ? Math.min(goal, session.inventory.countOf(job.objective.item))
- : session.activeJob!.progress
- return {
- job: job.id,
- name: job.name,
- progress,
- goal,
- ready: progress >= goal,
- }
- })()
- : null
- this.send(session, {
- t: 'jobs',
- market: marketId,
- available: this.world.content.jobsForMarket(marketId).map((j) => ({
- id: j.id,
- name: j.name,
- description: j.description,
- done: false,
- })),
- active,
- })
- }
-
- private sendReputation(session: PlayerSession): void {
- this.send(session, {
- t: 'reputation',
- factions: this.world.content.allFactions().map((f) => ({
- id: f.id,
- name: f.name,
- value: Math.round(session.reputation[f.id] ?? 0),
- stance: stanceToward(f, session.reputation),
- })),
- })
- }
-
- /** Range + prop-protection gate shared by all container operations. */
- private containerAccessDenied(
- session: PlayerSession,
- entity: GameEntity | undefined,
- ): string | null {
- if (!entity?.prop) return 'no_target'
- const d = Math.hypot(
- entity.transform.pos.x - session.move.pos.x,
- entity.transform.pos.y - session.move.pos.y,
- entity.transform.pos.z - session.move.pos.z,
- )
- if (d > 4.5) return 'out_of_range'
- if (!this.canManipulate(session, entity)) return 'not_owner'
- return null
- }
-
- private sendContainer(session: PlayerSession, entity: GameEntity): void {
- const box = entity.prop?.container ?? []
- this.send(session, {
- t: 'container',
- id: entity.id,
- size: box.length,
- slots: box.flatMap((slot, i) => (slot ? [{ i, def: slot.defId, count: slot.count }] : [])),
- })
- }
-
- private timeWire(): ServerMessage {
- return { t: 'time', frac: this.env.timeOfDay, weather: this.env.weather }
+ /** Players connected now (the readiness `sessions` check; a restart would disconnect them). */
+ onlineCount(): number {
+ return this.systems.sessions.onlineCount()
}
}
/**
/**
- * Utility + production sweep, once a second over prop entities. Plants
- * stay timestamp-lazy (a slower cadence touches them); machines and
- * generators are few, and their jobs are timestamp-based too — this
- * sweep only notices completions/starts, it never simulates.
+ * The client message types this server dispatches, one handler each. Diagnostics, and the
+ * handler-table test that pins it against the protocol.
*/
*/
- private tickProduction(nowMs: number, raining: boolean, ambientC: number): void {
- const content = this.world.content
- const maxStackOf = (defId: string) => content.item(defId)?.maxStack ?? 1
- // Pass 1: generators burn fuel; remember active ones for power checks.
- const activePower: { x: number; z: number; radius: number }[] = []
- for (const entity of this.world.entities.ofKind('prop')) {
- if (!entity.prop) continue
- const def = content.item(entity.prop.defId)
- if (!def?.powerProducer) continue
- if ((entity.prop.burnUntil ?? 0) <= nowMs && entity.prop.container) {
- // Reload: burn the first fuel item in the hopper.
- for (let i = 0; i < entity.prop.container.length; i++) {
- const slot = entity.prop.container[i]
- const fuel = slot ? content.item(slot.defId)?.fuel : undefined
- if (!slot || !fuel) continue
- slot.count -= 1
- if (slot.count <= 0) entity.prop.container[i] = null
- entity.prop.burnUntil =
- Math.max(nowMs, entity.prop.burnUntil ?? 0) + fuel.burnSeconds * 1000
- entity.dirty = true
- for (const other of this.sessions.values()) {
- if (other.openContainer === entity.id) this.sendContainer(other, entity)
- }
- break
- }
- }
- if ((entity.prop.burnUntil ?? 0) > nowMs) {
- activePower.push({
- x: entity.transform.pos.x,
- z: entity.transform.pos.z,
- radius: def.powerProducer.radius,
- })
- }
- }
- const powered = (x: number, z: number): boolean =>
- activePower.some((p) => Math.hypot(p.x - x, p.z - z) <= p.radius)
-
- // Pass 2: machines complete/start jobs (timestamp-based).
- for (const entity of this.world.entities.ofKind('prop')) {
- if (!entity.prop?.container) continue
- const def = content.item(entity.prop.defId)
- const machine = def?.machine
- if (!machine) continue
- entity.prop.machine ??= {}
- const shape = { inputSlots: machine.inputSlots, outputSlots: machine.outputSlots }
- let changed = false
- if (
- completeJob(
- entity.prop.machine,
- entity.prop.container,
- shape,
- (id) => content.recipe(id),
- maxStackOf,
- nowMs,
- )
- ) {
- changed = true
- }
- const hasPower =
- !machine.needsPower || powered(entity.transform.pos.x, entity.transform.pos.z)
- if (
- hasPower &&
- tryStartJob(
- entity.prop.machine,
- entity.prop.container,
- shape,
- content.machineRecipes(machine.kind),
- nowMs,
- )
- ) {
- changed = true
- }
- if (changed) {
- entity.dirty = true
- for (const other of this.sessions.values()) {
- if (other.openContainer === entity.id) this.sendContainer(other, entity)
- }
- }
- }
-
- // Pass 3 (every 5s): tanks catch rain; sprinklers water nearby planters.
- if (this.tick % (this.config.tickRate * 5) === 0) {
- const tanks: { entity: GameEntity; capacity: number }[] = []
- for (const entity of this.world.entities.ofKind('prop')) {
- const cap = entity.prop ? content.item(entity.prop.defId)?.waterTank : undefined
- if (!entity.prop || !cap) continue
- if (raining && (entity.prop.waterAmount ?? 0) < cap.capacity) {
- entity.prop.waterAmount = Math.min(cap.capacity, (entity.prop.waterAmount ?? 0) + 2.5)
- entity.dirty = true
- }
- tanks.push({ entity, capacity: cap.capacity })
- }
- for (const entity of this.world.entities.ofKind('prop')) {
- const spr = entity.prop ? content.item(entity.prop.defId)?.sprinkler : undefined
- if (!entity.prop || !spr) continue
- const tank = tanks.find(
- (t) =>
- (t.entity.prop!.waterAmount ?? 0) >= 0.5 &&
- Math.hypot(
- t.entity.transform.pos.x - entity.transform.pos.x,
- t.entity.transform.pos.z - entity.transform.pos.z,
- ) <= spr.tankRange,
- )
- if (!tank) continue
- // Planters via the spatial hash (no all-props walk per sprinkler).
- this.world.spatial.forEachInRadius(
- entity.transform.pos.x,
- entity.transform.pos.z,
- spr.radius,
- (id) => {
- const planter = this.world.entities.get(id)
- const plant = planter?.prop?.plant
- if (!planter || !plant) return
- if (
- Math.hypot(
- planter.transform.pos.x - entity.transform.pos.x,
- planter.transform.pos.z - entity.transform.pos.z,
- ) > spr.radius
- ) {
- return
- }
- const crop = content.crop(plant.crop)
- if (!crop) return
- updatePlant(plant, crop, { nowMs, raining, ambientC })
- if ((tank.entity.prop!.waterAmount ?? 0) >= 0.5 && waterPlant(plant)) {
- tank.entity.prop!.waterAmount = (tank.entity.prop!.waterAmount ?? 0) - 0.5
- tank.entity.dirty = true
- planter.dirty = true
- }
- },
- )
- }
- }
-
- // Pass 4 (every 15s): lazy plant advance + growth-stage broadcasts.
- if (this.tick % (this.config.tickRate * 15) !== 0) return
- for (const entity of this.world.entities.ofKind('prop')) {
- const plant = entity.prop?.plant
- if (!plant) continue
- const crop = content.crop(plant.crop)
- if (!crop) continue
- const beforeStage = plantStage(plant, crop)
- const beforeWater = plant.water
- updatePlant(plant, crop, { nowMs, raining, ambientC })
- entity.dirty = true
- const stageChanged = plantStage(plant, crop) !== beforeStage
- const driedOut = beforeWater > 0 && plant.water <= 0
- if (stageChanged || driedOut) {
- this.broadcastToKnowing(entity.id, {
- t: 'entity',
- id: entity.id,
- plant: { crop: plant.crop, t: plantProgress(plant, crop), water: plant.water },
- })
- }
- }
- }
-
- onDisconnect(conn: GameConnection): void {
- const session = this.sessionsByConn.get(conn)
- if (!session) return
- this.sessionsByConn.delete(conn)
- this.sessions.delete(session.playerId)
- this.sessionsByEntity.delete(session.entityId)
- // Keep protection checks fresh once the owner goes offline.
- this.offlineFriendsCache.set(session.playerId as string, {
- friends: new Set(session.friends),
- at: Date.now(),
- })
- if (session.held) {
- this.heldEntityIds.delete(session.held.entityId)
- session.held = null
- }
- const bodyId = this.playerBodies.get(session.playerId)
- if (bodyId !== undefined) {
- this.world.physics.removeBody(bodyId)
- this.playerBodies.delete(session.playerId)
- }
- this.world.entities.remove(session.entityId)
- this.world.spatial.remove(session.entityId)
- this.savePlayer(session, true)
- void this.integrations.progressSummary?.playerLeft(
- session.playerId as string,
- session.subjectId,
- sessionProgress(session).levels,
- this.world.content.world.id,
- )
- this.broadcastDespawn(session.entityId)
- this.metrics.sessions = this.sessions.size
- this.log.info('player disconnected', { playerId: session.playerId, name: session.name })
+ get messageTypes(): readonly string[] {
+ return [...this.handlers.keys()]
}
}
- /** Guards one hello per connection while it resolves (it awaits Network); see onMessage. */
- private async handleHello(conn: GameConnection, msg: ClientHello): Promise<void> {
- this.hellosInFlight.add(conn)
- try {
- await this.resolveHello(conn, msg)
- } finally {
- this.hellosInFlight.delete(conn)
- }
- }
-
- private async resolveHello(conn: GameConnection, msg: ClientHello): Promise<void> {
- if (msg.v !== PROTOCOL_VERSION) {
- conn.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))
- conn.close(4002, 'protocol_mismatch')
- return
- }
- if (this.sessions.size >= this.config.maxPlayers) {
- conn.send(encodeServerMessage({ t: 'reject', reason: 'server_full' }))
- conn.close(4003, 'server_full')
- return
- }
-
- const slot = msg.slot ?? 0
- // Account resolution. openvibe.network sign-in keys the account on the
- // canonical subject (3 slots, follows you across devices). Guests get ONE
- // character bound to their connection's browser token.
- let token = msg.token
- let subjectId: string | null = null
- let authIat: number | null = null
- let rank: 'owner' | 'admin' | 'moderator' | null = null
- if (msg.auth) {
- const user = await resolveNetworkUser(this.config.networkAuthUrl, msg.auth)
- // A signed-in account is keyed by its canonical subject (ADR-0006); a
- // token that carries none cannot name an account.
- if (!user || !user.subjectId) {
- conn.send(encodeServerMessage({ t: 'reject', reason: 'auth_failed' }))
- conn.close(4009, 'auth_failed')
- return
- }
- token = user.subjectId
- subjectId = user.subjectId
- authIat = tokenIat(msg.auth)
- rank = user.rank
- // Guest conversion (WS-B task 8): the character this browser played as a guest joins the
- // account in its first free slot, once.
- if (isGuestToken(msg.token)) {
- const g = await this.store.identity.adoptGuestCharacter(msg.token, subjectId, Date.now())
- if (g.moved > 0)
- this.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })
- else if (g.full)
- this.log.info('guest character kept: account slots full', { subject: subjectId })
- }
- } else {
- // A guest token may never look like an account key (`usr_…`): that
- // would open someone else's characters. The client mints plain
- // alphanumerics; anything key-shaped is refused.
- if (!isGuestToken(msg.token)) {
- conn.send(encodeServerMessage({ t: 'reject', reason: 'invalid_hello' }))
- conn.close(4007, 'invalid_guest_token')
- return
- }
- if (slot > 0) {
- conn.send(encodeServerMessage({ t: 'reject', reason: 'guest_one_character' }))
- conn.close(4010, 'guest_one_character')
- return
- }
- }
- // A disconnect save of this character may still be queued: read after it lands, not before.
- await this.flusher.drain(5000)
- const existing = await this.store.players.findByTokenSlot(token, slot)
- // One live session per CHARACTER; other characters of the same account
- // may stay online (an account still only plays one at a time in
- // practice — same token kicks apply per slot).
- for (const s of this.sessions.values()) {
- if (s.token === token && s.charSlot === slot) {
- s.closeConnection(4004, 'session_superseded')
- }
- }
-
- const world = this.world.content.world
- const playerId = existing ? asPlayerId(existing.id) : newPlayerId()
- const mapSpawn = worldSpawn(world)
- const spawn = existing
- ? vec3(existing.pos[0], existing.pos[1], existing.pos[2])
- : vec3(mapSpawn.pos[0], mapSpawn.pos[1], mapSpawn.pos[2])
- const inventory = existing
- ? Inventory.fromDto(existing.inventory, this.world.content)
- : new Inventory(INVENTORY_SIZE, HOTBAR_SIZE, this.world.content)
- const skills = existing
- ? SkillSet.fromDto(existing.skills, this.world.content)
- : new SkillSet(this.world.content)
- const friends = new Set(existing?.friends ?? [])
- // The client's customization is authoritative for looks (validated by
- // the protocol schema) — EXCEPT body size: everyone shares one hull and
- // silhouette so combat stays fair.
- const appearance = { ...msg.appearance, height: 1, build: 1 }
-
- // Starter kit: every scrapper carries a physgun. Also grants it to
- // players from before the tool system existed.
- for (const grant of STARTER_ITEMS) {
- if (inventory.countOf(grant.item) === 0) {
- const leftover = inventory.add(grant.item, grant.count)
- if (leftover > 0) {
- this.log.warn('starter item did not fit', { item: grant.item, playerId })
- }
- }
- }
-
- const session = createSession({
- playerId,
- charSlot: slot,
- entityId: newEntityId(),
- token,
- subjectId,
- authIat,
- rank,
- name: msg.name,
- spawn,
- yaw: existing?.yaw ?? mapSpawn.yaw,
- inventory,
- skills,
- friends,
- appearance,
- stats: existing?.stats ?? undefined,
- armor: existing?.armor ?? null,
- reputation: existing?.reputation ?? {},
- unlocks: existing?.unlocks ?? [],
- activeJob: existing?.activeJob ?? null,
- content: this.world.content,
- send: (text) => conn.send(text),
- closeConnection: (code, reason) => conn.close(code, reason),
- })
- this.sessions.set(playerId, session)
- this.sessionsByConn.set(conn, session)
- this.sessionsByEntity.set(session.entityId, session)
- this.offlineFriendsCache.delete(playerId as string)
-
- // Player entity (transient — players persist via the player repository).
- const entity: GameEntity = {
- id: session.entityId,
- kind: 'player',
- transform: { pos: session.move.pos, rot: qfromYaw(quat(), session.yaw) },
- persistent: false,
- dirty: false,
- }
- this.world.entities.add(entity)
- this.world.spatial.insert(entity.id, session.move.pos.x, session.move.pos.z)
-
- // Kinematic capsule so props collide with players. Shorter than the
- // movement hull and lifted off the feet: standing ON a prop must not
- // press it down (that caused sink/jitter loops when prop-surfing).
- const bodyId = this.world.physics.addBody({
- shape: { type: 'capsule', radius: MOVE.capsuleRadius, height: MOVE.capsuleHeight - 0.3 },
- motion: 'kinematic',
- pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),
- layer: CollisionLayer.Player,
- collidesWith: CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,
- })
- this.playerBodies.set(playerId, bodyId)
-
- this.send(session, {
- t: 'welcome',
- v: PROTOCOL_VERSION,
- rank,
- playerId: playerId as string,
- entityId: session.entityId as string,
- tick: this.tick,
- tickRate: this.config.tickRate,
- snapshotRate: this.config.tickRate / this.config.snapshotEvery,
- })
- this.sendInventory(session)
- this.sendSkills(session)
- await this.sendFriends(session)
- this.send(session, this.timeWire())
- this.sendReputation(session)
- this.metrics.sessions = this.sessions.size
- this.integrations.progressSummary?.playerJoined(session.playerId as string)
- const recorder = this.integrations.events
- if (recorder) {
- // Baseline = what the database holds for this character (nothing for a
- // new one), so the first save reports only what was earned since.
- const stored = existing
- ? progressOf(SkillSet.fromDto(existing.skills, this.world.content), existing.unlocks)
- : { levels: {}, unlocks: [] }
- await this.store.transaction(async (t) => {
- await recorder.recordJoin(t, eventPlayer(session), stored, existing !== null)
- })
- }
- this.log.info('player connected', {
- playerId: playerId as string,
- name: msg.name,
- restored: existing !== null,
- })
- }
-
- private handlePhysgun(session: PlayerSession, msg: ClientPhysgun): void {
- // Physgun actions require the physgun in the active hotbar slot.
- if ((msg.a === 'grab' || msg.a === 'unfreeze') && equippedTool(session)?.kind !== 'physgun') {
- this.send(session, {
- t: 'result',
- action: 'physgun',
- ok: false,
- error: 'no_physgun_equipped',
- })
- return
- }
- if (msg.a === 'grab') {
- if (session.held) return
- // Beam on: even with nothing under the crosshair, keep trying each
- // tick — sweeping the beam onto a prop picks it up (GMod behavior).
- session.grabbing = true
- const denied = this.attemptGrab(session)
- // Only meaningful denials are surfaced; an empty beam is not an error.
- if (denied && denied !== 'no_target') {
- this.send(session, { t: 'result', action: 'physgun', ok: false, error: denied })
- }
- } else if (msg.a === 'release') {
- session.grabbing = false
- this.releaseHeld(session)
- } else if (msg.a === 'adjust') {
- adjustDistance(session, msg.dist)
- } else if (msg.a === 'rotate') {
- rotateHeld(session, msg.dyaw, msg.dpitch, msg.snap ?? false, msg.snapStep)
- } else if (msg.a === 'grid') {
- if (session.held) {
- session.held.grid = msg.on
- if (msg.size !== undefined) session.held.gridSize = msg.size
- }
- } else if (msg.a === 'freeze') {
- // Freezing ends the beam — otherwise the sweep-to-grab retry would
- // immediately unfreeze what was just frozen.
- session.grabbing = false
- const frozen = freezeHeld(session, this.world)
- if (frozen) {
- this.heldEntityIds.delete(frozen.id)
- this.broadcastToKnowing(frozen.id, {
- t: 'entity',
- id: frozen.id,
- motion: 'frozen',
- pos: [frozen.transform.pos.x, frozen.transform.pos.y, frozen.transform.pos.z],
- rot: [
- frozen.transform.rot.x,
- frozen.transform.rot.y,
- frozen.transform.rot.z,
- frozen.transform.rot.w,
- ],
- })
- this.broadcastAll({ t: 'physgun_state', player: session.entityId, target: null })
- }
- } else if (msg.a === 'unfreeze') {
- const entity = this.world.entities.get(msg.target as EntityId)
- if (!entity?.prop || entity.prop.motion !== 'frozen') {
- this.send(session, { t: 'result', action: 'physgun', ok: false, error: 'no_target' })
- return
- }
- if (!this.world.zones.rulesAt(entity.transform.pos).physgun) {
- this.send(session, { t: 'result', action: 'physgun', ok: false, error: 'zone' })
- return
- }
- if (!this.canManipulate(session, entity)) {
- this.send(session, { t: 'result', action: 'physgun', ok: false, error: 'not_owner' })
- return
- }
- eyePosition(session, _eyeScratch)
- if (
- Math.hypot(
- entity.transform.pos.x - _eyeScratch.x,
- entity.transform.pos.y - _eyeScratch.y,
- entity.transform.pos.z - _eyeScratch.z,
- ) > 10
- ) {
- this.send(session, { t: 'result', action: 'physgun', ok: false, error: 'out_of_range' })
- return
- }
- this.world.setPropMotion(entity, 'dynamic')
- this.broadcastToKnowing(entity.id, { t: 'entity', id: entity.id, motion: 'dynamic' })
- }
- }
-
- private async handleTrust(
- session: PlayerSession,
- targetId: string,
- trusted: boolean,
- ): Promise<void> {
- if (targetId === (session.playerId as string)) {
- this.send(session, { t: 'result', action: 'trust', ok: false, error: 'self' })
- return
- }
- // Target must be a real player (online or persisted).
- const online = this.sessions.get(targetId as PlayerId)
- const known = online ?? (await this.store.players.findById(targetId))
- if (!known) {
- this.send(session, { t: 'result', action: 'trust', ok: false, error: 'unknown_player' })
+ onMessage(conn: GameConnection, msg: ClientMessage): void {
+ const session = this.ctx.sessions.getByConn(conn)
+ // Everything but `hello` needs a session; anything else first is a protocol error.
+ if (!session && msg.t !== 'hello') {
+ conn.close(4001, 'hello_first')
return
return
}
}
- if (trusted) session.friends.add(targetId)
- else session.friends.delete(targetId)
- session.dirty = true
- this.send(session, { t: 'result', action: 'trust', ok: true })
- await this.sendFriends(session)
- }
-
- private async sendFriends(session: PlayerSession): Promise<void> {
- const friends: { id: string; name: string }[] = []
- for (const id of session.friends) {
- const online = this.sessions.get(id as PlayerId)
- const name = online?.name ?? (await this.store.players.findById(id))?.name ?? 'unknown'
- friends.push({ id, name })
- }
- this.send(session, { t: 'friends', friends })
+ const handler = this.handlers.get(msg.t)
+ if (!handler) return
+ // The table guarantees `msg.t` matches the handler's declared message type. The one exception is
+ // `hello`, whose handler takes `PlayerSession | undefined` and ignores the argument.
+ handler(session as PlayerSession, msg as never, conn)
}
}
- /** One grab attempt down the view ray; latches + broadcasts on success. */
- private attemptGrab(session: PlayerSession): string | null {
- const grabbed = tryGrab(session, this.world, this.heldEntityIds, (e) =>
- this.canManipulate(session, e),
- )
- if (typeof grabbed === 'string') return grabbed
- this.heldEntityIds.add(grabbed.id)
- // Grabbing a frozen prop unfreezes it — tell clients about the motion
- // change (physics resumes; frozen visuals must clear).
- this.broadcastToKnowing(grabbed.id, { t: 'entity', id: grabbed.id, motion: 'dynamic' })
- const grab = session.held?.localOffset
- this.broadcastAll({
- t: 'physgun_state',
- player: session.entityId,
- target: grabbed.id,
- ...(grab ? { grab: [grab.x, grab.y, grab.z] as [number, number, number] } : {}),
- })
- return null
- }
-
- private releaseHeld(session: PlayerSession): void {
- if (!session.held) return
- // Wake the released body: if it was driven into a sleeping neighbor,
- // the depenetration solver needs it active to push them apart.
- const bodyId = this.world.bodyOf(session.held.entityId)
- if (bodyId !== undefined) {
- this.world.physics.wake(bodyId)
- // Source-feel throw cap: the drive can move props at 45 m/s, but a
- // LET-GO should toss, not rocket-launch. Clamp exit velocity.
- this.world.physics.getLinearVelocity(bodyId, _relVel)
- const speed = Math.hypot(_relVel.x, _relVel.y, _relVel.z)
- const cap = 9
- if (speed > cap) {
- const k = cap / speed
- _relVel.x *= k
- _relVel.y *= k
- _relVel.z *= k
- this.world.physics.setLinearVelocity(bodyId, _relVel)
- }
- }
- this.heldEntityIds.delete(session.held.entityId)
- release(session)
- this.broadcastAll({ t: 'physgun_state', player: session.entityId, target: null })
- }
-
- /** Melee swing on another player: range + zone PvP rules + tool damage. */
- private handleMelee(attacker: PlayerSession, victim: PlayerSession): void {
- const d = Math.hypot(
- victim.move.pos.x - attacker.move.pos.x,
- victim.move.pos.y - attacker.move.pos.y,
- victim.move.pos.z - attacker.move.pos.z,
- )
- const tool = equippedTool(attacker)
- if (tool?.kind === 'physgun') {
- this.send(attacker, { t: 'result', action: 'use', ok: false, error: 'not_a_weapon' })
- return
- }
- // ANY held item swings; weapon capability > tool power > improvised.
- const heldDef = attacker.holstered
- ? undefined
- : this.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')
- const weapon = heldDef?.weapon
- const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)
- if (d > range) {
- this.send(attacker, { t: 'result', action: 'use', ok: false, error: 'out_of_range' })
- return
- }
- // PvP must be legal where BOTH players stand (no shooting into the city).
- if (
- !this.world.zones.rulesAt(attacker.move.pos).pvp ||
- !this.world.zones.rulesAt(victim.move.pos).pvp
- ) {
- this.send(attacker, { t: 'result', action: 'use', ok: false, error: 'safe_zone' })
- return
- }
- attacker.lastUseTick = this.tick
- // Swinging costs stamina; an exhausted swing lands soft.
- const exhausted = attacker.stats.stamina < 10
- attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)
- attacker.statsDirty = true
- const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)
- // Edged tools cut (can open wounds); everything else is blunt.
- const type: DamageType =
- weapon || tool?.kind === 'axe' || tool?.kind === 'pickaxe' ? 'cutting' : 'blunt'
- // Knockback: shove the victim away (replicates through prediction).
- const kx = victim.move.pos.x - attacker.move.pos.x
- const kz = victim.move.pos.z - attacker.move.pos.z
- const kl = Math.hypot(kx, kz) || 1
- victim.move.vel.x += (kx / kl) * 4.5
- victim.move.vel.z += (kz / kl) * 4.5
- victim.move.vel.y += 2.2
- this.send(attacker, { t: 'result', action: 'use', ok: true })
- this.damagePlayer(attacker, victim, { type, amount: base * (exhausted ? 0.5 : 1) })
+ onDisconnect(conn: GameConnection): void {
+ this.systems.sessions.onDisconnect(conn)
}
}
/**
/**
- * Ranged fire: the client sent pure intent — the shot leaves the
- * player's AUTHORITATIVE eye along their AUTHORITATIVE view (from
- * movement inputs) plus server-rolled spread. Magazine, cadence and
- * reload state live in the weapon stack's meta and are validated here.
+ * Live map edit: every client refetches and rebuilds its terrain. The reconcile itself is
+ * main.ts's (it owns the map document); this is the wire half.
*/
*/
- private handleFire(session: PlayerSession): void {
- const held = session.holstered ? null : session.inventory.get(session.activeHotbar)
- const spec = held ? this.world.content.item(held.defId)?.rangedWeapon : undefined
- if (!held || !spec) {
- this.send(session, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })
- return
- }
- if (!this.world.zones.rulesAt(session.move.pos).pvp) {
- this.send(session, { t: 'result', action: 'attack', ok: false, error: 'safe_zone' })
- return
- }
- const nowMs = Date.now()
- const state = weaponStateFromMeta(held.meta)
- const verdict = canFire(state, spec, nowMs)
- if (verdict !== 'ok') {
- this.send(session, { t: 'result', action: 'attack', ok: false, error: verdict })
- return
- }
- recordShot(state, nowMs)
- held.meta = weaponStateToMeta(state, held.meta)
- session.dirty = true
-
- eyePosition(session, _eyeScratch)
- viewDirection(session, _fireDir)
- // Server-rolled spread: deflect the view ray inside the cone.
- const spread = (spec.spreadDeg * Math.PI) / 180
- const dyaw = (Math.random() * 2 - 1) * spread
- const dpitch = (Math.random() * 2 - 1) * spread
- const cosP = Math.cos(dpitch)
- const yaw = Math.atan2(_fireDir.x, _fireDir.z) + dyaw
- const pitch = Math.asin(Math.max(-1, Math.min(1, _fireDir.y))) + dpitch
- _fireDir.x = Math.sin(yaw) * Math.cos(pitch)
- _fireDir.y = Math.sin(pitch)
- _fireDir.z = Math.cos(yaw) * Math.cos(pitch)
- void cosP
- // Start past the shooter's own capsule (raycast cannot exclude bodies).
- _fireFrom.x = _eyeScratch.x + _fireDir.x * 0.6
- _fireFrom.y = _eyeScratch.y + _fireDir.y * 0.6
- _fireFrom.z = _eyeScratch.z + _fireDir.z * 0.6
- _fireTo.x = _fireFrom.x + _fireDir.x * spec.range
- _fireTo.y = _fireFrom.y + _fireDir.y * spec.range
- _fireTo.z = _fireFrom.z + _fireDir.z * spec.range
- const hit = this.world.physics.raycast(
- _fireFrom,
- _fireTo,
- CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,
- )
- let end = _fireTo
- let landed = false
- if (hit) {
- end = hit.point
- // Player hit?
- let victim: PlayerSession | null = null
- for (const other of this.sessions.values()) {
- if (this.playerBodies.get(other.playerId) === hit.bodyId) {
- victim = other
- break
- }
- }
- if (victim && victim !== session) {
- if (this.world.zones.rulesAt(victim.move.pos).pvp) {
- landed = true
- this.damagePlayer(session, victim, {
- type: 'projectile',
- amount: spec.damage,
- sourceId: session.entityId as string,
- })
- }
- } else if (!victim) {
- const entity = this.world.entityOfBody(hit.bodyId)
- if (entity?.kind === 'npc') {
- landed = this.applyNpcDamage(session, entity, spec.damage)
- } else if (entity?.prop && this.world.content.item(entity.prop.defId)?.health) {
- landed = this.applyPropDamage(entity, spec.damage, 'projectile')
- }
- }
- }
- this.sounds.push({ x: session.move.pos.x, z: session.move.pos.z })
- this.send(session, { t: 'result', action: 'attack', ok: true })
- const tracer: ServerMessage = {
- t: 'tracer',
- shooter: session.entityId as string,
- from: [_fireFrom.x, _fireFrom.y, _fireFrom.z],
- to: [end.x, end.y, end.z],
- hit: landed,
- }
- const encoded = encodeServerMessage(tracer)
- for (const other of this.sessions.values()) {
- if (other === session || other.known.has(session.entityId)) this.sendRaw(other, encoded)
- }
- this.sendInventory(session)
+ broadcastMapReload(): void {
+ this.systems.editor.broadcastMapReload()
}
}
/**
/**
- * The one place player damage lands: armor mitigation (with durability
- * wear), wound statuses, death handling, and hit feedback. Melee, bullets
- * and future explosions all converge here.
+ * Network moved this person's token cutoff (sign out everywhere, password changed, banned): close
+ * every session they opened with an older Network sign-in. Returns how many closed.
*/
*/
- private damagePlayer(
- attacker: PlayerSession | null,
- victim: PlayerSession,
- event: DamageEvent,
- ): void {
- const armorDef = victim.armor ? this.world.content.item(victim.armor.defId)?.armor : undefined
- const damage = mitigate(event, armorDef ? { reduction: armorDef.reduction } : null)
- // Armor wears: every mitigated hit costs a point of durability.
- if (victim.armor && armorDef && damage < event.amount) {
- const dur = Number(victim.armor.meta?.dur ?? armorDef.durability) - 1
- if (dur <= 0) {
- victim.armor = null
- this.send(victim, { t: 'announce', text: '🧥 Your armor fell apart!' })
- } else {
- victim.armor.meta = { ...victim.armor.meta, dur }
- }
- this.sendInventory(victim)
- }
- if (causesBleeding(event.type, damage)) {
- applyStatus(victim.stats, 'bleeding', BLEED_SECONDS, Date.now())
- }
- const died = applyDamage(victim.stats, damage)
- victim.statsDirty = true
- victim.dirty = true
- this.send(victim, { t: 'stats', ...this.statsWire(victim), ...(died ? { died: true } : {}) })
- victim.statsDirty = false
- this.broadcastToKnowing(victim.entityId, {
- t: 'fx',
- kind: died ? 'death' : 'hurt',
- id: victim.entityId as string,
- })
- if (died) {
- this.log.info('player killed', {
- victim: victim.playerId,
- attacker: attacker?.playerId ?? 'world',
- type: event.type,
- })
- this.respawn(victim, true)
- }
+ revokeSubject(subjectId: string, validAfterMs: number): number {
+ return this.systems.sessions.revokeSubject(subjectId, validAfterMs)
}
}
/**
/**
- * Melee swing on a damageable prop: range + zone build rules + the same
- * stamina/cooldown economics as PvP. Structures are only destructible
- * where building is legal (safe city props are untouchable).
+ * Graceful stop (WS-P lifecycle): everyone in the world is told the server is restarting. main.ts
+ * then closes every socket with 1012 (service restart); each close saves that character and records
+ * games.player.left, as when a player leaves, and the client reconnects on its own.
*/
*/
- private handlePropAttack(attacker: PlayerSession, entity: GameEntity): void {
- const def = entity.prop ? this.world.content.item(entity.prop.defId) : undefined
- const cap = def?.health
- if (!entity.prop || !cap) {
- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_damageable' })
- return
- }
- const tool = equippedTool(attacker)
- if (tool?.kind === 'physgun') {
- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })
- return
- }
- const heldDef = attacker.holstered
- ? undefined
- : this.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')
- const weapon = heldDef?.weapon
- const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)
- eyePosition(attacker, _eyeScratch)
- if (v3dist(_eyeScratch, entity.transform.pos) > range + 1) {
- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'out_of_range' })
- return
- }
- if (!this.world.zones.rulesAt(entity.transform.pos).build) {
- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'safe_zone' })
- return
- }
- attacker.lastUseTick = this.tick
- const exhausted = attacker.stats.stamina < 10
- attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)
- attacker.statsDirty = true
- const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)
- this.send(attacker, { t: 'result', action: 'attack', ok: true })
- this.applyPropDamage(entity, base * (exhausted ? 0.5 : 1), 'blunt')
- }
-
- /** Melee swing on an NPC: reach + the shared stamina economics. */
- private handleNpcAttack(attacker: PlayerSession, entity: GameEntity): void {
- const tool = equippedTool(attacker)
- if (tool?.kind === 'physgun') {
- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })
- return
- }
- const heldDef = attacker.holstered
- ? undefined
- : this.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')
- const weapon = heldDef?.weapon
- const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)
- eyePosition(attacker, _eyeScratch)
- if (v3dist(_eyeScratch, entity.transform.pos) > range + 1) {
- this.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'out_of_range' })
- return
- }
- attacker.lastUseTick = this.tick
- const exhausted = attacker.stats.stamina < 10
- attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)
- attacker.statsDirty = true
- const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)
- this.send(attacker, { t: 'result', action: 'attack', ok: true })
- this.applyNpcDamage(attacker, entity, base * (exhausted ? 0.5 : 1))
- }
-
- /** NPC damage sink: aggro marking, death, loot scatter, feedback. */
- private applyNpcDamage(
- attacker: PlayerSession | null,
- entity: GameEntity,
- amount: number,
- ): boolean {
- const nowMs = Date.now()
- const result = this.npcs.damage(entity.id, amount, nowMs)
- if (result === null) return false
- // Defensive NPCs (wardens) now treat this player as hostile for a while.
- if (attacker) this.aggro.set(attacker.entityId as string, nowMs + 45_000)
- if (result === 'hurt') {
- this.broadcastToKnowing(entity.id, { t: 'fx', kind: 'hurt', id: entity.id as string })
- return true
- }
- // Death: scatter loot rolls as physical props. The entity was already
- // dematerialized; interest diffs despawn it on the next snapshot.
- const arch = this.npcs.npcStateOf(entity.id)
- ? this.world.content.npc(this.npcs.npcStateOf(entity.id)!.archetype)
- : undefined
- const around = entity.transform.pos
- this.broadcastToKnowing(entity.id, { t: 'fx', kind: 'death', id: entity.id as string })
- let slot = 0
- for (const loot of arch?.loot ?? []) {
- if (Math.random() > loot.chance) continue
- const angle = slot * 1.6
- slot++
- const spawned = this.world.spawnProp({
- defId: loot.item,
- pos: vec3(
- around.x + Math.cos(angle) * 0.4,
- around.y + 0.3,
- around.z + Math.sin(angle) * 0.4,
- ),
- rot: qfromYaw(quat(), angle),
- motion: 'dynamic',
- lootCount: loot.count,
- velocity: vec3(Math.cos(angle) * 1.2, 1.5, Math.sin(angle) * 1.2),
- })
- this.broadcastSpawn(spawned)
- }
- if (attacker && arch) {
- addReputation(attacker.reputation, arch.faction, REP_DELTAS.killMember)
- // Kill contracts count matching archetypes.
- const job = attacker.activeJob ? this.world.content.job(attacker.activeJob.job) : undefined
- if (
- attacker.activeJob &&
- job?.objective.kind === 'kill' &&
- job.objective.archetype === arch.id &&
- attacker.activeJob.progress < job.objective.count
- ) {
- attacker.activeJob.progress++
- this.send(attacker, {
- t: 'announce',
- text: `📋 ${job.name}: ${attacker.activeJob.progress}/${job.objective.count}`,
- })
- this.sendJobs(attacker, job.market)
- }
- attacker.dirty = true
- this.sendReputation(attacker)
- }
- this.log.info('npc killed', {
- archetype: arch?.id ?? 'unknown',
- by: attacker?.playerId ?? 'world',
- })
- return true
- }
-
- /** Damage application for props: resistance, destruction, feedback.
- * Returns false when the prop has no health capability. */
- private applyPropDamage(entity: GameEntity, rawAmount: number, _type: DamageType): boolean {
- const cap = entity.prop ? this.world.content.item(entity.prop.defId)?.health : undefined
- if (!entity.prop || !cap) return false
- if (!this.world.zones.rulesAt(entity.transform.pos).build) return false
- entity.prop.health = (entity.prop.health ?? cap.max) - rawAmount * cap.resistance
- entity.dirty = true
- if (entity.prop.health <= 0) {
- this.destroyProp(entity)
- return true
- }
- this.broadcastToKnowing(entity.id, {
- t: 'entity',
- id: entity.id,
- health: Math.round(entity.prop.health),
- })
- this.broadcastToKnowing(entity.id, { t: 'fx', kind: 'hurt', id: entity.id as string })
- return true
- }
-
- /** Destruction: scatter salvage + stored contents as physical props. */
- private destroyProp(entity: GameEntity): void {
- const def = entity.prop ? this.world.content.item(entity.prop.defId) : undefined
- const drops: { defId: string; count: number }[] = []
- for (const loot of def?.health?.destroyLoot ?? []) {
- drops.push({ defId: loot.item, count: loot.count })
- }
- // A destroyed container spills everything it held.
- for (const slot of entity.prop?.container ?? []) {
- if (slot) drops.push({ defId: slot.defId, count: slot.count })
- }
- const around = entity.transform.pos
- this.world.despawn(entity.id)
- this.broadcastDespawn(entity.id)
- for (const [i, drop] of drops.entries()) {
- if (!this.world.content.item(drop.defId)) continue
- const angle = (i / Math.max(drops.length, 1)) * Math.PI * 2
- const spawned = this.world.spawnProp({
- defId: drop.defId,
- pos: vec3(
- around.x + Math.cos(angle) * 0.4,
- around.y + 0.4,
- around.z + Math.sin(angle) * 0.4,
- ),
- rot: qfromYaw(quat(), angle),
- motion: 'dynamic',
- lootCount: drop.count,
- velocity: vec3(Math.cos(angle) * 1.5, 2, Math.sin(angle) * 1.5),
- })
- this.broadcastSpawn(spawned)
- }
- this.log.info('prop destroyed', { def: def?.id ?? 'unknown', drops: drops.length })
+ announceRestart(): void {
+ this.systems.social.announceRestart()
}
}
- /**
- * 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 {
- const session = this.sessionsByEntity.get(entityId as EntityId)
- if (!session) return
- const secured = session.inventory.secureValuables(
- (defId) => this.world.content.item(defId)?.valuable !== undefined,
- )
- const spawn = worldSpawn(this.world.content.world).pos
- session.move.pos.x = spawn[0]
- session.move.pos.y = spawn[1]
- session.move.pos.z = spawn[2]
- session.move.vel.x = 0
- session.move.vel.y = 0
- session.move.vel.z = 0
- session.dirty = true
- this.send(session, {
- t: 'announce',
- text:
- secured > 0
- ? `🚁 Extracted! ${secured} stack${secured === 1 ? '' : 's'} of loot secured.`
- : '🚁 Extracted safely back to Scrap City.',
- })
- this.sendInventory(session)
- this.log.info('player extracted', { playerId: session.playerId, secured })
+ /** Full save on shutdown; main.ts awaits drain() after this. */
+ shutdown(): void {
+ this.systems.persistence.shutdown()
}
}
- /** E on a chassis: mount a valid assembly, or dismount if driving it. */
- private handleVehicleUse(session: PlayerSession, chassis: GameEntity): void {
- if (session.driving === chassis.id) {
- this.dismount(session, chassis)
- this.send(session, { t: 'result', action: 'use', ok: true })
- return
- }
- if (session.driving) {
- this.send(session, { t: 'result', action: 'use', ok: false, error: 'already_driving' })
- return
- }
- const d = v3dist(chassis.transform.pos, session.move.pos)
- if (d > 4) {
- this.send(session, { t: 'result', action: 'use', ok: false, error: 'out_of_range' })
- return
- }
- if (!this.canManipulate(session, chassis)) {
- this.send(session, { t: 'result', action: 'use', ok: false, error: 'not_owner' })
- return
- }
- // Somebody else at the wheel?
- for (const other of this.sessions.values()) {
- if (other.driving === chassis.id) {
- this.send(session, { t: 'result', action: 'use', ok: false, error: 'seat_taken' })
- return
- }
- }
- // A drivable assembly: >= 2 wheels rigged with axis/motor links.
- const wheels = this.world.constraintsFor(chassis.id).filter((rec) => {
- if (rec.type !== 'axis' && rec.type !== 'motor') return false
- const otherId = rec.a === chassis.id ? rec.b : rec.a
- const other = this.world.entities.get(otherId)
- return (
- other?.prop !== undefined &&
- this.world.content.item(other.prop.defId)?.vehiclePart?.part === 'wheel'
- )
- })
- if (wheels.length < 2) {
- this.send(session, { t: 'result', action: 'use', ok: false, error: 'needs_wheels' })
- return
- }
- // The wheel must turn: frozen carts wake up when mounted.
- if (chassis.prop!.motion !== 'dynamic') this.world.setPropMotion(chassis, 'dynamic')
- session.driving = chassis.id
- if (session.held) this.releaseHeld(session)
- this.send(session, { t: 'result', action: 'use', ok: true })
- this.send(session, { t: 'announce', text: '🛻 Driving — WASD steers, E dismounts.' })
+ /** Resolves when the write-behind queue is empty (shutdown). */
+ async drain(timeoutMs = 25_000): Promise<boolean> {
+ return this.systems.persistence.drain(timeoutMs)
}
}
- private dismount(session: PlayerSession, chassis: GameEntity | undefined): void {
- session.driving = null
- if (chassis) {
- // Step off beside the cart.
- const rot = chassis.transform.rot
- const side = qrotateVec(vec3(), rot, vec3(1.4, 0, 0))
- session.move.pos.x = chassis.transform.pos.x + side.x
- session.move.pos.y = chassis.transform.pos.y + 0.8
- session.move.pos.z = chassis.transform.pos.z + side.z
- session.move.vel.x = 0
- session.move.vel.y = 0
- session.move.vel.z = 0
- }
+ /** Exposes crafting context for the client-facing recipe availability (welcome-time). */
+ workstationsNear(session: PlayerSession): ReadonlySet<string> {
+ return nearbyWorkstationKinds(session, this.ctx.world)
}
}
/**
/**
- * Vehicle drive: intent (WASD) becomes thrust force + steering on the
- * chassis body; wheels roll on their rigged bearings; fuel burns from
- * the trunk like a generator. The rider is carried kinematically.
+ * The simulation tick, 30 Hz. This is the ONE place the order is written down; the systems'
+ * internal cadence (1 Hz sweeps, every-2-tick snapshots) is their own.
*/
*/
- private driveVehicle(session: PlayerSession): void {
- const chassis = session.driving ? this.world.entities.get(session.driving) : undefined
- const def = chassis?.prop ? this.world.content.item(chassis.prop.defId) : undefined
- const part = def?.vehiclePart
- const bodyId = chassis ? this.world.bodyOf(chassis.id) : undefined
- if (!chassis || !part || bodyId === undefined || chassis.prop!.motion !== 'dynamic') {
- this.dismount(session, chassis)
- return
- }
- // Drain inputs (ack them; the client does not predict while driving).
- let input = session.lastInput
- while (session.inputQueue.length > 0) {
- input = session.inputQueue.shift()!
- session.lastInput = input
- session.lastProcessedSeq = input.seq
- session.yaw = input.yaw
- session.pitch = input.pitch
- session.buttons = input.buttons
- }
- const nowMs = Date.now()
- // Fuel: burn items from the trunk while driving.
- if ((chassis.prop!.burnUntil ?? 0) <= nowMs && chassis.prop!.container) {
- let reloaded = false
- for (let i = 0; i < chassis.prop!.container.length; i++) {
- const slot = chassis.prop!.container[i]
- const fuel = slot ? this.world.content.item(slot.defId)?.fuel : undefined
- if (!slot || !fuel) continue
- slot.count -= 1
- if (slot.count <= 0) chassis.prop!.container[i] = null
- chassis.prop!.burnUntil = nowMs + fuel.burnSeconds * 1000
- chassis.dirty = true
- reloaded = true
- for (const other of this.sessions.values()) {
- if (other.openContainer === chassis.id) this.sendContainer(other, chassis)
- }
- break
- }
- if (!reloaded && input && (input.moveZ !== 0 || input.moveX !== 0)) {
- // Out of fuel: coast only. (A once-per-mount nag would need state;
- // the HUD prompt shows the tank.)
- }
- }
- const fueled = (chassis.prop!.burnUntil ?? 0) > nowMs
- if (input && fueled) {
- this.world.physics.wake(bodyId)
- // Thrust along the chassis' flat forward.
- qrotateVec(_vehicleFwd, chassis.transform.rot, _vehicleFwdLocal)
- _vehicleFwd.y = 0
- const len = Math.hypot(_vehicleFwd.x, _vehicleFwd.z) || 1
- _vehicleFwd.x /= len
- _vehicleFwd.z /= len
- const power = (part.power ?? 4000) * input.moveZ
- _vehicleForce.x = _vehicleFwd.x * power
- _vehicleForce.y = 0
- _vehicleForce.z = _vehicleFwd.z * power
- if (input.moveZ !== 0) this.world.physics.applyForce(bodyId, _vehicleForce)
- // Steering: blend yaw angular velocity toward the wheel input.
- this.world.physics.getAngularVelocity(bodyId, _vehicleAng)
- const steer = -input.moveX * 1.6 * (input.moveZ < 0 ? -1 : 1)
- _vehicleAng.y = _vehicleAng.y + (steer - _vehicleAng.y) * 0.25
- this.world.physics.setAngularVelocity(bodyId, _vehicleAng)
- // Top speed cap.
- this.world.physics.getLinearVelocity(bodyId, _vehicleVel)
- const speed = Math.hypot(_vehicleVel.x, _vehicleVel.z)
- const top = part.topSpeed ?? 9
- if (speed > top) {
- const k = top / speed
- _vehicleVel.x *= k
- _vehicleVel.z *= k
- this.world.physics.setLinearVelocity(bodyId, _vehicleVel)
- }
- }
- // Carry the rider: perch on the chassis, share its velocity.
- session.move.pos.x = chassis.transform.pos.x
- session.move.pos.y = chassis.transform.pos.y + 0.75
- session.move.pos.z = chassis.transform.pos.z
- this.world.physics.getLinearVelocity(bodyId, _vehicleVel)
- session.move.vel.x = _vehicleVel.x
- session.move.vel.y = _vehicleVel.y
- session.move.vel.z = _vehicleVel.z
- session.move.grounded = true
- session.fallVy = 0
- const body = this.playerBodies.get(session.playerId)
- if (body !== undefined) {
- _bodyPosScratch.x = session.move.pos.x
- _bodyPosScratch.y = session.move.pos.y + 0.15
- _bodyPosScratch.z = session.move.pos.z
- this.world.physics.setTransform(body, _bodyPosScratch)
- }
- const entity = this.world.entities.get(session.entityId)
- if (entity) qfromYaw(entity.transform.rot, session.yaw)
- this.world.spatial.move(session.entityId, session.move.pos.x, session.move.pos.z)
- }
-
- /** Death/rescue respawn: back to the city with restored vitals. */
- private respawn(session: PlayerSession, died: boolean): void {
- const spawn = worldSpawn(this.world.content.world).pos
- // Risk made real: UNSECURED valuables drop where you fell, in a bag
- // anyone can loot. Secured loot and ordinary gear stay with you.
- if (died) {
- const dropped = session.inventory.takeUnsecuredValuables(
- (defId) => this.world.content.item(defId)?.valuable !== undefined,
- )
- if (dropped.length > 0) {
- const bag = this.world.spawnProp({
- defId: 'loot_bag',
- pos: vec3(session.move.pos.x, session.move.pos.y + 0.4, session.move.pos.z),
- rot: qfromYaw(quat(), session.yaw),
- motion: 'dynamic',
- })
- if (bag.prop?.container) {
- for (let i = 0; i < dropped.length && i < bag.prop.container.length; i++) {
- bag.prop.container[i] = { defId: dropped[i]!.defId, count: dropped[i]!.count }
- }
- }
- this.broadcastSpawn(bag)
- this.send(session, { t: 'announce', text: '💀 Your unsecured loot hit the dirt.' })
- }
- }
- session.move.pos.x = spawn[0]
- session.move.pos.y = spawn[1]
- session.move.pos.z = spawn[2]
- session.move.vel.x = 0
- session.move.vel.y = 0
- session.move.vel.z = 0
- if (died) {
- session.stats.health = 60
- session.stats.hunger = Math.max(session.stats.hunger, 40)
- session.stats.thirst = Math.max(session.stats.thirst, 40)
- session.statsDirty = true
- this.send(session, { t: 'stats', ...this.statsWire(session), died: true })
- session.statsDirty = false
- }
- session.dirty = true
- }
-
- private statsWire(session: PlayerSession) {
- return {
- hp: Math.round(session.stats.health),
- hunger: Math.round(session.stats.hunger),
- thirst: Math.round(session.stats.thirst),
- stamina: Math.round(session.stats.stamina),
- temp: Math.round(session.stats.bodyTemp * 10) / 10,
- statuses: activeStatuses(session.stats, Date.now()) as string[],
- }
- }
-
- // ── Simulation tick ────────────────────────────────────────────────
-
step(): void {
step(): void {
+ const ctx = this.ctx
+ const s = this.systems
const tickStart = performance.now()
const tickStart = performance.now()
- this.tick++
+ ctx.clock.tick++
+ const dt = 1 / ctx.config.tickRate
// 1. Movement from queued inputs (server-simulated, never client positions).
// 1. Movement from queued inputs (server-simulated, never client positions).
- for (const session of this.sessions.values()) {
- this.stepSessionMovement(session)
- }
+ s.movement.tick(ctx, dt)
// 2. Physgun: retry unlatched beams (sweep-to-grab), drive held bodies.
// 2. Physgun: retry unlatched beams (sweep-to-grab), drive held bodies.
- for (const session of this.sessions.values()) {
- if (session.grabbing && !session.held && equippedTool(session)?.kind === 'physgun') {
- this.attemptGrab(session)
- }
- if (session.held) driveHeld(session, this.world)
- }
+ s.manipulation.tick(ctx, dt)
// 3. Fixed-step physics.
// 3. Fixed-step physics.
- const physStart = performance.now()
- this.world.physics.step(1 / this.config.tickRate)
- const physMs = performance.now() - physStart
-
// 4. Sync awake props; announce settles so clients pin final transforms.
// 4. Sync awake props; announce settles so clients pin final transforms.
- const { awake, settledCount } = this.world.syncFromPhysics({
- onSettle: (entity) => {
- this.broadcastToKnowing(entity.id, {
- t: 'entity',
- id: entity.id,
- pos: [entity.transform.pos.x, entity.transform.pos.y, entity.transform.pos.z],
- rot: [
- entity.transform.rot.x,
- entity.transform.rot.y,
- entity.transform.rot.z,
- entity.transform.rot.w,
- ],
- })
- },
- })
- this.metrics.awakeBodies = awake
- this.metrics.settledBodies = settledCount
-
- // 4b. NPC simulation (LOD-aware; abstract NPCs cost nothing here).
- this.npcs.step(Date.now(), this.tick, this.config.tickRate)
- if (this.tick % this.config.tickRate === 0) this.sounds = []
-
- // 5. Crafting queues (completions grant crafting/construction XP).
- for (const session of this.sessions.values()) {
- const completed = session.craftQueue.update(this.tick, this.world.content, session.inventory)
- if (completed.length > 0) {
- session.dirty = true
- this.sendInventory(session)
- this.sendCraftState(session)
- for (const recipe of completed) {
- const ups = session.skills.addXp(recipeSkill(recipe.category), recipeXp(recipe))
- for (const up of ups) {
- this.send(session, { t: 'levelup', skill: up.skill, level: up.level })
- }
- }
- this.sendSkills(session)
- }
- }
-
- // 6. Once a second: environment, survival vitals, void rescue, clock.
- if (this.tick % this.config.tickRate === 0) {
- const weatherBefore = this.env.weather
- stepEnvironment(this.env, 1, Math.random)
- if (this.env.weather !== weatherBefore) {
- this.broadcastAll(this.timeWire())
- this.log.info('weather changed', { from: weatherBefore, to: this.env.weather })
- }
- const ambientC = ambientTemperature(this.env)
- const raining = precipitation(this.env) > 0
- const nowMs = Date.now()
- // Region activation follows players (coarse, 1 Hz).
- this.regions.update(
- [...this.sessions.values()].map((s) => ({ x: s.move.pos.x, z: s.move.pos.z })),
- )
- for (const session of this.sessions.values()) {
- const sprinting =
- (session.buttons & Buttons.Sprint) !== 0 &&
- Math.hypot(session.move.vel.x, session.move.vel.z) > 1
- // Wet: wading/swimming, or out in the rain (no roof detection yet —
- // "indoors" arrives with the shelter model).
- const inWater =
- terrainHeight(this.world.content.world, session.move.pos.x, session.move.pos.z) <
- WATER_LEVEL - 0.03
- // Warmth: standing near a lit burn barrel (workstation scan; the
- // spatial index will replace this walk).
- const nearHeat = nearbyWorkstationKinds(session, this.world).has('campfire')
- const before = { ...session.stats }
- const statusesBefore = activeStatuses(session.stats, nowMs).join(',')
- const died = tickSurvival(session.stats, 1, {
- sprinting,
- ambientC,
- wet: inWater || raining,
- nearHeat,
- nowMs,
- })
- if (
- Math.round(before.health) !== Math.round(session.stats.health) ||
- Math.round(before.hunger) !== Math.round(session.stats.hunger) ||
- Math.round(before.thirst) !== Math.round(session.stats.thirst) ||
- Math.round(before.stamina) !== Math.round(session.stats.stamina) ||
- Math.round(before.bodyTemp * 2) !== Math.round(session.stats.bodyTemp * 2) ||
- activeStatuses(session.stats, nowMs).join(',') !== statusesBefore
- ) {
- session.statsDirty = true
- }
- if (died) {
- this.log.info('player died of exposure', { playerId: session.playerId })
- this.respawn(session, true)
- } else if (session.statsDirty) {
- this.send(session, { t: 'stats', ...this.statsWire(session) })
- session.statsDirty = false
- session.dirty = true
- }
- if (session.move.pos.y < -25) {
- this.respawn(session, false)
- this.log.info('void rescue', { playerId: session.playerId })
- }
- }
- if (this.tick % (this.config.tickRate * 10) === 0) {
- this.broadcastAll(this.timeWire())
- }
- this.events.tick(nowMs, this.sessions.size)
- this.tickProduction(nowMs, raining, ambientC)
- }
-
- // 7. Resource respawn sweep (once a second).
- if (this.tick % this.config.tickRate === 0) {
- for (const entity of this.world.respawnDueResources(Date.now())) {
- if (entity.resource) {
- this.broadcastToKnowing(entity.id, {
- t: 'entity',
- id: entity.id,
- remaining: entity.resource.remaining,
- })
- }
- }
- }
-
- // 7. Replication.
- if (this.tick % this.config.snapshotEvery === 0) {
- this.replicate()
- }
-
- // 8. Mods: reconcile installs (a revoked or disabled mod's effects are
- // retracted on the first tick after the change).
- this.integrations.mods?.tick(this.tick, this.modHost)
-
- // 9. Periodic persistence checkpoint (write-behind: returns immediately).
- if (this.tick - this.lastFlushTick >= this.config.persistFlushSeconds * this.config.tickRate) {
- this.lastFlushTick = this.tick
- this.checkpoint()
- }
-
- this.metrics.tick = this.tick
- this.metrics.lastTickAt = Date.now()
- this.metrics.entities = this.world.entities.size
- this.metrics.constraints = this.world.constraintCount
- this.metrics.constraintIslands = this.world.islands.islandCount()
- this.metrics.activeRegions = this.regions.activeRegionCount
- this.metrics.occupiedRegions = this.regions.occupiedRegionCount
- const [npcFull, npcAbstract] = this.npcs.counts()
- this.metrics.npcsFull = npcFull
- this.metrics.npcsAbstract = npcAbstract
- this.metrics.mapStatics = this.world.mapStaticCount()
- this.metrics.mapTerrains = this.world.mapTerrainCount()
- this.metrics.mapZones = this.world.mapZoneCount()
- this.metrics.mapRebuilds = this.world.mapRebuildCount()
- this.metrics.recordTick(performance.now() - tickStart, physMs)
- }
-
- private stepSessionMovement(session: PlayerSession): void {
- if (session.driving) {
- this.driveVehicle(session)
- return
- }
- this.sweepSelf = this.playerBodies.get(session.playerId)
- // Process at most 2 queued inputs per tick (catch-up), else repeat the
- // last input — the queue bound caps client-driven speedup.
- const budget = session.inputQueue.length > 2 ? 2 : 1
- let simulated = 0
- for (let i = 0; i < budget; i++) {
- const input = session.inputQueue.shift()
- if (!input) break
- session.lastInput = input
- session.starvedTicks = 0
- session.lastProcessedSeq = input.seq
- session.yaw = input.yaw
- session.pitch = input.pitch
- session.buttons = input.buttons
- stepMovement(session.move, input, MOVE, this.moveQueries, 1 / this.config.tickRate)
- simulated++
- }
- if (simulated === 0 && session.lastInput) {
- // Bridge short network jitter by repeating the last command, but only
- // briefly — a silent client must coast to a stop, not walk forever.
- session.starvedTicks++
- // Zero MOVEMENT only — buttons stay held. Zeroing buttons fabricates
- // release edges for toggle keys (prone/crouch), so any client hitch
- // longer than 3 ticks made the server flap stances endlessly.
- const input =
- session.starvedTicks <= 3 ? session.lastInput : { ...session.lastInput, moveX: 0, moveZ: 0 }
- stepMovement(session.move, input, MOVE, this.moveQueries, 1 / this.config.tickRate)
- }
- let bodyId = this.playerBodies.get(session.playerId)
- if (bodyId !== undefined && session.bodyStance !== session.move.stance) {
- // Stance changed: swap the kinematic hull to match the new posture.
- this.world.physics.removeBody(bodyId)
- const hull = hullHeightFor(session.move.stance)
- bodyId = this.world.physics.addBody({
- shape: {
- type: 'capsule',
- radius: MOVE.capsuleRadius,
- height: Math.max(hull - 0.3, 0.4),
- },
- motion: 'kinematic',
- pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),
- layer: CollisionLayer.Player,
- collidesWith: CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,
- })
- this.playerBodies.set(session.playerId, bodyId)
- session.bodyStance = session.move.stance
- }
- if (bodyId !== undefined) {
- _bodyPosScratch.x = session.move.pos.x
- _bodyPosScratch.y = session.move.pos.y + 0.15
- _bodyPosScratch.z = session.move.pos.z
- this.world.physics.setTransform(bodyId, _bodyPosScratch)
- }
- // Fall damage: track the hardest downward velocity while airborne and
- // cash it in on landing. ~13 m/s (≈2.5 story drop) is the free threshold.
- if (!session.move.grounded) {
- session.fallVy = Math.min(session.fallVy, session.move.vel.y)
- } else if (session.fallVy < -13) {
- const dmg = Math.round((-session.fallVy - 13) * 3.5)
- session.fallVy = 0
- if (applyDamage(session.stats, dmg)) {
- this.log.info('fall death', { playerId: session.playerId })
- this.respawn(session, true)
- } else {
- session.statsDirty = true
- }
- } else {
- session.fallVy = 0
- }
- const entity = this.world.entities.get(session.entityId)
- if (entity) qfromYaw(entity.transform.rot, session.yaw)
- this.world.spatial.move(session.entityId, session.move.pos.x, session.move.pos.z)
- }
-
- private replicate(): void {
- for (const session of this.sessions.values()) {
- const diff = updateInterest(session, this.world, this.config.interestRadius)
- if (diff.entered.length > 0) {
- this.send(session, {
- t: 'spawn',
- entities: diff.entered.map((e) => {
- const wire = wireEntityFor(this.world, e)
- const owner = this.sessionsByEntity.get(e.id)
- if (owner) {
- wire.name = owner.name
- wire.player = owner.playerId as string
- wire.appearance = owner.appearance
- }
- return wire
- }),
- })
- }
- if (diff.left.length > 0) {
- this.send(session, { t: 'despawn', ids: diff.left as string[] })
- }
- // Constraints touching newly-known props (visuals need endpoints).
- if (diff.entered.length > 0) {
- const sent = new Set<string>()
- for (const entity of diff.entered) {
- for (const rec of this.world.constraintsFor(entity.id)) {
- if (sent.has(rec.id)) continue
- sent.add(rec.id)
- this.send(session, constraintStateWire(rec, true))
- }
- }
- }
- const snapshot = buildSnapshot(session, this.world, this.sessions.values(), this.tick)
- const encoded = encodeServerMessage(snapshot)
- this.metrics.snapshotBytes = encoded.length
- this.sendRaw(session, encoded)
- }
- }
-
- // ── Persistence ────────────────────────────────────────────────────
-
- /**
- * The tick's persistence call: builds the snapshot synchronously and hands it to the write-behind
- * flusher. It returns immediately; a checkpoint already in flight coalesces this one.
- */
- private checkpoint(reason: 'checkpoint' | 'shutdown' = 'checkpoint'): void {
- this.flusher.checkpoint(() => this.buildFlushPayload(reason))
- }
-
- /**
- * Copy-on-write snapshot of everything dirty (clearing the flags as it copies). One transaction
- * writes meta, NPC/entity upserts and deletes, constraints, characters and the outbox events
- * describing them; on failure the snapshot's dirtiness is restored.
- */
- private buildFlushPayload(reason: 'checkpoint' | 'shutdown'): FlushPayload {
- const now = Date.now()
- const world = this.world.takeDirty()
- const npcs = this.npcs.takeDirty(now)
- const characters: FlushCharacter[] = []
- for (const session of this.sessions.values()) {
- if (!session.dirty) continue
- session.dirty = false
- characters.push(this.flushCharacter(session))
- }
- // World clock + weather and the market blobs ride along with every checkpoint.
- const meta: Record<string, unknown> = {
- env_time: this.env.timeOfDay,
- env_weather: this.env.weather,
- }
- for (const [id, market] of this.marketStock) meta[`market_${id}`] = Object.fromEntries(market)
- if (world.entityUpserts.length > 0 || characters.length > 0) {
- this.log.debug('persistence checkpoint', {
- entities: world.entityUpserts.length,
- npcs: npcs.length,
- players: characters.length,
- })
- }
- return {
- reason,
- leaving: false,
- worldSaved: true,
- meta,
- entityUpserts: world.entityUpserts,
- entityDeletes: world.entityDeletes,
- npcUpserts: npcs,
- constraintUpserts: world.constraintUpserts,
- constraintDeletes: world.constraintDeletes,
- characters,
- worldSavedCount: world.entityUpserts.length,
- restore: () => {
- this.world.restoreDirty(world)
- this.npcs.restoreDirty(npcs.map((n) => n.id))
- // A disconnected session was saved by its own queued save, which runs after this one.
- for (const c of characters) {
- if (this.sessions.has(c.session.playerId)) c.session.dirty = true
- }
- },
- }
- }
-
- private flushCharacter(session: PlayerSession): FlushCharacter {
- return {
- session,
- dto: this.playerToDto(session),
- player: eventPlayer(session),
- progress: sessionProgress(session),
- }
- }
-
- /** Full save on shutdown; main.ts awaits drain() after this. */
- shutdown(): void {
- for (const session of this.sessions.values()) session.dirty = true
- // Never coalesced: behind a checkpoint still in flight this one is queued, not dropped.
- this.flusher.checkpoint(() => this.buildFlushPayload('shutdown'), { final: true })
- this.log.info('world saved on shutdown', {})
- }
-
- /** Resolves when the write-behind queue is empty (shutdown). */
- async drain(timeoutMs = 25_000): Promise<boolean> {
- return this.flusher.drain(timeoutMs)
- }
-
- /**
- * Saves one character; `leaving` also records games.player.left in the same transaction. The DTO is
- * built synchronously and the transaction is queued on the same queue as checkpoints, so a
- * disconnect save always lands after an earlier checkpoint of that character. The caller does not
- * await it.
- */
- private savePlayer(session: PlayerSession, leaving = false): void {
- session.dirty = false
- this.flusher.savePlayer(
- {
- reason: 'checkpoint',
- leaving,
- worldSaved: false,
- meta: {},
- entityUpserts: [],
- entityDeletes: [],
- npcUpserts: [],
- constraintUpserts: [],
- constraintDeletes: [],
- characters: [this.flushCharacter(session)],
- worldSavedCount: 0,
- restore: () => {
- if (this.sessions.has(session.playerId)) session.dirty = true
- },
- },
- () => this.sessions.has(session.playerId),
- )
- }
-
- /**
- * What mods may do to the world, lent to the mod runtime only. Mod props
- * are owned by the mod id, so prop protection keeps players' hands off.
- */
- private readonly modHost: ModHost = {
- announce: (text) => this.broadcastAll({ t: 'announce', text }),
- placeProp: ({ item, pos, yaw, owner }) => {
- const entity = this.world.spawnProp({
- defId: item,
- pos: vec3(pos[0], pos[1] + terrainHeight(this.world.content.world, pos[0], pos[2]), pos[2]),
- rot: qfromYaw(quat(), yaw),
- motion: 'frozen',
- owner: owner as PlayerId,
- })
- this.broadcastSpawn(entity)
- return entity.id as string
- },
- removeEntity: (id) => {
- const entityId = id as EntityId
- if (!this.world.entities.get(entityId)) return false
- this.world.despawn(entityId)
- this.broadcastDespawn(entityId)
- return true
- },
- entityExists: (id) => this.world.entities.get(id as EntityId) !== undefined,
- }
-
- private playerToDto(session: PlayerSession): PlayerDto {
- const { pos } = session.move
- return {
- id: session.playerId as string,
- token: session.token,
- ...(session.subjectId ? { subjectId: session.subjectId } : {}),
- charSlot: session.charSlot,
- name: session.name,
- pos: [pos.x, pos.y, pos.z],
- yaw: session.yaw,
- inventory: session.inventory.toDto(),
- skills: session.skills.toDto(),
- friends: [...session.friends],
- appearance: session.appearance,
- stats: session.stats,
- armor: session.armor,
- reputation: session.reputation,
- unlocks: [...session.unlocks],
- activeJob: session.activeJob,
- updatedAt: Date.now(),
- }
- }
-
- // ── Messaging helpers ──────────────────────────────────────────────
-
- private send(session: PlayerSession, msg: ServerMessage): void {
- this.sendRaw(session, encodeServerMessage(msg))
- }
-
- private sendRaw(session: PlayerSession, encoded: string): void {
- session.send(encoded)
- this.metrics.bytesOut += encoded.length
- this.metrics.messagesOut++
- }
-
- private sendInventory(session: PlayerSession): void {
- const dto = session.inventory.toDto()
- this.send(session, {
- t: 'inventory',
- inv: {
- size: dto.size,
- hotbar: dto.hotbar,
- slots: dto.slots.map(({ i, stack }) => ({
- i,
- stack: {
- def: stack.defId,
- count: stack.count,
- ...(stack.meta !== undefined ? { meta: stack.meta } : {}),
- },
- })),
- },
- activeHotbar: session.activeHotbar,
- armor: session.armor
- ? {
- def: session.armor.defId,
- count: session.armor.count,
- ...(session.armor.meta !== undefined ? { meta: session.armor.meta } : {}),
- }
- : null,
- })
- }
-
- private sendSkills(session: PlayerSession): void {
- this.send(session, {
- t: 'skills',
- skills: session.skills.all(),
- unlocks: [...session.unlocks],
- })
- }
-
- private sendCraftState(session: PlayerSession): void {
- this.send(session, {
- t: 'craft_state',
- jobs: session.craftQueue.pending.map((j) => ({ recipe: j.recipeId, readyTick: j.readyTick })),
- })
- }
-
- private broadcastAll(msg: ServerMessage): void {
- const encoded = encodeServerMessage(msg)
- for (const session of this.sessions.values()) this.sendRaw(session, encoded)
- }
-
- private broadcastSpawn(entity: GameEntity): void {
- // Deliver immediately to sessions in range; interest diff would send it
- // next snapshot anyway, but placement feedback should be instant.
- const wire = wireEntityFor(this.world, entity)
- const encoded = encodeServerMessage({ t: 'spawn', entities: [wire] })
- const radiusSq = this.config.interestRadius ** 2
- for (const session of this.sessions.values()) {
- const d2 =
- (entity.transform.pos.x - session.move.pos.x) ** 2 +
- (entity.transform.pos.y - session.move.pos.y) ** 2 +
- (entity.transform.pos.z - session.move.pos.z) ** 2
- if (d2 <= radiusSq) {
- session.known.add(entity.id)
- this.sendRaw(session, encoded)
- }
- }
- }
-
- /** Constraint create/remove: tell every client that knows either prop. */
- private broadcastConstraintState(rec: ConstraintRecord, active: boolean): void {
- const msg = constraintStateWire(rec, active)
- const encoded = encodeServerMessage(msg)
- for (const session of this.sessions.values()) {
- if (session.known.has(rec.a) || session.known.has(rec.b)) {
- this.sendRaw(session, encoded)
- }
- }
- }
-
- private broadcastDespawn(id: string): void {
- for (const session of this.sessions.values()) {
- if (session.known.delete(id as EntityId)) {
- this.send(session, { t: 'despawn', ids: [id] })
- }
- }
- }
-
- private broadcastToKnowing(id: EntityId, msg: ServerMessage): void {
- const encoded = encodeServerMessage(msg)
- for (const session of this.sessions.values()) {
- if (session.known.has(id)) this.sendRaw(session, encoded)
- }
- }
-
- /** Live map edit: every client refetches and rebuilds its terrain. */
- /**
- * Network moved this person's token cutoff (sign out everywhere, password changed, banned): close
- * every session they opened with an older Network sign-in (4011 signed_out). The client's reconnect
- * then fails its /api/auth/me check. Returns how many closed.
- */
- revokeSubject(subjectId: string, validAfterMs: number): number {
- if (!subjectId || !Number.isFinite(validAfterMs)) return 0
- let closed = 0
- for (const session of [...this.sessions.values()]) {
- if (session.subjectId !== subjectId) continue
- if (session.authIat !== null && session.authIat * 1000 >= validAfterMs) continue
- try {
- session.send(encodeServerMessage({ t: 'reject', reason: 'signed_out' }))
- } catch {
- // closing anyway
- }
- session.closeConnection(4011, 'signed_out')
- closed++
- }
- return closed
+ s.physics.tick(ctx, dt)
+
+ // 5. NPC simulation (LOD-aware; abstract NPCs cost nothing here).
+ // The sound buffer resets once a second inside the same step.
+ s.npcs.tick(ctx, dt)
+
+ // 6. Crafting queues (completions grant crafting/construction XP).
+ s.production.tickCraftQueues()
+
+ // 7. Once a second: environment (clock, weather, region activation), survival vitals,
+ // death by exposure, void rescue and the periodic clock broadcast.
+ s.environment.tick(ctx, dt)
+
+ // 8. Once a second: world events (supply drops, extraction).
+ s.worldEvents.tick(ctx, dt)
+
+ // 9. Once a second: production sweep (generators, machines, tanks, crops).
+ s.production.tickProduction()
+
+ // 10. Once a second: resource respawn sweep.
+ s.interaction.respawnResources()
+
+ // 11. Replication: interest diff + snapshot (every `snapshotEvery` ticks → 15 Hz).
+ s.replication.tick(ctx, dt)
+
+ // 12. Mods: reconcile installs (a revoked or disabled mod's effects are
+ // retracted on the first tick after the change).
+ s.mods.tick(ctx, dt)
+
+ // 13. Periodic persistence checkpoint (write-behind: returns immediately).
+ s.persistence.tick(ctx, dt)
+
+ // 14. Metrics sampling.
+ ctx.metrics.tick = ctx.clock.tick
+ ctx.metrics.lastTickAt = Date.now()
+ ctx.metrics.entities = ctx.world.entities.size
+ ctx.metrics.constraints = ctx.world.constraintCount
+ ctx.metrics.constraintIslands = ctx.world.islands.islandCount()
+ ctx.metrics.activeRegions = ctx.regions.activeRegionCount
+ ctx.metrics.occupiedRegions = ctx.regions.occupiedRegionCount
+ const [npcFull, npcAbstract] = s.npcs.counts()
+ ctx.metrics.npcsFull = npcFull
+ ctx.metrics.npcsAbstract = npcAbstract
+ ctx.metrics.mapStatics = ctx.world.mapStaticCount()
+ ctx.metrics.mapTerrains = ctx.world.mapTerrainCount()
+ ctx.metrics.mapZones = ctx.world.mapZoneCount()
+ ctx.metrics.mapRebuilds = ctx.world.mapRebuildCount()
+ ctx.metrics.recordTick(performance.now() - tickStart, s.physics.physicsMs)
}
}
-
- /**
- * Graceful stop (WS-P lifecycle): everyone in the world is told the server is restarting. main.ts
- * then closes every socket with 1012 (service restart); each close saves that character and records
- * games.player.left, as when a player leaves, and the client reconnects on its own.
- */
- announceRestart(): void {
- this.broadcastAll({
- t: 'announce',
- text: '🔄 The server is restarting: you will reconnect in a moment…',
- })
- }
-
- broadcastMapReload(): void {
- this.broadcastAll({ t: 'map_reload' })
- this.broadcastAll({ t: 'announce', text: '🗺 The world was reshaped by the map editors…' })
- }
-
- /** Exposes crafting context for the client-facing recipe availability (welcome-time). */
- workstationsNear(session: PlayerSession): ReadonlySet<string> {
- return nearbyWorkstationKinds(session, this.world)
- }
-}
-
-const _eyeScratch = vec3()
-const _relVel = vec3()
-const _bodyPosScratch = vec3()
-const _fireDir = vec3()
-const _fireFrom = vec3()
-const _fireTo = vec3()
-const _vehicleFwdLocal = vec3(0, 0, 1)
-const _vehicleFwd = vec3()
-const _vehicleForce = vec3()
-const _vehicleAng = vec3()
-const _vehicleVel = vec3()
-
-function constraintStateWire(rec: ConstraintRecord, active: boolean): ServerMessage {
- return {
- t: 'constraint_state',
- id: rec.id,
- kind: rec.type,
- a: rec.a as string,
- b: rec.b as string,
- anchorA: rec.params.anchorA ?? [0, 0, 0],
- anchorB: rec.params.anchorB ?? [0, 0, 0],
- ...(rec.params.length !== undefined ? { length: rec.params.length } : {}),
- active,
- }
-}
-
-/** A session as the platform event recorder sees it. */
-function eventPlayer(session: PlayerSession): EventPlayer {
- return {
- playerId: session.playerId as string,
- slot: session.charSlot,
- name: session.name,
- subjectId: session.subjectId,
- }
-}
-
-function progressOf(skills: SkillSet, unlocks: Iterable<string>): ProgressSnapshot {
- const levels: Record<string, number> = {}
- for (const skill of skills.all()) levels[skill.id] = skill.level
- return { levels, unlocks: [...unlocks] }
-}
-
-function sessionProgress(session: PlayerSession): ProgressSnapshot {
- return progressOf(session.skills, session.unlocks)
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+/**
+ * The message handler table (ADR-0007 M1). The split moved 27 dispatch cases out of one switch and
+ * into the systems; these tests are what stops that table from rotting:
+ *
+ * - a duplicate message type is a startup error, not a silent shadowing;
+ * - every client message the protocol defines has exactly one handler.
+ *
+ * The GameServer is constructed with stub world/store objects: nothing here ticks or sends, so
+ * nothing may dereference them.
+ */
+import { mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createContent } from '@openvibe/content'
+import type { PersistenceStore } from '@openvibe/persistence'
+import { ClientMessageSchema } from '@openvibe/protocol'
+import { createConsoleLogger } from '@openvibe/shared'
+import { describe, expect, it } from 'vitest'
+import { loadConfig } from '../config.js'
+import { ServerMetrics } from '../observability/metrics.js'
+import { GameServer } from './gameServer.js'
+import type { GameWorld } from './gameWorld.js'
+import { buildHandlerTable, type System } from './systems/index.js'
+
+const dir = mkdtempSync(join(tmpdir(), 'openvibe-handlers-'))
+const config = loadConfig({ MAP_PATH: join(dir, 'map.json') })
+const log = createConsoleLogger({ app: 'test' }, 'error')
+
+/**
+ * Every message type the protocol's closed client union defines. The union nests — `physgun` is
+ * itself a discriminated union of six actions — so walk `options` recursively and read the `t`
+ * literal off each leaf object.
+ */
+function walkMessageTypes(schema: unknown): string[] {
+ const options = (schema as { options?: readonly unknown[] }).options
+ if (options) return options.flatMap(walkMessageTypes)
+ const shape = (schema as { shape?: Record<string, unknown> }).shape
+ const literal = shape?.t as { value?: unknown } | undefined
+ return typeof literal?.value === 'string' ? [literal.value] : []
+}
+
+/** The distinct message types — the six physgun actions are one type. */
+function protocolMessageTypes(schema: unknown): string[] {
+ return [...new Set(walkMessageTypes(schema))]
+}
+
+describe('the message handler table', () => {
+ it('rejects two systems claiming the same message type', () => {
+ const first: System = { name: 'first', handlers: { use: () => {} } }
+ const second: System = { name: 'second', handlers: { use: () => {} } }
+ expect(() => buildHandlerTable([first, second])).toThrow(
+ 'duplicate message handler for "use" (second)',
+ )
+ })
+
+ it('accepts systems that each claim distinct types', () => {
+ const first: System = { name: 'first', handlers: { use: () => {} } }
+ const second: System = { name: 'second', handlers: { attack: () => {} } }
+ expect([...buildHandlerTable([first, second]).keys()]).toEqual(['use', 'attack'])
+ })
+
+ it('has exactly one handler for every message type the protocol defines', () => {
+ // Constructing the real server also proves its own table has no duplicate type.
+ const game = new GameServer(
+ config,
+ { content: createContent() } as unknown as GameWorld,
+ {} as PersistenceStore,
+ new ServerMetrics(),
+ log,
+ )
+ const handled = game.messageTypes
+ const defined = protocolMessageTypes(ClientMessageSchema)
+ expect(defined.length).toBeGreaterThan(0)
+ for (const type of defined) {
+ expect(
+ handled.filter((t) => t === type),
+ `handlers for "${type}"`,
+ ).toHaveLength(1)
+ }
+ // …and that no handler is left over for a type the protocol does not have.
+ expect([...handled].sort()).toEqual([...defined].sort())
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,526 @@
@@ -0,0 +1,526 @@
+import {
+ BLEED_SECONDS,
+ REP_DELTAS,
+ addReputation,
+ applyDamage,
+ applyStatus,
+ canFire,
+ causesBleeding,
+ mitigate,
+ recordShot,
+ startReload,
+ weaponStateFromMeta,
+ weaponStateToMeta,
+ type DamageEvent,
+ type DamageType,
+ type GameEntity,
+} from '@openvibe/gameplay'
+import { worldSpawn } from '@openvibe/content'
+import { CollisionLayer } from '@openvibe/physics'
+import {
+ encodeServerMessage,
+ type ClientAttack,
+ type ClientFire,
+ type ClientReload,
+ type ServerMessage,
+} from '@openvibe/protocol'
+import { qfromYaw, quat, v3dist, vec3, type EntityId } from '@openvibe/shared'
+import { equippedTool } from '../interactions.js'
+import { eyePosition, viewDirection, type PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import type { HandlerMap, System } from './system.js'
+
+const _eyeScratch = vec3()
+const _fireDir = vec3()
+const _fireFrom = vec3()
+const _fireTo = vec3()
+
+/**
+ * Combat: melee, ranged fire, reload, the single damage sink, prop destruction and respawn. Every
+ * death path (NPC melee, bullets, falls, exposure, the void) converges on `damagePlayer`/`respawn`.
+ */
+export class CombatSystem implements System {
+ readonly name = 'combat'
+
+ readonly handlers: HandlerMap = {
+ attack: (session, msg: ClientAttack, _conn) => {
+ // Swing cooldown: silently drop spam faster than ~5 swings/sec.
+ if (this.ctx.clock.tick - session.lastUseTick < 6) return
+ const victimSession = this.ctx.sessions.getByEntity(msg.target)
+ if (victimSession) {
+ this.melee(session, victimSession)
+ return
+ }
+ const propTarget = this.ctx.world.entities.get(msg.target as EntityId)
+ if (propTarget?.prop) {
+ this.handlePropAttack(session, propTarget)
+ return
+ }
+ if (propTarget?.kind === 'npc') {
+ this.handleNpcAttack(session, propTarget)
+ return
+ }
+ this.ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: 'no_target' })
+ },
+ fire: (session, _msg: ClientFire, _conn) => {
+ this.handleFire(session)
+ },
+ reload: (session, _msg: ClientReload, _conn) => {
+ const ctx = this.ctx
+ const held = session.holstered ? null : session.inventory.get(session.activeHotbar)
+ const spec = held ? ctx.world.content.item(held.defId)?.rangedWeapon : undefined
+ if (!held || !spec) {
+ ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })
+ return
+ }
+ const state = weaponStateFromMeta(held.meta)
+ const available = session.inventory.countOf(spec.ammoItem)
+ const took = startReload(state, spec, available, Date.now())
+ if (took <= 0) {
+ ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: 'no_ammo' })
+ return
+ }
+ session.inventory.consume([{ item: spec.ammoItem, count: took }])
+ held.meta = weaponStateToMeta(state, held.meta)
+ session.dirty = true
+ ctx.net.send(session, { t: 'result', action: 'attack', ok: true })
+ ctx.net.sendInventory(session)
+ },
+ }
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ /** Melee swing on another player: range + zone PvP rules + tool damage. */
+ melee(attacker: PlayerSession, victim: PlayerSession): void {
+ const ctx = this.ctx
+ const d = Math.hypot(
+ victim.move.pos.x - attacker.move.pos.x,
+ victim.move.pos.y - attacker.move.pos.y,
+ victim.move.pos.z - attacker.move.pos.z,
+ )
+ const tool = equippedTool(attacker)
+ if (tool?.kind === 'physgun') {
+ ctx.net.send(attacker, { t: 'result', action: 'use', ok: false, error: 'not_a_weapon' })
+ return
+ }
+ // ANY held item swings; weapon capability > tool power > improvised.
+ const heldDef = attacker.holstered
+ ? undefined
+ : ctx.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')
+ const weapon = heldDef?.weapon
+ const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)
+ if (d > range) {
+ ctx.net.send(attacker, { t: 'result', action: 'use', ok: false, error: 'out_of_range' })
+ return
+ }
+ // PvP must be legal where BOTH players stand (no shooting into the city).
+ if (
+ !ctx.world.zones.rulesAt(attacker.move.pos).pvp ||
+ !ctx.world.zones.rulesAt(victim.move.pos).pvp
+ ) {
+ ctx.net.send(attacker, { t: 'result', action: 'use', ok: false, error: 'safe_zone' })
+ return
+ }
+ attacker.lastUseTick = ctx.clock.tick
+ // Swinging costs stamina; an exhausted swing lands soft.
+ const exhausted = attacker.stats.stamina < 10
+ attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)
+ attacker.statsDirty = true
+ const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)
+ // Edged tools cut (can open wounds); everything else is blunt.
+ const type: DamageType =
+ weapon || tool?.kind === 'axe' || tool?.kind === 'pickaxe' ? 'cutting' : 'blunt'
+ // Knockback: shove the victim away (replicates through prediction).
+ const kx = victim.move.pos.x - attacker.move.pos.x
+ const kz = victim.move.pos.z - attacker.move.pos.z
+ const kl = Math.hypot(kx, kz) || 1
+ victim.move.vel.x += (kx / kl) * 4.5
+ victim.move.vel.z += (kz / kl) * 4.5
+ victim.move.vel.y += 2.2
+ ctx.net.send(attacker, { t: 'result', action: 'use', ok: true })
+ this.damagePlayer(attacker, victim, { type, amount: base * (exhausted ? 0.5 : 1) })
+ }
+
+ /**
+ * Ranged fire: the client sent pure intent — the shot leaves the
+ * player's AUTHORITATIVE eye along their AUTHORITATIVE view (from
+ * movement inputs) plus server-rolled spread. Magazine, cadence and
+ * reload state live in the weapon stack's meta and are validated here.
+ */
+ private handleFire(session: PlayerSession): void {
+ const ctx = this.ctx
+ const held = session.holstered ? null : session.inventory.get(session.activeHotbar)
+ const spec = held ? ctx.world.content.item(held.defId)?.rangedWeapon : undefined
+ if (!held || !spec) {
+ ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })
+ return
+ }
+ if (!ctx.world.zones.rulesAt(session.move.pos).pvp) {
+ ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: 'safe_zone' })
+ return
+ }
+ const nowMs = Date.now()
+ const state = weaponStateFromMeta(held.meta)
+ const verdict = canFire(state, spec, nowMs)
+ if (verdict !== 'ok') {
+ ctx.net.send(session, { t: 'result', action: 'attack', ok: false, error: verdict })
+ return
+ }
+ recordShot(state, nowMs)
+ held.meta = weaponStateToMeta(state, held.meta)
+ session.dirty = true
+
+ eyePosition(session, _eyeScratch)
+ viewDirection(session, _fireDir)
+ // Server-rolled spread: deflect the view ray inside the cone.
+ const spread = (spec.spreadDeg * Math.PI) / 180
+ const dyaw = (Math.random() * 2 - 1) * spread
+ const dpitch = (Math.random() * 2 - 1) * spread
+ const cosP = Math.cos(dpitch)
+ const yaw = Math.atan2(_fireDir.x, _fireDir.z) + dyaw
+ const pitch = Math.asin(Math.max(-1, Math.min(1, _fireDir.y))) + dpitch
+ _fireDir.x = Math.sin(yaw) * Math.cos(pitch)
+ _fireDir.y = Math.sin(pitch)
+ _fireDir.z = Math.cos(yaw) * Math.cos(pitch)
+ void cosP
+ // Start past the shooter's own capsule (raycast cannot exclude bodies).
+ _fireFrom.x = _eyeScratch.x + _fireDir.x * 0.6
+ _fireFrom.y = _eyeScratch.y + _fireDir.y * 0.6
+ _fireFrom.z = _eyeScratch.z + _fireDir.z * 0.6
+ _fireTo.x = _fireFrom.x + _fireDir.x * spec.range
+ _fireTo.y = _fireFrom.y + _fireDir.y * spec.range
+ _fireTo.z = _fireFrom.z + _fireDir.z * spec.range
+ const hit = ctx.world.physics.raycast(
+ _fireFrom,
+ _fireTo,
+ CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,
+ )
+ let end = _fireTo
+ let landed = false
+ if (hit) {
+ end = hit.point
+ // Player hit?
+ let victim: PlayerSession | null = null
+ for (const other of ctx.sessions.values()) {
+ if (ctx.sessions.bodyOf(other.playerId) === hit.bodyId) {
+ victim = other
+ break
+ }
+ }
+ if (victim && victim !== session) {
+ if (ctx.world.zones.rulesAt(victim.move.pos).pvp) {
+ landed = true
+ this.damagePlayer(session, victim, {
+ type: 'projectile',
+ amount: spec.damage,
+ sourceId: session.entityId as string,
+ })
+ }
+ } else if (!victim) {
+ const entity = ctx.world.entityOfBody(hit.bodyId)
+ if (entity?.kind === 'npc') {
+ landed = this.applyNpcDamage(session, entity, spec.damage)
+ } else if (entity?.prop && ctx.world.content.item(entity.prop.defId)?.health) {
+ landed = this.applyPropDamage(entity, spec.damage, 'projectile')
+ }
+ }
+ }
+ ctx.systems.npcs.recordSound(session.move.pos.x, session.move.pos.z)
+ ctx.net.send(session, { t: 'result', action: 'attack', ok: true })
+ const tracer: ServerMessage = {
+ t: 'tracer',
+ shooter: session.entityId as string,
+ from: [_fireFrom.x, _fireFrom.y, _fireFrom.z],
+ to: [end.x, end.y, end.z],
+ hit: landed,
+ }
+ const encoded = encodeServerMessage(tracer)
+ for (const other of ctx.sessions.values()) {
+ if (other === session || other.known.has(session.entityId)) ctx.net.sendRaw(other, encoded)
+ }
+ ctx.net.sendInventory(session)
+ }
+
+ /**
+ * The one place player damage lands: armor mitigation (with durability
+ * wear), wound statuses, death handling, and hit feedback. Melee, bullets
+ * and future explosions all converge here.
+ */
+ damagePlayer(attacker: PlayerSession | null, victim: PlayerSession, event: DamageEvent): void {
+ const ctx = this.ctx
+ const armorDef = victim.armor ? ctx.world.content.item(victim.armor.defId)?.armor : undefined
+ const damage = mitigate(event, armorDef ? { reduction: armorDef.reduction } : null)
+ // Armor wears: every mitigated hit costs a point of durability.
+ if (victim.armor && armorDef && damage < event.amount) {
+ const dur = Number(victim.armor.meta?.dur ?? armorDef.durability) - 1
+ if (dur <= 0) {
+ victim.armor = null
+ ctx.net.send(victim, { t: 'announce', text: '🧥 Your armor fell apart!' })
+ } else {
+ victim.armor.meta = { ...victim.armor.meta, dur }
+ }
+ ctx.net.sendInventory(victim)
+ }
+ if (causesBleeding(event.type, damage)) {
+ applyStatus(victim.stats, 'bleeding', BLEED_SECONDS, Date.now())
+ }
+ const died = applyDamage(victim.stats, damage)
+ victim.statsDirty = true
+ victim.dirty = true
+ ctx.net.send(victim, {
+ t: 'stats',
+ ...ctx.net.statsWire(victim),
+ ...(died ? { died: true } : {}),
+ })
+ victim.statsDirty = false
+ ctx.net.broadcastToKnowing(victim.entityId, {
+ t: 'fx',
+ kind: died ? 'death' : 'hurt',
+ id: victim.entityId as string,
+ })
+ if (died) {
+ ctx.log.info('player killed', {
+ victim: victim.playerId,
+ attacker: attacker?.playerId ?? 'world',
+ type: event.type,
+ })
+ this.respawn(victim, true)
+ }
+ }
+
+ /**
+ * Melee swing on a damageable prop: range + zone build rules + the same
+ * stamina/cooldown economics as PvP. Structures are only destructible
+ * where building is legal (safe city props are untouchable).
+ */
+ private handlePropAttack(attacker: PlayerSession, entity: GameEntity): void {
+ const ctx = this.ctx
+ const def = entity.prop ? ctx.world.content.item(entity.prop.defId) : undefined
+ const cap = def?.health
+ if (!entity.prop || !cap) {
+ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_damageable' })
+ return
+ }
+ const tool = equippedTool(attacker)
+ if (tool?.kind === 'physgun') {
+ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })
+ return
+ }
+ const heldDef = attacker.holstered
+ ? undefined
+ : ctx.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')
+ const weapon = heldDef?.weapon
+ const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)
+ eyePosition(attacker, _eyeScratch)
+ if (v3dist(_eyeScratch, entity.transform.pos) > range + 1) {
+ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'out_of_range' })
+ return
+ }
+ if (!ctx.world.zones.rulesAt(entity.transform.pos).build) {
+ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'safe_zone' })
+ return
+ }
+ attacker.lastUseTick = ctx.clock.tick
+ const exhausted = attacker.stats.stamina < 10
+ attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)
+ attacker.statsDirty = true
+ const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)
+ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: true })
+ this.applyPropDamage(entity, base * (exhausted ? 0.5 : 1), 'blunt')
+ }
+
+ /** Melee swing on an NPC: reach + the shared stamina economics. */
+ private handleNpcAttack(attacker: PlayerSession, entity: GameEntity): void {
+ const ctx = this.ctx
+ const tool = equippedTool(attacker)
+ if (tool?.kind === 'physgun') {
+ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'not_a_weapon' })
+ return
+ }
+ const heldDef = attacker.holstered
+ ? undefined
+ : ctx.world.content.item(attacker.inventory.get(attacker.activeHotbar)?.defId ?? '')
+ const weapon = heldDef?.weapon
+ const range = weapon?.range ?? (tool ? Math.min(tool.range, 3.5) : 2.4)
+ eyePosition(attacker, _eyeScratch)
+ if (v3dist(_eyeScratch, entity.transform.pos) > range + 1) {
+ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: false, error: 'out_of_range' })
+ return
+ }
+ attacker.lastUseTick = ctx.clock.tick
+ const exhausted = attacker.stats.stamina < 10
+ attacker.stats.stamina = Math.max(0, attacker.stats.stamina - 12)
+ attacker.statsDirty = true
+ const base = weapon?.damage ?? (tool ? 6 + tool.power * 4 : heldDef ? 5 : 6)
+ ctx.net.send(attacker, { t: 'result', action: 'attack', ok: true })
+ this.applyNpcDamage(attacker, entity, base * (exhausted ? 0.5 : 1))
+ }
+
+ /** NPC damage sink: aggro marking, death, loot scatter, feedback. */
+ private applyNpcDamage(
+ attacker: PlayerSession | null,
+ entity: GameEntity,
+ amount: number,
+ ): boolean {
+ const ctx = this.ctx
+ const nowMs = Date.now()
+ const result = ctx.systems.npcs.damage(entity.id, amount, nowMs)
+ if (result === null) return false
+ // Defensive NPCs (wardens) now treat this player as hostile for a while.
+ if (attacker) ctx.systems.npcs.markAggro(attacker.entityId as string, nowMs + 45_000)
+ if (result === 'hurt') {
+ ctx.net.broadcastToKnowing(entity.id, { t: 'fx', kind: 'hurt', id: entity.id as string })
+ return true
+ }
+ // Death: scatter loot rolls as physical props. The entity was already
+ // dematerialized; interest diffs despawn it on the next snapshot.
+ const state = ctx.systems.npcs.npcStateOf(entity.id)
+ const arch = state ? ctx.world.content.npc(state.archetype) : undefined
+ const around = entity.transform.pos
+ ctx.net.broadcastToKnowing(entity.id, { t: 'fx', kind: 'death', id: entity.id as string })
+ let slot = 0
+ for (const loot of arch?.loot ?? []) {
+ if (Math.random() > loot.chance) continue
+ const angle = slot * 1.6
+ slot++
+ const spawned = ctx.world.spawnProp({
+ defId: loot.item,
+ pos: vec3(
+ around.x + Math.cos(angle) * 0.4,
+ around.y + 0.3,
+ around.z + Math.sin(angle) * 0.4,
+ ),
+ rot: qfromYaw(quat(), angle),
+ motion: 'dynamic',
+ lootCount: loot.count,
+ velocity: vec3(Math.cos(angle) * 1.2, 1.5, Math.sin(angle) * 1.2),
+ })
+ ctx.net.broadcastSpawn(spawned)
+ }
+ if (attacker && arch) {
+ addReputation(attacker.reputation, arch.faction, REP_DELTAS.killMember)
+ // Kill contracts count matching archetypes.
+ const job = attacker.activeJob ? ctx.world.content.job(attacker.activeJob.job) : undefined
+ if (
+ attacker.activeJob &&
+ job?.objective.kind === 'kill' &&
+ job.objective.archetype === arch.id &&
+ attacker.activeJob.progress < job.objective.count
+ ) {
+ attacker.activeJob.progress++
+ ctx.net.send(attacker, {
+ t: 'announce',
+ text: `📋 ${job.name}: ${attacker.activeJob.progress}/${job.objective.count}`,
+ })
+ ctx.systems.economy.sendJobs(attacker, job.market)
+ }
+ attacker.dirty = true
+ ctx.systems.economy.sendReputation(attacker)
+ }
+ ctx.log.info('npc killed', {
+ archetype: arch?.id ?? 'unknown',
+ by: attacker?.playerId ?? 'world',
+ })
+ return true
+ }
+
+ /** Damage application for props: resistance, destruction, feedback.
+ * Returns false when the prop has no health capability. */
+ private applyPropDamage(entity: GameEntity, rawAmount: number, _type: DamageType): boolean {
+ const ctx = this.ctx
+ const cap = entity.prop ? ctx.world.content.item(entity.prop.defId)?.health : undefined
+ if (!entity.prop || !cap) return false
+ if (!ctx.world.zones.rulesAt(entity.transform.pos).build) return false
+ entity.prop.health = (entity.prop.health ?? cap.max) - rawAmount * cap.resistance
+ entity.dirty = true
+ if (entity.prop.health <= 0) {
+ this.destroyProp(entity)
+ return true
+ }
+ ctx.net.broadcastToKnowing(entity.id, {
+ t: 'entity',
+ id: entity.id,
+ health: Math.round(entity.prop.health),
+ })
+ ctx.net.broadcastToKnowing(entity.id, { t: 'fx', kind: 'hurt', id: entity.id as string })
+ return true
+ }
+
+ /** Destruction: scatter salvage + stored contents as physical props. */
+ private destroyProp(entity: GameEntity): void {
+ const ctx = this.ctx
+ const def = entity.prop ? ctx.world.content.item(entity.prop.defId) : undefined
+ const drops: { defId: string; count: number }[] = []
+ for (const loot of def?.health?.destroyLoot ?? []) {
+ drops.push({ defId: loot.item, count: loot.count })
+ }
+ // A destroyed container spills everything it held.
+ for (const slot of entity.prop?.container ?? []) {
+ if (slot) drops.push({ defId: slot.defId, count: slot.count })
+ }
+ const around = entity.transform.pos
+ ctx.world.despawn(entity.id)
+ ctx.net.broadcastDespawn(entity.id)
+ for (const [i, drop] of drops.entries()) {
+ if (!ctx.world.content.item(drop.defId)) continue
+ const angle = (i / Math.max(drops.length, 1)) * Math.PI * 2
+ const spawned = ctx.world.spawnProp({
+ defId: drop.defId,
+ pos: vec3(
+ around.x + Math.cos(angle) * 0.4,
+ around.y + 0.4,
+ around.z + Math.sin(angle) * 0.4,
+ ),
+ rot: qfromYaw(quat(), angle),
+ motion: 'dynamic',
+ lootCount: drop.count,
+ velocity: vec3(Math.cos(angle) * 1.5, 2, Math.sin(angle) * 1.5),
+ })
+ ctx.net.broadcastSpawn(spawned)
+ }
+ ctx.log.info('prop destroyed', { def: def?.id ?? 'unknown', drops: drops.length })
+ }
+
+ /** Death/rescue respawn: back to the city with restored vitals. */
+ respawn(session: PlayerSession, died: boolean): void {
+ const ctx = this.ctx
+ const spawn = worldSpawn(ctx.world.content.world).pos
+ // Risk made real: UNSECURED valuables drop where you fell, in a bag
+ // anyone can loot. Secured loot and ordinary gear stay with you.
+ if (died) {
+ const dropped = session.inventory.takeUnsecuredValuables(
+ (defId) => ctx.world.content.item(defId)?.valuable !== undefined,
+ )
+ if (dropped.length > 0) {
+ const bag = ctx.world.spawnProp({
+ defId: 'loot_bag',
+ pos: vec3(session.move.pos.x, session.move.pos.y + 0.4, session.move.pos.z),
+ rot: qfromYaw(quat(), session.yaw),
+ motion: 'dynamic',
+ })
+ if (bag.prop?.container) {
+ for (let i = 0; i < dropped.length && i < bag.prop.container.length; i++) {
+ bag.prop.container[i] = { defId: dropped[i]!.defId, count: dropped[i]!.count }
+ }
+ }
+ ctx.net.broadcastSpawn(bag)
+ ctx.net.send(session, { t: 'announce', text: '💀 Your unsecured loot hit the dirt.' })
+ }
+ }
+ session.move.pos.x = spawn[0]
+ session.move.pos.y = spawn[1]
+ session.move.pos.z = spawn[2]
+ session.move.vel.x = 0
+ session.move.vel.y = 0
+ session.move.vel.z = 0
+ if (died) {
+ session.stats.health = 60
+ session.stats.hunger = Math.max(session.stats.hunger, 40)
+ session.stats.thirst = Math.max(session.stats.thirst, 40)
+ session.statsDirty = true
+ ctx.net.send(session, { t: 'stats', ...ctx.net.statsWire(session), died: true })
+ session.statsDirty = false
+ }
+ session.dirty = true
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,212 @@
@@ -0,0 +1,212 @@
+import { RegionTracker, type EnvironmentState, type GameEntity } from '@openvibe/gameplay'
+import type { PersistenceStore } from '@openvibe/persistence'
+import type { BodyId } from '@openvibe/physics'
+import type { ServerMessage, ServerStats } from '@openvibe/protocol'
+import type { EntityId, Logger, PlayerId } from '@openvibe/shared'
+import type { ServerConfig } from '../../config.js'
+import type { ModRuntime } from '../../mods/runtime.js'
+import type { ServerMetrics } from '../../observability/metrics.js'
+import type { GameEventRecorder } from '../../platform/gameEvents.js'
+import type { ProgressSummaryWriter } from '../../platform/progressSummary.js'
+import type { ConstraintRecord, GameWorld } from '../gameWorld.js'
+import type { PlayerSession } from '../playerSession.js'
+import type { CombatSystem } from './combat.js'
+import type { EconomySystem } from './economy.js'
+import type { EditorSystem } from './editor.js'
+import type { EnvironmentSystem } from './environment.js'
+import type { InteractionSystem } from './interaction.js'
+import type { InventorySystem } from './inventory.js'
+import type { ManipulationSystem } from './manipulation.js'
+import type { ModSystem } from './mods.js'
+import type { MovementSystem } from './movement.js'
+import type { NpcSystem } from './npcs.js'
+import type { PersistenceSystem } from './persistence.js'
+import type { PhysicsSystem } from './physics.js'
+import type { ProductionSystem } from './production.js'
+import type { ReplicationSystem } from './replication.js'
+import type { SessionsSystem } from './sessions.js'
+import type { SocialSystem } from './social.js'
+import type { VehicleSystem } from './vehicles.js'
+import type { WorldEventSystem } from './worldEvents.js'
+
+/** A network connection as the game sees it — transport-agnostic. */
+export interface GameConnection {
+ send(text: string): void
+ close(code: number, reason: string): void
+}
+
+/**
+ * Platform adapters the server drives (roadmap Wave 12). Both optional: the
+ * game runs exactly as before without them.
+ */
+export interface GameIntegrations {
+ /** Durable lifecycle/progression events through the outbox. */
+ events?: GameEventRecorder
+ /** Installed mods (content packs), reconciled every tick. */
+ mods?: ModRuntime
+ /** The person's games.progress.summary user module on Network, written when a character leaves. */
+ progressSummary?: ProgressSummaryWriter
+}
+
+/** The mutable tick counter, shared by every system that reads cadence or cooldowns. */
+export interface Clock {
+ tick: number
+}
+
+/** The wire helpers every system sends through (implemented by ReplicationSystem). */
+export interface Net {
+ send(session: PlayerSession, msg: ServerMessage): void
+ sendRaw(session: PlayerSession, encoded: string): void
+ broadcastAll(msg: ServerMessage): void
+ /** Sends to every session whose interest set currently contains `id`. */
+ broadcastToKnowing(id: EntityId, msg: ServerMessage): void
+ /** Immediate spawn to in-range sessions, adding the id to their interest sets. */
+ broadcastSpawn(entity: GameEntity): void
+ /** Despawn to every session that knew it, removing it from their interest sets. */
+ broadcastDespawn(id: string): void
+ /** Constraint create/remove to every client that knows either end. */
+ broadcastConstraintState(rec: ConstraintRecord, active: boolean): void
+ timeWire(): ServerMessage
+ sendInventory(session: PlayerSession): void
+ sendSkills(session: PlayerSession): void
+ sendCraftState(session: PlayerSession): void
+ sendContainer(session: PlayerSession, entity: GameEntity): void
+ /** The vitals payload shared by every stats message (without `t`/`died`). */
+ statsWire(session: PlayerSession): Omit<ServerStats, 't' | 'died'>
+ /** Interest diff + snapshot for every session (every `snapshotEvery` ticks). */
+ replicate(): void
+}
+
+/**
+ * Every live session and the handles the server keeps beside them. This is the one piece of state
+ * more than half the systems touch, so it lives behind the context rather than inside a system.
+ */
+export class SessionRegistry {
+ private readonly byPlayer = new Map<PlayerId, PlayerSession>()
+ private readonly byConn = new Map<GameConnection, PlayerSession>()
+ private readonly byEntity = new Map<EntityId, PlayerSession>()
+ /** Kinematic capsule handle per player (movement, vehicles and combat all need it). */
+ private readonly bodies = new Map<PlayerId, BodyId>()
+
+ get size(): number {
+ return this.byPlayer.size
+ }
+
+ get(playerId: PlayerId | string): PlayerSession | undefined {
+ return this.byPlayer.get(playerId as PlayerId)
+ }
+
+ getByConn(conn: GameConnection): PlayerSession | undefined {
+ return this.byConn.get(conn)
+ }
+
+ getByEntity(entityId: EntityId | string): PlayerSession | undefined {
+ return this.byEntity.get(entityId as EntityId)
+ }
+
+ values(): IterableIterator<PlayerSession> {
+ return this.byPlayer.values()
+ }
+
+ /** A snapshot, for loops that may remove a session. */
+ all(): PlayerSession[] {
+ return [...this.byPlayer.values()]
+ }
+
+ add(conn: GameConnection, session: PlayerSession): void {
+ this.byPlayer.set(session.playerId, session)
+ this.byConn.set(conn, session)
+ this.byEntity.set(session.entityId, session)
+ }
+
+ /** Removes the session from all three maps; returns it (undefined when the conn is unknown). */
+ remove(conn: GameConnection): PlayerSession | undefined {
+ const session = this.byConn.get(conn)
+ if (!session) return undefined
+ this.byConn.delete(conn)
+ this.byPlayer.delete(session.playerId)
+ this.byEntity.delete(session.entityId)
+ return session
+ }
+
+ bodyOf(playerId: PlayerId): BodyId | undefined {
+ return this.bodies.get(playerId)
+ }
+
+ setBody(playerId: PlayerId, bodyId: BodyId): void {
+ this.bodies.set(playerId, bodyId)
+ }
+
+ deleteBody(playerId: PlayerId): BodyId | undefined {
+ const bodyId = this.bodies.get(playerId)
+ this.bodies.delete(playerId)
+ return bodyId
+ }
+}
+
+/**
+ * The systems, late-bound so a system can reach its peers. Assigned once, after every system is
+ * constructed; no system may touch it before the first message or tick.
+ */
+export interface ServerSystems {
+ sessions: SessionsSystem
+ movement: MovementSystem
+ replication: ReplicationSystem
+ manipulation: ManipulationSystem
+ combat: CombatSystem
+ vehicles: VehicleSystem
+ inventory: InventorySystem
+ interaction: InteractionSystem
+ economy: EconomySystem
+ production: ProductionSystem
+ npcs: NpcSystem
+ social: SocialSystem
+ worldEvents: WorldEventSystem
+ persistence: PersistenceSystem
+ mods: ModSystem
+ editor: EditorSystem
+ environment: EnvironmentSystem
+ physics: PhysicsSystem
+}
+
+/**
+ * Drives the systems' `onJoin`/`onLeave` hooks in construction order. GameServer binds this to its
+ * ordered system list, so the session system never has to know which systems keep session state.
+ */
+export interface Lifecycle {
+ joined(session: PlayerSession): void
+ left(session: PlayerSession): void
+}
+
+/**
+ * What every system shares (ADR-0007 M1): the world, the sessions registry, content, config, log,
+ * metrics, the store, the wire helpers and the clock. Anything a system owns alone stays inside it.
+ */
+export class ServerContext {
+ readonly sessions = new SessionRegistry()
+ readonly clock: Clock = { tick: 0 }
+ /** Coarse activation regions: the environment system moves them, NPC LOD and metrics read them. */
+ readonly regions = new RegionTracker(32)
+ /** The wire helpers; assigned right after this context is constructed. */
+ net!: Net
+ /** Every system; assigned once they all exist. */
+ systems!: ServerSystems
+ /** The join/leave hooks; assigned with the systems. */
+ lifecycle!: Lifecycle
+
+ constructor(
+ readonly config: ServerConfig,
+ readonly world: GameWorld,
+ readonly store: PersistenceStore,
+ readonly metrics: ServerMetrics,
+ readonly log: Logger,
+ readonly integrations: GameIntegrations,
+ /** Authoritative world environment: clock, weather, temperature. */
+ readonly env: EnvironmentState,
+ ) {}
+
+ /** The zod content registry the world was built with. */
+ get content(): GameWorld['content'] {
+ return this.world.content
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,258 @@
@@ -0,0 +1,258 @@
+import { REP_DELTAS, addReputation, stanceToward } from '@openvibe/gameplay'
+import type {
+ ClientJobAccept,
+ ClientJobTurnIn,
+ ClientMarketBuy,
+ ClientMarketOpen,
+ ClientMarketSell,
+} from '@openvibe/protocol'
+import type { EntityId } from '@openvibe/shared'
+import type { PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import type { HandlerMap, System } from './system.js'
+
+export class EconomySystem implements System {
+ readonly name = 'economy'
+
+ readonly handlers: HandlerMap = {
+ market_open: (session, msg: ClientMarketOpen, _conn) => this.handleMarket(session, msg),
+ market_buy: (session, msg: ClientMarketBuy, _conn) => this.handleMarket(session, msg),
+ market_sell: (session, msg: ClientMarketSell, _conn) => this.handleMarket(session, msg),
+ job_accept: (session, msg: ClientJobAccept, _conn) => this.handleJob(session, msg),
+ job_turnin: (session, msg: ClientJobTurnIn, _conn) => this.handleJob(session, msg),
+ }
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ /** Live sell-stock per market: item -> remaining + next restock time. */
+ private readonly marketStock = new Map<string, Map<string, { stock: number; at: number }>>()
+
+ /**
+ * Reads the persisted market blobs. main.ts awaits this before starting the tick loop
+ * (alongside the rest of the boot load).
+ */
+ async load(): Promise<void> {
+ for (const { key, value } of await this.ctx.store.meta.list('market_')) {
+ const market = new Map<string, { stock: number; at: number }>()
+ if (value && typeof value === 'object') {
+ for (const [item, state] of Object.entries(
+ value as Record<string, { stock: number; at: number }>,
+ )) {
+ market.set(item, state)
+ }
+ }
+ this.marketStock.set(key.slice('market_'.length), market)
+ }
+ }
+
+ /** The market blobs a checkpoint rides along with (one `market_<id>` per live market). */
+ marketMeta(): Record<string, unknown> {
+ const meta: Record<string, unknown> = {}
+ for (const [id, market] of this.marketStock) meta[`market_${id}`] = Object.fromEntries(market)
+ return meta
+ }
+
+ /** Lazily restocked stock entry for one market sell line. */
+ private stockOf(
+ marketId: string,
+ entry: { item: string; stock: number; restockSeconds: number },
+ nowMs: number,
+ ): { stock: number; at: number } {
+ let market = this.marketStock.get(marketId)
+ if (!market) {
+ // Persisted market blobs are loaded at boot (GameServer.load); an unknown market starts fresh.
+ market = new Map()
+ this.marketStock.set(marketId, market)
+ }
+ let state = market.get(entry.item)
+ if (!state) {
+ state = { stock: entry.stock, at: nowMs + entry.restockSeconds * 1000 }
+ market.set(entry.item, state)
+ }
+ if (nowMs >= state.at) {
+ state.stock = entry.stock
+ state.at = nowMs + entry.restockSeconds * 1000
+ }
+ return state
+ }
+
+ /**
+ * Market interactions: open/buy/sell against the market a shop prop
+ * references. Everything server-atomic: proximity, faction stance,
+ * live stock, coins and space all validated here.
+ */
+ private handleMarket(
+ session: PlayerSession,
+ msg: ClientMarketOpen | ClientMarketBuy | ClientMarketSell,
+ ): void {
+ const deny = (error: string) =>
+ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: false, error })
+ const entity = this.ctx.world.entities.get(msg.target as EntityId)
+ const marketId = entity?.prop
+ ? this.ctx.world.content.item(entity.prop.defId)?.shop?.market
+ : undefined
+ const market = marketId ? this.ctx.world.content.market(marketId) : undefined
+ if (!entity || !market) return deny('no_merchant')
+ const d = Math.hypot(
+ entity.transform.pos.x - session.move.pos.x,
+ entity.transform.pos.z - session.move.pos.z,
+ )
+ if (d > 5) return deny('out_of_range')
+ const faction = this.ctx.world.content.faction(market.faction)
+ const stance = faction ? stanceToward(faction, session.reputation) : 'neutral'
+ if (stance === 'hostile') return deny('they_hate_you')
+ const nowMs = Date.now()
+
+ if (msg.t === 'market_buy' && msg.item) {
+ const entry = market.sells.find((s) => s.item === msg.item)
+ if (!entry) return deny('no_such_trade')
+ const state = this.stockOf(market.id, entry, nowMs)
+ if (state.stock <= 0) return deny('out_of_stock')
+ if (session.inventory.countOf('coin') < entry.price) return deny('missing_coins')
+ if (!session.inventory.canFit(entry.item, entry.count)) return deny('inventory_full')
+ const paid = session.inventory.consume([{ item: 'coin', count: entry.price }])
+ if (!paid.ok) return deny('missing_coins')
+ session.inventory.add(entry.item, entry.count)
+ state.stock -= 1
+ addReputation(session.reputation, market.faction, REP_DELTAS.trade)
+ session.dirty = true
+ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })
+ this.ctx.net.sendInventory(session)
+ this.sendReputation(session)
+ } else if (msg.t === 'market_sell' && msg.item) {
+ const entry = market.buys.find((b) => b.item === msg.item)
+ if (!entry) return deny('no_such_trade')
+ if (session.inventory.countOf(entry.item) < entry.count) return deny('missing_items')
+ if (!session.inventory.canFit('coin', entry.price)) return deny('inventory_full')
+ const taken = session.inventory.consume([{ item: entry.item, count: entry.count }])
+ if (!taken.ok) return deny('missing_items')
+ session.inventory.add('coin', entry.price)
+ addReputation(session.reputation, market.faction, REP_DELTAS.trade)
+ session.dirty = true
+ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })
+ this.ctx.net.sendInventory(session)
+ this.sendReputation(session)
+ }
+ // Every path (including plain open) refreshes the market + contracts.
+ this.sendJobs(session, market.id)
+ this.ctx.net.send(session, {
+ t: 'market',
+ id: market.id,
+ name: market.name,
+ stance,
+ sells: market.sells.map((s) => ({
+ item: s.item,
+ count: s.count,
+ price: s.price,
+ stock: this.stockOf(market.id, s, nowMs).stock,
+ })),
+ buys: market.buys.map((b) => ({ item: b.item, count: b.count, price: b.price })),
+ })
+ }
+
+ /** Contract accept/turn-in at a trading post. */
+ private handleJob(session: PlayerSession, msg: ClientJobAccept | ClientJobTurnIn): void {
+ const deny = (error: string) =>
+ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: false, error })
+ const entity = this.ctx.world.entities.get(msg.target as EntityId)
+ const marketId = entity?.prop
+ ? this.ctx.world.content.item(entity.prop.defId)?.shop?.market
+ : undefined
+ if (!entity || !marketId) return deny('no_merchant')
+ const d = Math.hypot(
+ entity.transform.pos.x - session.move.pos.x,
+ entity.transform.pos.z - session.move.pos.z,
+ )
+ if (d > 5) return deny('out_of_range')
+
+ if (msg.t === 'job_accept' && msg.job) {
+ const job = this.ctx.world.content.job(msg.job)
+ if (!job || job.market !== marketId) return deny('no_such_job')
+ if (session.activeJob) return deny('job_in_progress')
+ session.activeJob = { job: job.id, progress: 0 }
+ session.dirty = true
+ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })
+ this.ctx.net.send(session, { t: 'announce', text: `📋 Contract accepted: ${job.name}` })
+ } else if (msg.t === 'job_turnin') {
+ const job = session.activeJob ? this.ctx.world.content.job(session.activeJob.job) : undefined
+ if (!session.activeJob || !job) return deny('no_active_job')
+ if (job.market !== marketId) return deny('wrong_merchant')
+ if (job.objective.kind === 'deliver') {
+ const need = { item: job.objective.item, count: job.objective.count }
+ if (session.inventory.countOf(need.item) < need.count) return deny('missing_items')
+ const taken = session.inventory.consume([need])
+ if (!taken.ok) return deny('missing_items')
+ } else if (session.activeJob.progress < job.objective.count) {
+ return deny('not_finished')
+ }
+ // Rewards: coins, reputation, items, XP — all atomic-enough (coins
+ // and items overflow to the floor is prevented by canFit pre-check).
+ if (job.reward.coins > 0 && !session.inventory.canFit('coin', job.reward.coins)) {
+ return deny('inventory_full')
+ }
+ if (job.reward.coins > 0) session.inventory.add('coin', job.reward.coins)
+ for (const it of job.reward.items) session.inventory.add(it.item, it.count)
+ const market = this.ctx.world.content.market(marketId)
+ if (job.reward.reputation !== 0 && market) {
+ addReputation(session.reputation, market.faction, job.reward.reputation)
+ this.sendReputation(session)
+ }
+ if (job.reward.xp) {
+ const ups = session.skills.addXp(job.reward.xp.skill, job.reward.xp.amount)
+ for (const up of ups)
+ this.ctx.net.send(session, { t: 'levelup', skill: up.skill, level: up.level })
+ this.ctx.net.sendSkills(session)
+ }
+ session.activeJob = null
+ session.dirty = true
+ this.ctx.net.send(session, { t: 'result', action: 'trade', ok: true })
+ this.ctx.net.send(session, { t: 'announce', text: `✅ Contract complete: ${job.name}` })
+ this.ctx.net.sendInventory(session)
+ }
+ this.sendJobs(session, marketId)
+ }
+
+ sendJobs(session: PlayerSession, marketId: string): void {
+ const active = session.activeJob
+ ? (() => {
+ const job = this.ctx.world.content.job(session.activeJob!.job)
+ if (!job) return null
+ const goal = job.objective.count
+ const progress =
+ job.objective.kind === 'deliver'
+ ? Math.min(goal, session.inventory.countOf(job.objective.item))
+ : session.activeJob!.progress
+ return {
+ job: job.id,
+ name: job.name,
+ progress,
+ goal,
+ ready: progress >= goal,
+ }
+ })()
+ : null
+ this.ctx.net.send(session, {
+ t: 'jobs',
+ market: marketId,
+ available: this.ctx.world.content.jobsForMarket(marketId).map((j) => ({
+ id: j.id,
+ name: j.name,
+ description: j.description,
+ done: false,
+ })),
+ active,
+ })
+ }
+
+ sendReputation(session: PlayerSession): void {
+ this.ctx.net.send(session, {
+ t: 'reputation',
+ factions: this.ctx.world.content.allFactions().map((f) => ({
+ id: f.id,
+ name: f.name,
+ value: Math.round(session.reputation[f.id] ?? 0),
+ stance: stanceToward(f, session.reputation),
+ })),
+ })
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+import type { ServerContext } from './context.js'
+import type { System } from './system.js'
+
+/**
+ * The editor's one wire effect: tell every client the map changed. The reconcile itself is main.ts's
+ * (it owns the map document) and stays there.
+ */
+export class EditorSystem implements System {
+ readonly name = 'editor'
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ broadcastMapReload(): void {
+ this.ctx.net.broadcastAll({ t: 'map_reload' })
+ this.ctx.net.broadcastAll({
+ t: 'announce',
+ text: '🗺 The world was reshaped by the map editors…',
+ })
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+import {
+ Buttons,
+ activeStatuses,
+ ambientTemperature,
+ precipitation,
+ stepEnvironment,
+ tickSurvival,
+ type WeatherKind,
+} from '@openvibe/gameplay'
+import { WATER_LEVEL, terrainHeight } from '@openvibe/content'
+import type { ServerContext } from './context.js'
+import type { System } from './system.js'
+import { nearbyWorkstationKinds } from '../interactions.js'
+
+/** The five weather kinds a persisted env_weather may name. */
+const WEATHERS = new Set<WeatherKind>(['clear', 'cloudy', 'rain', 'storm', 'fog'])
+
+export class EnvironmentSystem implements System {
+ readonly name = 'environment'
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ /**
+ * Async boot: the world clock and weather are read from PostgreSQL.
+ * main.ts awaits this before starting the tick loop.
+ */
+ async load(): Promise<void> {
+ const rawTime = await this.ctx.store.meta.get('env_time')
+ const savedTime = Number(rawTime ?? Number.NaN)
+ if (Number.isFinite(savedTime)) this.ctx.env.timeOfDay = savedTime
+ const savedWeather = await this.ctx.store.meta.get('env_weather')
+ if (typeof savedWeather === 'string' && WEATHERS.has(savedWeather as WeatherKind)) {
+ this.ctx.env.weather = savedWeather as WeatherKind
+ }
+ }
+
+ tick(ctx: ServerContext, _dt: number): void {
+ if (ctx.clock.tick % ctx.config.tickRate !== 0) return
+ const weatherBefore = ctx.env.weather
+ stepEnvironment(ctx.env, 1, Math.random)
+ if (ctx.env.weather !== weatherBefore) {
+ ctx.net.broadcastAll(ctx.net.timeWire())
+ ctx.log.info('weather changed', { from: weatherBefore, to: ctx.env.weather })
+ }
+ const ambientC = ambientTemperature(ctx.env)
+ const raining = precipitation(ctx.env) > 0
+ const nowMs = Date.now()
+ // Region activation follows players (coarse, 1 Hz).
+ ctx.regions.update(
+ [...ctx.sessions.values()].map((s) => ({ x: s.move.pos.x, z: s.move.pos.z })),
+ )
+ for (const session of ctx.sessions.values()) {
+ const sprinting =
+ (session.buttons & Buttons.Sprint) !== 0 &&
+ Math.hypot(session.move.vel.x, session.move.vel.z) > 1
+ // Wet: wading/swimming, or out in the rain (no roof detection yet —
+ // "indoors" arrives with the shelter model).
+ const inWater =
+ terrainHeight(ctx.world.content.world, session.move.pos.x, session.move.pos.z) <
+ WATER_LEVEL - 0.03
+ // Warmth: standing near a lit burn barrel (workstation scan; the
+ // spatial index will replace this walk).
+ const nearHeat = nearbyWorkstationKinds(session, ctx.world).has('campfire')
+ const before = { ...session.stats }
+ const statusesBefore = activeStatuses(session.stats, nowMs).join(',')
+ const died = tickSurvival(session.stats, 1, {
+ sprinting,
+ ambientC,
+ wet: inWater || raining,
+ nearHeat,
+ nowMs,
+ })
+ if (
+ Math.round(before.health) !== Math.round(session.stats.health) ||
+ Math.round(before.hunger) !== Math.round(session.stats.hunger) ||
+ Math.round(before.thirst) !== Math.round(session.stats.thirst) ||
+ Math.round(before.stamina) !== Math.round(session.stats.stamina) ||
+ Math.round(before.bodyTemp * 2) !== Math.round(session.stats.bodyTemp * 2) ||
+ activeStatuses(session.stats, nowMs).join(',') !== statusesBefore
+ ) {
+ session.statsDirty = true
+ }
+ if (died) {
+ ctx.log.info('player died of exposure', { playerId: session.playerId })
+ ctx.systems.combat.respawn(session, true)
+ } else if (session.statsDirty) {
+ ctx.net.send(session, { t: 'stats', ...ctx.net.statsWire(session) })
+ session.statsDirty = false
+ session.dirty = true
+ }
+ if (session.move.pos.y < -25) {
+ ctx.systems.combat.respawn(session, false)
+ ctx.log.info('void rescue', { playerId: session.playerId })
+ }
+ }
+ if (ctx.clock.tick % (ctx.config.tickRate * 10) === 0) {
+ ctx.net.broadcastAll(ctx.net.timeWire())
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+/**
+ * The gameplay systems (ADR-0007 M1). `gameServer.ts` is the coordinator: it constructs these,
+ * assembles their message handlers in one table, and calls their ticks in the documented order.
+ * Everything else lives in the file named after the system.
+ */
+export { ServerContext, SessionRegistry } from './context.js'
+export type {
+ Clock,
+ GameConnection,
+ GameIntegrations,
+ Lifecycle,
+ Net,
+ ServerSystems,
+} from './context.js'
+export { buildHandlerTable } from './system.js'
+export type { HandlerMap, MessageHandler, System } from './system.js'
+
+export { SessionsSystem } from './sessions.js'
+export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'
+export { ReplicationSystem } from './replication.js'
+export { ManipulationSystem } from './manipulation.js'
+export { CombatSystem } from './combat.js'
+export { VehicleSystem } from './vehicles.js'
+export { InventorySystem } from './inventory.js'
+export { InteractionSystem } from './interaction.js'
+export { EconomySystem } from './economy.js'
+export { ProductionSystem } from './production.js'
+export { NpcSystem } from './npcs.js'
+export { SocialSystem } from './social.js'
+export { WorldEventSystem } from './worldEvents.js'
+export { PersistenceSystem } from './persistence.js'
+export { ModSystem } from './mods.js'
+export { EditorSystem } from './editor.js'
+export { EnvironmentSystem } from './environment.js'
+export { PhysicsSystem } from './physics.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+import { ambientTemperature, plantProgress, precipitation } from '@openvibe/gameplay'
+import type { ClientUse } from '@openvibe/protocol'
+import type { EntityId } from '@openvibe/shared'
+import { handleUse } from '../interactions.js'
+import type { PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import type { HandlerMap, System } from './system.js'
+
+/**
+ * Using a tool or bare hand against the world (ADR-0007 M1): gathering resources, doors, plants,
+ * repairing and disassembling — plus the world's resource-respawn sweep.
+ */
+export class InteractionSystem implements System {
+ readonly name = 'interaction'
+
+ readonly handlers: HandlerMap = {
+ use: (session: PlayerSession, msg: ClientUse, _conn) => {
+ // Swing cooldown: silently drop spam faster than ~5 swings/sec.
+ if (this.ctx.clock.tick - session.lastUseTick < 6) return
+ const targetSession = this.ctx.sessions.getByEntity(msg.target as EntityId)
+ if (targetSession) {
+ // Legacy path: melee also arrives as 'use' from older flows.
+ this.ctx.systems.combat.melee(session, targetSession)
+ return
+ }
+ const vehicleTarget = this.ctx.world.entities.get(msg.target as EntityId)
+ if (
+ vehicleTarget?.prop &&
+ this.ctx.world.content.item(vehicleTarget.prop.defId)?.vehiclePart?.part === 'chassis'
+ ) {
+ this.ctx.systems.vehicles.useVehicle(session, vehicleTarget)
+ return
+ }
+ const gather = handleUse(
+ session,
+ this.ctx.world,
+ msg,
+ Date.now(),
+ (e) => this.ctx.systems.manipulation.canManipulate(session, e),
+ {
+ nowMs: Date.now(),
+ raining: precipitation(this.ctx.env) > 0,
+ ambientC: ambientTemperature(this.ctx.env),
+ },
+ )
+ this.ctx.net.send(session, gather.outcome)
+ if (!gather.outcome.ok) return
+ session.lastUseTick = this.ctx.clock.tick
+ this.ctx.net.sendInventory(session)
+ if (gather.xpChanged) this.ctx.net.sendSkills(session)
+ for (const up of gather.levelUps) {
+ this.ctx.net.send(session, { t: 'levelup', skill: up.skill, level: up.level })
+ }
+ if (gather.pickedUp) {
+ if (session.held?.entityId === gather.pickedUp.id) {
+ this.ctx.systems.manipulation.releaseHeld(session)
+ }
+ this.ctx.net.broadcastDespawn(gather.pickedUp.id)
+ }
+ if (gather.spawned) this.ctx.net.broadcastSpawn(gather.spawned)
+ if (gather.changed?.prop) {
+ // Door swing / plant change / repair: pin authoritative state.
+ const e = gather.changed
+ const plant = e.prop?.plant
+ const max = this.ctx.world.content.item(e.prop!.defId)?.health?.max
+ this.ctx.net.broadcastToKnowing(e.id, {
+ t: 'entity',
+ id: e.id,
+ pos: [e.transform.pos.x, e.transform.pos.y, e.transform.pos.z],
+ rot: [e.transform.rot.x, e.transform.rot.y, e.transform.rot.z, e.transform.rot.w],
+ plant: (() => {
+ const crop = plant ? this.ctx.world.content.crop(plant.crop) : undefined
+ return plant && crop
+ ? { crop: plant.crop, t: plantProgress(plant, crop), water: plant.water }
+ : null
+ })(),
+ ...(max !== undefined ? { health: Math.round(e.prop!.health ?? max) } : {}),
+ })
+ }
+ if (gather.changed?.resource) {
+ this.ctx.net.broadcastToKnowing(gather.changed.id, {
+ t: 'entity',
+ id: gather.changed.id,
+ remaining: gather.changed.resource.remaining,
+ })
+ }
+ },
+ }
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ /** Resource respawn sweep (once a second). */
+ respawnResources(): void {
+ if (this.ctx.clock.tick % this.ctx.config.tickRate !== 0) return
+ for (const entity of this.ctx.world.respawnDueResources(Date.now())) {
+ if (entity.resource) {
+ this.ctx.net.broadcastToKnowing(entity.id, {
+ t: 'entity',
+ id: entity.id,
+ remaining: entity.resource.remaining,
+ })
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,288 @@
@@ -0,0 +1,288 @@
+import { WATER_LEVEL, terrainHeight } from '@openvibe/content'
+import { clearStatus, containerAdd, containerSort, eat, type GameEntity } from '@openvibe/gameplay'
+import type {
+ ClientConsume,
+ ClientContainerMove,
+ ClientContainerOpen,
+ ClientContainerSort,
+ ClientDrink,
+ ClientDrop,
+ ClientEquipArmor,
+ ClientHotbarSelect,
+ ClientInvMove,
+ ClientPlace,
+} from '@openvibe/protocol'
+import type { EntityId } from '@openvibe/shared'
+import { equippedTool, handleDrop, handleInvMove, handlePlace } from '../interactions.js'
+import { HOTBAR_SIZE, type PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import type { HandlerMap, System } from './system.js'
+
+/**
+ * The player's items: worn armor, the hotbar, consumables, dropped and placed props, and the
+ * containers they open and move things through (ADR-0007 M1).
+ */
+export class InventorySystem implements System {
+ readonly name = 'inventory'
+ readonly handlers: HandlerMap
+
+ constructor(private readonly ctx: ServerContext) {
+ this.handlers = {
+ equip_armor: (session, msg: ClientEquipArmor, _conn) => {
+ if (msg.slot === undefined) {
+ // Unequip into the first free inventory slot.
+ if (!session.armor) {
+ ctx.net.send(session, { t: 'result', action: 'inv_move', ok: false, error: 'no_armor' })
+ return
+ }
+ if (!session.inventory.addStack(session.armor)) {
+ ctx.net.send(session, {
+ t: 'result',
+ action: 'inv_move',
+ ok: false,
+ error: 'inventory_full',
+ })
+ return
+ }
+ session.armor = null
+ session.dirty = true
+ ctx.net.send(session, { t: 'result', action: 'inv_move', ok: true })
+ ctx.net.sendInventory(session)
+ return
+ }
+ const stack = session.inventory.get(msg.slot)
+ const armorDef = stack ? ctx.world.content.item(stack.defId)?.armor : undefined
+ if (!stack || !armorDef) {
+ ctx.net.send(session, { t: 'result', action: 'inv_move', ok: false, error: 'not_armor' })
+ return
+ }
+ const removed = session.inventory.removeFromSlot(msg.slot, 1)
+ if (!removed) return
+ // Fresh pieces get their full durability stamped into the stack.
+ if (removed.meta?.dur === undefined) {
+ removed.meta = { ...removed.meta, dur: armorDef.durability }
+ }
+ const previous = session.armor
+ session.armor = removed
+ if (previous) session.inventory.addStack(previous)
+ session.dirty = true
+ ctx.net.send(session, { t: 'result', action: 'inv_move', ok: true })
+ ctx.net.sendInventory(session)
+ },
+ drop: (session, msg: ClientDrop, _conn) => {
+ const { outcome, droppedId } = handleDrop(session, ctx.world, msg)
+ ctx.net.send(session, outcome)
+ if (outcome.ok) {
+ ctx.net.sendInventory(session)
+ if (droppedId) {
+ const entity = ctx.world.entities.get(droppedId as EntityId)
+ if (entity) ctx.net.broadcastSpawn(entity)
+ }
+ }
+ },
+ place: (session, msg: ClientPlace, _conn) => {
+ const { outcome, placedId } = handlePlace(session, ctx.world, msg)
+ ctx.net.send(session, outcome)
+ if (outcome.ok) {
+ ctx.net.sendInventory(session)
+ if (placedId) {
+ const entity = ctx.world.entities.get(placedId as EntityId)
+ if (entity) ctx.net.broadcastSpawn(entity)
+ }
+ }
+ },
+ inv_move: (session, msg: ClientInvMove, _conn) => {
+ const outcome = handleInvMove(session, msg)
+ ctx.net.send(session, outcome)
+ ctx.net.sendInventory(session)
+ },
+ hotbar: (session, msg: ClientHotbarSelect, _conn) => {
+ if (msg.slot < HOTBAR_SIZE) {
+ // Re-pressing the active slot holsters/unholsters (empty hands).
+ if (msg.slot === session.activeHotbar) {
+ session.holstered = !session.holstered
+ } else {
+ session.activeHotbar = msg.slot
+ session.holstered = false
+ }
+ // Switching away from the physgun drops beam and anything held.
+ if (equippedTool(session)?.kind !== 'physgun') {
+ session.grabbing = false
+ if (session.held) ctx.systems.manipulation.releaseHeld(session)
+ }
+ }
+ },
+ consume: (session, msg: ClientConsume, _conn) => {
+ const stack = session.inventory.get(msg.slot)
+ const def = stack ? ctx.world.content.item(stack.defId) : undefined
+ // Blueprints: using one permanently unlocks its recipe.
+ if (stack && def?.blueprint) {
+ if (session.unlocks.has(def.blueprint.recipe)) {
+ ctx.net.send(session, {
+ t: 'result',
+ action: 'consume',
+ ok: false,
+ error: 'already_known',
+ })
+ return
+ }
+ session.inventory.removeFromSlot(msg.slot, 1)
+ session.unlocks.add(def.blueprint.recipe)
+ session.dirty = true
+ const recipeName = ctx.world.content.recipe(def.blueprint.recipe)?.name ?? 'recipe'
+ ctx.net.send(session, { t: 'result', action: 'consume', ok: true })
+ ctx.net.send(session, { t: 'announce', text: `📜 Blueprint learned: ${recipeName}!` })
+ ctx.net.sendInventory(session)
+ ctx.net.sendSkills(session)
+ return
+ }
+ if (!stack || (!def?.food && !def?.medical)) {
+ ctx.net.send(session, { t: 'result', action: 'consume', ok: false, error: 'not_food' })
+ return
+ }
+ session.inventory.removeFromSlot(msg.slot, 1)
+ if (def.food) eat(session.stats, def.food, Date.now())
+ if (def.medical) {
+ session.stats.health = Math.min(100, session.stats.health + def.medical.heal)
+ if (def.medical.curesBleeding) clearStatus(session.stats, 'bleeding')
+ }
+ session.statsDirty = true
+ session.dirty = true
+ ctx.net.send(session, { t: 'result', action: 'consume', ok: true })
+ ctx.net.send(session, { t: 'stats', ...ctx.net.statsWire(session) })
+ session.statsDirty = false
+ ctx.net.sendInventory(session)
+ },
+ drink: (session, _msg: ClientDrink, _conn) => {
+ if (ctx.clock.tick - session.lastUseTick < 15) return
+ const ground = terrainHeight(
+ ctx.world.content.world,
+ session.move.pos.x,
+ session.move.pos.z,
+ )
+ if (ground > WATER_LEVEL - 0.03) {
+ ctx.net.send(session, { t: 'result', action: 'consume', ok: false, error: 'no_water' })
+ return
+ }
+ session.lastUseTick = ctx.clock.tick
+ // A held watering can fills instead of drinking.
+ const held = session.holstered ? null : session.inventory.get(session.activeHotbar)
+ const fluidCap = held ? ctx.world.content.item(held.defId)?.fluidContainer : undefined
+ if (held && fluidCap) {
+ held.meta = { ...held.meta, fluid: fluidCap.capacity }
+ session.dirty = true
+ ctx.net.send(session, { t: 'result', action: 'consume', ok: true })
+ ctx.net.sendInventory(session)
+ return
+ }
+ session.stats.thirst = Math.min(100, session.stats.thirst + 30)
+ session.statsDirty = true
+ session.dirty = true
+ ctx.net.send(session, { t: 'result', action: 'consume', ok: true })
+ ctx.net.send(session, { t: 'stats', ...ctx.net.statsWire(session) })
+ session.statsDirty = false
+ },
+ container_open: (session, msg: ClientContainerOpen, _conn) => {
+ const entity = ctx.world.entities.get(msg.target as EntityId)
+ const denied = this.containerAccessDenied(session, entity)
+ if (denied || !entity?.prop?.container) {
+ ctx.net.send(session, {
+ t: 'result',
+ action: 'container',
+ ok: false,
+ error: denied ?? 'no_container',
+ })
+ return
+ }
+ session.openContainer = entity.id
+ ctx.net.sendContainer(session, entity)
+ },
+ container_move: (session, msg: ClientContainerMove, _conn) => {
+ const entity = ctx.world.entities.get(msg.target as EntityId)
+ const denied = this.containerAccessDenied(session, entity)
+ const box = entity?.prop?.container
+ if (denied || !entity || !box) {
+ ctx.net.send(session, {
+ t: 'result',
+ action: 'container',
+ ok: false,
+ error: denied ?? 'no_container',
+ })
+ return
+ }
+ const maxStackOf = (defId: string) => ctx.world.content.item(defId)?.maxStack ?? 1
+ let ok = false
+ if (msg.dir === 'in') {
+ const stack = session.inventory.get(msg.slot)
+ if (stack) {
+ const want = msg.count !== undefined ? Math.min(msg.count, stack.count) : stack.count
+ const moved = containerAdd(box, stack.defId, want, maxStackOf)
+ if (moved > 0) {
+ session.inventory.removeFromSlot(msg.slot, moved)
+ ok = true
+ }
+ }
+ } else {
+ const slot = box[msg.slot]
+ if (slot) {
+ const want = msg.count !== undefined ? Math.min(msg.count, slot.count) : slot.count
+ const leftover = session.inventory.add(slot.defId, want)
+ const moved = want - leftover
+ if (moved > 0) {
+ slot.count -= moved
+ if (slot.count <= 0) box[msg.slot] = null
+ ok = true
+ }
+ }
+ }
+ if (ok) {
+ entity.dirty = true
+ session.dirty = true
+ ctx.net.sendInventory(session)
+ // Push fresh contents to EVERYONE with this container open.
+ for (const other of ctx.sessions.values()) {
+ if (other.openContainer === entity.id) ctx.net.sendContainer(other, entity)
+ }
+ } else {
+ ctx.net.send(session, { t: 'result', action: 'container', ok: false, error: 'no_space' })
+ }
+ },
+ container_sort: (session, msg: ClientContainerSort, _conn) => {
+ const entity = ctx.world.entities.get(msg.target as EntityId)
+ const denied = this.containerAccessDenied(session, entity)
+ const box = entity?.prop?.container
+ if (denied || !entity || !box) {
+ ctx.net.send(session, {
+ t: 'result',
+ action: 'container',
+ ok: false,
+ error: denied ?? 'no_container',
+ })
+ return
+ }
+ containerSort(box, (defId) => ctx.world.content.item(defId)?.maxStack ?? 1)
+ entity.dirty = true
+ ctx.net.send(session, { t: 'result', action: 'container', ok: true })
+ for (const other of ctx.sessions.values()) {
+ if (other.openContainer === entity.id) ctx.net.sendContainer(other, entity)
+ }
+ },
+ }
+ }
+
+ /** Range + prop-protection gate shared by all container operations. */
+ private containerAccessDenied(
+ session: PlayerSession,
+ entity: GameEntity | undefined,
+ ): string | null {
+ if (!entity?.prop) return 'no_target'
+ const d = Math.hypot(
+ entity.transform.pos.x - session.move.pos.x,
+ entity.transform.pos.y - session.move.pos.y,
+ entity.transform.pos.z - session.move.pos.z,
+ )
+ if (d > 4.5) return 'out_of_range'
+ if (!this.ctx.systems.manipulation.canManipulate(session, entity)) return 'not_owner'
+ return null
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,280 @@
@@ -0,0 +1,280 @@
+import type { GameEntity } from '@openvibe/gameplay'
+import type { ClientConstraint, ClientConstraintRemove, ClientPhysgun } from '@openvibe/protocol'
+import { v3dist, vec3, type EntityId } from '@openvibe/shared'
+import { equippedTool, handleConstraint } from '../interactions.js'
+import { adjustDistance, driveHeld, freezeHeld, release, rotateHeld, tryGrab } from '../physgun.js'
+import { eyePosition, type PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import type { HandlerMap, System } from './system.js'
+
+const _eyeScratch = vec3()
+const _relVel = vec3()
+
+/**
+ * Manipulation: the physgun beam and the rigging tool — everything gated by prop protection. It
+ * owns who is holding what (the exclusivity latch) and the offline-owner friend cache that
+ * `canManipulate` consults, so no other system ever has to look up an offline owner.
+ */
+export class ManipulationSystem implements System {
+ readonly name = 'manipulation'
+
+ /** Exclusivity set for physgun grabs (one beam per prop). */
+ private readonly heldEntityIds = new Set<string>()
+ /** Short-lived cache of OFFLINE owners' friend lists (prop protection). */
+ private readonly offlineFriendsCache = new Map<string, { friends: Set<string>; at: number }>()
+ /** Offline-owner lookups in flight, so the tick never issues the same read twice. */
+ private readonly friendsLoadInFlight = new Set<string>()
+
+ readonly handlers: HandlerMap = {
+ physgun: (session, msg: ClientPhysgun, _conn) => {
+ this.handlePhysgun(session, msg)
+ },
+ constraint: (session, msg: ClientConstraint, _conn) => {
+ const { outcome, created } = handleConstraint(session, this.ctx.world, msg, (e) =>
+ this.canManipulate(session, e),
+ )
+ this.ctx.net.send(session, outcome)
+ if (created) {
+ this.ctx.net.broadcastConstraintState(created, true)
+ this.ctx.net.sendSkills(session)
+ // Rope/spring/motor constraints consume materials.
+ this.ctx.net.sendInventory(session)
+ }
+ },
+ constraint_remove: (session, msg: ClientConstraintRemove, _conn) => {
+ const ctx = this.ctx
+ const tool = equippedTool(session)
+ if (tool?.kind !== 'rigging') {
+ ctx.net.send(session, {
+ t: 'result',
+ action: 'constraint',
+ ok: false,
+ error: 'requires_rigging_tool',
+ })
+ return
+ }
+ const target = ctx.world.entities.get(msg.target as EntityId)
+ if (!target?.prop) {
+ ctx.net.send(session, { t: 'result', action: 'constraint', ok: false, error: 'no_target' })
+ return
+ }
+ if (!this.canManipulate(session, target)) {
+ ctx.net.send(session, { t: 'result', action: 'constraint', ok: false, error: 'not_owner' })
+ return
+ }
+ eyePosition(session, _eyeScratch)
+ if (v3dist(_eyeScratch, target.transform.pos) > (tool.range ?? 6) + 1.5) {
+ ctx.net.send(session, {
+ t: 'result',
+ action: 'constraint',
+ ok: false,
+ error: 'out_of_range',
+ })
+ return
+ }
+ const removed = ctx.world.removeConstraintsFor(target.id)
+ ctx.net.send(session, {
+ t: 'result',
+ action: 'constraint',
+ ok: removed.length > 0,
+ ...(removed.length === 0 ? { error: 'no_constraints' } : {}),
+ })
+ for (const rec of removed) ctx.net.broadcastConstraintState(rec, false)
+ },
+ }
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ /** A player joined: their own offline-friend entry is stale. */
+ onJoin(session: PlayerSession): void {
+ this.offlineFriendsCache.delete(session.playerId as string)
+ }
+
+ /** A player left: keep protection checks fresh, and drop the beam latch (no broadcast). */
+ onLeave(session: PlayerSession): void {
+ this.offlineFriendsCache.set(session.playerId as string, {
+ friends: new Set(session.friends),
+ at: Date.now(),
+ })
+ if (session.held) {
+ this.heldEntityIds.delete(session.held.entityId)
+ session.held = null
+ }
+ }
+
+ /**
+ * Prop protection: world props (no owner) are free; otherwise the owner
+ * or anyone the OWNER trusts may manipulate. Works for offline owners via
+ * a TTL-cached repository lookup.
+ */
+ canManipulate(session: PlayerSession, entity: GameEntity): boolean {
+ if (entity.owner === undefined) return true
+ if (entity.owner === session.playerId) return true
+ const ownerSession = this.ctx.sessions.get(entity.owner)
+ if (ownerSession) return ownerSession.friends.has(session.playerId)
+ const cached = this.offlineFriendsCache.get(entity.owner)
+ if (cached && Date.now() - cached.at < 30_000) {
+ return cached.friends.has(session.playerId)
+ }
+ // Offline owner, no cached answer: start an async load and DENY this tick. The tick never awaits
+ // I/O; the cached answer is used from the next tick on (the same 30 s TTL).
+ const owner = entity.owner
+ if (!this.friendsLoadInFlight.has(owner)) {
+ this.friendsLoadInFlight.add(owner)
+ void this.ctx.store.players
+ .findById(owner)
+ .then((found) => {
+ this.offlineFriendsCache.set(owner, {
+ friends: new Set(found?.friends ?? []),
+ at: Date.now(),
+ })
+ })
+ .catch((err: unknown) => {
+ this.ctx.log.warn('offline owner lookup failed', { owner, error: String(err) })
+ })
+ .finally(() => this.friendsLoadInFlight.delete(owner))
+ }
+ return false
+ }
+
+ /** Retry unlatched beams (sweep-to-grab) and drive held bodies. */
+ tick(ctx: ServerContext, _dt: number): void {
+ for (const session of ctx.sessions.values()) {
+ if (session.grabbing && !session.held && equippedTool(session)?.kind === 'physgun') {
+ this.attemptGrab(session)
+ }
+ if (session.held) driveHeld(session, ctx.world)
+ }
+ }
+
+ private handlePhysgun(session: PlayerSession, msg: ClientPhysgun): void {
+ const ctx = this.ctx
+ // Physgun actions require the physgun in the active hotbar slot.
+ if ((msg.a === 'grab' || msg.a === 'unfreeze') && equippedTool(session)?.kind !== 'physgun') {
+ ctx.net.send(session, {
+ t: 'result',
+ action: 'physgun',
+ ok: false,
+ error: 'no_physgun_equipped',
+ })
+ return
+ }
+ if (msg.a === 'grab') {
+ if (session.held) return
+ // Beam on: even with nothing under the crosshair, keep trying each
+ // tick — sweeping the beam onto a prop picks it up (GMod behavior).
+ session.grabbing = true
+ const denied = this.attemptGrab(session)
+ // Only meaningful denials are surfaced; an empty beam is not an error.
+ if (denied && denied !== 'no_target') {
+ ctx.net.send(session, { t: 'result', action: 'physgun', ok: false, error: denied })
+ }
+ } else if (msg.a === 'release') {
+ session.grabbing = false
+ this.releaseHeld(session)
+ } else if (msg.a === 'adjust') {
+ adjustDistance(session, msg.dist)
+ } else if (msg.a === 'rotate') {
+ rotateHeld(session, msg.dyaw, msg.dpitch, msg.snap ?? false, msg.snapStep)
+ } else if (msg.a === 'grid') {
+ if (session.held) {
+ session.held.grid = msg.on
+ if (msg.size !== undefined) session.held.gridSize = msg.size
+ }
+ } else if (msg.a === 'freeze') {
+ // Freezing ends the beam — otherwise the sweep-to-grab retry would
+ // immediately unfreeze what was just frozen.
+ session.grabbing = false
+ const frozen = freezeHeld(session, ctx.world)
+ if (frozen) {
+ this.heldEntityIds.delete(frozen.id)
+ ctx.net.broadcastToKnowing(frozen.id, {
+ t: 'entity',
+ id: frozen.id,
+ motion: 'frozen',
+ pos: [frozen.transform.pos.x, frozen.transform.pos.y, frozen.transform.pos.z],
+ rot: [
+ frozen.transform.rot.x,
+ frozen.transform.rot.y,
+ frozen.transform.rot.z,
+ frozen.transform.rot.w,
+ ],
+ })
+ ctx.net.broadcastAll({ t: 'physgun_state', player: session.entityId, target: null })
+ }
+ } else if (msg.a === 'unfreeze') {
+ const entity = ctx.world.entities.get(msg.target as EntityId)
+ if (!entity?.prop || entity.prop.motion !== 'frozen') {
+ ctx.net.send(session, { t: 'result', action: 'physgun', ok: false, error: 'no_target' })
+ return
+ }
+ if (!ctx.world.zones.rulesAt(entity.transform.pos).physgun) {
+ ctx.net.send(session, { t: 'result', action: 'physgun', ok: false, error: 'zone' })
+ return
+ }
+ if (!this.canManipulate(session, entity)) {
+ ctx.net.send(session, { t: 'result', action: 'physgun', ok: false, error: 'not_owner' })
+ return
+ }
+ eyePosition(session, _eyeScratch)
+ if (
+ Math.hypot(
+ entity.transform.pos.x - _eyeScratch.x,
+ entity.transform.pos.y - _eyeScratch.y,
+ entity.transform.pos.z - _eyeScratch.z,
+ ) > 10
+ ) {
+ ctx.net.send(session, { t: 'result', action: 'physgun', ok: false, error: 'out_of_range' })
+ return
+ }
+ ctx.world.setPropMotion(entity, 'dynamic')
+ ctx.net.broadcastToKnowing(entity.id, { t: 'entity', id: entity.id, motion: 'dynamic' })
+ }
+ }
+
+ /** One grab attempt down the view ray; latches + broadcasts on success. */
+ private attemptGrab(session: PlayerSession): string | null {
+ const grabbed = tryGrab(session, this.ctx.world, this.heldEntityIds, (e) =>
+ this.canManipulate(session, e),
+ )
+ if (typeof grabbed === 'string') return grabbed
+ this.heldEntityIds.add(grabbed.id)
+ // Grabbing a frozen prop unfreezes it — tell clients about the motion
+ // change (physics resumes; frozen visuals must clear).
+ this.ctx.net.broadcastToKnowing(grabbed.id, { t: 'entity', id: grabbed.id, motion: 'dynamic' })
+ const grab = session.held?.localOffset
+ this.ctx.net.broadcastAll({
+ t: 'physgun_state',
+ player: session.entityId,
+ target: grabbed.id,
+ ...(grab ? { grab: [grab.x, grab.y, grab.z] as [number, number, number] } : {}),
+ })
+ return null
+ }
+
+ releaseHeld(session: PlayerSession): void {
+ const ctx = this.ctx
+ if (!session.held) return
+ // Wake the released body: if it was driven into a sleeping neighbor,
+ // the depenetration solver needs it active to push them apart.
+ const bodyId = ctx.world.bodyOf(session.held.entityId)
+ if (bodyId !== undefined) {
+ ctx.world.physics.wake(bodyId)
+ // Source-feel throw cap: the drive can move props at 45 m/s, but a
+ // LET-GO should toss, not rocket-launch. Clamp exit velocity.
+ ctx.world.physics.getLinearVelocity(bodyId, _relVel)
+ const speed = Math.hypot(_relVel.x, _relVel.y, _relVel.z)
+ const cap = 9
+ if (speed > cap) {
+ const k = cap / speed
+ _relVel.x *= k
+ _relVel.y *= k
+ _relVel.z *= k
+ ctx.world.physics.setLinearVelocity(bodyId, _relVel)
+ }
+ }
+ this.heldEntityIds.delete(session.held.entityId)
+ release(session)
+ ctx.net.broadcastAll({ t: 'physgun_state', player: session.entityId, target: null })
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+import { terrainHeight } from '@openvibe/content'
+import { qfromYaw, quat, vec3, type EntityId, type PlayerId } from '@openvibe/shared'
+import type { ModHost } from '../../mods/runtime.js'
+import type { ServerContext } from './context.js'
+import type { System } from './system.js'
+
+/**
+ * The mod runtime's host: the tick that reconciles installs and the capability surface it is lent.
+ */
+export class ModSystem implements System {
+ readonly name = 'mods'
+
+ /**
+ * What mods may do to the world, lent to the mod runtime only. Mod props
+ * are owned by the mod id, so prop protection keeps players' hands off.
+ */
+ readonly host: ModHost = {
+ announce: (text) => this.ctx.net.broadcastAll({ t: 'announce', text }),
+ placeProp: ({ item, pos, yaw, owner }) => {
+ const entity = this.ctx.world.spawnProp({
+ defId: item,
+ pos: vec3(
+ pos[0],
+ pos[1] + terrainHeight(this.ctx.world.content.world, pos[0], pos[2]),
+ pos[2],
+ ),
+ rot: qfromYaw(quat(), yaw),
+ motion: 'frozen',
+ owner: owner as PlayerId,
+ })
+ this.ctx.net.broadcastSpawn(entity)
+ return entity.id as string
+ },
+ removeEntity: (id) => {
+ const entityId = id as EntityId
+ if (!this.ctx.world.entities.get(entityId)) return false
+ this.ctx.world.despawn(entityId)
+ this.ctx.net.broadcastDespawn(entityId)
+ return true
+ },
+ entityExists: (id) => this.ctx.world.entities.get(id as EntityId) !== undefined,
+ }
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ tick(ctx: ServerContext, _dt: number): void {
+ // Reconcile installs (a revoked or disabled mod's effects are
+ // retracted on the first tick after the change).
+ ctx.integrations.mods?.tick(ctx.clock.tick, this.host)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -0,0 +1,155 @@
+import {
+ DEFAULT_MOVEMENT,
+ applyDamage,
+ hullHeightFor,
+ stepMovement,
+ type CollisionQueries,
+} from '@openvibe/gameplay'
+import { CollisionLayer, type BodyId } from '@openvibe/physics'
+import type { ClientEditMode, ClientInput } from '@openvibe/protocol'
+import { qfromYaw, vec3 } from '@openvibe/shared'
+import { canEditMap } from '../../net/networkAuth.js'
+import type { PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import type { HandlerMap, System } from './system.js'
+
+export const MOVE = DEFAULT_MOVEMENT
+/** Bounded input queue: the cap stops a client from buying simulation speed with backlog. */
+export const MAX_INPUT_QUEUE = 6
+
+const _bodyPosScratch = vec3()
+
+/**
+ * Authoritative movement: queued input commands become positions, the kinematic capsule follows,
+ * stance swaps rebuild the hull, and falling hurts. Vehicle driving is the one exception — it
+ * lives in the vehicles system and takes over a session's movement while it is mounted.
+ */
+export class MovementSystem implements System {
+ readonly name = 'movement'
+
+ private readonly moveQueries: CollisionQueries
+ /** Body excluded from the current movement sweep (the moving player's own). */
+ private sweepSelf: BodyId | undefined
+
+ readonly handlers: HandlerMap = {
+ input: (session, msg: ClientInput, _conn) => {
+ if (session.inputQueue.length < MAX_INPUT_QUEUE) session.inputQueue.push(msg)
+ },
+ editmode: (session, msg: ClientEditMode, _conn) => {
+ // Rank-gated noclip build mode. The flag lives in the move state so
+ // server sim and client prediction stay in lockstep via the normal
+ // self-state replication path.
+ if (canEditMap(session.rank)) {
+ session.move.noclip = msg.on
+ session.move.vel.x = 0
+ session.move.vel.y = 0
+ session.move.vel.z = 0
+ this.ctx.net.send(session, {
+ t: 'announce',
+ text: msg.on ? '🛠 Edit mode ON — noclip flight' : '🛠 Edit mode OFF',
+ })
+ }
+ },
+ }
+
+ constructor(private readonly ctx: ServerContext) {
+ // Players block players: sweeps include the Player layer, minus the
+ // mover's own kinematic body.
+ this.moveQueries = {
+ sweepCapsule: (from, to, radius, height) =>
+ ctx.world.physics.sweepCapsule(
+ from,
+ to,
+ radius,
+ height,
+ CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,
+ this.sweepSelf,
+ ),
+ }
+ }
+
+ tick(ctx: ServerContext, dt: number): void {
+ for (const session of ctx.sessions.values()) {
+ this.stepSession(ctx, session, dt)
+ }
+ }
+
+ private stepSession(ctx: ServerContext, session: PlayerSession, dt: number): void {
+ if (session.driving) {
+ ctx.systems.vehicles.driveVehicle(session)
+ return
+ }
+ this.sweepSelf = ctx.sessions.bodyOf(session.playerId)
+ // Process at most 2 queued inputs per tick (catch-up), else repeat the
+ // last input — the queue bound caps client-driven speedup.
+ const budget = session.inputQueue.length > 2 ? 2 : 1
+ let simulated = 0
+ for (let i = 0; i < budget; i++) {
+ const input = session.inputQueue.shift()
+ if (!input) break
+ session.lastInput = input
+ session.starvedTicks = 0
+ session.lastProcessedSeq = input.seq
+ session.yaw = input.yaw
+ session.pitch = input.pitch
+ session.buttons = input.buttons
+ stepMovement(session.move, input, MOVE, this.moveQueries, dt)
+ simulated++
+ }
+ if (simulated === 0 && session.lastInput) {
+ // Bridge short network jitter by repeating the last command, but only
+ // briefly — a silent client must coast to a stop, not walk forever.
+ session.starvedTicks++
+ // Zero MOVEMENT only — buttons stay held. Zeroing buttons fabricates
+ // release edges for toggle keys (prone/crouch), so any client hitch
+ // longer than 3 ticks made the server flap stances endlessly.
+ const input =
+ session.starvedTicks <= 3 ? session.lastInput : { ...session.lastInput, moveX: 0, moveZ: 0 }
+ stepMovement(session.move, input, MOVE, this.moveQueries, dt)
+ }
+ let bodyId = ctx.sessions.bodyOf(session.playerId)
+ if (bodyId !== undefined && session.bodyStance !== session.move.stance) {
+ // Stance changed: swap the kinematic hull to match the new posture.
+ ctx.world.physics.removeBody(bodyId)
+ const hull = hullHeightFor(session.move.stance)
+ bodyId = ctx.world.physics.addBody({
+ shape: {
+ type: 'capsule',
+ radius: MOVE.capsuleRadius,
+ height: Math.max(hull - 0.3, 0.4),
+ },
+ motion: 'kinematic',
+ pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),
+ layer: CollisionLayer.Player,
+ collidesWith: CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,
+ })
+ ctx.sessions.setBody(session.playerId, bodyId)
+ session.bodyStance = session.move.stance
+ }
+ if (bodyId !== undefined) {
+ _bodyPosScratch.x = session.move.pos.x
+ _bodyPosScratch.y = session.move.pos.y + 0.15
+ _bodyPosScratch.z = session.move.pos.z
+ ctx.world.physics.setTransform(bodyId, _bodyPosScratch)
+ }
+ // Fall damage: track the hardest downward velocity while airborne and
+ // cash it in on landing. ~13 m/s (≈2.5 story drop) is the free threshold.
+ if (!session.move.grounded) {
+ session.fallVy = Math.min(session.fallVy, session.move.vel.y)
+ } else if (session.fallVy < -13) {
+ const dmg = Math.round((-session.fallVy - 13) * 3.5)
+ session.fallVy = 0
+ if (applyDamage(session.stats, dmg)) {
+ ctx.log.info('fall death', { playerId: session.playerId })
+ ctx.systems.combat.respawn(session, true)
+ } else {
+ session.statsDirty = true
+ }
+ } else {
+ session.fallVy = 0
+ }
+ const entity = ctx.world.entities.get(session.entityId)
+ if (entity) qfromYaw(entity.transform.rot, session.yaw)
+ ctx.world.spatial.move(session.entityId, session.move.pos.x, session.move.pos.z)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+import type { NpcState } from '@openvibe/gameplay'
+import type { WorldEntityDto } from '@openvibe/persistence'
+import { v3dist, type EntityId } from '@openvibe/shared'
+import { NpcManager } from '../npcManager.js'
+import type { ServerContext } from './context.js'
+import type { System } from './system.js'
+
+/**
+ * The server-authoritative NPC simulation (ADR-0007 M1): an {@link NpcManager} plus the two pieces
+ * of mutable state its AI reads — the per-entity aggro deadlines (a player who recently attacked
+ * draws defensive NPCs) and the per-second sound-event buffer (gunshots, fights) for NPC hearing.
+ */
+export class NpcSystem implements System {
+ readonly name = 'npcs'
+ /** Players who recently attacked someone (defensive NPCs respond); entity id -> aggro-until ms. */
+ private readonly aggro = new Map<string, number>()
+ /** Sound events (gunshots, fights) accumulated for NPC hearing. */
+ private sounds: { x: number; z: number }[] = []
+ private readonly npcs: NpcManager
+
+ constructor(private readonly ctx: ServerContext) {
+ this.npcs = new NpcManager(
+ ctx.world,
+ ctx.regions,
+ {
+ onAttackPlayer: (npcId, playerEntityId, damage) => {
+ const victim = ctx.sessions.getByEntity(playerEntityId as EntityId)
+ const npcEntity = ctx.world.entities.get(npcId)
+ if (!victim || !npcEntity) return
+ // NPC melee has reach limits too (no cross-map slaps).
+ const d = v3dist(npcEntity.transform.pos, victim.move.pos)
+ if (d > 3) return
+ ctx.systems.combat.damagePlayer(null, victim, {
+ type: 'blunt',
+ amount: damage,
+ sourceId: npcId,
+ })
+ },
+ soundsThisTick: () => this.sounds,
+ hostilesNear: (x, z, radius, faction) => {
+ const stance = ctx.world.content.faction(faction)?.playerStance ?? 'neutral'
+ const nowMs = Date.now()
+ const out: { id: string; x: number; z: number; hostile: boolean }[] = []
+ for (const other of ctx.sessions.values()) {
+ const dx = other.move.pos.x - x
+ const dz = other.move.pos.z - z
+ if (dx * dx + dz * dz > radius * radius) continue
+ const aggro = (this.aggro.get(other.entityId as string) ?? 0) > nowMs
+ const hostile = stance === 'hostile' || aggro
+ out.push({
+ id: other.entityId as string,
+ x: other.move.pos.x,
+ z: other.move.pos.z,
+ hostile,
+ })
+ }
+ return out
+ },
+ },
+ ctx.log.child({ system: 'npc' }),
+ )
+ }
+
+ /** Boot: restore persisted NPCs, then seed content spawns not yet known. */
+ async load(): Promise<void> {
+ await this.npcs.seedOrRestore(this.ctx.store)
+ }
+
+ /** One NPC simulation step; the sound buffer is cleared once a second. */
+ tick(ctx: ServerContext, _dt: number): void {
+ this.npcs.step(Date.now(), ctx.clock.tick, ctx.config.tickRate)
+ if (ctx.clock.tick % ctx.config.tickRate === 0) this.sounds = []
+ }
+
+ /** Damage an NPC (melee/ranged already mitigated upstream). */
+ damage(entityId: EntityId, amount: number, nowMs: number): 'dead' | 'hurt' | null {
+ return this.npcs.damage(entityId, amount, nowMs)
+ }
+
+ npcStateOf(entityId: EntityId): NpcState | undefined {
+ return this.npcs.npcStateOf(entityId)
+ }
+
+ /** Counts for metrics: [full-sim, abstract]. */
+ counts(): [number, number] {
+ return this.npcs.counts()
+ }
+
+ /** A sound event (gunshot, fight) for NPC hearing this second. */
+ recordSound(x: number, z: number): void {
+ this.sounds.push({ x, z })
+ }
+
+ /** A player recently attacked, so defensive NPCs respond until `untilMs`. */
+ markAggro(entityId: string, untilMs: number): void {
+ this.aggro.set(entityId, untilMs)
+ }
+
+ /** Copy-on-write snapshot of the NPC rows whose abstract state changed (for the flusher). */
+ takeDirty(nowMs: number): WorldEntityDto[] {
+ return this.npcs.takeDirty(nowMs)
+ }
+
+ /** A failed flush: mark these NPCs dirty again. */
+ restoreDirty(ids: readonly string[]): void {
+ this.npcs.restoreDirty(ids)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
+import type { PlayerDto } from '@openvibe/persistence'
+import { Flusher, type FlushCharacter, type FlushPayload } from '../flusher.js'
+import type { PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import { eventPlayer, sessionProgress } from './platform.js'
+import type { System } from './system.js'
+
+/**
+ * The write-behind checkpoint cadence (ADR-0007 M1): the tick builds a copy-on-write snapshot of
+ * everything dirty and hands it to the {@link Flusher}, which runs one database transaction at a
+ * time. The tick never awaits I/O.
+ */
+export class PersistenceSystem implements System {
+ readonly name = 'persistence'
+
+ private readonly flusher: Flusher
+ private lastFlushTick = 0
+
+ constructor(private readonly ctx: ServerContext) {
+ this.flusher = new Flusher(
+ ctx.store,
+ ctx.metrics,
+ ctx.log.child({ system: 'persist' }),
+ ctx.integrations.events,
+ )
+ }
+
+ /**
+ * The tick's persistence call: builds the snapshot synchronously and hands it to the write-behind
+ * flusher. It returns immediately; a checkpoint already in flight coalesces this one.
+ */
+ checkpoint(reason: 'checkpoint' | 'shutdown' = 'checkpoint'): void {
+ this.flusher.checkpoint(() => this.buildFlushPayload(reason))
+ }
+
+ /** The periodic checkpoint gate, called once per tick by GameServer.step. */
+ tick(ctx: ServerContext, _dt: number): void {
+ if (
+ ctx.clock.tick - this.lastFlushTick >=
+ ctx.config.persistFlushSeconds * ctx.config.tickRate
+ ) {
+ this.lastFlushTick = ctx.clock.tick
+ this.checkpoint()
+ }
+ }
+
+ /**
+ * Copy-on-write snapshot of everything dirty (clearing the flags as it copies). One transaction
+ * writes meta, NPC/entity upserts and deletes, constraints, characters and the outbox events
+ * describing them; on failure the snapshot's dirtiness is restored.
+ */
+ private buildFlushPayload(reason: 'checkpoint' | 'shutdown'): FlushPayload {
+ const now = Date.now()
+ const world = this.ctx.world.takeDirty()
+ const npcs = this.ctx.systems.npcs.takeDirty(now)
+ const characters: FlushCharacter[] = []
+ for (const session of this.ctx.sessions.values()) {
+ if (!session.dirty) continue
+ session.dirty = false
+ characters.push(this.flushCharacter(session))
+ }
+ // World clock + weather and the market blobs ride along with every checkpoint.
+ const meta: Record<string, unknown> = {
+ env_time: this.ctx.env.timeOfDay,
+ env_weather: this.ctx.env.weather,
+ }
+ Object.assign(meta, this.ctx.systems.economy.marketMeta())
+ if (world.entityUpserts.length > 0 || characters.length > 0) {
+ this.ctx.log.debug('persistence checkpoint', {
+ entities: world.entityUpserts.length,
+ npcs: npcs.length,
+ players: characters.length,
+ })
+ }
+ return {
+ reason,
+ leaving: false,
+ worldSaved: true,
+ meta,
+ entityUpserts: world.entityUpserts,
+ entityDeletes: world.entityDeletes,
+ npcUpserts: npcs,
+ constraintUpserts: world.constraintUpserts,
+ constraintDeletes: world.constraintDeletes,
+ characters,
+ worldSavedCount: world.entityUpserts.length,
+ restore: () => {
+ this.ctx.world.restoreDirty(world)
+ this.ctx.systems.npcs.restoreDirty(npcs.map((n) => n.id))
+ // A disconnected session was saved by its own queued save, which runs after this one.
+ for (const c of characters) {
+ if (this.ctx.sessions.get(c.session.playerId) !== undefined) c.session.dirty = true
+ }
+ },
+ }
+ }
+
+ private flushCharacter(session: PlayerSession): FlushCharacter {
+ return {
+ session,
+ dto: this.playerToDto(session),
+ player: eventPlayer(session),
+ progress: sessionProgress(session),
+ }
+ }
+
+ /** Full save on shutdown; main.ts awaits drain() after this. */
+ shutdown(): void {
+ for (const session of this.ctx.sessions.values()) session.dirty = true
+ // Never coalesced: behind a checkpoint still in flight this one is queued, not dropped.
+ this.flusher.checkpoint(() => this.buildFlushPayload('shutdown'), { final: true })
+ this.ctx.log.info('world saved on shutdown', {})
+ }
+
+ /** Resolves when the write-behind queue is empty (shutdown). */
+ async drain(timeoutMs = 25_000): Promise<boolean> {
+ return this.flusher.drain(timeoutMs)
+ }
+
+ /**
+ * Saves one character; `leaving` also records games.player.left in the same transaction. The DTO is
+ * built synchronously and the transaction is queued on the same queue as checkpoints, so a
+ * disconnect save always lands after an earlier checkpoint of that character. The caller does not
+ * await it.
+ */
+ savePlayer(session: PlayerSession, leaving = false): void {
+ session.dirty = false
+ this.flusher.savePlayer(
+ {
+ reason: 'checkpoint',
+ leaving,
+ worldSaved: false,
+ meta: {},
+ entityUpserts: [],
+ entityDeletes: [],
+ npcUpserts: [],
+ constraintUpserts: [],
+ constraintDeletes: [],
+ characters: [this.flushCharacter(session)],
+ worldSavedCount: 0,
+ restore: () => {
+ if (this.ctx.sessions.get(session.playerId) !== undefined) session.dirty = true
+ },
+ },
+ () => this.ctx.sessions.get(session.playerId) !== undefined,
+ )
+ }
+
+ /**
+ * A session left the world; it has already been removed from the registry, so the save's `returned`
+ * check sees it as gone. The session system calls this through `ctx.lifecycle.left(session)`.
+ */
+ onLeave(session: PlayerSession): void {
+ this.savePlayer(session, true)
+ }
+
+ private playerToDto(session: PlayerSession): PlayerDto {
+ const { pos } = session.move
+ return {
+ id: session.playerId as string,
+ token: session.token,
+ ...(session.subjectId ? { subjectId: session.subjectId } : {}),
+ charSlot: session.charSlot,
+ name: session.name,
+ pos: [pos.x, pos.y, pos.z],
+ yaw: session.yaw,
+ inventory: session.inventory.toDto(),
+ skills: session.skills.toDto(),
+ friends: [...session.friends],
+ appearance: session.appearance,
+ stats: session.stats,
+ armor: session.armor,
+ reputation: session.reputation,
+ unlocks: [...session.unlocks],
+ activeJob: session.activeJob,
+ updatedAt: Date.now(),
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+import type { ServerContext } from './context.js'
+import type { System } from './system.js'
+
+/**
+ * The fixed-step physics: advance the world, sync awake props and announce settles so clients pin
+ * final transforms. The measured step duration is exposed for the tick metrics.
+ */
+export class PhysicsSystem implements System {
+ readonly name = 'physics'
+ /** Duration of the last physics step, for `metrics.recordTick`. */
+ physicsMs = 0
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ tick(ctx: ServerContext, dt: number): void {
+ const physStart = performance.now()
+ ctx.world.physics.step(dt)
+ this.physicsMs = performance.now() - physStart
+
+ const { awake, settledCount } = ctx.world.syncFromPhysics({
+ onSettle: (entity) => {
+ ctx.net.broadcastToKnowing(entity.id, {
+ t: 'entity',
+ id: entity.id,
+ pos: [entity.transform.pos.x, entity.transform.pos.y, entity.transform.pos.z],
+ rot: [
+ entity.transform.rot.x,
+ entity.transform.rot.y,
+ entity.transform.rot.z,
+ entity.transform.rot.w,
+ ],
+ })
+ },
+ })
+ ctx.metrics.awakeBodies = awake
+ ctx.metrics.settledBodies = settledCount
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+import type { SkillSet } from '@openvibe/gameplay'
+import type { EventPlayer, ProgressSnapshot } from '../../platform/gameEvents.js'
+import type { PlayerSession } from '../playerSession.js'
+
+/**
+ * Pure adapters between a session and the platform event/progress payloads. Both the session
+ * lifecycle (join) and the persistence flusher (saves, leaves) need them, so they live here rather
+ * than inside either system.
+ */
+
+/** A session as the platform event recorder sees it. */
+export function eventPlayer(session: PlayerSession): EventPlayer {
+ return {
+ playerId: session.playerId as string,
+ slot: session.charSlot,
+ name: session.name,
+ subjectId: session.subjectId,
+ }
+}
+
+export function progressOf(skills: SkillSet, unlocks: Iterable<string>): ProgressSnapshot {
+ const levels: Record<string, number> = {}
+ for (const skill of skills.all()) levels[skill.id] = skill.level
+ return { levels, unlocks: [...unlocks] }
+}
+
+export function sessionProgress(session: PlayerSession): ProgressSnapshot {
+ return progressOf(session.skills, session.unlocks)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,228 @@
@@ -0,0 +1,228 @@
+import { recipeSkill, recipeXp } from '@openvibe/content'
+import {
+ ambientTemperature,
+ completeJob,
+ type GameEntity,
+ plantProgress,
+ plantStage,
+ precipitation,
+ tryStartJob,
+ updatePlant,
+ waterPlant,
+} from '@openvibe/gameplay'
+import type { ClientCraft } from '@openvibe/protocol'
+import { handleCraft } from '../interactions.js'
+import type { ServerContext } from './context.js'
+import type { HandlerMap, System } from './system.js'
+
+export class ProductionSystem implements System {
+ readonly name = 'production'
+
+ readonly handlers: HandlerMap = {
+ craft: (session, msg: ClientCraft, _conn) => {
+ const outcome = handleCraft(
+ session,
+ this.ctx.world,
+ msg,
+ this.ctx.clock.tick,
+ this.ctx.config.tickRate,
+ )
+ this.ctx.net.send(session, outcome)
+ if (outcome.ok) {
+ this.ctx.net.sendInventory(session)
+ this.ctx.net.sendCraftState(session)
+ }
+ },
+ }
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ /**
+ * Utility + production sweep, once a second over prop entities. Plants
+ * stay timestamp-lazy (a slower cadence touches them); machines and
+ * generators are few, and their jobs are timestamp-based too — this
+ * sweep only notices completions/starts, it never simulates.
+ */
+ tickProduction(): void {
+ const nowMs = Date.now()
+ const raining = precipitation(this.ctx.env) > 0
+ const ambientC = ambientTemperature(this.ctx.env)
+ const content = this.ctx.world.content
+ const maxStackOf = (defId: string) => content.item(defId)?.maxStack ?? 1
+ // Pass 1: generators burn fuel; remember active ones for power checks.
+ const activePower: { x: number; z: number; radius: number }[] = []
+ for (const entity of this.ctx.world.entities.ofKind('prop')) {
+ if (!entity.prop) continue
+ const def = content.item(entity.prop.defId)
+ if (!def?.powerProducer) continue
+ if ((entity.prop.burnUntil ?? 0) <= nowMs && entity.prop.container) {
+ // Reload: burn the first fuel item in the hopper.
+ for (let i = 0; i < entity.prop.container.length; i++) {
+ const slot = entity.prop.container[i]
+ const fuel = slot ? content.item(slot.defId)?.fuel : undefined
+ if (!slot || !fuel) continue
+ slot.count -= 1
+ if (slot.count <= 0) entity.prop.container[i] = null
+ entity.prop.burnUntil =
+ Math.max(nowMs, entity.prop.burnUntil ?? 0) + fuel.burnSeconds * 1000
+ entity.dirty = true
+ for (const other of this.ctx.sessions.values()) {
+ if (other.openContainer === entity.id) this.ctx.net.sendContainer(other, entity)
+ }
+ break
+ }
+ }
+ if ((entity.prop.burnUntil ?? 0) > nowMs) {
+ activePower.push({
+ x: entity.transform.pos.x,
+ z: entity.transform.pos.z,
+ radius: def.powerProducer.radius,
+ })
+ }
+ }
+ const powered = (x: number, z: number): boolean =>
+ activePower.some((p) => Math.hypot(p.x - x, p.z - z) <= p.radius)
+
+ // Pass 2: machines complete/start jobs (timestamp-based).
+ for (const entity of this.ctx.world.entities.ofKind('prop')) {
+ if (!entity.prop?.container) continue
+ const def = content.item(entity.prop.defId)
+ const machine = def?.machine
+ if (!machine) continue
+ entity.prop.machine ??= {}
+ const shape = { inputSlots: machine.inputSlots, outputSlots: machine.outputSlots }
+ let changed = false
+ if (
+ completeJob(
+ entity.prop.machine,
+ entity.prop.container,
+ shape,
+ (id) => content.recipe(id),
+ maxStackOf,
+ nowMs,
+ )
+ ) {
+ changed = true
+ }
+ const hasPower =
+ !machine.needsPower || powered(entity.transform.pos.x, entity.transform.pos.z)
+ if (
+ hasPower &&
+ tryStartJob(
+ entity.prop.machine,
+ entity.prop.container,
+ shape,
+ content.machineRecipes(machine.kind),
+ nowMs,
+ )
+ ) {
+ changed = true
+ }
+ if (changed) {
+ entity.dirty = true
+ for (const other of this.ctx.sessions.values()) {
+ if (other.openContainer === entity.id) this.ctx.net.sendContainer(other, entity)
+ }
+ }
+ }
+
+ // Pass 3 (every 5s): tanks catch rain; sprinklers water nearby planters.
+ if (this.ctx.clock.tick % (this.ctx.config.tickRate * 5) === 0) {
+ const tanks: { entity: GameEntity; capacity: number }[] = []
+ for (const entity of this.ctx.world.entities.ofKind('prop')) {
+ const cap = entity.prop ? content.item(entity.prop.defId)?.waterTank : undefined
+ if (!entity.prop || !cap) continue
+ if (raining && (entity.prop.waterAmount ?? 0) < cap.capacity) {
+ entity.prop.waterAmount = Math.min(cap.capacity, (entity.prop.waterAmount ?? 0) + 2.5)
+ entity.dirty = true
+ }
+ tanks.push({ entity, capacity: cap.capacity })
+ }
+ for (const entity of this.ctx.world.entities.ofKind('prop')) {
+ const spr = entity.prop ? content.item(entity.prop.defId)?.sprinkler : undefined
+ if (!entity.prop || !spr) continue
+ const tank = tanks.find(
+ (t) =>
+ (t.entity.prop!.waterAmount ?? 0) >= 0.5 &&
+ Math.hypot(
+ t.entity.transform.pos.x - entity.transform.pos.x,
+ t.entity.transform.pos.z - entity.transform.pos.z,
+ ) <= spr.tankRange,
+ )
+ if (!tank) continue
+ // Planters via the spatial hash (no all-props walk per sprinkler).
+ this.ctx.world.spatial.forEachInRadius(
+ entity.transform.pos.x,
+ entity.transform.pos.z,
+ spr.radius,
+ (id) => {
+ const planter = this.ctx.world.entities.get(id)
+ const plant = planter?.prop?.plant
+ if (!planter || !plant) return
+ if (
+ Math.hypot(
+ planter.transform.pos.x - entity.transform.pos.x,
+ planter.transform.pos.z - entity.transform.pos.z,
+ ) > spr.radius
+ ) {
+ return
+ }
+ const crop = content.crop(plant.crop)
+ if (!crop) return
+ updatePlant(plant, crop, { nowMs, raining, ambientC })
+ if ((tank.entity.prop!.waterAmount ?? 0) >= 0.5 && waterPlant(plant)) {
+ tank.entity.prop!.waterAmount = (tank.entity.prop!.waterAmount ?? 0) - 0.5
+ tank.entity.dirty = true
+ planter.dirty = true
+ }
+ },
+ )
+ }
+ }
+
+ // Pass 4 (every 15s): lazy plant advance + growth-stage broadcasts.
+ if (this.ctx.clock.tick % (this.ctx.config.tickRate * 15) !== 0) return
+ for (const entity of this.ctx.world.entities.ofKind('prop')) {
+ const plant = entity.prop?.plant
+ if (!plant) continue
+ const crop = content.crop(plant.crop)
+ if (!crop) continue
+ const beforeStage = plantStage(plant, crop)
+ const beforeWater = plant.water
+ updatePlant(plant, crop, { nowMs, raining, ambientC })
+ entity.dirty = true
+ const stageChanged = plantStage(plant, crop) !== beforeStage
+ const driedOut = beforeWater > 0 && plant.water <= 0
+ if (stageChanged || driedOut) {
+ this.ctx.net.broadcastToKnowing(entity.id, {
+ t: 'entity',
+ id: entity.id,
+ plant: { crop: plant.crop, t: plantProgress(plant, crop), water: plant.water },
+ })
+ }
+ }
+ }
+
+ tickCraftQueues(): void {
+ // 5. Crafting queues (completions grant crafting/construction XP).
+ for (const session of this.ctx.sessions.values()) {
+ const completed = session.craftQueue.update(
+ this.ctx.clock.tick,
+ this.ctx.world.content,
+ session.inventory,
+ )
+ if (completed.length > 0) {
+ session.dirty = true
+ this.ctx.net.sendInventory(session)
+ this.ctx.net.sendCraftState(session)
+ for (const recipe of completed) {
+ 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.net.sendSkills(session)
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,208 @@
@@ -0,0 +1,208 @@
+import { activeStatuses, type GameEntity } from '@openvibe/gameplay'
+import { encodeServerMessage, type ServerMessage, type ServerStats } from '@openvibe/protocol'
+import type { EntityId } from '@openvibe/shared'
+import type { ConstraintRecord } from '../gameWorld.js'
+import type { PlayerSession } from '../playerSession.js'
+import { buildSnapshot, updateInterest, wireEntityFor } from '../replication.js'
+import type { Net, ServerContext } from './context.js'
+import type { System } from './system.js'
+
+/**
+ * Replication: the one place every system sends through, plus interest management and the
+ * authoritative snapshot phase. It owns the byte/message counters and the geometry of who knows
+ * what; it holds no gameplay state of its own.
+ */
+export class ReplicationSystem implements System, Net {
+ readonly name = 'replication'
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ tick(ctx: ServerContext, _dt: number): void {
+ // Snapshots go out at `tickRate / snapshotEvery` Hz (15 with the defaults).
+ if (ctx.clock.tick % ctx.config.snapshotEvery === 0) this.replicate()
+ }
+
+ // ── The wire surface (Net) ─────────────────────────────────────────
+
+ send(session: PlayerSession, msg: ServerMessage): void {
+ this.sendRaw(session, encodeServerMessage(msg))
+ }
+
+ sendRaw(session: PlayerSession, encoded: string): void {
+ session.send(encoded)
+ this.ctx.metrics.bytesOut += encoded.length
+ this.ctx.metrics.messagesOut++
+ }
+
+ sendInventory(session: PlayerSession): void {
+ const dto = session.inventory.toDto()
+ this.send(session, {
+ t: 'inventory',
+ inv: {
+ size: dto.size,
+ hotbar: dto.hotbar,
+ slots: dto.slots.map(({ i, stack }) => ({
+ i,
+ stack: {
+ def: stack.defId,
+ count: stack.count,
+ ...(stack.meta !== undefined ? { meta: stack.meta } : {}),
+ },
+ })),
+ },
+ activeHotbar: session.activeHotbar,
+ armor: session.armor
+ ? {
+ def: session.armor.defId,
+ count: session.armor.count,
+ ...(session.armor.meta !== undefined ? { meta: session.armor.meta } : {}),
+ }
+ : null,
+ })
+ }
+
+ sendSkills(session: PlayerSession): void {
+ this.send(session, {
+ t: 'skills',
+ skills: session.skills.all(),
+ unlocks: [...session.unlocks],
+ })
+ }
+
+ sendCraftState(session: PlayerSession): void {
+ this.send(session, {
+ t: 'craft_state',
+ jobs: session.craftQueue.pending.map((j) => ({ recipe: j.recipeId, readyTick: j.readyTick })),
+ })
+ }
+
+ sendContainer(session: PlayerSession, entity: GameEntity): void {
+ const box = entity.prop?.container ?? []
+ this.send(session, {
+ t: 'container',
+ id: entity.id,
+ size: box.length,
+ slots: box.flatMap((slot, i) => (slot ? [{ i, def: slot.defId, count: slot.count }] : [])),
+ })
+ }
+
+ timeWire(): ServerMessage {
+ return { t: 'time', frac: this.ctx.env.timeOfDay, weather: this.ctx.env.weather }
+ }
+
+ statsWire(session: PlayerSession): Omit<ServerStats, 't' | 'died'> {
+ return {
+ hp: Math.round(session.stats.health),
+ hunger: Math.round(session.stats.hunger),
+ thirst: Math.round(session.stats.thirst),
+ stamina: Math.round(session.stats.stamina),
+ temp: Math.round(session.stats.bodyTemp * 10) / 10,
+ statuses: activeStatuses(session.stats, Date.now()) as string[],
+ }
+ }
+
+ broadcastAll(msg: ServerMessage): void {
+ const encoded = encodeServerMessage(msg)
+ for (const session of this.ctx.sessions.values()) this.sendRaw(session, encoded)
+ }
+
+ broadcastSpawn(entity: GameEntity): void {
+ // Deliver immediately to sessions in range; interest diff would send it
+ // next snapshot anyway, but placement feedback should be instant.
+ const wire = wireEntityFor(this.ctx.world, entity)
+ const encoded = encodeServerMessage({ t: 'spawn', entities: [wire] })
+ const radiusSq = this.ctx.config.interestRadius ** 2
+ for (const session of this.ctx.sessions.values()) {
+ const d2 =
+ (entity.transform.pos.x - session.move.pos.x) ** 2 +
+ (entity.transform.pos.y - session.move.pos.y) ** 2 +
+ (entity.transform.pos.z - session.move.pos.z) ** 2
+ if (d2 <= radiusSq) {
+ session.known.add(entity.id)
+ this.sendRaw(session, encoded)
+ }
+ }
+ }
+
+ /** Constraint create/remove: tell every client that knows either prop. */
+ broadcastConstraintState(rec: ConstraintRecord, active: boolean): void {
+ const msg = constraintStateWire(rec, active)
+ const encoded = encodeServerMessage(msg)
+ for (const session of this.ctx.sessions.values()) {
+ if (session.known.has(rec.a) || session.known.has(rec.b)) {
+ this.sendRaw(session, encoded)
+ }
+ }
+ }
+
+ broadcastDespawn(id: string): void {
+ for (const session of this.ctx.sessions.values()) {
+ if (session.known.delete(id as EntityId)) {
+ this.send(session, { t: 'despawn', ids: [id] })
+ }
+ }
+ }
+
+ broadcastToKnowing(id: EntityId, msg: ServerMessage): void {
+ const encoded = encodeServerMessage(msg)
+ for (const session of this.ctx.sessions.values()) {
+ if (session.known.has(id)) this.sendRaw(session, encoded)
+ }
+ }
+
+ // ── Interest + snapshots ───────────────────────────────────────────
+
+ replicate(): void {
+ const ctx = this.ctx
+ for (const session of ctx.sessions.values()) {
+ const diff = updateInterest(session, ctx.world, ctx.config.interestRadius)
+ if (diff.entered.length > 0) {
+ this.send(session, {
+ t: 'spawn',
+ entities: diff.entered.map((e) => {
+ const wire = wireEntityFor(ctx.world, e)
+ const owner = ctx.sessions.getByEntity(e.id)
+ if (owner) {
+ wire.name = owner.name
+ wire.player = owner.playerId as string
+ wire.appearance = owner.appearance
+ }
+ return wire
+ }),
+ })
+ }
+ if (diff.left.length > 0) {
+ this.send(session, { t: 'despawn', ids: diff.left as string[] })
+ }
+ // Constraints touching newly-known props (visuals need endpoints).
+ if (diff.entered.length > 0) {
+ const sent = new Set<string>()
+ for (const entity of diff.entered) {
+ for (const rec of ctx.world.constraintsFor(entity.id)) {
+ if (sent.has(rec.id)) continue
+ sent.add(rec.id)
+ this.send(session, constraintStateWire(rec, true))
+ }
+ }
+ }
+ const snapshot = buildSnapshot(session, ctx.world, ctx.sessions.values(), ctx.clock.tick)
+ const encoded = encodeServerMessage(snapshot)
+ ctx.metrics.snapshotBytes = encoded.length
+ this.sendRaw(session, encoded)
+ }
+ }
+}
+
+export function constraintStateWire(rec: ConstraintRecord, active: boolean): ServerMessage {
+ return {
+ t: 'constraint_state',
+ id: rec.id,
+ kind: rec.type,
+ a: rec.a as string,
+ b: rec.b as string,
+ anchorA: rec.params.anchorA ?? [0, 0, 0],
+ anchorB: rec.params.anchorB ?? [0, 0, 0],
+ ...(rec.params.length !== undefined ? { length: rec.params.length } : {}),
+ active,
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,300 @@
@@ -0,0 +1,300 @@
+import { STARTER_ITEMS, worldSpawn } from '@openvibe/content'
+import { Inventory, SkillSet, type GameEntity } from '@openvibe/gameplay'
+import { CollisionLayer } from '@openvibe/physics'
+import { PROTOCOL_VERSION, encodeServerMessage, type ClientHello } from '@openvibe/protocol'
+import { asPlayerId, newEntityId, newPlayerId, qfromYaw, quat, vec3 } from '@openvibe/shared'
+import { resolveNetworkUser } from '../../net/networkAuth.js'
+import { isGuestToken } from '../../platform/accounts.js'
+import type { GameConnection, ServerContext } from './context.js'
+import { MOVE } from './movement.js'
+import { eventPlayer, progressOf } from './platform.js'
+import { createSession, HOTBAR_SIZE, INVENTORY_SIZE } from '../playerSession.js'
+import type { HandlerMap, System } from './system.js'
+
+/** A JWT's iat (seconds), read from a token the Network has already accepted; null otherwise. */
+function tokenIat(jwt: string): number | null {
+ const part = jwt.split('.')[1]
+ if (!part) return null
+ try {
+ const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as { iat?: unknown }
+ return typeof claims.iat === 'number' ? claims.iat : null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Connect, hello, disconnect, supersede and revocation close: everything about a session's
+ * lifetime in the world. It owns no gameplay state — the session record itself is the state, and
+ * the registry in the context is how every other system reaches it.
+ */
+export class SessionsSystem implements System {
+ readonly name = 'sessions'
+
+ /** Connections whose hello is still resolving (a second one while it is refused). */
+ private readonly hellosInFlight = new Set<GameConnection>()
+
+ readonly handlers: HandlerMap
+
+ constructor(private readonly ctx: ServerContext) {
+ this.handlers = {
+ hello: (session, msg, conn) => {
+ // A session already exists: a duplicate hello is silently ignored.
+ if (session) return
+ // A second hello while the first is still resolving (it awaits Network) is refused, so two
+ // frames cannot both create a session for one connection.
+ if (this.hellosInFlight.has(conn)) {
+ conn.close(4008, 'hello_in_flight')
+ return
+ }
+ void this.handleHello(conn, msg as ClientHello)
+ },
+ }
+ }
+
+ /** Players connected now (the readiness `sessions` check; a restart would disconnect them). */
+ onlineCount(): number {
+ return this.ctx.sessions.size
+ }
+
+ onDisconnect(conn: GameConnection): void {
+ const session = this.ctx.sessions.getByConn(conn)
+ if (!session) return
+ this.ctx.sessions.remove(conn)
+ // Systems that hold per-session state release it: the offline friend cache and the physgun
+ // latch (manipulation), and the disconnect save (persistence, which records player.left).
+ this.ctx.lifecycle.left(session)
+ const bodyId = this.ctx.sessions.bodyOf(session.playerId)
+ if (bodyId !== undefined) {
+ this.ctx.world.physics.removeBody(bodyId)
+ this.ctx.sessions.deleteBody(session.playerId)
+ }
+ this.ctx.world.entities.remove(session.entityId)
+ this.ctx.world.spatial.remove(session.entityId)
+ void this.ctx.integrations.progressSummary?.playerLeft(
+ session.playerId as string,
+ session.subjectId,
+ progressOf(session.skills, session.unlocks).levels,
+ this.ctx.world.content.world.id,
+ )
+ this.ctx.net.broadcastDespawn(session.entityId)
+ this.ctx.metrics.sessions = this.ctx.sessions.size
+ this.ctx.log.info('player disconnected', { playerId: session.playerId, name: session.name })
+ }
+
+ /** Guards one hello per connection while it resolves (it awaits Network); see the handler. */
+ private async handleHello(conn: GameConnection, msg: ClientHello): Promise<void> {
+ this.hellosInFlight.add(conn)
+ try {
+ await this.resolveHello(conn, msg)
+ } finally {
+ this.hellosInFlight.delete(conn)
+ }
+ }
+
+ private async resolveHello(conn: GameConnection, msg: ClientHello): Promise<void> {
+ const ctx = this.ctx
+ if (msg.v !== PROTOCOL_VERSION) {
+ conn.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))
+ conn.close(4002, 'protocol_mismatch')
+ return
+ }
+ if (ctx.sessions.size >= ctx.config.maxPlayers) {
+ conn.send(encodeServerMessage({ t: 'reject', reason: 'server_full' }))
+ conn.close(4003, 'server_full')
+ return
+ }
+
+ const slot = msg.slot ?? 0
+ // Account resolution. openvibe.network sign-in keys the account on the
+ // canonical subject (3 slots, follows you across devices). Guests get ONE
+ // character bound to their connection's browser token.
+ let token = msg.token
+ let subjectId: string | null = null
+ let authIat: number | null = null
+ let rank: 'owner' | 'admin' | 'moderator' | null = null
+ if (msg.auth) {
+ const user = await resolveNetworkUser(ctx.config.networkAuthUrl, msg.auth)
+ // A signed-in account is keyed by its canonical subject (ADR-0006); a
+ // token that carries none cannot name an account.
+ if (!user || !user.subjectId) {
+ conn.send(encodeServerMessage({ t: 'reject', reason: 'auth_failed' }))
+ conn.close(4009, 'auth_failed')
+ return
+ }
+ token = user.subjectId
+ subjectId = user.subjectId
+ authIat = tokenIat(msg.auth)
+ rank = user.rank
+ // Guest conversion (WS-B task 8): the character this browser played as a guest joins the
+ // account in its first free slot, once.
+ if (isGuestToken(msg.token)) {
+ const g = await ctx.store.identity.adoptGuestCharacter(msg.token, subjectId, Date.now())
+ if (g.moved > 0)
+ ctx.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })
+ else if (g.full)
+ ctx.log.info('guest character kept: account slots full', { subject: subjectId })
+ }
+ } else {
+ // A guest token may never look like an account key (`usr_…`): that
+ // would open someone else's characters. The client mints plain
+ // alphanumerics; anything key-shaped is refused.
+ if (!isGuestToken(msg.token)) {
+ conn.send(encodeServerMessage({ t: 'reject', reason: 'invalid_hello' }))
+ conn.close(4007, 'invalid_guest_token')
+ return
+ }
+ if (slot > 0) {
+ conn.send(encodeServerMessage({ t: 'reject', reason: 'guest_one_character' }))
+ conn.close(4010, 'guest_one_character')
+ return
+ }
+ }
+ // A disconnect save of this character may still be queued: read after it lands, not before.
+ await ctx.systems.persistence.drain(5000)
+ const existing = await ctx.store.players.findByTokenSlot(token, slot)
+ // One live session per CHARACTER; other characters of the same account
+ // may stay online (an account still only plays one at a time in
+ // practice — same token kicks apply per slot).
+ for (const s of ctx.sessions.values()) {
+ if (s.token === token && s.charSlot === slot) {
+ s.closeConnection(4004, 'session_superseded')
+ }
+ }
+
+ const world = ctx.world.content.world
+ const playerId = existing ? asPlayerId(existing.id) : newPlayerId()
+ const mapSpawn = worldSpawn(world)
+ const spawn = existing
+ ? vec3(existing.pos[0], existing.pos[1], existing.pos[2])
+ : vec3(mapSpawn.pos[0], mapSpawn.pos[1], mapSpawn.pos[2])
+ const inventory = existing
+ ? Inventory.fromDto(existing.inventory, ctx.world.content)
+ : new Inventory(INVENTORY_SIZE, HOTBAR_SIZE, ctx.world.content)
+ const skills = existing
+ ? SkillSet.fromDto(existing.skills, ctx.world.content)
+ : new SkillSet(ctx.world.content)
+ const friends = new Set(existing?.friends ?? [])
+ // The client's customization is authoritative for looks (validated by
+ // the protocol schema) — EXCEPT body size: everyone shares one hull and
+ // silhouette so combat stays fair.
+ const appearance = { ...msg.appearance, height: 1, build: 1 }
+
+ // Starter kit: every scrapper carries a physgun. Also grants it to
+ // players from before the tool system existed.
+ for (const grant of STARTER_ITEMS) {
+ if (inventory.countOf(grant.item) === 0) {
+ const leftover = inventory.add(grant.item, grant.count)
+ if (leftover > 0) {
+ ctx.log.warn('starter item did not fit', { item: grant.item, playerId })
+ }
+ }
+ }
+
+ const session = createSession({
+ playerId,
+ charSlot: slot,
+ entityId: newEntityId(),
+ token,
+ subjectId,
+ authIat,
+ rank,
+ name: msg.name,
+ spawn,
+ yaw: existing?.yaw ?? mapSpawn.yaw,
+ inventory,
+ skills,
+ friends,
+ appearance,
+ stats: existing?.stats ?? undefined,
+ armor: existing?.armor ?? null,
+ reputation: existing?.reputation ?? {},
+ unlocks: existing?.unlocks ?? [],
+ activeJob: existing?.activeJob ?? null,
+ content: ctx.world.content,
+ send: (text) => conn.send(text),
+ closeConnection: (code, reason) => conn.close(code, reason),
+ })
+ ctx.sessions.add(conn, session)
+ ctx.lifecycle.joined(session)
+
+ // Player entity (transient — players persist via the player repository).
+ const entity: GameEntity = {
+ id: session.entityId,
+ kind: 'player',
+ transform: { pos: session.move.pos, rot: qfromYaw(quat(), session.yaw) },
+ persistent: false,
+ dirty: false,
+ }
+ ctx.world.entities.add(entity)
+ ctx.world.spatial.insert(entity.id, session.move.pos.x, session.move.pos.z)
+
+ // Kinematic capsule so props collide with players. Shorter than the
+ // movement hull and lifted off the feet: standing ON a prop must not
+ // press it down (that caused sink/jitter loops when prop-surfing).
+ const bodyId = ctx.world.physics.addBody({
+ shape: { type: 'capsule', radius: MOVE.capsuleRadius, height: MOVE.capsuleHeight - 0.3 },
+ motion: 'kinematic',
+ pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),
+ layer: CollisionLayer.Player,
+ collidesWith: CollisionLayer.Static | CollisionLayer.Prop | CollisionLayer.Player,
+ })
+ ctx.sessions.setBody(playerId, bodyId)
+
+ ctx.net.send(session, {
+ t: 'welcome',
+ v: PROTOCOL_VERSION,
+ rank,
+ playerId: playerId as string,
+ entityId: session.entityId as string,
+ tick: ctx.clock.tick,
+ tickRate: ctx.config.tickRate,
+ snapshotRate: ctx.config.tickRate / ctx.config.snapshotEvery,
+ })
+ ctx.net.sendInventory(session)
+ ctx.net.sendSkills(session)
+ await ctx.systems.social.sendFriends(session)
+ ctx.net.send(session, ctx.net.timeWire())
+ ctx.systems.economy.sendReputation(session)
+ ctx.metrics.sessions = ctx.sessions.size
+ ctx.integrations.progressSummary?.playerJoined(session.playerId as string)
+ const recorder = ctx.integrations.events
+ if (recorder) {
+ // Baseline = what the database holds for this character (nothing for a
+ // new one), so the first save reports only what was earned since.
+ const stored = existing
+ ? progressOf(SkillSet.fromDto(existing.skills, ctx.world.content), existing.unlocks)
+ : { levels: {}, unlocks: [] }
+ await ctx.store.transaction(async (t) => {
+ await recorder.recordJoin(t, eventPlayer(session), stored, existing !== null)
+ })
+ }
+ ctx.log.info('player connected', {
+ playerId: playerId as string,
+ name: msg.name,
+ restored: existing !== null,
+ })
+ }
+
+ /**
+ * Network moved this person's token cutoff (sign out everywhere, password changed, banned): close
+ * every session they opened with an older Network sign-in (4011 signed_out). The client's reconnect
+ * then fails its /api/auth/me check. Returns how many closed.
+ */
+ revokeSubject(subjectId: string, validAfterMs: number): number {
+ if (!subjectId || !Number.isFinite(validAfterMs)) return 0
+ let closed = 0
+ for (const session of this.ctx.sessions.all()) {
+ if (session.subjectId !== subjectId) continue
+ if (session.authIat !== null && session.authIat * 1000 >= validAfterMs) continue
+ try {
+ session.send(encodeServerMessage({ t: 'reject', reason: 'signed_out' }))
+ } catch {
+ // closing anyway
+ }
+ session.closeConnection(4011, 'signed_out')
+ closed++
+ }
+ return closed
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+import type { ClientTrust } from '@openvibe/protocol'
+import type { PlayerId } from '@openvibe/shared'
+import type { PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import type { HandlerMap, System } from './system.js'
+
+/**
+ * The player's trust list (who may manipulate their props) and the server-wide
+ * announcements. There is no chat message in this protocol.
+ */
+export class SocialSystem implements System {
+ readonly name = 'social'
+ readonly handlers: HandlerMap = {
+ trust: (session, msg: ClientTrust, _conn) => {
+ void this.handleTrust(session, msg.player, msg.trusted)
+ },
+ }
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ async sendFriends(session: PlayerSession): Promise<void> {
+ const friends: { id: string; name: string }[] = []
+ for (const id of session.friends) {
+ const online = this.ctx.sessions.get(id as PlayerId)
+ const name = online?.name ?? (await this.ctx.store.players.findById(id))?.name ?? 'unknown'
+ friends.push({ id, name })
+ }
+ this.ctx.net.send(session, { t: 'friends', friends })
+ }
+
+ private async handleTrust(
+ session: PlayerSession,
+ targetId: string,
+ trusted: boolean,
+ ): Promise<void> {
+ if (targetId === (session.playerId as string)) {
+ this.ctx.net.send(session, { t: 'result', action: 'trust', ok: false, error: 'self' })
+ return
+ }
+ // Target must be a real player (online or persisted).
+ const online = this.ctx.sessions.get(targetId as PlayerId)
+ const known = online ?? (await this.ctx.store.players.findById(targetId))
+ if (!known) {
+ this.ctx.net.send(session, {
+ t: 'result',
+ action: 'trust',
+ ok: false,
+ error: 'unknown_player',
+ })
+ return
+ }
+ if (trusted) session.friends.add(targetId)
+ else session.friends.delete(targetId)
+ session.dirty = true
+ this.ctx.net.send(session, { t: 'result', action: 'trust', ok: true })
+ await this.sendFriends(session)
+ }
+
+ /**
+ * Graceful stop (WS-P lifecycle): everyone in the world is told the server is restarting. main.ts
+ * then closes every socket with 1012 (service restart); each close saves that character and records
+ * games.player.left, as when a player leaves, and the client reconnects on its own.
+ */
+ announceRestart(): void {
+ this.ctx.net.broadcastAll({
+ t: 'announce',
+ text: '🔄 The server is restarting: you will reconnect in a moment…',
+ })
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+import type { ClientMessage } from '@openvibe/protocol'
+import type { PlayerSession } from '../playerSession.js'
+import type { GameConnection, ServerContext } from './context.js'
+
+/**
+ * A message handler for ONE client message type. The handler table is keyed by `msg.t`, so a
+ * handler registered under `physgun` only ever sees `ClientPhysgun`; it declares that type and the
+ * table stores it as {@link MessageHandler}`<never>`. A function taking `never` accepts every
+ * narrower parameter type, which is exactly the guarantee the key gives us.
+ *
+ * `hello` is the one message a connection sends before it has a session, so its handler declares
+ * `PlayerSession | undefined` — still assignable here, because the parameter widens.
+ */
+export type MessageHandler<M extends ClientMessage = ClientMessage> = (
+ session: PlayerSession,
+ msg: M,
+ conn: GameConnection,
+) => void
+
+/** What a system registers: message type -> handler. */
+export type HandlerMap = Readonly<Record<string, MessageHandler<never>>>
+
+/**
+ * One gameplay system (ADR-0007 M1). Systems own their state and reach shared state, other systems
+ * and the wire through the ServerContext; no system reads another's private fields.
+ */
+export interface System {
+ /** Stable name, used in duplicate-handler errors and logging. */
+ readonly name: string
+ /** Message types this system handles; assembled into ONE table by {@link buildHandlerTable}. */
+ readonly handlers?: HandlerMap
+ /** Per-tick work, called by GameServer.step in the documented order. */
+ tick?(ctx: ServerContext, dt: number): void
+ /** A session joined the world (after it is registered, before the welcome). */
+ onJoin?(session: PlayerSession): void
+ /** A session left the world (after it is unregistered, before its entity is despawned). */
+ onLeave?(session: PlayerSession): void
+}
+
+/**
+ * The one place the message handler table is assembled (ADR-0007 M1). A duplicate message type is
+ * a startup error: two systems claiming `use` would otherwise silently shadow each other.
+ */
+export function buildHandlerTable(systems: readonly System[]): Map<string, MessageHandler<never>> {
+ const table = new Map<string, MessageHandler<never>>()
+ for (const system of systems) {
+ for (const [type, handler] of Object.entries(system.handlers ?? {})) {
+ if (table.has(type)) {
+ throw new Error(`duplicate message handler for "${type}" (${system.name})`)
+ }
+ table.set(type, handler)
+ }
+ }
+ return table
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+import type { GameEntity } from '@openvibe/gameplay'
+import { qfromYaw, qrotateVec, v3dist, vec3 } from '@openvibe/shared'
+import type { PlayerSession } from '../playerSession.js'
+import type { ServerContext } from './context.js'
+import type { System } from './system.js'
+
+const _bodyPosScratch = vec3()
+const _vehicleFwdLocal = vec3(0, 0, 1)
+const _vehicleFwd = vec3()
+const _vehicleForce = vec3()
+const _vehicleAng = vec3()
+const _vehicleVel = vec3()
+
+export class VehicleSystem implements System {
+ readonly name = 'vehicles'
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ /** E on a chassis: mount a valid assembly, or dismount if driving it. */
+ useVehicle(session: PlayerSession, chassis: GameEntity): void {
+ if (session.driving === chassis.id) {
+ this.dismount(session, chassis)
+ this.ctx.net.send(session, { t: 'result', action: 'use', ok: true })
+ return
+ }
+ if (session.driving) {
+ this.ctx.net.send(session, {
+ t: 'result',
+ action: 'use',
+ ok: false,
+ error: 'already_driving',
+ })
+ return
+ }
+ const d = v3dist(chassis.transform.pos, session.move.pos)
+ if (d > 4) {
+ this.ctx.net.send(session, { t: 'result', action: 'use', ok: false, error: 'out_of_range' })
+ return
+ }
+ if (!this.ctx.systems.manipulation.canManipulate(session, chassis)) {
+ this.ctx.net.send(session, { t: 'result', action: 'use', ok: false, error: 'not_owner' })
+ return
+ }
+ // Somebody else at the wheel?
+ for (const other of this.ctx.sessions.values()) {
+ if (other.driving === chassis.id) {
+ this.ctx.net.send(session, { t: 'result', action: 'use', ok: false, error: 'seat_taken' })
+ return
+ }
+ }
+ // A drivable assembly: >= 2 wheels rigged with axis/motor links.
+ const wheels = this.ctx.world.constraintsFor(chassis.id).filter((rec) => {
+ if (rec.type !== 'axis' && rec.type !== 'motor') return false
+ const otherId = rec.a === chassis.id ? rec.b : rec.a
+ const other = this.ctx.world.entities.get(otherId)
+ return (
+ other?.prop !== undefined &&
+ this.ctx.world.content.item(other.prop.defId)?.vehiclePart?.part === 'wheel'
+ )
+ })
+ if (wheels.length < 2) {
+ this.ctx.net.send(session, { t: 'result', action: 'use', ok: false, error: 'needs_wheels' })
+ return
+ }
+ // The wheel must turn: frozen carts wake up when mounted.
+ if (chassis.prop!.motion !== 'dynamic') this.ctx.world.setPropMotion(chassis, 'dynamic')
+ session.driving = chassis.id
+ if (session.held) this.ctx.systems.manipulation.releaseHeld(session)
+ this.ctx.net.send(session, { t: 'result', action: 'use', ok: true })
+ this.ctx.net.send(session, { t: 'announce', text: '🛻 Driving — WASD steers, E dismounts.' })
+ }
+
+ dismount(session: PlayerSession, chassis: GameEntity | undefined): void {
+ session.driving = null
+ if (chassis) {
+ // Step off beside the cart.
+ const rot = chassis.transform.rot
+ const side = qrotateVec(vec3(), rot, vec3(1.4, 0, 0))
+ session.move.pos.x = chassis.transform.pos.x + side.x
+ session.move.pos.y = chassis.transform.pos.y + 0.8
+ session.move.pos.z = chassis.transform.pos.z + side.z
+ session.move.vel.x = 0
+ session.move.vel.y = 0
+ session.move.vel.z = 0
+ }
+ }
+
+ /**
+ * Vehicle drive: intent (WASD) becomes thrust force + steering on the
+ * chassis body; wheels roll on their rigged bearings; fuel burns from
+ * the trunk like a generator. The rider is carried kinematically.
+ */
+ driveVehicle(session: PlayerSession): void {
+ const chassis = session.driving ? this.ctx.world.entities.get(session.driving) : undefined
+ const def = chassis?.prop ? this.ctx.world.content.item(chassis.prop.defId) : undefined
+ const part = def?.vehiclePart
+ const bodyId = chassis ? this.ctx.world.bodyOf(chassis.id) : undefined
+ if (!chassis || !part || bodyId === undefined || chassis.prop!.motion !== 'dynamic') {
+ this.dismount(session, chassis)
+ return
+ }
+ // Drain inputs (ack them; the client does not predict while driving).
+ let input = session.lastInput
+ while (session.inputQueue.length > 0) {
+ input = session.inputQueue.shift()!
+ session.lastInput = input
+ session.lastProcessedSeq = input.seq
+ session.yaw = input.yaw
+ session.pitch = input.pitch
+ session.buttons = input.buttons
+ }
+ const nowMs = Date.now()
+ // Fuel: burn items from the trunk while driving.
+ if ((chassis.prop!.burnUntil ?? 0) <= nowMs && chassis.prop!.container) {
+ let reloaded = false
+ for (let i = 0; i < chassis.prop!.container.length; i++) {
+ const slot = chassis.prop!.container[i]
+ const fuel = slot ? this.ctx.world.content.item(slot.defId)?.fuel : undefined
+ if (!slot || !fuel) continue
+ slot.count -= 1
+ if (slot.count <= 0) chassis.prop!.container[i] = null
+ chassis.prop!.burnUntil = nowMs + fuel.burnSeconds * 1000
+ chassis.dirty = true
+ reloaded = true
+ for (const other of this.ctx.sessions.values()) {
+ if (other.openContainer === chassis.id) this.ctx.net.sendContainer(other, chassis)
+ }
+ break
+ }
+ if (!reloaded && input && (input.moveZ !== 0 || input.moveX !== 0)) {
+ // Out of fuel: coast only. (A once-per-mount nag would need state;
+ // the HUD prompt shows the tank.)
+ }
+ }
+ const fueled = (chassis.prop!.burnUntil ?? 0) > nowMs
+ if (input && fueled) {
+ this.ctx.world.physics.wake(bodyId)
+ // Thrust along the chassis' flat forward.
+ qrotateVec(_vehicleFwd, chassis.transform.rot, _vehicleFwdLocal)
+ _vehicleFwd.y = 0
+ const len = Math.hypot(_vehicleFwd.x, _vehicleFwd.z) || 1
+ _vehicleFwd.x /= len
+ _vehicleFwd.z /= len
+ const power = (part.power ?? 4000) * input.moveZ
+ _vehicleForce.x = _vehicleFwd.x * power
+ _vehicleForce.y = 0
+ _vehicleForce.z = _vehicleFwd.z * power
+ if (input.moveZ !== 0) this.ctx.world.physics.applyForce(bodyId, _vehicleForce)
+ // Steering: blend yaw angular velocity toward the wheel input.
+ this.ctx.world.physics.getAngularVelocity(bodyId, _vehicleAng)
+ const steer = -input.moveX * 1.6 * (input.moveZ < 0 ? -1 : 1)
+ _vehicleAng.y = _vehicleAng.y + (steer - _vehicleAng.y) * 0.25
+ this.ctx.world.physics.setAngularVelocity(bodyId, _vehicleAng)
+ // Top speed cap.
+ this.ctx.world.physics.getLinearVelocity(bodyId, _vehicleVel)
+ const speed = Math.hypot(_vehicleVel.x, _vehicleVel.z)
+ const top = part.topSpeed ?? 9
+ if (speed > top) {
+ const k = top / speed
+ _vehicleVel.x *= k
+ _vehicleVel.z *= k
+ this.ctx.world.physics.setLinearVelocity(bodyId, _vehicleVel)
+ }
+ }
+ // Carry the rider: perch on the chassis, share its velocity.
+ session.move.pos.x = chassis.transform.pos.x
+ session.move.pos.y = chassis.transform.pos.y + 0.75
+ session.move.pos.z = chassis.transform.pos.z
+ this.ctx.world.physics.getLinearVelocity(bodyId, _vehicleVel)
+ session.move.vel.x = _vehicleVel.x
+ session.move.vel.y = _vehicleVel.y
+ session.move.vel.z = _vehicleVel.z
+ session.move.grounded = true
+ session.fallVy = 0
+ const body = this.ctx.sessions.bodyOf(session.playerId)
+ if (body !== undefined) {
+ _bodyPosScratch.x = session.move.pos.x
+ _bodyPosScratch.y = session.move.pos.y + 0.15
+ _bodyPosScratch.z = session.move.pos.z
+ this.ctx.world.physics.setTransform(body, _bodyPosScratch)
+ }
+ const entity = this.ctx.world.entities.get(session.entityId)
+ if (entity) qfromYaw(entity.transform.rot, session.yaw)
+ this.ctx.world.spatial.move(session.entityId, session.move.pos.x, session.move.pos.z)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+import { worldSpawn } from '@openvibe/content'
+import type { EntityId } from '@openvibe/shared'
+import { EventManager } from '../eventManager.js'
+import type { ServerContext } from './context.js'
+import type { System } from './system.js'
+
+/**
+ * Generic world events (supply drops, extraction): the manager's hooks are bound to the wire here,
+ * and the 1 Hz driver lives in `tick`.
+ */
+export class WorldEventSystem implements System {
+ readonly name = 'worldEvents'
+ private readonly events: EventManager
+
+ constructor(private readonly ctx: ServerContext) {
+ this.events = new EventManager(
+ ctx.world,
+ {
+ announce: (text) => ctx.net.broadcastAll({ t: 'announce', text }),
+ announceTo: (entityId, text) => {
+ const target = ctx.sessions.getByEntity(entityId as EntityId)
+ if (target) ctx.net.send(target, { t: 'announce', text })
+ },
+ playersIn: (x, z, radius) => {
+ const out: { entityId: string; alive: boolean }[] = []
+ for (const s of ctx.sessions.values()) {
+ if (Math.hypot(s.move.pos.x - x, s.move.pos.z - z) <= radius) {
+ out.push({ entityId: s.entityId as string, alive: s.stats.health > 0 })
+ }
+ }
+ return out
+ },
+ extractPlayer: (entityId) => this.extractPlayer(entityId),
+ broadcastSpawn: (entity) => ctx.net.broadcastSpawn(entity),
+ broadcastDespawn: (id) => ctx.net.broadcastDespawn(id),
+ },
+ ctx.config.eventIntervalScale,
+ ctx.log.child({ system: 'events' }),
+ )
+ }
+
+ tick(ctx: ServerContext, _dt: number): void {
+ if (ctx.clock.tick % ctx.config.tickRate !== 0) return
+ this.events.tick(Date.now(), ctx.sessions.size)
+ }
+
+ /**
+ * 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 {
+ const session = this.ctx.sessions.getByEntity(entityId as EntityId)
+ if (!session) return
+ const secured = session.inventory.secureValuables(
+ (defId) => this.ctx.world.content.item(defId)?.valuable !== undefined,
+ )
+ const spawn = worldSpawn(this.ctx.world.content.world).pos
+ session.move.pos.x = spawn[0]
+ session.move.pos.y = spawn[1]
+ session.move.pos.z = spawn[2]
+ session.move.vel.x = 0
+ session.move.vel.y = 0
+ session.move.vel.z = 0
+ session.dirty = true
+ this.ctx.net.send(session, {
+ t: 'announce',
+ text:
+ secured > 0
+ ? `🚁 Extracted! ${secured} stack${secured === 1 ? '' : 's'} of loot secured.`
+ : '🚁 Extracted safely back to Scrap City.',
+ })
+ this.ctx.net.sendInventory(session)
+ this.ctx.log.info('player extracted', { playerId: session.playerId, secured })
+ }
+}
@@ -302,6 +302,7 @@ export const ClientMessageSchema = z.union([
@@ -302,6 +302,7 @@ export const ClientMessageSchema = z.union([
])
])
export type ClientHello = z.infer<typeof ClientHelloSchema>
export type ClientHello = z.infer<typeof ClientHelloSchema>
+export type ClientEditMode = z.infer<typeof ClientEditModeSchema>
export type ClientInput = z.infer<typeof ClientInputSchema>
export type ClientInput = z.infer<typeof ClientInputSchema>
export type ClientUse = z.infer<typeof ClientUseSchema>
export type ClientUse = z.infer<typeof ClientUseSchema>
export type ClientAttack = z.infer<typeof ClientAttackSchema>
export type ClientAttack = z.infer<typeof ClientAttackSchema>