openvibers/openvibe.games Commits

NPCs in the map: the editor places wardens, townsfolk, hares and Rustjaw thugs, the server follows the map's spawns at boot and on live saves, and Scrap Island gets 26 in place of 7 (#115)

OP OpenVibers committed 24 days ago · b42fd188c6 · 21 files, +373 −25

* NPCs in the map: the v2 document places them (the editor's Entity tool has wardens, townsfolk, hares and Rustjaw thugs), the server matches NPCs to the map's spawns at boot and on every live save, and Scrap Island gets 26 in place of the procedural world's 7 (thugs stood just outside the city wall)

* main.ts keeps its own formatting (telemetryRoute.test reads its telemetry line verbatim); the live map save's NPC reconcile is the only change

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f012b01..53871ef 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -27,6 +27,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
++- **NPCs in the map, and 26 on Scrap Island.** The v2 map document has `npcs` (an archetype and a ground position),
++ and the editor's Entity tool places them: City warden, Townsfolk, Dust hare and Rustjaw thug. A map with NPCs
++ replaces the content's seven spawns, which were laid out for the procedural world and stood Rustjaw thugs just
++ outside Scrap City's wall. The server matches the NPCs to the map's spawns at boot and on every live save: a spawn
++ placed, moved or deleted in the editor shows up at once, and an NPC no spawn claims goes, saved row and all. Scrap
++ Island now has:
++ - a warden at each gate and one by the spawn square;
++ - six townsfolk on the avenues and the ring road;
++ - nine hares at the farm and in the fields;
++ - six Rustjaw thugs at the far end of the scrapyard, in the deep forest and on Rust Ridge, all beyond a leash's
++ reach of a gate.
++
++ Creator worlds still run no NPCs, and a creator map with NPC spawns says so.
- **Something flies off a hit.** Wood chips off a tree or branches, grit off a rock, sparks off scrap, leaves off a
bush. Before, a swing changed nothing on screen until the server moved a count. Nothing flies toward the camera, and a
point-blank hit throws smaller pieces. Two thin-instanced meshes draw every particle.
ddiff --git a/apps/client/src/editor/catalog.ts b/apps/client/src/editor/catalog.ts
iindex d00f972..4c443e8 100644
---- a/apps/client/src/editor/catalog.ts
++++ b/apps/client/src/editor/catalog.ts
@@@ -16,12 +16,14 @@ export const newId = (prefix: string): string =>
export interface Placeable {
name: string
-- kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch'
++ kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch' | 'npc'
shape?: StaticBody['shape']
color?: string
tex?: string
decor?: string
node?: string
++ /** NPC archetype id (kind 'npc'). */
++ npc?: string
modelId?: string
}
@@@ -58,6 +60,10 @@ export const ENTITY_DEFS: Placeable[] = [
{ name: '🏪 Merchant stall (shop)', kind: 'prop', node: 'merchant_stall' },
{ name: '📦 Wooden crate (prop)', kind: 'prop', node: 'wooden_crate' },
{ name: '🛢 Metal barrel (prop)', kind: 'prop', node: 'metal_barrel' },
++ { name: '🛡 City warden (NPC)', kind: 'npc', npc: 'city_warden' },
++ { name: '🧑 Townsfolk (NPC)', kind: 'npc', npc: 'townsfolk' },
++ { name: '🐇 Dust hare (NPC)', kind: 'npc', npc: 'dust_hare' },
++ { name: '☠ Rustjaw thug (NPC, hostile)', kind: 'npc', npc: 'rustjaw_thug' },
]
export const TEXTURES = [
@@@ -74,6 +80,20 @@ export const TEXTURES = [
'wood_planks',
]
++/** Stand-ins for NPC spawns: a body-sized column in the faction's colour (hostile red). */
++export const NPC_LOOKS: Record<string, { color: string; shape: StaticBody['shape'] }> = {
++ city_warden: { color: '#3d6fb0', shape: { type: 'cylinder', radius: 0.4, height: 1.9 } },
++ townsfolk: { color: '#c9a25a', shape: { type: 'cylinder', radius: 0.35, height: 1.75 } },
++ dust_hare: { color: '#a08868', shape: { type: 'box', size: [0.35, 0.3, 0.6] } },
++ rustjaw_thug: { color: '#b0413e', shape: { type: 'cylinder', radius: 0.42, height: 1.85 } },
++}
++const NPC_FALLBACK = {
++ color: '#9a7ab0',
++ shape: { type: 'cylinder', radius: 0.4, height: 1.8 },
++} as const
++export const npcLook = (archetype: string): { color: string; shape: StaticBody['shape'] } =>
++ NPC_LOOKS[archetype] ?? NPC_FALLBACK
++
/** Visual stand-ins for resource nodes (matched loosely to the game's). */
export const NODE_LOOKS: Record<string, { color: string; shape: StaticBody['shape'] }> = {
oak_tree: { color: '#4c7a3a', shape: { type: 'cylinder', radius: 1.3, height: 5 } },
ddiff --git a/apps/client/src/editor/document/editorDocument.ts b/apps/client/src/editor/document/editorDocument.ts
iindex 1dabe99..48839f5 100644
---- a/apps/client/src/editor/document/editorDocument.ts
++++ b/apps/client/src/editor/document/editorDocument.ts
@@@ -26,6 +26,7 @@ import {
type MapTextureEntry,
type MapLightV2,
type MapNodeV2,
++ type MapNpcV2,
type MapPropV2,
type MapZoneV2,
type StaticObjectV2,
@@@ -50,6 +51,7 @@ export interface EditorObjectByKind {
static: StaticObjectV2
node: MapNodeV2
prop: MapPropV2
++ npc: MapNpcV2
light: MapLightV2
zone: MapZoneV2
spawn: SpawnObjectV2
@@@ -64,6 +66,7 @@ const ARRAY_KINDS = [
['static', 'statics'],
['node', 'nodes'],
['prop', 'props'],
++ ['npc', 'npcs'],
['light', 'lights'],
['zone', 'zones'],
] as const satisfies readonly (readonly [EditorObjectKind, keyof MapFileV2])[]
ddiff --git a/apps/client/src/editor/document/editorObject.ts b/apps/client/src/editor/document/editorObject.ts
iindex 02952d7..7953647 100644
---- a/apps/client/src/editor/document/editorObject.ts
++++ b/apps/client/src/editor/document/editorObject.ts
@@@ -61,6 +61,14 @@ export const KIND_INFO: Record<EditorObjectKind, KindInfo> = {
rotatable: true,
scalable: false,
},
++ npc: {
++ label: 'NPC',
++ group: 'Gameplay',
++ icon: '🧍',
++ transformable: true,
++ rotatable: false,
++ scalable: false,
++ },
light: {
label: 'Light',
group: 'Lights',
@@@ -103,6 +111,8 @@ export function displayName(kind: EditorObjectKind, object: EditorObject): strin
return (object as { node: string }).node
case 'prop':
return (object as { item: string }).item
++ case 'npc':
++ return (object as { npc: string }).npc
case 'light':
return `${(object as MapLightV2).type} light`
case 'spawn':
@@@ -194,6 +204,7 @@ export function transformOf(doc: EditorDocument, id: string): EditorTransform |
)
}
case 'node':
++ case 'npc':
return transformFromEuler([...(o as { pos: [number, number, number] }).pos], [0, 0, 0])
case 'prop': {
const p = o as { pos: [number, number, number]; yaw?: number }
@@@ -263,7 +274,8 @@ export function setTransform(doc: EditorDocument, id: string, t: EditorTransform
})
return
case 'node':
-- // Nodes sit ON the ground; only their footprint is authored.
++ case 'npc':
++ // Nodes and NPC homes sit ON the ground; only their footprint is authored.
doc.update(id, { pos: [pos[0], 0, pos[2]] })
return
case 'prop':
ddiff --git a/apps/client/src/editor/selection/selectionManager.ts b/apps/client/src/editor/selection/selectionManager.ts
iindex e37eebe..3a0b6ff 100644
---- a/apps/client/src/editor/selection/selectionManager.ts
++++ b/apps/client/src/editor/selection/selectionManager.ts
@@@ -12,7 +12,8 @@
*/
/** Everything the editor can select. Face selection is separate. */
--export type EditorObjectKind = 'static' | 'model' | 'terrain' | 'node' | 'prop' | 'spawn' | 'light'
++export type EditorObjectKind =
++ 'static' | 'model' | 'terrain' | 'node' | 'prop' | 'npc' | 'spawn' | 'light'
export interface SelectionSnapshot {
primaryId: string | null
ddiff --git a/apps/client/src/editor/tools/placementTool.ts b/apps/client/src/editor/tools/placementTool.ts
iindex e80adcf..c8d7884 100644
---- a/apps/client/src/editor/tools/placementTool.ts
++++ b/apps/client/src/editor/tools/placementTool.ts
@@@ -21,7 +21,7 @@ import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh.js'
import { blankHeights, type MapModelV2, type StaticObjectV2 } from '@openvibe/content'
--import { NODE_LOOKS, newId, type Placeable } from '../catalog.js'
++import { NODE_LOOKS, newId, npcLook, type Placeable } from '../catalog.js'
import { meshForShape } from '../../render/sceneSetup.js'
import type { EditorObjectKind } from '../document/editorDocument.js'
@@@ -49,6 +49,7 @@ export function placeableShape(
): StaticObjectV2['shape'] {
if (def.kind === 'static') return def.shape!
if (def.kind === 'node') return NODE_LOOKS[def.node!]?.shape ?? { type: 'sphere', radius: 0.6 }
++ if (def.kind === 'npc') return npcLook(def.npc!).shape
if (def.kind === 'model') {
const b = models.find((m) => m.id === def.modelId)?.bounds ?? [1, 1, 1]
return { type: 'box', size: [b[0]!, b[1]!, b[2]!] }
@@@ -57,7 +58,7 @@ export function placeableShape(
}
/** Kinds that always stand upright, whatever they were dropped on. */
--const UPRIGHT = new Set(['node', 'spawn', 'prop'])
++const UPRIGHT = new Set(['node', 'spawn', 'prop', 'npc'])
export class PlacementTool {
private ghost: Mesh | null = null
@@@ -99,7 +100,9 @@ export class PlacementTool {
? (def.color ?? '#8a8d90')
: def.kind === 'node'
? (NODE_LOOKS[def.node!]?.color ?? '#888888')
-- : '#e8b54a'
++ : def.kind === 'npc'
++ ? npcLook(def.npc!).color
++ : '#e8b54a'
this.ghost = meshForShape(this.ctx.scene, 'ghost', shape, color)
this.ghost.visibility = 0.5
this.ghost.isPickable = false
@@@ -220,6 +223,12 @@ export function objectForPlacement(
kind: 'prop',
object: { id: newId('pr'), item: def.node!, pos: [pose.pos.x, 1, pose.pos.z], yaw: e.y },
}
++ case 'npc':
++ // Like a node: a ground position the NPC lives and wanders around.
++ return {
++ kind: 'npc',
++ object: { id: newId('npc'), npc: def.npc!, pos: [pose.pos.x, 0, pose.pos.z] },
++ }
case 'spawn':
return {
kind: 'spawn',
ddiff --git a/apps/client/src/editor/ui/inspector.ts b/apps/client/src/editor/ui/inspector.ts
iindex 5160167..f169f5e 100644
---- a/apps/client/src/editor/ui/inspector.ts
++++ b/apps/client/src/editor/ui/inspector.ts
@@@ -148,6 +148,8 @@ export function fieldsFor(kind: EditorObjectKind, object: EditorObject | null):
return [{ key: 'node', label: 'Resource', type: 'readonly', section: 'Node' }, ...common]
case 'prop':
return [{ key: 'item', label: 'Item', type: 'readonly', section: 'Prop' }, ...common]
++ case 'npc':
++ return [{ key: 'npc', label: 'NPC', type: 'readonly', section: 'NPC' }, ...common]
case 'spawn':
return common
case 'light': {
ddiff --git a/apps/client/src/editor/viewport/views/index.ts b/apps/client/src/editor/viewport/views/index.ts
iindex 6fcc7ba..7f3efea 100644
---- a/apps/client/src/editor/viewport/views/index.ts
++++ b/apps/client/src/editor/viewport/views/index.ts
@@@ -27,13 +27,14 @@ import {
type ContentRegistry,
type MapLightV2,
type MapNodeV2,
++ type MapNpcV2,
type MapPropV2,
type MapZoneV2,
type StaticObjectV2,
type SurfaceMaterialData,
type TerrainObjectV2,
} from '@openvibe/content'
--import { NODE_LOOKS } from '../../catalog.js'
++import { NODE_LOOKS, npcLook } from '../../catalog.js'
import { meshForShape } from '../../../render/sceneSetup.js'
import { applyStaticStyle, instantiateMapLight } from '../../../render/mapStyle.js'
import { LayeredSurfaceMaterial } from '../../../render/layeredSurface.js'
@@@ -279,6 +280,53 @@ class NodeView implements EditorView {
}
}
++/** An NPC's home: a body-sized stand-in on the ground (the game's NPC wanders from here). */
++class NpcView implements EditorView {
++ readonly kind = 'npc' as const
++ readonly root: Mesh
++
++ constructor(
++ readonly id: string,
++ private npc: MapNpcV2,
++ private readonly ctx: ViewContext,
++ ) {
++ const look = npcLook(npc.npc)
++ this.root = meshForShape(ctx.scene, `npc:${id}`, look.shape, look.color)
++ this.root.metadata = { height: shapeHeight(look.shape) }
++ this.applyPose()
++ }
++
++ private applyPose(): void {
++ const n = this.npc
++ const h = (this.root.metadata as { height: number }).height
++ this.root.position.set(
++ n.pos[0],
++ n.pos[1] + this.ctx.sampleGround(n.pos[0], n.pos[2]) + h / 2,
++ n.pos[2],
++ )
++ }
++
++ meshes(): Mesh[] {
++ return [this.root]
++ }
++
++ update(object: EditorObject, keys: readonly string[]): boolean {
++ this.npc = object as MapNpcV2
++ // The look follows the archetype, so changing it needs a new mesh.
++ if (keys.includes('npc')) return false
++ this.applyPose()
++ return true
++ }
++
++ setVisible(on: boolean): void {
++ this.root.setEnabled(on)
++ }
++
++ dispose(): void {
++ this.root.dispose(false, true)
++ }
++}
++
class PropView implements EditorView {
readonly kind = 'prop' as const
readonly root: Mesh
@@@ -674,6 +722,8 @@ export function createViewFactory(ctx: ViewContext) {
return new NodeView(id, object as MapNodeV2, ctx)
case 'prop':
return new PropView(id, object as MapPropV2, ctx)
++ case 'npc':
++ return new NpcView(id, object as MapNpcV2, ctx)
case 'light':
return new LightView(id, object as MapLightV2, ctx)
case 'zone':
ddiff --git a/apps/server/src/game/flusher.test.ts b/apps/server/src/game/flusher.test.ts
iindex b6482f8..e2c09b7 100644
---- a/apps/server/src/game/flusher.test.ts
++++ b/apps/server/src/game/flusher.test.ts
@@@ -4,7 +4,7 @@
* disconnect save lands after an earlier checkpoint of the same character; shutdown drains.
*/
import { describe, expect, it } from 'vitest'
--import { createContent } from '@openvibe/content'
++import { NPC_SPAWNS, createContent, setMapOverride } from '@openvibe/content'
import { openTestStore } from '@openvibe/persistence/testing'
import type {
ConstraintDto,
@@@ -415,6 +415,57 @@ describe('write-behind flusher', () => {
npcs.restoreDirty(changed.map((d) => d.id))
expect(npcs.takeDirty(1000).map((d) => d.id)).toEqual([victim])
})
++
++ it("(i) NPCs follow the map's own spawns: the content's go, saved rows and all, and a restart keeps the map's", async () => {
++ const store = await openTestStore()
++ const content = createContent()
++ const forgotten: string[] = []
++ const world = {
++ content,
++ forgetPersisted: (id: string) => forgotten.push(id),
++ } as unknown as GameWorld
++ const hooks = { onAttackPlayer: () => {}, soundsThisTick: () => [], hostilesNear: () => [] }
++ const first = new NpcManager(world, new RegionTracker(32), hooks, log)
++ await first.seedOrRestore(store)
++ const contentRows = first.takeDirty(1000)
++ expect(contentRows.length).toBe(NPC_SPAWNS.length)
++ await store.worldEntities.upsertMany(contentRows)
++
++ setMapOverride({
++ terrains: [],
++ npcs: [
++ { id: 'w', npc: 'city_warden', pos: [3, 0, -13] },
++ { id: 'h', npc: 'dust_hare', pos: [60, 0, 50] },
++ { id: 'x', npc: 'no_such_npc', pos: [0, 0, 0] },
++ ],
++ })
++ try {
++ first.reconcileSpawns()
++ expect(forgotten.sort()).toEqual(contentRows.map((r) => r.id).sort())
++ const mapRows = first.takeDirty(1000)
++ expect(mapRows.map((r) => [r.defId, r.pos[0], r.pos[2]])).toEqual([
++ ['city_warden', 3, -13],
++ ['dust_hare', 60, 50],
++ ])
++ expect(first.counts()).toEqual([0, 2])
++ // Reconciling again changes nothing.
++ first.reconcileSpawns()
++ expect(first.takeDirty(1000)).toEqual([])
++ await store.worldEntities.deleteMany(forgotten)
++ await store.worldEntities.upsertMany(mapRows)
++
++ const restarted = new NpcManager(world, new RegionTracker(32), hooks, log)
++ await restarted.seedOrRestore(store)
++ expect(
++ restarted
++ .takeDirty(1000)
++ .map((r) => r.id)
++ .sort(),
++ ).toEqual(mapRows.map((r) => r.id).sort())
++ } finally {
++ setMapOverride(null)
++ }
++ })
})
describe('GameWorld dirty snapshot', () => {
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex a709e3f..b40d9be 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -220,6 +220,11 @@ export class GameServer {
this.systems.editor.broadcastMapReload()
}
++ /** A live map save: NPCs follow the map's spawns (not in a sandbox, which runs none). */
++ reconcileMapNpcs(): void {
++ if (!this.ctx.config.sandbox) this.systems.npcs.reconcileSpawns()
++ }
++
announce(text: string): void {
this.ctx.net.broadcastAll({ t: 'announce', text })
}
ddiff --git a/apps/server/src/game/gameWorld.ts b/apps/server/src/game/gameWorld.ts
iindex 40b55f4..0f31e35 100644
---- a/apps/server/src/game/gameWorld.ts
++++ b/apps/server/src/game/gameWorld.ts
@@@ -332,6 +332,11 @@ export class GameWorld {
return entity
}
++ /** Delete a saved row at the next flush: an NPC dropped while abstract has no entity to despawn. */
++ forgetPersisted(id: EntityId): void {
++ this.deletedIds.add(id)
++ }
++
despawn(id: EntityId): void {
const entity = this.entities.remove(id)
if (!entity) return
ddiff --git a/apps/server/src/game/npcManager.ts b/apps/server/src/game/npcManager.ts
iindex 6dedfc4..c8b78a5 100644
---- a/apps/server/src/game/npcManager.ts
++++ b/apps/server/src/game/npcManager.ts
@@@ -7,7 +7,7 @@ import {
type NpcState,
type PerceptionCandidate,
} from '@openvibe/gameplay'
--import { NPC_SPAWNS, terrainHeight } from '@openvibe/content'
++import { npcSpawnsFor, terrainHeight } from '@openvibe/content'
import { CollisionLayer, type BodyId } from '@openvibe/physics'
import type { PersistenceStore, WorldEntityDto } from '@openvibe/persistence'
import {
@@@ -63,7 +63,7 @@ export class NpcManager {
private readonly log: Logger,
) {}
-- /** Boot: restore persisted NPCs, then seed content spawns not yet known. */
++ /** Boot: restore persisted NPCs, then match them to the world's spawns. */
async seedOrRestore(store: PersistenceStore): Promise<void> {
const rows = (await store.worldEntities.loadAll()).filter((r) => r.kind === 'npc')
for (const row of rows) {
@@@ -86,16 +86,44 @@ export class NpcManager {
respawnAt: typeof s.respawnAt === 'number' ? s.respawnAt : 0,
})
}
-- // Content spawns missing from the save (fresh world or new content).
-- for (const [index, spawn] of NPC_SPAWNS.entries()) {
-- const arch = this.world.content.npc(spawn.archetype)
-- if (!arch) continue
-- const known = [...this.states.values()].some(
++ this.reconcileSpawns()
++ this.log.info('npcs ready', { count: this.states.size })
++ }
++
++ /**
++ * Make the NPCs match the world's spawns (npcSpawnsFor: the map's own, or
++ * the content's): an NPC whose home no spawn claims goes, saved row and all,
++ * and a spawn with nobody home gets its NPC. Boot runs it, and so does a live
++ * map save, so an NPC placed, moved or deleted in the editor shows up at once.
++ * An NPC is its spawn's while it has the archetype and a home within 2 m.
++ */
++ reconcileSpawns(): void {
++ const spawns = npcSpawnsFor(this.world.content.world).filter((s) =>
++ this.world.content.npc(s.archetype),
++ )
++ const claimed = new Set<string>()
++ const homeOf = (spawn: (typeof spawns)[number]): NpcState | undefined =>
++ [...this.states.values()].find(
(s) =>
++ !claimed.has(s.id) &&
s.archetype === spawn.archetype &&
Math.hypot(s.homeX - spawn.pos[0], s.homeZ - spawn.pos[2]) < 2,
)
-- if (known) continue
++ const missing: [number, (typeof spawns)[number]][] = []
++ for (const [index, spawn] of spawns.entries()) {
++ const npc = homeOf(spawn)
++ if (npc) claimed.add(npc.id)
++ else missing.push([index, spawn])
++ }
++ for (const npc of [...this.states.values()]) {
++ if (claimed.has(npc.id)) continue
++ if (this.bodies.has(npc.id)) this.dematerialize(npc)
++ this.states.delete(npc.id)
++ this.dirtyIds.delete(npc.id)
++ this.world.forgetPersisted(asEntityId(npc.id))
++ }
++ for (const [index, spawn] of missing) {
++ const arch = this.world.content.npc(spawn.archetype)!
const id = newEntityId()
this.states.set(id, {
id,
@@@ -113,7 +141,6 @@ export class NpcManager {
})
this.dirtyIds.add(id)
}
-- this.log.info('npcs ready', { count: this.states.size })
}
/** Counts for metrics: [full-sim, abstract]. */
ddiff --git a/apps/server/src/game/systems/npcs.ts b/apps/server/src/game/systems/npcs.ts
iindex 2bc773e..f688d44 100644
---- a/apps/server/src/game/systems/npcs.ts
++++ b/apps/server/src/game/systems/npcs.ts
@@@ -66,6 +66,11 @@ export class NpcSystem implements System {
await this.npcs.seedOrRestore(this.ctx.store)
}
++ /** A live map save: NPCs follow the map's spawns. */
++ reconcileSpawns(): void {
++ this.npcs.reconcileSpawns()
++ }
++
/** 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)
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 7331d44..cd39592 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -841,6 +841,7 @@ async function main(): Promise<void> {
room.world.reconcileMapZones()
room.world.reconcileMapNodes()
room.world.reconcileMapProps()
++ room.game.reconcileMapNpcs()
room.game.broadcastMapReload()
}
editors.broadcastSaved(revision)
ddiff --git a/apps/server/src/maps/scrapIsland.test.ts b/apps/server/src/maps/scrapIsland.test.ts
iindex 8b284d3..969fc1f 100644
---- a/apps/server/src/maps/scrapIsland.test.ts
++++ b/apps/server/src/maps/scrapIsland.test.ts
@@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
ITEMS,
-- NPC_SPAWNS,
++ NPC_ARCHETYPES,
RESOURCE_NODES,
WATER_LEVEL,
decodeHeights,
@@@ -23,7 +23,11 @@ describe('Scrap Island', () => {
const items = new Set(ITEMS.map((i) => i.id))
expect(map.nodes.filter((n) => !nodeKinds.has(n.node))).toEqual([])
expect(map.props.filter((p) => !items.has(p.item))).toEqual([])
-- const ids = [...map.statics, ...map.nodes, ...map.props, ...map.lights].map((o) => o.id)
++ const archetypes = new Set(NPC_ARCHETYPES.map((a) => a.id))
++ expect(map.npcs.filter((n) => !archetypes.has(n.npc))).toEqual([])
++ const ids = [...map.statics, ...map.nodes, ...map.props, ...map.lights, ...map.npcs].map(
++ (o) => o.id,
++ )
expect(new Set(ids).size).toBe(ids.length)
})
@@@ -101,12 +105,20 @@ describe('Scrap Island', () => {
expect(inside).toEqual([])
})
-- it("keeps the content's NPC homes on land and outside the walls, except the town's own", () => {
++ it('has people and animals of its own: the town inside the walls, the rest out on land, Rustjaw far from the gates', () => {
const height = makeHeight(20261019)
-- for (const s of NPC_SPAWNS) {
-- expect(height(s.pos[0], s.pos[2])).toBeGreaterThan(WATER_LEVEL + 0.5)
-- const outside = Math.max(Math.abs(s.pos[0]), Math.abs(s.pos[2])) > ISLAND.wall
-- expect(outside).toBe(s.archetype !== 'city_warden' && s.archetype !== 'townsfolk')
++ const count = (a: string) => map.npcs.filter((n) => n.npc === a).length
++ expect(count('city_warden')).toBeGreaterThanOrEqual(4)
++ expect(count('townsfolk')).toBeGreaterThanOrEqual(4)
++ expect(count('dust_hare')).toBeGreaterThanOrEqual(6)
++ expect(count('rustjaw_thug')).toBeGreaterThanOrEqual(4)
++ for (const n of map.npcs) {
++ const [x, , z] = n.pos
++ expect(height(x, z)).toBeGreaterThan(WATER_LEVEL + 0.5)
++ const inside = Math.max(Math.abs(x), Math.abs(z)) < ISLAND.wall
++ expect(inside).toBe(n.npc === 'city_warden' || n.npc === 'townsfolk')
++ // A hostile thug's leash (35 m) never reaches a city gate.
++ if (n.npc === 'rustjaw_thug') expect(Math.hypot(x, z)).toBeGreaterThan(ISLAND.wall + 50)
}
})
})
ddiff --git a/apps/server/src/maps/scrapIsland.ts b/apps/server/src/maps/scrapIsland.ts
iindex b98eebf..67b021b 100644
---- a/apps/server/src/maps/scrapIsland.ts
++++ b/apps/server/src/maps/scrapIsland.ts
@@@ -943,6 +943,57 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
prop(pick(['wooden_crate', 'supply_crate', 'storage_box']), cx, cz, yaw)
}
++ // Who lives here. Wardens keep the gates and the square by the spawn; townsfolk walk the avenues and the ring road
++ // (lots and the plaza's stalls stay clear); hares graze the farm and the fields; Rustjaw thugs hold the far end of the
++ // scrapyard, the deep forest and Rust Ridge, all beyond a leash's reach of a gate.
++ const npcs: MapFileV2['npcs'] = []
++ const npc = (id: string, archetype: string, x: number, z: number): void => {
++ npcs.push({ id, npc: archetype, pos: [x, 0, z] })
++ }
++ npc('warden-north', 'city_warden', 2.5, 32)
++ npc('warden-south', 'city_warden', -2.5, -32)
++ npc('warden-east', 'city_warden', 32, -2.5)
++ npc('warden-west', 'city_warden', -32, 2.5)
++ npc('warden-square', 'city_warden', 7.5, -19)
++ for (const [i, [x, z]] of (
++ [
++ [2.5, 18],
++ [-2.5, -19],
++ [18.5, -2.5],
++ [-18.5, 2.5],
++ [26.5, 12],
++ [-26.5, -14],
++ ] as const
++ ).entries())
++ npc(`townsfolk-${i + 1}`, 'townsfolk', x, z)
++ const [fx, fz] = ISLAND.farm.center
++ for (const [i, [x, z]] of (
++ [
++ [fx - 8, fz + 8],
++ [fx + 8, fz - 8],
++ [fx + 6, fz + 10],
++ [fx - 6, fz - 10],
++ [60, 50],
++ [70, 40],
++ [-30, 80],
++ [30, 85],
++ [-50, -30],
++ ] as const
++ ).entries())
++ npc(`hare-${i + 1}`, 'dust_hare', x, z)
++ const [rx, rz] = ISLAND.ridge.center
++ for (const [i, [x, z]] of (
++ [
++ [ISLAND.scrapyard.max[0] - 8, 14],
++ [ISLAND.scrapyard.max[0] - 6, -16],
++ [-140, 34],
++ [-128, -6],
++ [rx - 12, rz + 6],
++ [rx + 16, rz - 4],
++ ] as const
++ ).entries())
++ npc(`rustjaw-${i + 1}`, 'rustjaw_thug', x, z)
++
const terrain: MapFileV2['terrains'][number] = {
id: 'island',
name: 'Scrap Island',
@@@ -970,6 +1021,7 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
statics,
nodes,
props,
++ npcs,
lights,
zones: [
{
ddiff --git a/packages/content/src/defs/npcs.ts b/packages/content/src/defs/npcs.ts
iindex 3fc5d69..618bc37 100644
---- a/packages/content/src/defs/npcs.ts
++++ b/packages/content/src/defs/npcs.ts
@@@ -1,4 +1,6 @@
import type { FactionDef, NpcArchetype } from '../schema/npc.js'
++import type { WorldDef } from '../schema/world.js'
++import { getMapOverride } from '../terrain.js'
/**
* NPC content: factions, archetypes, and world spawn points. Behavior is
@@@ -108,8 +110,25 @@ export const NPC_ARCHETYPES: NpcArchetype[] = [
},
]
++export interface NpcSpawn {
++ archetype: string
++ pos: [number, number, number]
++}
++
++/**
++ * Where a world's NPCs live: the map's own NPC spawns when it places any (the
++ * editor owns them), else NPC_SPAWNS. Those were laid out for the procedural
++ * world, so on a map of its own they stood in odd places — Rustjaw thugs just
++ * outside Scrap City's wall, seven NPCs on a whole island.
++ */
++export function npcSpawnsFor(world?: WorldDef): NpcSpawn[] {
++ const mapped = getMapOverride(world)?.npcs ?? []
++ if (mapped.length) return mapped.map((n) => ({ archetype: n.npc, pos: n.pos }))
++ return NPC_SPAWNS
++}
++
/** World NPC spawn points (positions are terrain-relative, like props). */
--export const NPC_SPAWNS: { archetype: string; pos: [number, number, number] }[] = [
++export const NPC_SPAWNS: NpcSpawn[] = [
// Rustjaw ambushers haunt the scrapyard and the deep forest.
{ archetype: 'rustjaw_thug', pos: [43, 0, -40] },
{ archetype: 'rustjaw_thug', pos: [40, 0, 48] },
ddiff --git a/packages/content/src/mapFileV2.test.ts b/packages/content/src/mapFileV2.test.ts
iindex 7e567ee..4c7f72d 100644
---- a/packages/content/src/mapFileV2.test.ts
++++ b/packages/content/src/mapFileV2.test.ts
@@@ -10,6 +10,8 @@ import {
type MapFileV2,
type ParsedMap,
} from './mapFileV2.js'
++import { NPC_SPAWNS, npcSpawnsFor } from './defs/npcs.js'
++import { setMapOverride } from './terrain.js'
const SUB = 8
@@@ -444,3 +446,39 @@ describe('map-authored zones', () => {
if (again.ok) expect(again.map.zones[0]).toEqual(r.map.zones[0])
})
})
++
++describe('map-authored NPCs', () => {
++ it('an older map with no npcs reads as none, and a new one starts with none', () => {
++ const raw = JSON.parse(JSON.stringify(emptyMapV2())) as Record<string, unknown>
++ delete raw.npcs
++ const parsed = parseMapFile(raw)
++ expect(parsed.ok && parsed.map.npcs).toEqual([])
++ expect(emptyMapV2().npcs).toEqual([])
++ })
++
++ it('compiles NPC spawns into the override, and npcSpawnsFor prefers them to the content', () => {
++ const map: MapFileV2 = {
++ ...emptyMapV2(),
++ npcs: [{ id: 'npc-1', npc: 'dust_hare', pos: [12, 0, -4] }],
++ }
++ expect(validateMapFile(map)).toEqual([])
++ const override = compileMapFileV2(map)
++ expect(override.npcs).toEqual([{ id: 'npc-1', npc: 'dust_hare', pos: [12, 0, -4] }])
++ setMapOverride(override)
++ try {
++ expect(npcSpawnsFor()).toEqual([{ archetype: 'dust_hare', pos: [12, 0, -4] }])
++ } finally {
++ setMapOverride(null)
++ }
++ expect(npcSpawnsFor()).toBe(NPC_SPAWNS)
++ })
++
++ it('an NPC id may not repeat another object id', () => {
++ const map: MapFileV2 = {
++ ...emptyMapV2(),
++ nodes: [{ id: 'x', node: 'oak_tree', pos: [0, 0, 0] }],
++ npcs: [{ id: 'x', npc: 'townsfolk', pos: [1, 0, 1] }],
++ }
++ expect(validateMapFile(map).some((i) => i.includes('"x"'))).toBe(true)
++ })
++})
ddiff --git a/packages/content/src/mapFileV2.ts b/packages/content/src/mapFileV2.ts
iindex 9ffd380..eed33db 100644
---- a/packages/content/src/mapFileV2.ts
++++ b/packages/content/src/mapFileV2.ts
@@@ -159,6 +159,11 @@ export const MapFileV2Schema = z.object({
)
.max(20_000)
.default([]),
++ /** NPC spawn points: an archetype id (`npc`) and the ground position it lives around. */
++ npcs: z
++ .array(z.object({ id: documentId, npc: z.string(), pos: finiteVec3 }))
++ .max(2000)
++ .default([]),
lights: z.array(MapLightSchemaV2).max(256).default([]),
/** Map-authored zones; they augment the base world's content zones. */
zones: z.array(MapZoneSchemaV2).max(512).default([]),
@@@ -188,6 +193,7 @@ export type MapLightV2 = z.infer<typeof MapLightSchemaV2>
export type MapZoneV2 = z.infer<typeof MapZoneSchemaV2>
export type MapNodeV2 = MapFileV2['nodes'][number]
export type MapPropV2 = MapFileV2['props'][number]
++export type MapNpcV2 = MapFileV2['npcs'][number]
export type MapModelV2 = MapFileV2['models'][number]
/** The synthetic document id for the spawn point (stored top-level on the wire). */
@@@ -201,6 +207,7 @@ export function emptyMapV2(): MapFileV2 {
statics: [],
nodes: [],
props: [],
++ npcs: [],
lights: [],
zones: [],
models: [],
@@@ -216,6 +223,7 @@ const ID_KINDS = [
['props', 'pr'],
['lights', 'light'],
['zones', 'zone'],
++ ['npcs', 'npc'],
] as const
/**
@@@ -344,6 +352,7 @@ export function validateMapFile(map: MapFileV2): string[] {
}
for (const n of map.nodes) claim(n.id, 'node')
for (const p of map.props) claim(p.id, 'prop')
++ for (const n of map.npcs) claim(n.id, 'npc')
for (const l of map.lights) claim(l.id, 'light')
for (const z0 of map.zones) claim(z0.id, 'zone')
if (seen.has(SPAWN_OBJECT_ID)) issues.push(`"${SPAWN_OBJECT_ID}" is reserved for the spawn point`)
@@@ -409,6 +418,7 @@ export function compileMapFileV2(map: MapFileV2): MapOverride {
...(t.surface?.base.uv ? { uv: t.surface.base.uv as FaceStyle } : {}),
})),
nodes: map.nodes.map((n) => ({ id: n.id, node: n.node, pos: n.pos })),
++ npcs: map.npcs.map((n) => ({ id: n.id, npc: n.npc, pos: n.pos })),
props: map.props.map((p) => ({
id: p.id,
item: p.item,
ddiff --git a/packages/content/src/packs/creatorWorld.ts b/packages/content/src/packs/creatorWorld.ts
iindex 1d39bdb..2e3468c 100644
---- a/packages/content/src/packs/creatorWorld.ts
++++ b/packages/content/src/packs/creatorWorld.ts
@@@ -71,6 +71,7 @@ export function creatorMapIssues(map: MapFileV2, bytes: number): string[] {
limit('props', map.props.length)
limit('lights', map.lights.length)
limit('zones', map.zones.length)
++ if (map.npcs.length > 0) issues.push('npcs: creator worlds have no NPCs yet')
if (map.models.length > 0)
issues.push('models: uploaded models are not available in creator worlds yet')
if (map.textures.length > 0)
ddiff --git a/packages/content/src/terrain.ts b/packages/content/src/terrain.ts
iindex 69e8f79..21b4f2c 100644
---- a/packages/content/src/terrain.ts
++++ b/packages/content/src/terrain.ts
@@@ -121,6 +121,13 @@ export interface MapPropSpawn {
yaw?: number
}
++/** An editor-placed NPC: where an archetype (`npc`) lives and wanders from. */
++export interface MapNpcSpawn {
++ id: string
++ npc: string
++ pos: [number, number, number]
++}
++
export interface TerrainPatchData {
id: string
origin: [number, number, number]
@@@ -168,6 +175,11 @@ export interface MapOverride {
nodes?: MapNodeSpawn[]
/** Editor-placed initial props merged into fresh-world seeding. */
props?: MapPropSpawn[]
++ /**
++ * Editor-placed NPCs. A map with any replaces the content's NPC_SPAWNS,
++ * which were laid out for the procedural world (see npcSpawnsFor).
++ */
++ npcs?: MapNpcSpawn[]
/** Every terrain object in the map. May be empty. */
terrains: TerrainPatchData[]
/**