Home World Item Spawns

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_ticks sim ticks (30 Hz → 1800 ticks = 60 s), or
  • Once per characteronce_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_ticks is 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_nodes merge (outdoor segment + every interior_instances value, keyed by def.id). New def → spawn its drop immediately (state = Spawned); existing id → keep its runtime state across reload.
  • Spawnspawn_ground_drop(...) with permanent = true, owner = None, ffa_delay = 0, building_id = Some(building) when the def came from an interior, and a new drop_key prefix itemspawn-{def_id}. Add spawn_source: Option<String> to GroundDrop (serde default) so pickup can route back to the spawn def.
  • Pickup — in submit_pickup, after resolving the drop: if once_per_character, grant the stack, insert the character id into collected_by, and leave the GroundDrop in the world. Shared mode: ground_drops.remove; then respawn_ticks == 0Consumed; else PickedUp { respawn_at_tick: tick + respawn_ticks }. The existing is_harvest_node() guard stays — harvest-node templates are still harvested, not picked. Hired workers count as the employer character.
  • Viewsground_drop_views_for_observer omits a spawn-sourced drop when the observer's character (or employer) is in collected_by. Pickup search / worker loot-seek use the same filter. Clients need no hide logic.
  • Tick — new respawn_item_spawns() next to respawn_nodes(): PickedUp where tick >= respawn_at_tick → respawn drop, state = Spawned. Once-per-character runtimes stay Spawned.
  • PersistenceRegionWorldSnapshot.item_spawns: Vec<ItemSpawnSnapshot> where ItemSpawnSnapshot { id, state: PersistedItemSpawnState, collected_by: Vec<Uuid> } with Spawned | PickedUp { until_tick } | Consumed. Add spawn_source: Option<String> to GroundDropSnapshot. On hydrate: Spawned → recreate the drop if missing; PickedUp → resume timer; Consumed → stays gone; collected_by restored. Content republish keeps collected_by (and shared FSM state) by spawn id. Admin Reset now clears collected_by and forces Spawned.

Protocol & client

  • GroundDropView already carries template_id, quantity, x, y, z, tile_id, yaw, pitch, roll, draw_scale — no structural change needed. Add display_name: Option<String> (from item catalog) and use it in crates/client-lib/src/use_world.rs loot labels (currently raw template_id), so the hover hint reads "Cloth Pants" instead of "cloth_pants".
  • Pickup UX already works: fUseWorldKind::Loot cascade → 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.tsworld_item_spawns in MapLayout + emptyLayout(); new PlaceMode "item" (or a sub-tool of the resource tool).
  • plugins/map.ts — place/brush mode that adds world_item_spawns entries with a dropdown of all item templates (no isWorldResourceNodeTemplate filter), default qty 1, respawn_ticks: 0 (world one-time), once_per_character: false, scatter_m: 0.25.
  • map_panels.tsrenderWorldItemSpawnPanel: 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 resourceOccupiesCell conflict (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.jsonworld_item_spawns array (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_template exists in the item catalog (chain through crates/sim/src/content.rs and npc_loot.rs item lists like resource nodes do). Mirror in tools/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 } in crates/region-worker/src/admin.rs → sets runtime to Spawned and 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-admin CLI command calling the same endpoint.

Testing

  • Sim unit tests (crates/sim/src/region.rs test module, mirroring existing ground-drop tests): one-shot pickup → drop gone + Consumed; respawn pickup → drop gone, returns at respawn_at_tick; once-per-character: A picks, A no longer sees it, B still does; A cannot pick twice; snapshot keeps collected_by; Reset now restores visibility; hired worker pickup marks the employer; qty > 1; interior spawn hidden outside the building (building_id visibility); persistence round-trip preserves PickedUp/Consumed; content publish merge keeps runtime state by id.
  • Manual: content-admin places a cloth_pants one-shot in a dungeon room + a respawning coin_sack outdoors; 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: WorldItemSpawnDef on WorldSegment + InteriorBlueprint; ItemSpawnRuntime (Spawned | PickedUp{respawn_at} | Consumed) runtime merged from outdoor + interiors; sync_item_spawn_drops (startup + content reload + hydrate), respawn_item_spawns tick, submit_pickup routing, spawn_ground_drop gains spawn_source backlink and returns the drop id; interior ids namespaced {building}::node with building_id on the drop for interior visibility. Snapshot persist/hydrate via ItemSpawnSnapshot + GroundDropSnapshot.spawn_source. Public reset_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_segment bounds/quantity rules.
  • Gfx: ground-drop fallback draws item.coin_sack when 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/reset to the region worker.
  • Worker admin: ItemSpawnResetRequest channel + POST /v1/world/item-spawn/reset route; content-admin proxy.
  • Schema: segment.schema.json world_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.