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 assets/items/custom.yaml) 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 crates/sim/src/segment.rs:
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)
crates/sim/src/region.rs — 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_node()guard 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_spawns()next 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 incrates/client-lib/src/use_world.rsloot 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 (assets/gfx/sprites/item.*.yaml), so drops look right.
Content admin editor
Outdoor map (tools/content-admin/web):
map_types.ts—world_item_spawnsinMapLayout+emptyLayout(); newPlaceMode "item"(or a sub-tool of the resource tool).plugins/map.ts— 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.map_panels.ts—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.map_layout_normalize.ts,map_hit.ts,map_labels.ts,map_duplicate.ts,map_table/rows.ts(+ filters) — include item spawns alongside resource nodes.- Item spawns are non-blocking — no
resourceOccupiesCellconflict (can share a cell with a resource/table).
Interiors (plugins/building_interiors.ts, interior_placement_panels.ts,
interior_hit.ts, interior_placement_panels.ts):
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).crates/sim/src/validate.rs— bounds + non-empty id +item_templateexists in the item catalog (chain throughcrates/sim/src/content.rsandnpc_loot.rsitem lists like resource nodes do). Mirror intools/flatland-admin/src/validate.rs.- Interior blueprints validate the same (interiors have no standalone JSON schema today; add checks to the interior validator path used by content-admin).
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):
- Worker admin route
POST /v1/world/item-spawn/reset { spawn_id }incrates/region-worker/src/admin.rs→ sets runtime toSpawnedand respawns the drop. - Content-admin 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:
flatland-adminCLI command calling the same endpoint.
Testing
- Sim unit tests (
crates/sim/src/region.rstest module, mirroring existing ground-drop tests): one-shot pickup → drop gone +Consumed; respawn pickup → drop gone, returns atrespawn_at_tick; once-per-character: A picks, A no longer sees it, B still does; A cannot pick twice; snapshot keepscollected_by; Reset now restores visibility; hired worker pickup marks the employer; qty > 1; interior spawn hidden outside the building (building_idvisibility); persistence round-trip preservesPickedUp/Consumed; content publish merge keeps runtime state by id. - Manual: content-admin places a
cloth_pantsone-shot in a dungeon room + a respawningcoin_sackoutdoors; player picks up both; verify one-shot stays gone after segment reload, respawner returns on schedule; admin reset button.
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 (
assets/items/custom.yaml+ content-admin Items plugin) — this plan just lets those items be placed in the world.
Follow-ups
- Gfx hover label on ground drops using
display_name(included above). - Sprite pass for the cloth set + any future authored items.
docs/world-item-spawns.md+ content-editor doc update.
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)admin op,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.
- "Reset now" button proxies
POST /v1/world/item-spawn/resetto the region worker.
- Map API
- Worker admin:
ItemSpawnResetRequestchannel +POST /v1/world/item-spawn/resetroute; content-admin proxy. - 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.