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.

OP OpenVibers committed 29 Sept 2026 · cac9644872 · 25 files, +3816 −2819


Changes

ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex c0196b2..82925b9 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -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.systems
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).
-- 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)
}
ddiff --git a/apps/server/src/game/handlerTable.test.ts b/apps/server/src/game/handlerTable.test.ts
nnew file mode 100644
iindex 0000000..c5389ed
---- /dev/null
++++ b/apps/server/src/game/handlerTable.test.ts
@@@ -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())
++ })
++})
ddiff --git a/apps/server/src/game/systems/combat.ts b/apps/server/src/game/systems/combat.ts
nnew file mode 100644
iindex 0000000..1201e3f
---- /dev/null
++++ b/apps/server/src/game/systems/combat.ts
@@@ -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
++ }
++}
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
nnew file mode 100644
iindex 0000000..2ce0157
---- /dev/null
++++ b/apps/server/src/game/systems/context.ts
@@@ -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
++ }
++}
ddiff --git a/apps/server/src/game/systems/economy.ts b/apps/server/src/game/systems/economy.ts
nnew file mode 100644
iindex 0000000..fa84fe7
---- /dev/null
++++ b/apps/server/src/game/systems/economy.ts
@@@ -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),
++ })),
++ })
++ }
++}
ddiff --git a/apps/server/src/game/systems/editor.ts b/apps/server/src/game/systems/editor.ts
nnew file mode 100644
iindex 0000000..ce7af0a
---- /dev/null
++++ b/apps/server/src/game/systems/editor.ts
@@@ -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…',
++ })
++ }
++}
ddiff --git a/apps/server/src/game/systems/environment.ts b/apps/server/src/game/systems/environment.ts
nnew file mode 100644
iindex 0000000..a25715c
---- /dev/null
++++ b/apps/server/src/game/systems/environment.ts
@@@ -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())
++ }
++ }
++}
ddiff --git a/apps/server/src/game/systems/index.ts b/apps/server/src/game/systems/index.ts
nnew file mode 100644
iindex 0000000..4300a49
---- /dev/null
++++ b/apps/server/src/game/systems/index.ts
@@@ -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'
ddiff --git a/apps/server/src/game/systems/interaction.ts b/apps/server/src/game/systems/interaction.ts
nnew file mode 100644
iindex 0000000..497b6ca
---- /dev/null
++++ b/apps/server/src/game/systems/interaction.ts
@@@ -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,
++ })
++ }
++ }
++ }
++}
ddiff --git a/apps/server/src/game/systems/inventory.ts b/apps/server/src/game/systems/inventory.ts
nnew file mode 100644
iindex 0000000..ed6b508
---- /dev/null
++++ b/apps/server/src/game/systems/inventory.ts
@@@ -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
++ }
++}
ddiff --git a/apps/server/src/game/systems/manipulation.ts b/apps/server/src/game/systems/manipulation.ts
nnew file mode 100644
iindex 0000000..7de499f
---- /dev/null
++++ b/apps/server/src/game/systems/manipulation.ts
@@@ -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 })
++ }
++}
ddiff --git a/apps/server/src/game/systems/mods.ts b/apps/server/src/game/systems/mods.ts
nnew file mode 100644
iindex 0000000..3fe787c
---- /dev/null
++++ b/apps/server/src/game/systems/mods.ts
@@@ -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)
++ }
++}
ddiff --git a/apps/server/src/game/systems/movement.ts b/apps/server/src/game/systems/movement.ts
nnew file mode 100644
iindex 0000000..65907f3
---- /dev/null
++++ b/apps/server/src/game/systems/movement.ts
@@@ -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)
++ }
++}
ddiff --git a/apps/server/src/game/systems/npcs.ts b/apps/server/src/game/systems/npcs.ts
nnew file mode 100644
iindex 0000000..2bc773e
---- /dev/null
++++ b/apps/server/src/game/systems/npcs.ts
@@@ -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)
++ }
++}
ddiff --git a/apps/server/src/game/systems/persistence.ts b/apps/server/src/game/systems/persistence.ts
nnew file mode 100644
iindex 0000000..d34abf2
---- /dev/null
++++ b/apps/server/src/game/systems/persistence.ts
@@@ -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(),
++ }
++ }
++}
ddiff --git a/apps/server/src/game/systems/physics.ts b/apps/server/src/game/systems/physics.ts
nnew file mode 100644
iindex 0000000..3a9810b
---- /dev/null
++++ b/apps/server/src/game/systems/physics.ts
@@@ -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
++ }
++}
ddiff --git a/apps/server/src/game/systems/platform.ts b/apps/server/src/game/systems/platform.ts
nnew file mode 100644
iindex 0000000..4b5702b
---- /dev/null
++++ b/apps/server/src/game/systems/platform.ts
@@@ -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)
++}
ddiff --git a/apps/server/src/game/systems/production.ts b/apps/server/src/game/systems/production.ts
nnew file mode 100644
iindex 0000000..969b840
---- /dev/null
++++ b/apps/server/src/game/systems/production.ts
@@@ -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)
++ }
++ }
++ }
++}
ddiff --git a/apps/server/src/game/systems/replication.ts b/apps/server/src/game/systems/replication.ts
nnew file mode 100644
iindex 0000000..24d2a4e
---- /dev/null
++++ b/apps/server/src/game/systems/replication.ts
@@@ -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,
++ }
++}
ddiff --git a/apps/server/src/game/systems/sessions.ts b/apps/server/src/game/systems/sessions.ts
nnew file mode 100644
iindex 0000000..7ca78e8
---- /dev/null
++++ b/apps/server/src/game/systems/sessions.ts
@@@ -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
++ }
++}
ddiff --git a/apps/server/src/game/systems/social.ts b/apps/server/src/game/systems/social.ts
nnew file mode 100644
iindex 0000000..429358a
---- /dev/null
++++ b/apps/server/src/game/systems/social.ts
@@@ -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…',
++ })
++ }
++}
ddiff --git a/apps/server/src/game/systems/system.ts b/apps/server/src/game/systems/system.ts
nnew file mode 100644
iindex 0000000..83332e1
---- /dev/null
++++ b/apps/server/src/game/systems/system.ts
@@@ -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
++}
ddiff --git a/apps/server/src/game/systems/vehicles.ts b/apps/server/src/game/systems/vehicles.ts
nnew file mode 100644
iindex 0000000..9bbc279
---- /dev/null
++++ b/apps/server/src/game/systems/vehicles.ts
@@@ -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)
++ }
++}
ddiff --git a/apps/server/src/game/systems/worldEvents.ts b/apps/server/src/game/systems/worldEvents.ts
nnew file mode 100644
iindex 0000000..a250ef1
---- /dev/null
++++ b/apps/server/src/game/systems/worldEvents.ts
@@@ -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 })
++ }
++}
ddiff --git a/packages/protocol/src/messages/client.ts b/packages/protocol/src/messages/client.ts
iindex c7b2733..bce49d8 100644
---- a/packages/protocol/src/messages/client.ts
++++ b/packages/protocol/src/messages/client.ts
@@@ -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>