Session Model — Field Context vs Quest State
The key words MUST, MUST NOT, MAY, and REQUIRED below are used as in RFC 2119. This page defines the target contract; the current divergence section records where the code conflates the two layers today.
A field run involves two independent concerns that are easy to entangle. The session model keeps them separate:
The two layers
- Field Context — the expedition: which area, the generated
sections/ cell grid, thecurrent_section, per-cell state (keys, gates, drops, cleared waves), and the player's position. This is the map and where you stand on it. It is REQUIRED whenever the player is in a field, and it is the only thing needed to rebuild the field when returning to it. - Quest State — an optional overlay: the
quest_id, objectives + progress, completion, and pending rewards. A field MAY have a quest attached. A free field has none.
Crucially, presence or absence of Quest State is the only thing that distinguishes a quest run from a free field. There MUST NOT be a separate "type": "field" | "quest" discriminator on the field context — a free field is simply a Field Context with no Quest State.
Field Context lifecycle
- A Field Context MUST be created when the player enters a field — whether via accepting a quest or via the free-field teleporter.
- A Field Context MUST persist across round-trips to the city (telepipe or the city teleporter). Completing a quest's objectives MUST NOT destroy it — after the last objective the player is still in a live field and MAY keep exploring and round-tripping.
- A quest's Field Context ends only at the quest's exit — when the player reports the quest (rewards claimed) or cancels it. Accepting a (new) quest or a title return also clears it. A free field's context follows the Free-Roam rules in Quest vs Field (retained across city visits, cleared on quest accept).
The city-hub position cache (CityState)
Field Context holds the player's position in a field. There is a second, separate in-memory holder of player position for the city hub: CityState (the _position / _rotation / _area / _spawn_key autoload). It is written by city_area_base.gd every time the player walks between city areas or up to a shop NPC, so a push/pop back into the same area restores where they were standing. It is NOT part of SessionManager._session.
- A title return MUST clear
CityStatein addition to the session/quest state. The player's city position is in-memory session state, so it MUST be torn down by the same teardown that ends the play session. SessionManager.reset_all_state()is the single title-return chokepoint (invoked once fromtitle.gd::_ready); it MUST callCityState.clear()so every route to title (Return-to-Title, defeat, credits, input-select) clears the cache through one path.- After a title return, login MUST resolve the canonical area
DEFAULT_SPAWN, never a leftover city position. The spawn decision incity_area_base._spawn_playeruses the predicateCityState.get_player_position() != null && CityState.get_area() == area; a cleared cache makes it false so the canonical spawn is used. - Soft return-to-title and a hard reboot MUST be behaviorally identical here. A freshly-booted
CityStatealready has_position == null; clearing on title return closes the gap that previously made only a reboot reach the canonical spawn (#425).
This reconciles — it does not contradict — the lifecycle rule above that “a title return clears the Field Context (incl. the player's position).” That claim only covered the SessionManager-owned position; this section closes the gap that the city-hub position lived in a separate autoload the teardown never touched. The start-menu “Return to Title” action (Start Menu) delegates its teardown to this title-scene reset; it does not clear CityState itself.
Quest State lifecycle (independent)
- Quest State transitions accepted → in-progress → complete → reported. The complete step is reached in-field and is independent of the Field Context lifecycle.
- Reaching the final objective MUST only flip Quest State to complete (rewards pending at the guild). It MUST NOT clear the Field Context, cancel a placed telepipe, or force a city return — the player stays in the field with a usable pipe.
- Reporting at the guild counter grants the rewards, clears the completed Quest State, and is the moment the quest's Field Context and any open telepipe close. Cancelling does the same without rewards. These two are the quest's exit.
The telepipe is a movement primitive
A telepipe is a two-endpoint warp — like an area_warp — and it binds to the Field Context, not to objectives or rewards:
- A placed telepipe MUST carry its own destination (field scene + section + cell + world position), so the round trip works from the Field Context alone.
- Because the Field Context outlives quest-objective completion, meeting the final objective MUST NOT strand a placed telepipe or destroy the field around it — the player is still standing in a live field with a usable pipe.
- The telepipe's open/close lifecycle (one-active rule, round-trip return, replacement, and the quest accept / report / cancel + title-return clears — never objective completion) is defined by Telepipe. This page does not redefine it; the two specs MUST agree.
Quest lifecycle & the telepipe (settled)
The boundary is completion vs. exit:
- Accepting a quest MUST close any open telepipe (a fresh expedition).
- Completing the objectives MUST NOT close the telepipe — the player is still inside the quest field and may ride to and from the city.
- Reporting the quest at the guild (to claim rewards) and cancelling the quest MUST close any open telepipe — these are the quest's exit.
Worked example: accept Search and Rescue → enter the valley → meet the objectives. The quest is now complete, but the player keeps exploring and round-tripping through the pipe. Only when they report at the guild (or cancel) does the pipe — and the Field Context — close.
Why this matters
Tying the telepipe and the free-field run to Quest State is the root cause of a cluster of bugs: the field is procedurally built from the quest's sections, so “return to the field” needs that layout — and historically the layout only lived inside a single quest-flavoured session dict that quest completion cleared. Keeping the Field Context separate and alive until the player leaves removes that coupling entirely.
Implemented by
scripts/autoloads/session_manager.gd(SessionManager) — owns both layers. A quest run uses the single_suspended_sessionslot; a free field uses the per-area_free_roam_statestore viaflush_free_roam_field()/enter_free_roam_field()/clear_free_roam_state(). Both restore the live_sessionwhen the player returns to a field. Itsreset_all_state()is the title-return chokepoint and also clearsCityState(#425).scripts/autoloads/city_state.gd(CityState) — the city-hub position cache (_position/_rotation/_area/_spawn_key). Restores where the player stood when re-entering a city area;clear()is called fromreset_all_state()on title return so login falls through toDEFAULT_SPAWN.scripts/autoloads/telepipe_manager.gd(TelepipeManager) — the single placed-pipe slot. Should bind its return data to the Field Context, not be cancelled on quest end.scripts/3d/elements/telepipe.gd(Telepipe) — the placed in-world pipe element.scripts/3d/field/valley_field_controller.gd(ValleyField) — rebuilds a cell from the Field Context (get_field_sections()/get_current_section()). TheInvalid section indexguard fires when the context was wrongly cleared.scripts/3d/elements/quest_item_pickup.gd(QuestItem) — runs pickup actions.complete_quest(clears) vsend_quest(marks complete, keeps context + spawns pipe) is the action-level expression of this split.
Divergence history (mostly resolved)
The original single-dict design conflated the two layers and is what these issues describe:
_sessionis one dict mixing Field Context (sections,cell_states, position) and Quest State (quest_id, objectives, completion), tagged with a"type"discriminator.SessionManager.complete_quest()→return_to_city()→_session.clear()wipes the Field Context along with the Quest State.- A pickup using
["complete_quest", "telepipe"]therefore spawns a pipe into a cleared context; a subsequent cell move hitsInvalid section indexand tears down uncleanly.
Tracked in the architecture issue, with the crash detail in #378 and the free-field/quest confusion in #359 / #360.
Resolved: Field Context now survives city round-trips
The completion-vs-exit split (#384) keeps a quest's Field Context alive
through objective completion and city round-trips, tearing it down only at the
quest's exit. Free fields are now decoupled from the single
suspended-session slot entirely: each unlocked field keeps its own run-state in a
per-area Free-Roam store (SessionManager._free_roam_state),
in memory, retained across city returns and switching between free fields, and
reset only on quest accept, the quest's exit
(report/cancel), and title return. This is the normative behavior in
Quest vs Field (Free-Roam rules). The leftover
divergence is cosmetic — _session still carries a "type"
field — but a free field's context no longer rides in _suspended_session
(which would evict one field's progress when another was entered).