Plan 45 — World Item Spawns (place any item, one-time, tick respawn, or once per character)
Goal
Author any item template onto the world or into a building interior as a findable pickup — no harvest, no loot table. Each spawn is one of:
- World one-time — the first player to pick it up takes it and it's gone for everyone (until the author re-publishes the segment or hits Reset now), or
- Respawn on tick basis — after the item is picked up it reappears at the same spot
after
respawn_tickssim ticks (30 Hz → 1800 ticks = 60 s), or - Once per character —
once_per_character: true. The pile stays in the world. Each character may pick it once, then never sees it again; other characters who have not taken it still see and can take it.respawn_ticksis ignored.
Example: drop a cloth_pants in a dungeon room; a player finds it, picks it up; the author
decides it never comes back for anyone, that it respawns every few minutes for everyone, or
that each adventurer may claim it once.
This is the placement primitive that also unlocks future content: custom items (new item
templates in the content files) dropped as dungeon finds, and unique items handed
out as quest rewards (quest item rewards already work today via QuestReward.items —
this plan makes the placed in the world half of the loop possible).
Design decision: new def list, not an extended resource node
ResourceNodeDef is a harvest concept (harvest_ticks, loot_table, blocking, crop_tags,
tile_id). A world item spawn is a pickup concept. Rather than bolt pickup semantics onto
resource nodes, add a sibling def list world_item_spawns to both WorldSegment (outdoor)
and InteriorBlueprint (interiors/dungeons). It reuses the existing GroundDrop runtime +
pickup path (submit_pickup, UseWorldKind::Loot, f key) and the existing
AOI/observer building_id visibility, but keeps semantics clean and lets the editor show a
dropdown of all item templates instead of only isWorldResourceNodeTemplate ones.
Data model
WorldSegment::world_item_spawns and InteriorBlueprint::world_item_spawns:
world_item_spawns:
- id: dungeon-pants-1
label: Cloth Pants
item_template: cloth_pants
quantity: 1
x: 12.5
y: 8.5
z: 0.0
# 0/omitted = world one-time pickup (never respawns).
# > 0 = respawn this many sim ticks (30 Hz) after pickup.
respawn_ticks: 0
# true = each character may pick this once; the pile stays for others.
# respawn_ticks is ignored when set.
once_per_character: false
scatter_m: 0.25 # optional spawn scatter; default small
room_id: null # interior editor metadata (like resource nodes)
yaw: 0.0 # optional fixed facing; random at spawn if omitted
New WorldItemSpawnDef in sim module:
id, label, item_template, quantity (default 1), x, y, z, respawn_ticks (default 0), once_per_character (default false), scatter_m (default 0.25), room_id: Option<String>, yaw (default 0, skip default).
Runtime (sim)
sim module — new member item_spawns: HashMap<String, ItemSpawnRuntime>:
enum ItemSpawnState {
Spawned, // a GroundDrop exists at the spot
PickedUp { respawn_at_tick: u64 }, // drop taken; respawn timer (only when respawn_ticks > 0)
Consumed, // one-time spawn taken forever
}
struct ItemSpawnRuntime { def: WorldItemSpawnDef, state: ItemSpawnState, collected_by: HashSet<Uuid> }
- Init / content publish merge — mirror the existing
resource_nodesmerge (outdoor segment + everyinterior_instancesvalue, keyed bydef.id). New def → spawn its drop immediately (state = Spawned); existing id → keep its runtime state across reload. - Spawn —
spawn_ground_drop(...)withpermanent = true,owner = None,ffa_delay = 0,building_id = Some(building)when the def came from an interior, and a newdrop_keyprefixitemspawn-{def_id}. Addspawn_source: Option<String>toGroundDrop(serde default) so pickup can route back to the spawn def. - Pickup — in
submit_pickup, after resolving the drop: ifonce_per_character, grant the stack, insert the character id intocollected_by, and leave theGroundDropin the world. Shared mode:ground_drops.remove; thenrespawn_ticks == 0→Consumed; elsePickedUp { respawn_at_tick: tick + respawn_ticks }. The existingis_harvest_nodeguard stays — harvest-node templates are still harvested, not picked. Hired workers count as the employer character. - Views —
ground_drop_views_for_observeromits a spawn-sourced drop when the observer's character (or employer) is incollected_by. Pickup search / worker loot-seek use the same filter. Clients need no hide logic. - Tick — new
respawn_item_spawnsnext torespawn_nodes:PickedUpwheretick >= respawn_at_tick→ respawn drop,state = Spawned. Once-per-character runtimes staySpawned. - Persistence —
RegionWorldSnapshot.item_spawns: Vec<ItemSpawnSnapshot>whereItemSpawnSnapshot { id, state: PersistedItemSpawnState, collected_by: Vec<Uuid> }withSpawned | PickedUp { until_tick } | Consumed. Addspawn_source: Option<String>toGroundDropSnapshot. On hydrate:Spawned→ recreate the drop if missing;PickedUp→ resume timer;Consumed→ stays gone;collected_byrestored. Content republish keepscollected_by(and shared FSM state) by spawn id. Admin Reset now clearscollected_byand forcesSpawned.
Protocol & client
GroundDropViewalready carriestemplate_id, quantity, x, y, z, tile_id, yaw, pitch, roll, draw_scale— no structural change needed. Adddisplay_name: Option<String>(from item catalog) and use it inclient lib moduleloot labels (currently rawtemplate_id), so the hover hint reads "Cloth Pants" instead of "cloth_pants".- Pickup UX already works:
f→UseWorldKind::Lootcascade →client.pickup_nearest.
Visibility (sprites)
try_draw_item_tile tries item.<template>, resource.<template>, container.<template>
then falls back to draw_cell_sprite("item.<template>") — which no-ops if no sprite
exists. Cloth armor has no sprite today, so a placed cloth_pants would be invisible.
- Fallback: when no item sprite exists, draw the generic
item.coin_sack(or a new generic "loot" sprite) so every placed item is at least visible on the ground. - Author sprites for the cloth set (
item.cloth_pants,item.cloth_shirt, …) via the existing sprite-gen pipeline (the content files*.yaml), so drops look right.
Content staff tools
Outdoor map (content admin tooling):
the editor UI—world_item_spawnsinMapLayout+emptyLayout; newPlaceMode "item"(or a sub-tool of the resource tool).plugins/the editor UI— place/brush mode that addsworld_item_spawnsentries with a dropdown of all item templates (noisWorldResourceNodeTemplatefilter), default qty 1,respawn_ticks: 0(world one-time),once_per_character: false,scatter_m: 0.25.the editor UI—renderWorldItemSpawnPanel: id/label, item template (all items), quantity, x/y/z, Once per character checkbox, respawn ticks (0 = world one-time; disabled when once-per-character), scatter, yaw; Reset now; delete button.the editor UI,the editor UI,the editor UI,the editor UI,map_table/the editor UI(+ filters) — include item spawns alongside resource nodes.- Item spawns are non-blocking — no
resourceOccupiesCellconflict (can share a cell with a resource/table).
Interiors (plugins/the editor UI, the editor UI,
the editor UI, the editor UI):
InteriorBlueprint.world_item_spawns(same def struct); place tool in the interior canvas constrained to rooms; panel with room select; delete.
Schemas & validation
schemas/segment.schema.json—world_item_spawnsarray (id/label/item_template/qty/ x/y/z/respawn_ticks/once_per_character/scatter_m/room_id).sim module— bounds + non-empty id +item_templateexists in the item catalog (chain throughsim moduleandthe sourceitem lists like resource nodes do). Mirror inflatland admin tooling.- Interior blueprints validate the same (interiors have no standalone JSON schema today; add checks to the interior validator path used by content editor).
Manual respawn reset (runtime)
One-shot spawns reset via re-publishing the segment. For respawning spawns where an admin wants to force the item back now (and clear once-per-character collectors):
- content editor proxy API + small button (Overseer tab or a compact "Item spawns" runtime section) listing live spawn states (spawned / respawning at tick / consumed) with a Reset button.
- Optional:
the admin toolsin-game tools command calling the same endpoint.
Out of scope (future, now unlocked)
- Unique item instances (named magic sword with
props) — needs item-instance support in spawn defs (item_instance_id+ props on the drop) — later plan. - Quest-reward item placement — quest rewards already grant items directly; a future plan can hand out placed spawns as rewards (claim a location).
- Custom item authoring itself is existing functionality (
the content files+ content editor Items plugin) — this plan just lets those items be placed in the world.
Implementation status (done)
Implemented end-to-end:
- Sim:
WorldItemSpawnDefonWorldSegment+InteriorBlueprint;ItemSpawnRuntime(Spawned | PickedUp{respawn_at} | Consumed) runtime merged from outdoor + interiors;sync_item_spawn_drops(startup + content reload + hydrate),respawn_item_spawnstick,submit_pickuprouting,spawn_ground_dropgainsspawn_sourcebacklink and returns the drop id; interior ids namespaced{building}::nodewithbuilding_idon the drop for interior visibility. Snapshot persist/hydrate viaItemSpawnSnapshot+GroundDropSnapshot.spawn_source. Publicreset_item_spawn(spawn_id)staff tools,item_spawn_views. - Protocol:
GroundDropView.display_name(client loot label) +ItemSpawnView/ItemSpawnStateView. - Validation:
validate_world_item_spawn_refs(content load) +validate_segmentbounds/quantity rules. - Gfx: ground-drop fallback draws
item.coin_sackwhen an item has no authored sprite (placed cloth stays visible). - Content admin:
- Map API
MapLayoutDto.world_item_spawns; blueprint API passes it through automatically. - Outdoor editor: "Place Item spawn" tool, dedicated panel (all item templates, qty / respawn / scatter / yaw), canvas marker, hit-test, delete/duplicate/move, locks, layers, map table "Item spawns" category.
- Interior editor: "Item spawn" tool + panel + marker + move/delete.
- Map API
- Schema:
segment.schema.jsonworld_item_spawns. - Tests: one-shot consume, tick respawn, snapshot round-trip (PickedUp preserved), interior building_id.
Pre-existing (not caused by this plan)
9 sim wildlife/craft/worker tests and region-worker + client-ui test modules fail on baseline.