Przeglądaj źródła

Fix 1AU ending freeze, controller warp, beam surge, scorch and objectives

Ending freeze. An authored region is sliceSetIndex + stateOrdinal, so apex
gameplay (region 0) and both ending bookends (regions 1 and 2) are sibling
states of slice set 0. Selecting a bookend was treated as a world replacement:
the roster opened an arrival window and withheld region 1's groups until the
client reported holding region 1, while the client's current region leg only
advances on a real slice-set switch, and the teleport arm named a slice-set
index that is not a slice set. The client logged the transition start and
never reached "Finished synchronizing". Only a slice-set change now opens the
arrival window, an intra-slice-set move arms no teleport, and the ending drops
its region-49 staging detour and selects each bookend directly. Seed refusals
now log at warn: one strands the selected state, so it is not a debug detail.

Electron Controllers. Both dispenser anchors sit inside
security_center_player_trigger, while every access volume lies east of x=-331.
Under the access objective the native task selector walked them out of the
room; no access task group could hold them, so pinning a chosen pair could not
work either. They and their support now take the security objective that owns
that room and pick their own task within it.

Beam. The three placed ring objects were held active for the whole fight, so
the beam never changed. The laser and ring now come up with their devices at
the warning and go dark at recovery, the core stays as the weapon's body, and
the deposit shuts the beam down and keeps it off across an escape restart.

Scorch. REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON and FOUNDRY_THERMAL_DOT_HOP_ON
both reference effect resource 80C1D9E0, the burn already working in the
Foundry; the hot-pipe and rail-top hop-ons carry 80B82484 and 80C1D389, which
is why contact read as a shock. Apex drives the one authored scorch for both
hazards and swaps only its filter: the five narrow pipe volumes while the cell
is carried up, then the rail top during the escape.

Explosions. The prefab's four authored player triggers were never armed, so
nothing advanced the sequence past the deposit. They are armed with the escape
volumes and fire as each section is reached.

Objectives. Native type-68 state 0 enters the directive and the Lua surface
always publishes it, so republishing on every navpoint change replayed the
banner's entry animation while its text stayed put. The directive is keyed on
its element alone and carries one stable navpoint, so it enters once per
milestone. Each authored directive already carries both HUD lines.

22 portable tests (new mission_seed_world_change regression) and five Lua
suites pass; full route peaks at 234/512 variables, 61/63 intents per callback
and 3/32 timers. Beam visuals, controller geometry, scorch damage, explosion
pacing and both movies still need a live run.
Millie 3 dni temu
rodzic
commit
94e79af90e

+ 13 - 0
Sunrise/src/server/activity/mission/mission_script_runtime_dispatch.cpp

@@ -212,6 +212,19 @@ void arm_state_region_teleport(RuntimeInstance& instance,
             instance.view.binding.sessionId, membership::kAbsentSliceSetIndex, 0));
         return;
     }
+    // A region is `sliceSetIndex + stateOrdinal`, so a sibling state sits in the slice set the
+    // client already holds and there is nothing to transition to. Arming anyway hands the client
+    // a slice-set index that is not a slice set (region 1 with slice set 0's name hash), and it
+    // starts a teleportation it can never finish. Clear the arm and let the roster publish the
+    // new state's groups into the world that is already standing.
+    const std::int32_t heldSliceSet =
+        membership::reported_slice_set(instance.view.binding.sessionId);
+    if (heldSliceSet >= 0 && heldSliceSet == static_cast<std::int32_t>(plan.sliceSetIndex)) {
+        static_cast<void>(membership::arm_host_teleport(
+            instance.view.binding.sessionId, membership::kAbsentSliceSetIndex, 0));
+        log_line(core::log::Level::info, &instance, "state_region", "teleport_not_required");
+        return;
+    }
     const std::string_view name(reinterpret_cast<const char*>(destination.packageName.data()),
                                 destination.packageNameLength);
     ::sunrise::state::build_data::scenarios::Definition layout{};

+ 15 - 2
Sunrise/src/server/bap/bap_route.cpp

@@ -18,6 +18,7 @@
 #include "activity_authority_query_owner.h"
 #include "activity_authority_reset_owner.h"
 #include "activity_mission_seed_lease.h"
+#include "encrypted/push/activity/mission_seed_world_change.h"
 #include "core/threading/srw_lock.h"
 #include "encrypted/bap_connection_publication.h"
 #include "internal.h"
@@ -528,10 +529,22 @@ select_activity_mission_seed(const state::activity::SessionBinding& binding,
                 }
                 lease.registeredRegions[lease.registeredRegionCount++] = plan.effectiveRegion;
             }
-            // A region change replaces the instantiated world. Publications keep answering the
+            // A SLICE-SET change replaces the instantiated world. Publications keep answering the
             // previous plan until the client's post-arrival solicited answer advances the region
             // epoch, because registering the new region's groups mid-teardown races the teardown.
-            if (lease.configured && lease.plan.effectiveRegion != plan.effectiveRegion) {
+            //
+            // An authored region is `sliceSetIndex + stateOrdinal`, so sibling states share one
+            // slice set: Ember's apex gameplay (0) and both ending bookends (1, 2) are all slice
+            // set 0. Moving between them instantiates nothing new, and the client's current region
+            // leg only advances on an actual slice-set switch. Waiting for an arrival there is a
+            // deadlock: the roster withholds the new region's groups forever and the client never
+            // finishes synchronizing. Only a real slice-set change opens the arrival window.
+            if (lease.configured
+                && encrypted::push::activity::mission_seed_region_change_replaces_world(
+                    lease.plan.sliceSetIndex,
+                    lease.plan.effectiveRegion,
+                    plan.sliceSetIndex,
+                    plan.effectiveRegion)) {
                 lease.previousPlan = lease.plan;
                 lease.regionArrivalPending = true;
             }

+ 3 - 1
Sunrise/src/server/bap/encrypted/push/activity/activity_mission_seed_roster.cpp

@@ -26,8 +26,10 @@ namespace layouts = state::build_data::scenarios;
                                       static_cast<int>(reason.size()),
                                       reason.data());
     if (written > 0) {
+        // A refusal strands the selected state: its records are never seeded and the client waits
+        // for content that never arrives. That is not a debug detail, so it is reported at warn.
         core::log::write(core::log::Channel::server,
-                         core::log::Level::debug,
+                         core::log::Level::warn,
                          {line.data(), static_cast<std::size_t>(written)});
     }
     return MissionSeedRosterResult::refused;

+ 1 - 0
Sunrise/src/server/bap/encrypted/push/activity/activity_mission_seed_roster.h

@@ -4,6 +4,7 @@
 #include <cstdint>
 
 #include "internal.h"
+#include "mission_seed_world_change.h"
 
 namespace sunrise::server::bap::encrypted::push::activity {
 

+ 36 - 0
Sunrise/src/server/bap/encrypted/push/activity/mission_seed_world_change.h

@@ -0,0 +1,36 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::server::bap::encrypted::push::activity {
+
+/**
+ * Checks whether moving to a newly selected state actually replaces the instantiated world.
+ *
+ * An authored region is `sliceSetIndex + stateOrdinal`, so sibling states of one slice set are
+ * different regions inside the same instantiated content: Ember's apex gameplay (region 0) and
+ * its two ending bookends (regions 1 and 2) all live in slice set 0. Only a slice-set change
+ * tears the world down and rebuilds it.
+ *
+ * Treating a sibling move as a replacement deadlocks publication: the roster withholds the new
+ * region's groups until the client reports holding it, while the client's current region leg
+ * advances only on a real slice-set switch, so that report never arrives.
+ *
+ * Kept dependency-free so the decision can be exercised without the BAP session types.
+ *
+ * @param currentSliceSetIndex Slice set the lease's published plan belongs to.
+ * @param currentEffectiveRegion That plan's authored region.
+ * @param selectedSliceSetIndex Slice set the newly selected plan belongs to.
+ * @param selectedEffectiveRegion The newly selected plan's authored region.
+ * @return True only when the client must tear its world down and rebuild it.
+ */
+[[nodiscard]] constexpr bool
+mission_seed_region_change_replaces_world(std::uint32_t currentSliceSetIndex,
+                                          std::uint32_t currentEffectiveRegion,
+                                          std::uint32_t selectedSliceSetIndex,
+                                          std::uint32_t selectedEffectiveRegion) noexcept {
+    return currentEffectiveRegion != selectedEffectiveRegion
+           && currentSliceSetIndex != selectedSliceSetIndex;
+}
+
+} // namespace sunrise::server::bap::encrypted::push::activity

+ 94 - 31
scripts/mission_ember/apex.lua

@@ -16,6 +16,15 @@ return function(m, a, ending)
     local escape_triggers = {"APEX_MOTHER_BRAIN_005_DIALOG_PLAYER_TRIGGER", "APEX_MOTHER_BRAIN_006_DIALOG_PLAYER_TRIGGER",
         "APEX_MOTHER_BRAIN_007_DIALOG_PLAYER_TRIGGER", "APEX_MOTHER_BRAIN_008_DIALOG_PLAYER_TRIGGER",
         "APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER"}
+    -- The authored explosion prefab's four player triggers, west to east along the rails:
+    -- set A x -448.75..-438.75, B -403.75..-393.75, C -368.75..-358.75, D -323.75..-313.75,
+    -- all spanning y 2967..3002.5 and z 185..212.5. They were never armed, so the sequence
+    -- had nothing to advance it and everything the scene did happened at the deposit.
+    local explosion_triggers = {}
+    for _, set in ipairs({"A", "B", "C", "D"}) do
+        explosion_triggers[#explosion_triggers + 1] =
+            "EMBER_APEX_EXPLOSION_SEQUENCE_PREFAB_EXPLOSION_SET_" .. set .. "_PLAYER_TRIGGER"
+    end
     local function phase(s) return s:variable("ember.apex.phase") or 0 end
     local function set(c, p) c:set_variable("ember.apex.phase", p) end
     local function generation(s) return s:variable("ember.apex.generation") or 1 end
@@ -41,24 +50,57 @@ return function(m, a, ending)
         end
         a.device(c, "REACTOR_SHIELD_DEVICE", open, snap)
     end
-    local function beam(c, active, snap)
-        -- Both devices address the placed ring/laser objects and their native VFX graphs.
+    -- The three placed ring objects carry the beam's own authored effect graphs; the two
+    -- devices are its mechanical lanes. Holding all three objects active for the whole fight
+    -- is what made the beam look constant, so the surge is those authored objects coming up
+    -- with their devices at the warning and going dark again at recovery.
+    -- The core is the weapon's own body and stays present; the laser and ring are what
+    -- brighten, so only those two are modulated.
+    local ring_objects = {"SPECOPS_APEX_RING_LASER_OBJECT", "SPECOPS_APEX_RING_RING_OBJECT"}
+    local function beam(c, s, active, snap)
+        -- Idempotent: the cycle re-asserts the same pose every step, and each redundant
+        -- publication would spend intents from the callback's native budget.
+        if s:variable("ember.apex.beam") == active then return end
+        c:set_variable("ember.apex.beam", active)
+        a.objects(c, ring_objects, active)
         a.device(c, "SPECOPS_APEX_RING_LASER_DEVICE", active, snap)
         a.device(c, "SPECOPS_APEX_RING_RING_DEVICE", active, snap)
         -- FX power is independent of mechanical position; pulse both authored graphs.
         lane(c, "SPECOPS_APEX_RING_LASER_DEVICE", active and "power_on" or "power_off", snap)
         lane(c, "SPECOPS_APEX_RING_RING_DEVICE", active and "power_on" or "power_off", snap)
     end
-    local function hazards(c, s, enabled)
-        a.effect(c, s, "REACTOR_MOTHER_BRAIN_HOT_PIPES_THERMAL_HOP_ON",
-            "REACTOR_MOTHER_BRAIN_HOT_PIPES_OBJECT_FILTER_80B3C09F",
-            {players = true, inside_any = {
-                a.slot(c, "REACTOR_MOTHER_BRAIN_HOT_PIPES_02_TRIGGER_VOLUME"),
-                a.slot(c, "REACTOR_MOTHER_BRAIN_HOT_PIPES_03_TRIGGER_VOLUME"),
-                a.slot(c, "SLOT_0005_80B3C09F"), a.slot(c, "SLOT_0006_80B3C09F"),
-                a.slot(c, "SLOT_0008_80B3C09F")}}, enabled)
-        a.effect(c, s, "AOD_REACTOR_RAIL_TOP_HOP_ON", "AOD_REACTOR_RAIL_TOP_OBJECT_FILTER",
-            {players = true, inside = a.slot(c, "SLOT_019E")}, enabled)
+    -- `REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON` and `FOUNDRY_THERMAL_DOT_HOP_ON` both reference
+    -- effect resource 80C1D9E0 -- the burn already working in the Foundry. The hot-pipe and
+    -- rail-top hop-ons carry 80B82484 and 80C1D389 instead, which is why contact read as a
+    -- shock rather than a scorch. Apex drives the one authored scorch for both hazards and
+    -- swaps only its filter, so the climb and the escape share the same effect. A new revision
+    -- removes the previous attachment (native 9EF8A0/9F1F10) before attaching the new filter.
+    local function rail_filter(c) return {players = true, inside = a.slot(c, "SLOT_019E")} end
+    local function hazards(c, s, mode)
+        if mode == "climb" then
+            -- The five narrow authored pipe volumes on the way up to the deposit. Slot 7 is a
+            -- broad kill volume well below the walkable route and is deliberately not a pipe.
+            a.effect(c, s, "REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON",
+                "REACTOR_MOTHER_BRAIN_HOT_PIPES_OBJECT_FILTER_80B3C21C",
+                {players = true, inside_any = {
+                    a.slot(c, "REACTOR_MOTHER_BRAIN_HOT_PIPES_02_TRIGGER_VOLUME"),
+                    a.slot(c, "REACTOR_MOTHER_BRAIN_HOT_PIPES_03_TRIGGER_VOLUME"),
+                    a.slot(c, "SLOT_0005_80B3C09F"), a.slot(c, "SLOT_0006_80B3C09F"),
+                    a.slot(c, "SLOT_0008_80B3C09F")}}, true)
+        elseif mode == "escape" then
+            a.effect(c, s, "REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON",
+                "AOD_REACTOR_RAIL_TOP_OBJECT_FILTER", rail_filter(c), true)
+        else
+            a.effect(c, s, "REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON",
+                "AOD_REACTOR_RAIL_TOP_OBJECT_FILTER", rail_filter(c), false)
+        end
+    end
+    -- Activate the authored explosion scene once, then arm its own four progress triggers
+    -- alongside the escape dialogue volumes so each section detonates as the player reaches it.
+    local function arm_escape(c)
+        a.scene(c, "EMBER_APEX_EXPLOSION_SEQUENCE_PREFAB_TRIGGERED_EXPLOSIONS_SCENE")
+        for _, name in ipairs(explosion_triggers) do a.slot(c, name):fire_trigger{} end
+        for _, name in ipairs(escape_triggers) do a.slot(c, name):fire_trigger{} end
     end
     local function doors(c, side, open, snap)
         for _, part in ipairs({"DOOR_A", "DOOR_B", "LIGHT_A", "LIGHT_B", "TARGET"}) do
@@ -81,14 +123,14 @@ return function(m, a, ending)
         c:set_variable("ember.apex.vent_step", step)
         c:set_variable("ember.apex.vents_open", step == "open")
         if step == "warning" then
-            beam(c, true)
+            beam(c, s, true)
             if phase(s) == 3 then
                 for _, side in ipairs(sides) do
                     if not dead(s, side) then a.slot(c, "REACTOR_CLAMSHELL_" .. side .. "_ALARM_SEQUENCE"):play_sequence{} end
                 end
             else a.slot(c, "REACTOR_COFFIN_ALARM_SEQUENCE"):play_sequence{} end
         else
-            if step == "closed" then beam(c, false, snap) end
+            if step == "closed" then beam(c, s, false, snap) end
             if phase(s) == 3 then
                 for _, side in ipairs(sides) do if not dead(s, side) then doors(c, side, step == "open", snap) end end
             elseif phase(s) == 4 then
@@ -110,7 +152,7 @@ return function(m, a, ending)
         lane(c, "MOTHER_BRAIN_DOOR_DEVICE", "lock")
         unlock(c, "SPECOPS_APEX_RING_LASER_DEVICE")
         unlock(c, "SPECOPS_APEX_RING_RING_DEVICE")
-        beam(c, false, true)
+        beam(c, s, false, true)
     end
     local function start_reactor(c, s)
         if phase(s) >= 3 then return end
@@ -136,11 +178,13 @@ return function(m, a, ending)
             a.device(c, "MOTHER_BRAIN_ENGINE_RIGHT_DEVICE", true)
             a.device(c, "REACTOR_GETAWAY_SHIP_DEVICE", true)
             a.objects(c, {"REACTOR_GETAWAY_SHIP_OBJECT", "SUNBURN_DAMAGE_OBJECT"}, true)
-            a.device(c, "SPECOPS_APEX_RING_LASER_DEVICE", true)
-            a.device(c, "SPECOPS_APEX_RING_RING_DEVICE", true)
+            -- The weapon is dead once the cell is in: the beam shuts down and stays down,
+            -- including across an escape checkpoint restart. The previous code opened both
+            -- devices here, which left it running through the whole escape.
+            beam(c, s, false, true)
+            c:cancel_timer(vent_timer(s))
             a.scene(c, "MOTHER_BRAIN_HOLE_EXPLOSION_SCENE")
-            a.scene(c, "EMBER_APEX_EXPLOSION_SEQUENCE_PREFAB_TRIGGERED_EXPLOSIONS_SCENE")
-            for _, name in ipairs(escape_triggers) do a.slot(c, name):fire_trigger{} end
+            arm_escape(c)
             a.cue(c, s, 51); A.guidance(c, s)
         end)
     function A.enter(c, s)
@@ -155,8 +199,8 @@ return function(m, a, ending)
         a.device(c, "ACCESS_DOOR_OUTER_DEVICE", true)
         a.device(c, "SECURITY_DOOR_DEVICE", false, true)
         a.slot(c, "SECURITY_PLACED_INTERCEPTOR_OBJECT"):set_interactable_object{generation = 1}
-        a.objects(c, {"SPECOPS_APEX_RING_LASER_OBJECT",
-            "SPECOPS_APEX_RING_CORE_OBJECT", "SPECOPS_APEX_RING_RING_OBJECT"}, true)
+        -- Only the core is held on here; `beam()` owns the laser and ring so they can surge.
+        a.objects(c, {"SPECOPS_APEX_RING_CORE_OBJECT"}, true)
         for _, side in ipairs(sides) do
             doors(c, side, false, true)
             a.device(c, "CLAMSHELL_PIPES_" .. side .. "_DEVICE", false, true)
@@ -194,7 +238,7 @@ return function(m, a, ending)
             if a.matches(c, e, "APEX_MOTHER_BRAIN_008_DIALOG_PLAYER_TRIGGER") then a.cue(c, s, 54) end
             if a.matches(c, e, "APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER") then
                 set(c, 7); a.darkness(c, s, false)
-                hazards(c, s, false)
+                hazards(c, s, nil)
                 c:cancel_timer("ember.apex.hazards")
                 c:cancel_timer(vent_timer(s))
                 ending.start(c, s)
@@ -235,7 +279,10 @@ return function(m, a, ending)
             set(c, 5)
             c:cancel_timer(vent_timer(s)); c:cancel_timer("ember.apex.explain." .. generation(s))
             coffin_doors(c, true)
-            beam(c, true)
+            -- The weapon keeps firing until the cell goes in, so the beam stays up here.
+            beam(c, s, true)
+            -- Arm the climb scorch now: the pipes burn while the cell is carried up.
+            c:start_timer("ember.apex.hazards", 1)
             unlock(c, "MOTHER_BRAIN_DOOR_DEVICE")
             unlock(c, "COFFIN_BUNKER_DOOR_SOUTH_DEVICE")
             a.device(c, "MOTHER_BRAIN_DOOR_DEVICE", true)
@@ -258,7 +305,15 @@ return function(m, a, ending)
     end
     function A.timer(c, s, e)
         if e.timer_name == "ember.apex.hazards" then
-            if phase(s) == 6 and s:variable("ember.region") == 0 then hazards(c, s, true) end
+            -- The climb pipes burn while the cell is being carried up (phase 5), well before
+            -- the deposit; the rail top burns during the escape (phase 6). Any earlier phase
+            -- owns no hazard, so this is also how a checkpoint reset detaches one.
+            if s:variable("ember.region") == 0 then
+                local p = phase(s)
+                if p == 5 then hazards(c, s, "climb")
+                elseif p == 6 then hazards(c, s, "escape")
+                else hazards(c, s, nil) end
+            end
             return true
         end
         if carry.timer(c, s, e) then return true end
@@ -293,20 +348,23 @@ return function(m, a, ending)
     function A.guidance(c, s)
         if s:variable("ember.region") ~= 0 then return end
         local p = phase(s)
-        if p == 1 then a.directive(c, s, "4E4862BB", "SECURITY_INTERCEPTOR_NAV_POINT", a.combat(s, access))
+        if p == 1 then a.directive(c, s, "4E4862BB", "SECURITY_INTERCEPTOR_NAV_POINT")
         elseif p == 2 then a.directive(c, s, s:variable("ember.apex.interceptor_boarded") and "4E4862BB" or "3CBFC90B",
-            s:variable("ember.apex.interceptor_boarded") and "APEX_DIRECTIVE_REACTOR_CLAMSHELL_GOTO_NAV_POINT" or "SECURITY_INTERCEPTOR_NAV_POINT", a.combat(s, access))
-        elseif p == 3 then a.directive(c, s, "26C3C19D", "APEX_DIRECTIVE_REACTOR_CLAMSHELL_GOTO_NAV_POINT", a.combat(s, reactor))
-        elseif p == 4 then a.directive(c, s, "61D2B286", "APEX_DIRECTIVE_REACTOR_COFFIN_TARGET_NAV_POINT", a.combat(s, reactor))
+            s:variable("ember.apex.interceptor_boarded") and "APEX_DIRECTIVE_REACTOR_CLAMSHELL_GOTO_NAV_POINT" or "SECURITY_INTERCEPTOR_NAV_POINT")
+        elseif p == 3 then a.directive(c, s, "26C3C19D", "APEX_DIRECTIVE_REACTOR_CLAMSHELL_GOTO_NAV_POINT")
+        elseif p == 4 then a.directive(c, s, "61D2B286", "APEX_DIRECTIVE_REACTOR_COFFIN_TARGET_NAV_POINT")
         elseif p == 5 then a.directive(c, s, "4746660F",
-            carry.held(s) and "EMBER_DIRECTIVE_REACTOR_MOTHER_BRAIN_DELIVERY_NAV_POINT" or "APEX_WEAPON_NAV_POINT", a.combat(s, reactor))
-        elseif p == 6 then a.directive(c, s, "40FC40AD", "APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_NAV_POINT", false) end
+            carry.held(s) and "EMBER_DIRECTIVE_REACTOR_MOTHER_BRAIN_DELIVERY_NAV_POINT" or "APEX_WEAPON_NAV_POINT")
+        elseif p == 6 then a.directive(c, s, "40FC40AD", "APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_NAV_POINT") end
     end
     function A.reset(c, s, name)
         if name == "escape" then
             c:start_timer("ember.apex.hazards", 1)
             set(c, 6)
-            for _, t in ipairs(escape_triggers) do a.slot(c, t):fire_trigger{} end
+            -- The weapon is already dead at this checkpoint: restoring it must leave the
+            -- beam off and re-arm the escape's own progress triggers.
+            beam(c, s, false, true)
+            arm_escape(c)
             a.darkness(c, s, true)
         else
             c:cancel_timer(vent_timer(s)); c:cancel_timer("ember.apex.core." .. generation(s))
@@ -329,6 +387,9 @@ return function(m, a, ending)
             a.device(c, "MOTHER_BRAIN_DOOR_DEVICE", false, true)
             a.device(c, "COFFIN_BUNKER_DOOR_SOUTH_DEVICE", false, true)
             a.device(c, "REACTOR_SHIELD_DEVICE", false, true)
+            -- Detaching the hazard has its own callback: retiring 45 squads already spends
+            -- most of this one's native intent budget.
+            c:start_timer("ember.apex.hazards", 1)
             set(c, 2)
             -- Publish the reset now; repopulate on the first playable client report
             -- after the wipe handshake. A one-millisecond timer can expire during the fade.
@@ -342,6 +403,8 @@ return function(m, a, ending)
             c:start_timer("ember.apex.core." .. generation(s), 1)
         end
         a.darkness(c, s, phase(s) >= 3 and phase(s) <= 6)
+        -- Streaming back into the area re-attaches the hazard this phase owns.
+        if phase(s) == 5 or phase(s) == 6 then c:start_timer("ember.apex.hazards", 1) end
         if phase(s) == 3 or phase(s) == 4 then
             cycle(c, s, "closed", true)
             if phase(s) == 3 and not s:variable("ember.r.cue.41") then c:start_timer("ember.apex.explain." .. generation(s), 9000) end

+ 7 - 7
scripts/mission_ember/cinder.lua

@@ -134,13 +134,13 @@ return function(m, a)
         if s:variable("ember.region") ~= 40 then return end
         local p = phase(s)
         local combat = a.combat(s, current_groups[math.min(p, 11)] or {})
-        if p <= 2 then a.directive(c, s, "ECECF63D", "CINDER_DIRECTIVE_SUNBURN_GOTO_NAV_POINT", combat)
-        elseif p <= 5 then a.directive(c, s, "D496059B", "CINDER_DIRECTIVE_READY_ROOM_02_GOTO_NAV_POINT", combat)
-        elseif p == 6 then a.directive(c, s, combat and "7FF69D75" or "DF93A91C", "CINDER_DIRECTIVE_MEAT_GRINDER_GOTO_NAV_POINT", combat)
-        elseif p == 7 then a.directive(c, s, "62E3AEFB", "CINDER_DIRECTIVE_MEAT_GRINDER_GOTO_NAV_POINT", combat)
-        elseif p <= 10 then a.directive(c, s, "BAD7D583", "CINDER_DIRECTIVE_FOUNDRY_GOTO_NAV_POINT", combat)
-        elseif p <= 13 then a.directive(c, s, "7FF69D75", "FOUNDRY_HATCH_DOOR_CENTER_NAV_POINT", combat)
-        else a.directive(c, s, "FF7AB219", "CINDER_DIRECTIVE_CHUTE_GOTO_NAV_POINT", false) end
+        if p <= 2 then a.directive(c, s, "ECECF63D", "CINDER_DIRECTIVE_SUNBURN_GOTO_NAV_POINT")
+        elseif p <= 5 then a.directive(c, s, "D496059B", "CINDER_DIRECTIVE_READY_ROOM_02_GOTO_NAV_POINT")
+        elseif p == 6 then a.directive(c, s, combat and "7FF69D75" or "DF93A91C", "CINDER_DIRECTIVE_MEAT_GRINDER_GOTO_NAV_POINT")
+        elseif p == 7 then a.directive(c, s, "62E3AEFB", "CINDER_DIRECTIVE_MEAT_GRINDER_GOTO_NAV_POINT")
+        elseif p <= 10 then a.directive(c, s, "BAD7D583", "CINDER_DIRECTIVE_FOUNDRY_GOTO_NAV_POINT")
+        elseif p <= 13 then a.directive(c, s, "7FF69D75", "FOUNDRY_HATCH_DOOR_CENTER_NAV_POINT")
+        else a.directive(c, s, "FF7AB219", "CINDER_DIRECTIVE_CHUTE_GOTO_NAV_POINT") end
     end
     function C.resume(c, s)
         if (s:variable("ember.r.furthest") or 0) > 2 then return end

+ 23 - 23
scripts/mission_ember/ending.lua

@@ -1,38 +1,38 @@
--- Authored post-escape cinematic regions; exact native completion/skip incidents advance once.
+-- Authored post-escape cinematic states; exact native completion/skip incidents advance once.
+--
+-- An authored region is `sliceSetIndex + stateOrdinal`, so apex gameplay (region 0) and both
+-- ending bookends (regions 1 and 2) are sibling states of one slice set. Selecting a bookend
+-- instantiates no new world: the client keeps the slice set it already holds and never reports
+-- a new held region, so this must not wait for one. The earlier region-49 staging detour forced
+-- a cross-slice-set round trip to manufacture that report and stalled instead.
+--
+-- The selection intent completes only once its own roster revision publishes, and intents are
+-- dispatched in order, so the activation queued on a later callback always follows the seed.
 return function(m)
     local movies = {
         {state = m.states.STATE_80B3C09E_0000_0001_80B3C091, slot = m.Slot.PF_CINEMATIC_BOOKEND_STM_CINEMATIC},
         {state = m.states.STATE_80B3C09E_0000_0002_80B3C093, slot = m.Slot.PF_CINEMATIC_BOOKEND_CNN_CINEMATIC},
     }
     local E = {}
-    local staging = assert(m.states.STATE_80B3C09E_0006_0001_80B3C09A)
-    local function transit(c)
-        c:set_variable("ember.ending.transit", true)
-        c:select_state(staging)
-    end
     local music = require("mission_ember.music")(m)
+    local function select_movie(c, index)
+        c:set_variable("ember.ending", index)
+        c:set_variable("ember.ending.selected", index)
+        c:select_state(assert(movies[index].state))
+    end
     function E.start(c, s)
         if s:variable("ember.ending") then return end
-        c:set_variable("ember.ending", 1)
         music.update(c, s)
-        transit(c)
+        select_movie(c, 1)
     end
     function E.client(c, s, e)
         local index = s:variable("ember.ending")
         local row = index and movies[index]
-        if not row or s:variable("ember.ending.playing") == index then return end
-        local held = e.held_region_index or e.current_region_index
-        if s:variable("ember.ending.transit") then
-            if held == staging.region_index then
-                c:clear_variable("ember.ending.transit")
-                c:select_state(row.state)
-            end
-            return
-        end
-        if held == row.state.region_index then
-            c:set_variable("ember.ending.playing", index)
-            c:slot(assert(row.slot)):set_cinematic_active{active = true}
-        end
+        -- Activate on a callback after the selection, never in the one that requested it.
+        if not row or s:variable("ember.ending.selected") ~= index
+            or s:variable("ember.ending.playing") == index then return end
+        c:set_variable("ember.ending.playing", index)
+        c:slot(assert(row.slot)):set_cinematic_active{active = true}
     end
     function E.terminated(c, s, e)
         local index = s:variable("ember.ending")
@@ -40,10 +40,10 @@ return function(m)
         if not row or s:variable("ember.ending.playing") ~= index then return end
         local slot = c:slot(row.slot)
         if e.registry_key ~= slot.registry_key or e.slot_type ~= slot.slot_type or e.slot_index ~= slot.slot_index then return end
-        c:set_variable("ember.ending", index + 1)
         slot:set_cinematic_active{active = false}
-        if movies[index + 1] then transit(c)
+        if movies[index + 1] then select_movie(c, index + 1)
         else
+            c:set_variable("ember.ending", index + 1)
             c:set_variable("ember.complete", true)
             -- Native lifetime 6 enters the completion/reward branch (BEA9D0/B37100).
             c.lifetime:set{state = c.sdk.lifetime_states:at(6)}

+ 4 - 4
scripts/mission_ember/processing.lua

@@ -88,10 +88,10 @@ return function(m, a)
     function P.guidance(c, s)
         if s:variable("ember.region") ~= 56 then return end
         local p = phase(s)
-        if p == 1 then a.directive(c, s, "883C188D", "LINK_DIRECTIVE_CONTROL_INTERACT_NAV_POINT", a.combat(s, {"processing_entry"}))
-        elseif p == 2 then a.directive(c, s, s:variable("ember.carry.processing.notified") and "57050F62" or "591B1D88", carry.held(s) and "LINK_DIRECTIVE_CONTROL_INTERACT_NAV_POINT" or "LINK_CARRY_OBJECT_NAV_POINT", a.combat(s, {"processing_entry"}))
-        elseif p == 3 then a.directive(c, s, "03285502", nil, true)
-        elseif p == 4 then a.directive(c, s, "65D5979D", "CINDER_DIRECTIVE_CINDER_GOTO_BUBBLE_NAV_POINT", false) end
+        if p == 1 then a.directive(c, s, "883C188D", "LINK_DIRECTIVE_CONTROL_INTERACT_NAV_POINT")
+        elseif p == 2 then a.directive(c, s, s:variable("ember.carry.processing.notified") and "57050F62" or "591B1D88", carry.held(s) and "LINK_DIRECTIVE_CONTROL_INTERACT_NAV_POINT" or "LINK_CARRY_OBJECT_NAV_POINT")
+        elseif p == 3 then a.directive(c, s, "03285502", nil)
+        elseif p == 4 then a.directive(c, s, "65D5979D", "CINDER_DIRECTIVE_CINDER_GOTO_BUBBLE_NAV_POINT") end
     end
     function P.reset(c, s)
         a.effect(c, s, "TUMBLER_DAMAGE_HOP_ON", nil, nil, false)

+ 10 - 2
scripts/mission_ember/route_roster.lua

@@ -29,8 +29,16 @@ return {
     {name = "foundry_final", region = 40, objective = "EMBER_CINDER_FOUNDRY_OBJECTIVE", task_groups = 16, squads = {"FOUNDRY_ANCHOR_A_SQUAD", "FOUNDRY_SUPPORT_G_SQUAD", "FOUNDRY_SUPPORT_H_SQUAD", "FOUNDRY_SUPPORT_I_SQUAD"}},
     {name = "access1", region = 0, objective = "EMBER_APEX_ACCESS_OBJECTIVE", task_groups = 14, squads = {"ACCESS_JUMP_ONE_SUPPORT_A_SQUAD", "ACCESS_JUMP_ONE_SUPPORT_B_SQUAD", "ACCESS_JUMP_ONE_SUPPORT_C_SQUAD"}},
     {name = "access2", region = 0, objective = "EMBER_APEX_ACCESS_OBJECTIVE", task_groups = 14, squads = {"ACCESS_JUMP_TWO_SUPPORT_A_SQUAD", "ACCESS_JUMP_TWO_SUPPORT_B_SQUAD", "ACCESS_JUMP_TWO_SUPPORT_C_SQUAD"}},
-    {name = "electron_controllers", region = 0, objective = "EMBER_APEX_ACCESS_OBJECTIVE", task_groups = 14, fixed_tasks = {9, 10}, squads = {"DISPENSER_SUPPORT_A_SQUAD", "DISPENSER_SUPPORT_B_SQUAD"}},
-    {name = "dispenser", region = 0, objective = "EMBER_APEX_ACCESS_OBJECTIVE", task_groups = 14, squads = {"DISPENSER_SUPPORT_C_SQUAD"}},
+    -- The dispenser squads hold the security room, not the access approach. Their authored
+    -- anchors (-363,2643,181.5) and (-360,2645,181.5) are both inside
+    -- `security_center_player_trigger` (-387..-344.5, 2639.5..2662, 177..197), while every
+    -- access volume lies east of x=-331 (jump one -331..-312, jump two -256.5..-224, inner
+    -- door -224..-174). Under the access objective the native task selector pulled the two
+    -- Electron Controllers out to those eastern areas -- through the security door and out of
+    -- reach. No access task group could hold them, so pinning a pair of them never could work.
+    -- The security objective owns this room, so they keep their own cost-selected tasks here.
+    {name = "electron_controllers", region = 0, objective = "EMBER_APEX_SECURITY_OBJECTIVE", task_groups = 7, squads = {"DISPENSER_SUPPORT_A_SQUAD", "DISPENSER_SUPPORT_B_SQUAD"}},
+    {name = "dispenser", region = 0, objective = "EMBER_APEX_SECURITY_OBJECTIVE", task_groups = 7, squads = {"DISPENSER_SUPPORT_C_SQUAD"}},
     {name = "security", region = 0, objective = "EMBER_APEX_SECURITY_OBJECTIVE", task_groups = 7, squads = {"SECURITY_LEDGE_SUPPORT_A_SQUAD", "SECURITY_LEDGE_SUPPORT_B_SQUAD"}},
     {name = "reactor_east_entry", region = 0, objective = "EMBER_APEX_REACTOR_CLAMSHELL_EAST_OBJECTIVE", task_groups = 19, squads = {"REACTOR_CLAMSHELL_EAST_DEFENSE_A_SQUAD", "REACTOR_CLAMSHELL_EAST_SUPPORT_A_SQUAD", "REACTOR_CLAMSHELL_EAST_SUPPORT_B_SQUAD"}},
     {name = "reactor_east_reinforce", region = 0, objective = "EMBER_APEX_REACTOR_CLAMSHELL_EAST_OBJECTIVE", task_groups = 19, squads = {"REACTOR_CLAMSHELL_EAST_DEFENSE_B_SQUAD", "REACTOR_CLAMSHELL_EAST_MELEE_A_SQUAD", "REACTOR_CLAMSHELL_EAST_MELEE_B_SQUAD", "REACTOR_CLAMSHELL_EAST_SUPPORT_C_SQUAD", "REACTOR_CLAMSHELL_EAST_SUPPORT_D_SQUAD"}},

+ 8 - 1
scripts/mission_ember/route_support.lua

@@ -99,11 +99,18 @@ return function(mission, on_clear)
         c:set_variable("ember.checkpoint.hash", hash)
         c:set_variable("ember.checkpoint.name", name)
     end
+    -- Each authored directive carries both HUD lines: `title` is the main objective and
+    -- `description` the sub-objective, so the banner advances by selecting a new element.
+    -- Native type-68 state 0 ENTERS the directive, and the Lua surface always publishes it,
+    -- so every republication replays the banner's entry animation. Republishing whenever the
+    -- navpoint changed therefore made the objective flash on each combat transition while its
+    -- text stayed put. The directive is now keyed on its element alone and carries one stable
+    -- navpoint, so the body changes only at a real milestone and enters exactly once there.
     function api.directive(c, s, hash, marker, combat)
         local rank = ({[56] = 1, [40] = 2, [0] = 3})[s:variable("ember.region")] or 0
         if rank < (s:variable("ember.r.furthest") or 0) then return end
         music.update(c, s)
-        local shown = combat and "" or (marker or "")
+        local shown = marker or ""
         local signature = hash .. ":" .. shown
         if s:variable("ember.r.guidance") == signature then return end
         local binding = assert(directives[hash], "missing authored directive " .. hash)

+ 9 - 0
tests/CMakeLists.txt

@@ -232,3 +232,12 @@ else()
     target_compile_options(behavior_scope_test PRIVATE -Wall -Wextra -Werror -UNDEBUG)
 endif()
 add_test(NAME behavior_scope COMMAND behavior_scope_test)
+
+add_executable(mission_seed_world_change_test mission_seed_world_change_test.cpp)
+target_compile_features(mission_seed_world_change_test PRIVATE cxx_std_20)
+if(MSVC)
+    target_compile_options(mission_seed_world_change_test PRIVATE /W4 /WX /UNDEBUG)
+else()
+    target_compile_options(mission_seed_world_change_test PRIVATE -Wall -Wextra -Werror -UNDEBUG)
+endif()
+add_test(NAME mission_seed_world_change COMMAND mission_seed_world_change_test)

+ 48 - 12
tests/mission_ember_routes_test.lua

@@ -215,16 +215,29 @@ use('SECURITY_PLACED_INTERCEPTOR_OBJECT',1)
 assert(vars['ember.apex.interceptor_boarded'])
 local used=#calls;use('SECURITY_PLACED_INTERCEPTOR_OBJECT',1);assert(#calls==used)
 local controllers=byname.electron_controllers.squads
+-- The dispenser squads hold the security room: both controller anchors are inside
+-- security_center_player_trigger, while every access volume lies east of x=-331. Under the
+-- access objective the native task selector walked them out through the security door, and
+-- no access group could hold them, so pinning a chosen pair of groups could not work either.
+assert(byname.electron_controllers.objective=='EMBER_APEX_SECURITY_OBJECTIVE'
+    and byname.dispenser.objective=='EMBER_APEX_SECURITY_OBJECTIVE',
+    'dispenser squads must hold the security room, not the access approach')
+assert(byname.electron_controllers.fixed_tasks==nil,
+    'controllers pick their own task inside the security objective')
+local security=c:slot(m.Slot.EMBER_APEX_SECURITY_OBJECTIVE)
 for i,n in ipairs(controllers)do
     local values=c:squad(m.Squad[n]).default_counts
-        local assigned
+    local assigned
     for _, row in ipairs(calls) do
-        if row[1]=='assign_combat_objective' and row[2]==slotDefs[m.Slot[n]].name then assigned=row[3].task_group end
+        if row[1]=='assign_combat_objective' and row[2]==slotDefs[m.Slot[n]].name then assigned=row[3] end
     end
-    assert(assigned==byname.electron_controllers.fixed_tasks[i])
+    assert(assigned and assigned.objective.slot_type==security.slot_type
+        and assigned.objective.slot_index==security.slot_index,
+        'controllers must be assigned the security objective')
+    -- Cost selection runs inside that objective's seven authored groups.
     local beforeCosts=#calls
-    call(R.squad,c,s,event(n,{objective_revision=1,task_costs={0,0,0,0,0,0,0,0,0,10,10,0,0,0}}))
-    assert(#calls==beforeCosts,'gate controllers migrated to an unrelated combat area')
+    call(R.squad,c,s,event(n,{objective_revision=1,task_costs={10,10,0,10,10,10,10}}))
+    assert(#calls>beforeCosts,'controllers must follow their security task costs')
     call(R.squad,c,s,event(n,{alive_count=1,previous_alive_count=0,slot_counts=values}))
     call(R.squad,c,s,event(n,{alive_count=0,previous_alive_count=1,slot_counts=values}))
     assert((transition('SECURITY_DOOR_DEVICE').transition=='open')==(i==2), 'controller gate must require both deaths')
@@ -318,15 +331,38 @@ assert(vars['ember.carry.apex.done'] and not vars['ember.carry.apex.held'])
 local before=#calls;use('MOTHER_BRAIN_INTERACT_OBJECT');assert(#calls==before)
 call(R.dispatch,'object',c,s,event('MOTHER_BRAIN_CARRY_OBJECT',{generation=3,present=false,alive=false}))
 assert(not timers['ember.carry.recover.apex.3'],'consumed final cell respawned')
+-- The escape scorch is the same authored effect the climb used, re-filtered to the rail top.
 timer('ember.apex.hazards')
-assert(vars['ember.effect.AOD_REACTOR_RAIL_TOP_HOP_ON'])
-reset_check('escape',function()assert(vars['ember.apex.phase']==6 and vars['ember.apex.dead.COFFIN'])end)
+assert(vars['ember.effect.REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON'],'escape must scorch')
+local escapeFilter
+for i=#calls,1,-1 do
+    if calls[i][1]=='set_object_filter' then escapeFilter=calls[i][2];break end
+end
+assert(escapeFilter=='aod_reactor_rail_top_object_filter','escape hazard must use the rail top')
+-- The weapon is dead once the cell is in: the beam goes down and stays down.
+assert(vars['ember.apex.beam']==false,'beam must shut down after the deposit')
+-- Each authored explosion set is armed so it can fire as the player reaches it.
+for _,set in ipairs({'A','B','C','D'})do
+    local name='ember_apex_explosion_sequence_prefab.explosion_set_'..set:lower()..'_player_trigger'
+    local armed=false
+    for _,row in ipairs(calls)do
+        if row[1]=='fire_trigger' and row[2]==name then armed=true end
+    end
+    assert(armed,'explosion set '..set..' was never armed')
+end
+reset_check('escape',function()
+    assert(vars['ember.apex.phase']==6 and vars['ember.apex.dead.COFFIN'])
+    assert(vars['ember.apex.beam']==false,'escape restart must leave the beam off')
+end)
 trigger('APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER')
-local beforeTransit=#calls
-region(1)
-assert(#calls==beforeTransit and not vars['ember.ending.playing'],'movie started before staging arrival')
-region(49);region(1);call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_STM_CINEMATIC'))
-region(49);region(2);call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_CNN_CINEMATIC'))
+-- Regions 0, 1 and 2 are sibling states of one slice set, so the client never reports a new
+-- held region. The bookend is activated on a later callback, never in the selecting one.
+assert(vars['ember.ending']==1 and not vars['ember.ending.playing'],'movie played in its own selection')
+region(0);assert(vars['ember.ending.playing']==1,'first movie never started')
+call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_STM_CINEMATIC'))
+assert(vars['ember.ending']==2 and vars['ember.ending.playing']==1)
+region(0);assert(vars['ember.ending.playing']==2,'second movie never started')
+call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_CNN_CINEMATIC'))
 assert(vars['ember.complete'])
 local objective=vars['ember.r.guidance']
 region(56);region(40);assert(vars['ember.r.guidance']==objective,'backtracking reset the forward objective')

+ 87 - 0
tests/mission_seed_world_change_test.cpp

@@ -0,0 +1,87 @@
+// Regression for the selected-state arrival window.
+//
+// An authored region is `sliceSetIndex + stateOrdinal`. Ember's apex gameplay and both ending
+// bookends are sibling states of slice set 0 (regions 0, 1 and 2), while every earlier area is
+// its own slice set. Only a slice-set change replaces the client's instantiated world.
+//
+// Treating a sibling move as a replacement is what froze the mission's ending: the roster
+// withheld region 1's groups until the client reported holding region 1, and the client's
+// current region leg only advances on a real slice-set switch, so the report never came.
+
+#include <cassert>
+#include <cstdint>
+
+#include "../Sunrise/src/server/bap/encrypted/push/activity/mission_seed_world_change.h"
+
+namespace {
+
+namespace seed = sunrise::server::bap::encrypted::push::activity;
+
+/** Ember's authored states, as `{sliceSetIndex, effectiveRegion}`. */
+constexpr std::uint32_t kApexSliceSet = 0;
+constexpr std::uint32_t kApexRegion = 0;
+constexpr std::uint32_t kFirstMovieRegion = 1;
+constexpr std::uint32_t kSecondMovieRegion = 2;
+constexpr std::uint32_t kArrivalSliceSet = 48;
+constexpr std::uint32_t kArrivalRegion = 49;
+constexpr std::uint32_t kPowerhouseSliceSet = 64;
+constexpr std::uint32_t kPowerhouseRegion = 64;
+constexpr std::uint32_t kLinkSliceSet = 56;
+constexpr std::uint32_t kLinkRegion = 56;
+constexpr std::uint32_t kCinderSliceSet = 40;
+constexpr std::uint32_t kCinderRegion = 40;
+
+void sibling_states_keep_their_world() {
+    // Apex gameplay to the first ending bookend: same slice set, so nothing is rebuilt.
+    assert(!seed::mission_seed_region_change_replaces_world(
+        kApexSliceSet, kApexRegion, kApexSliceSet, kFirstMovieRegion));
+    // First bookend to the second: still slice set 0.
+    assert(!seed::mission_seed_region_change_replaces_world(
+        kApexSliceSet, kFirstMovieRegion, kApexSliceSet, kSecondMovieRegion));
+    // And back, so a checkpoint or reselection is treated the same way.
+    assert(!seed::mission_seed_region_change_replaces_world(
+        kApexSliceSet, kSecondMovieRegion, kApexSliceSet, kApexRegion));
+}
+
+void slice_set_changes_replace_the_world() {
+    // Every transition the mission already made is a real slice-set change and must stay one,
+    // or the roster would register the new region's groups into a world being torn down.
+    assert(seed::mission_seed_region_change_replaces_world(
+        kArrivalSliceSet, kArrivalRegion, kPowerhouseSliceSet, kPowerhouseRegion));
+    assert(seed::mission_seed_region_change_replaces_world(
+        kPowerhouseSliceSet, kPowerhouseRegion, kLinkSliceSet, kLinkRegion));
+    assert(seed::mission_seed_region_change_replaces_world(
+        kLinkSliceSet, kLinkRegion, kCinderSliceSet, kCinderRegion));
+    assert(seed::mission_seed_region_change_replaces_world(
+        kCinderSliceSet, kCinderRegion, kApexSliceSet, kApexRegion));
+    // Backtracking is a replacement in both directions.
+    assert(seed::mission_seed_region_change_replaces_world(
+        kCinderSliceSet, kCinderRegion, kLinkSliceSet, kLinkRegion));
+}
+
+void reselecting_the_same_state_is_never_a_replacement() {
+    assert(!seed::mission_seed_region_change_replaces_world(
+        kApexSliceSet, kApexRegion, kApexSliceSet, kApexRegion));
+    assert(!seed::mission_seed_region_change_replaces_world(
+        kCinderSliceSet, kCinderRegion, kCinderSliceSet, kCinderRegion));
+    // A same-region selection stays a no-op even if the slice sets disagree: the region is the
+    // publication key, and re-registering identical groups must not reopen an arrival window.
+    assert(!seed::mission_seed_region_change_replaces_world(
+        kApexSliceSet, kApexRegion, kCinderSliceSet, kApexRegion));
+}
+
+void decision_is_available_at_compile_time() {
+    // The production caller is in a lock-held path, so the predicate must fold away.
+    static_assert(!seed::mission_seed_region_change_replaces_world(0, 0, 0, 1));
+    static_assert(seed::mission_seed_region_change_replaces_world(40, 40, 0, 0));
+}
+
+} // namespace
+
+int main() {
+    sibling_states_keep_their_world();
+    slice_set_changes_replace_the_world();
+    reselecting_the_same_state_is_never_a_replacement();
+    decision_is_available_at_compile_time();
+    return 0;
+}