Quellcode durchsuchen

Queue the ending bookends with their selection; split beam from structure

Ending. The teleport guard worked: the run logs state_region
result=teleport_not_required and no freeze. But no cinematic was ever enqueued.
Removing the teleport also removed the client region report the activation was
waiting on, so the movie could never start: the last msg12 arrived long before
the selection and none followed it. The bookend is now queued together with its
own state selection. Intents dispatch in order and a state selection completes
only once its roster revision publishes, so the cinematic Auth still follows the
seed that carries its slot, and slot handles resolve against the static SDK
definition table rather than the selected state.

Beam. Both ring devices are ordinary type-23s, carrying the same class refs as a
working clamshell door, and neither their position nor their power changes
anything visible: the beam stayed lit through the deposit. Object presence is the
only lever that moves it, and the three objects split by role -- the core and ring
models carry solid geometry with full LOD tables, while the laser model carries
the emitter's effect components and no geometry LODs. The core and ring are
therefore the structure and are placed once for the whole mission; the laser is
the beam and is switched off at the deposit, leaving the weapon installed but
dark. The exposure cycle no longer toggles it, since presence switching reads as
the beam vanishing rather than dimming.

The surge itself is NOT implemented: no authored lane has been shown to change
this beam's intensity, and it will not be guessed at again.

22 portable tests and five Lua suites pass; route peaks at 233/512 variables,
61/63 intents per callback and 3/32 timers.
Millie vor 3 Tagen
Ursprung
Commit
b44a174fa4

+ 25 - 30
scripts/mission_ember/apex.lua

@@ -50,26 +50,24 @@ return function(m, a, ending)
         end
         a.device(c, "REACTOR_SHIELD_DEVICE", open, snap)
     end
-    -- The three placed ring objects ARE the weapon's structure and stay active for the whole
-    -- mission: deactivating them removes the beam and everything built around it, rather than
-    -- dimming it. The two devices are its lanes, so the beam has three authored states:
-    --   off   -- present but dead: powered off, the pose the weapon holds once the cell is in.
-    --   idle  -- powered and running, the beam's baseline between exposures.
-    --   surge -- powered and driven open, the intensification before the shutters expose a target.
-    local ring_objects = {"SPECOPS_APEX_RING_LASER_OBJECT", "SPECOPS_APEX_RING_CORE_OBJECT",
-        "SPECOPS_APEX_RING_RING_OBJECT"}
-    local BEAM_OFF, BEAM_IDLE, BEAM_SURGE = 0, 1, 2
-    local function beam(c, s, mode, 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") == mode then return end
-        c:set_variable("ember.apex.beam", mode)
-        local powered = mode ~= BEAM_OFF
+    -- Both ring devices are ordinary type-23s -- their configs carry the same class refs as a
+    -- working clamshell door -- and driving their position or power changes nothing visible.
+    -- Presence of the placed objects is the only lever that moves this beam, and the three
+    -- objects split by role: the core and ring models carry solid geometry with full LOD tables,
+    -- while the laser model carries the emitter's own effect components and no geometry LODs.
+    -- So the core and ring are the structure the weapon is built from and stay for the whole
+    -- mission, and the laser is the beam itself. Firing it off leaves the weapon installed but
+    -- dark, rather than deleting it and everything around it.
+    local structure_objects = {"SPECOPS_APEX_RING_CORE_OBJECT", "SPECOPS_APEX_RING_RING_OBJECT"}
+    local function beam(c, s, firing, snap)
+        -- Idempotent: redundant publications would spend intents from the callback's budget.
+        if s:variable("ember.apex.beam") == firing then return end
+        c:set_variable("ember.apex.beam", firing)
+        a.objects(c, {"SPECOPS_APEX_RING_LASER_OBJECT"}, firing)
+        -- The authored device lanes still follow, so the weapon's own state matches what is
+        -- drawn even though the beam is not rendered from them.
         for _, name in ipairs({"SPECOPS_APEX_RING_LASER_DEVICE", "SPECOPS_APEX_RING_RING_DEVICE"}) do
-            a.device(c, name, mode == BEAM_SURGE, snap)
-            -- FX power is independent of mechanical position, so the weapon can stay
-            -- installed and lit, installed and dark, or driven wide open.
-            lane(c, name, powered and "power_on" or "power_off", snap)
+            lane(c, name, firing and "power_on" or "power_off", snap)
         end
     end
     -- `REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON` and `FOUNDRY_THERMAL_DOT_HOP_ON` both reference
@@ -132,14 +130,12 @@ 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, s, BEAM_SURGE)
             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, s, BEAM_IDLE, 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
@@ -161,7 +157,8 @@ 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, s, BEAM_IDLE, true)
+        -- The weapon is firing at the sun from the moment the area comes up.
+        beam(c, s, true, true)
     end
     local function start_reactor(c, s)
         if phase(s) >= 3 then return end
@@ -190,7 +187,7 @@ return function(m, a, ending)
             -- The weapon is dead once the cell is in: powered off but still installed, so
             -- the beam and everything built around it stay in the world. The previous code
             -- opened both devices here, which left it running through the whole escape.
-            beam(c, s, BEAM_OFF, true)
+            beam(c, s, false, true)
             c:cancel_timer(vent_timer(s))
             a.scene(c, "MOTHER_BRAIN_HOLE_EXPLOSION_SCENE")
             arm_escape(c)
@@ -208,9 +205,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}
-        -- The weapon's structure is placed once and stays for the whole mission; `beam()`
-        -- only changes its power and drive.
-        a.objects(c, ring_objects, true)
+        -- The weapon's structure is placed once and is never taken back out of the world.
+        a.objects(c, structure_objects, true)
         for _, side in ipairs(sides) do
             doors(c, side, false, true)
             a.device(c, "CLAMSHELL_PIPES_" .. side .. "_DEVICE", false, true)
@@ -289,9 +285,8 @@ 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)
-            -- The weapon keeps running until the cell goes in; the exposure cycle is over,
-            -- so it settles back to its baseline rather than holding the surge.
-            beam(c, s, BEAM_IDLE)
+            -- The weapon keeps firing until the cell goes in.
+            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")
@@ -374,7 +369,7 @@ return function(m, a, ending)
             set(c, 6)
             -- 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, BEAM_OFF, true)
+            beam(c, s, false, true)
             arm_escape(c)
             a.darkness(c, s, true)
         else

+ 16 - 19
scripts/mission_ember/ending.lua

@@ -2,12 +2,16 @@
 --
 -- 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.
+-- instantiates no new world, so no slice-set teleport is armed for it -- and that also means the
+-- client never sends another region report. Waiting for one leaves the movie unstarted forever,
+-- which is exactly what a run with the teleport removed showed: the state was selected and no
+-- cinematic was ever enqueued.
 --
--- 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.
+-- So the activation is queued with the selection instead of on a later callback. Intents are
+-- dispatched in order and a state selection completes only once its own roster revision has
+-- published, so the cinematic Auth always follows the seed that carries its slot. Slot handles
+-- resolve against the static SDK definition table, not the selected state, so naming the
+-- bookend before its state is live is safe.
 return function(m)
     local movies = {
         {state = m.states.STATE_80B3C09E_0000_0001_80B3C091, slot = m.Slot.PF_CINEMATIC_BOOKEND_STM_CINEMATIC},
@@ -15,24 +19,17 @@ return function(m)
     }
     local E = {}
     local music = require("mission_ember.music")(m)
-    local function select_movie(c, index)
+    local function play(c, index)
+        local row = assert(movies[index])
         c:set_variable("ember.ending", index)
-        c:set_variable("ember.ending.selected", index)
-        c:select_state(assert(movies[index].state))
+        c:set_variable("ember.ending.playing", index)
+        c:select_state(assert(row.state))
+        c:slot(assert(row.slot)):set_cinematic_active{active = true}
     end
     function E.start(c, s)
         if s:variable("ember.ending") then return end
         music.update(c, s)
-        select_movie(c, 1)
-    end
-    function E.client(c, s, e)
-        local index = s:variable("ember.ending")
-        local row = index and movies[index]
-        -- 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}
+        play(c, 1)
     end
     function E.terminated(c, s, e)
         local index = s:variable("ember.ending")
@@ -41,7 +38,7 @@ return function(m)
         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
         slot:set_cinematic_active{active = false}
-        if movies[index + 1] then select_movie(c, index + 1)
+        if movies[index + 1] then play(c, index + 1)
         else
             c:set_variable("ember.ending", index + 1)
             c:set_variable("ember.complete", true)

+ 2 - 1
scripts/mission_ember/routes.lua

@@ -10,7 +10,8 @@ return function(m)
     controllers[0] = require("mission_ember.apex")(m, a, ending)
     local R = {}
     function R.client(c, s, e)
-        ending.client(c, s, e)
+        -- Once the ending owns the mission the route stops driving anything. The bookends
+        -- need no client report: they are queued with their own state selection.
         if s:variable("ember.ending") then return end
         local active = controllers[s:variable("ember.region")]
         if not active then return end

+ 21 - 13
tests/mission_ember_routes_test.lua

@@ -258,9 +258,8 @@ timer('ember.apex.explain.');assert(vars['ember.r.cue.41'] and vars['ember.music
 assert(timers['ember.apex.vents.1']==14000)
 timer('ember.apex.vents.') -- Surge precedes any target exposure.
 assert(vars['ember.apex.vent_step']=='warning' and timers['ember.apex.vents.1']==6000)
--- The baseline is a running weapon, not a dark one, so the surge is a drive change.
-assert(vars['ember.apex.beam']==2,'warning must surge the beam')
-assert(transition('SPECOPS_APEX_RING_LASER_DEVICE', true).transition=='open')
+-- The weapon fires continuously through the fight; the exposure cycle must not blink it.
+assert(vars['ember.apex.beam']==true,'the weapon must keep firing across the cycle')
 assert(transition('SPECOPS_APEX_RING_LASER_DEVICE').transition=='power_on')
 assert(transition('REACTOR_CLAMSHELL_EAST_DOOR_A_DEVICE').transition=='close')
 timer('ember.apex.vents.')
@@ -344,13 +343,16 @@ 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: powered off, but still installed. Deactivating the
 -- ring objects would take the beam and its surrounding structure out of the world entirely.
-assert(vars['ember.apex.beam']==0,'beam must be powered off after the deposit')
-local ringRemoved=false
+assert(vars['ember.apex.beam']==false,'the beam must stop firing after the deposit')
+-- Only the laser is the beam. Removing the core or ring would take the weapon's structure,
+-- and everything built around it, out of the world.
 for _,row in ipairs(calls)do
-    if row[1]=='set_object_active' and row[3] and row[3].active==false
-        and tostring(row[2]):find('specops_apex_ring',1,true) then ringRemoved=true end
+    if row[1]=='set_object_active' and row[3] and row[3].active==false then
+        local n=tostring(row[2])
+        assert(not (n:find('specops_apex_ring.core',1,true) or n:find('specops_apex_ring.ring',1,true)),
+            'the weapon structure must never be deactivated: '..n)
+    end
 end
-assert(not ringRemoved,'the weapon structure must never be deactivated')
 -- 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'
@@ -362,16 +364,22 @@ for _,set in ipairs({'A','B','C','D'})do
 end
 reset_check('escape',function()
     assert(vars['ember.apex.phase']==6 and vars['ember.apex.dead.COFFIN'])
-    assert(vars['ember.apex.beam']==0,'escape restart must leave the beam powered off')
+    assert(vars['ember.apex.beam']==false,'escape restart must leave the beam off')
 end)
 trigger('APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER')
 -- 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')
+-- No teleport is armed for a sibling state, so no further client report arrives. The movie
+-- must be queued with its own selection or it never starts.
+assert(vars['ember.ending']==1 and vars['ember.ending.playing']==1,'first movie never started')
+local started=false
+for _,row in ipairs(calls)do
+    if row[1]=='set_cinematic_active' and row[2]=='pf_cinematic_bookend_stm._cinematic'
+        and row[3].active then started=true end
+end
+assert(started,'first bookend was never activated')
 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')
+assert(vars['ember.ending']==2 and 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']