mission_ember_routes_test.lua 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. package.path = "scripts/?.lua;" .. package.path
  2. package.preload["sunrise.activity_sdk"] = function() return {} end
  3. local m = dofile(arg[1] or "build/sdk-full-mission/sdk/lua/missions/mission_ember_80b3c09e.lua")
  4. local slotDefs, squadDefs = {}, {}
  5. for _, row in pairs(m.slots) do slotDefs[row.id] = row end
  6. for _, row in pairs(m.squads) do squadDefs[row.id] = row end
  7. local vars, timers, calls, handles, counts = {}, {}, {}, {}, {}
  8. local peak, burst, peakBurst, peakTimers = 0, 0, 0, 0
  9. local function record(method, name, args)
  10. burst = burst + 1; peakBurst = math.max(peakBurst, burst)
  11. assert(burst <= 63, "intent burst exceeded at " .. method .. ":" .. tostring(name))
  12. calls[#calls+1] = {method, name, args}
  13. end
  14. local c = {sdk = {device_transitions = {open = "open", close = "close", power_on = "power_on", power_off = "power_off", lock = "lock", unlock = "unlock"}, squad_modes = {replace = "replace", reserve = "reserve"},
  15. lifetime_states = {at = function(_, n) return n end}}}
  16. local s = {variable = function(_, k) return vars[k] end}
  17. function c:set_variable(k, v)
  18. assert(type(v) == "number" or type(v) == "boolean" or type(v) == "string", "opaque value stored")
  19. vars[k] = v; local n = 0; for _ in pairs(vars) do n=n+1 end; peak=math.max(peak,n); assert(n <= 512)
  20. end
  21. function c:clear_variable(k) vars[k] = nil end
  22. function c:start_timer(k, ms)
  23. timers[k] = ms; local n=0; for _ in pairs(timers) do n=n+1 end; peakTimers=math.max(n,peakTimers); assert(n<=32)
  24. end
  25. function c:cancel_timer(k) timers[k] = nil end
  26. function c:set_phase(p) record("phase",p) end
  27. function c:select_state(state) assert(state.region_index); record("select",state.region_index) end
  28. local movieStatus={}
  29. function c:play_prerendered_movie(args)
  30. record('movie',args.index,args)
  31. if not args.stop then movieStatus[args.index]='queued' end
  32. end
  33. function c:prerendered_movie_status(index) return movieStatus[index] or 'absent' end
  34. c.lifetime = {set = function(_, args) record("lifetime",args.state) end}
  35. local types = {set_engagement_state=70,set_music_section=11,set_mission_effect=26,set_object_filter=34,transition=23,set_interactable_object=4,set_object_active=4,watch_damage=20,
  36. set_scene_events=43,fire_trigger=31,set_directive=68,set_darkness_zone=35,play_dialogue_cue=53,play_sequence=5,
  37. assign_combat_objective=1,reset_objectives=3,set_cinematic_active=6}
  38. function c:slot(id)
  39. assert(id, "nil SDK slot")
  40. if handles[id] then return handles[id] end
  41. local d=assert(slotDefs[id],id)
  42. local h={registry_key=id:sub(6,13),slot_type=d.type,slot_index=d.index}
  43. for method,t in pairs(types) do h[method]=function(_,args)
  44. assert(d.type==t,method.." wrong type "..d.type..":"..d.name)
  45. if method=='set_directive' and args.navpoint then assert(args.navpoint.slot_type==47) end
  46. if method=='watch_damage' then assert(args.target.slot_type==4 and args.revision>0) end
  47. if method=='assign_combat_objective' then assert(args.objective.slot_type==3) end
  48. record(method,d.name,args)
  49. end end
  50. handles[id]=h;return h
  51. end
  52. function c:squad(id)
  53. local d=assert(squadDefs[id],id); local values={}
  54. for _,member in ipairs(d.members) do values[#values+1]=member.count or member.default_count or 1 end
  55. local h={default_counts=values}
  56. function h:counts() local out={};function out:set(i,v) out[i]=v end;return out end
  57. function h:place(args) counts[id]=args.counts or values; record('squad',id,args) end
  58. return h
  59. end
  60. function c:scene(id) assert(id); return {activate=function() record('scene',id) end} end
  61. function c:restart_checkpoint(args) record('checkpoint',args.region,args);return{value='9007199254740993'}end
  62. local R=require('mission_ember.routes')(m)
  63. local roster=require('mission_ember.route_roster')
  64. local byname={};for _,g in ipairs(roster)do byname[g.name]=g end
  65. local function event(name,extra)
  66. local h=c:slot(assert(m.Slot[name],name));local e={registry_key=h.registry_key,slot_type=h.slot_type,slot_index=h.slot_index}
  67. for k,v in pairs(extra or {})do e[k]=v end;return e
  68. end
  69. local maxInstructions = 0
  70. local function call(fn,...)
  71. burst=0;local instructions=0
  72. debug.sethook(function()instructions=instructions+1000;assert(instructions<=100000,'callback instruction budget exceeded')end,'',1000)
  73. -- Production removes pattern functions even from the string metatable.
  74. -- Restrict callbacks too: a normal system Lua interpreter hid a live foundry fault.
  75. local removed={}
  76. for _,name in ipairs({'dump','find','match','gmatch','gsub'})do removed[name]=string[name];string[name]=nil end
  77. local result=table.pack(pcall(fn,...))
  78. for name,value in pairs(removed)do string[name]=value end
  79. debug.sethook();maxInstructions=math.max(maxInstructions,instructions)
  80. assert(result[1], result[2])
  81. return table.unpack(result,2,result.n)
  82. end
  83. local function region(n) vars['ember.region']=n;call(R.client,c,s,{held_region_index=n}) end
  84. local function trigger(n) call(R.dispatch,'trigger',c,s,event(n)) end
  85. local function kill(name)
  86. for _,n in ipairs(assert(byname[name],name).squads)do
  87. local id=m.Squad[n];assert(counts[id], 'unspawned squad '..n)
  88. local values=c:squad(id).default_counts
  89. call(R.squad,c,s,event(n,{alive_count=1,previous_alive_count=0,slot_counts=values}))
  90. call(R.squad,c,s,event(n,{alive_count=0,previous_alive_count=1,slot_counts=values}))
  91. end
  92. assert(vars['ember.r.'..name..'.phase']==2,'uncleared '..name)
  93. end
  94. local function use(n,g) call(R.dispatch,'interaction',c,s,event(n,{generation=g or 1}))end
  95. local function damage(n,h,g)call(R.dispatch,'damage',c,s,event(n..'_TARGET_DAMAGE',{health=h,shield=0,revision=g or 1}))end
  96. local function timer(prefix)
  97. local key;for k in pairs(timers)do if k:find(prefix,1,true)==1 then key=k;break end end
  98. assert(key,'no timer '..prefix);timers[key]=nil;call(R.dispatch,'timer',c,s,{timer_name=key})
  99. end
  100. local function copy(t) local out={};for k,v in pairs(t)do out[k]=v end;return out end
  101. local function reset_check(name, verify)
  102. local savedVars, savedTimers, savedCounts=copy(vars),copy(timers),copy(counts)
  103. assert(vars['ember.checkpoint.name']==name)
  104. call(R.reset,c,s)
  105. verify()
  106. vars,timers,counts=savedVars,savedTimers,savedCounts
  107. end
  108. local function transition(name, position)
  109. for i=#calls,1,-1 do
  110. if calls[i][1]=='transition' and calls[i][2]==slotDefs[m.Slot[name]].name
  111. and (not position or calls[i][3].transition=='open' or calls[i][3].transition=='close') then return calls[i][3] end
  112. end
  113. end
  114. region(56)
  115. assert(transition('TUMBLER_DOOR_DEVICE').transition=='open','drill entrance must reveal the rock obstruction')
  116. kill('processing_entry');assert(vars['ember.processing.phase']==2)
  117. do
  118. local savedVars,savedTimers=copy(vars),copy(timers)
  119. -- Late first observation: expiry must recover without a preceding pickup receipt.
  120. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=1,present=false,alive=false}))
  121. timer('ember.carry.recover.processing.')
  122. assert(vars['ember.carry.processing.generation']==3)
  123. -- The expired carried cell may still have an entity awaiting native cleanup.
  124. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=3,present=true,alive=true,owner_known=true,has_owner=true}))
  125. assert(next(timers)==nil,'living carried cell must not recover')
  126. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=3,present=true,alive=false,owner_known=true,has_owner=false}))
  127. timer('ember.carry.recover.processing.')
  128. assert(vars['ember.carry.processing.generation']==5)
  129. use('CARRY_OBJECT',5);use('CARRY_RECEPTACLE_INTERACT_OBJECT',1)
  130. assert(vars['ember.carry.processing.done'])
  131. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=5,present=false,alive=false}))
  132. assert(next(timers)==nil,'inserted cell must not respawn')
  133. vars,timers=savedVars,savedTimers
  134. end
  135. do
  136. local savedVars,savedTimers,savedCounts=copy(vars),copy(timers),copy(counts)
  137. use('CARRY_RECEPTACLE_INTERACT_OBJECT');assert(vars['ember.processing.phase']==2,'empty-handed console advanced')
  138. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=1,present=true,owner_known=true,has_owner=true}))
  139. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=1,present=false}))
  140. region(40);timer('ember.carry.recover.processing.')
  141. assert(vars['ember.carry.processing.generation']==nil,'unloaded region respawned its cell')
  142. region(56);timer('ember.carry.recover.processing.')
  143. assert(vars['ember.carry.processing.generation']==3)
  144. use('CARRY_OBJECT',1);use('CARRY_RECEPTACLE_INTERACT_OBJECT',1);assert(vars['ember.processing.phase']==2)
  145. use('CARRY_OBJECT',3);use('CARRY_RECEPTACLE_INTERACT_OBJECT',1);assert(vars['ember.processing.phase']==3)
  146. vars,timers,counts=savedVars,savedTimers,savedCounts
  147. end
  148. -- Carry pickups publish ownership; they need not have an 80804FB7 use component.
  149. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=1,present=true,alive=true,owner_known=true,has_owner=true}))
  150. assert(vars['ember.carry.processing.notified'] and vars['ember.carry.processing.held'])
  151. use('CARRY_RECEPTACLE_INTERACT_OBJECT')
  152. assert(vars['ember.processing.phase']==3 and vars['ember.carry.processing.done'])
  153. assert(not vars['ember.carry.processing.held'] and not vars['ember.carry.processing.present'])
  154. do
  155. local consumed
  156. for i=#calls,1,-1 do
  157. if calls[i][1]=='set_interactable_object' and calls[i][2]==slotDefs[m.Slot.CARRY_OBJECT].name then
  158. consumed=calls[i][3];break
  159. end
  160. end
  161. assert(consumed and consumed.active==false and consumed.generation==2,
  162. 'accepted deposit must consume the carried cell using a newer native generation')
  163. local before=#calls
  164. use('CARRY_RECEPTACLE_INTERACT_OBJECT')
  165. assert(#calls==before,'duplicate deposit repeated completion')
  166. end
  167. assert(transition('TUMBLER_DOOR_DEVICE').transition=='close' and not transition('TUMBLER_DOOR_DEVICE').snap)
  168. assert(vars['ember.darkness.enabled'])
  169. reset_check('processing',function()
  170. assert(vars['ember.processing.phase']==2 and not vars['ember.darkness.enabled'])
  171. assert(transition('TUMBLER_DOOR_DEVICE').transition=='open')
  172. assert(vars['ember.r.processing_entry.phase']==2)
  173. use('CARRY_RECEPTACLE_INTERACT_OBJECT',1);assert(vars['ember.processing.phase']==2)
  174. use('CARRY_OBJECT',3);use('CARRY_RECEPTACLE_INTERACT_OBJECT',3)
  175. kill('processing_wave1');kill('processing_wave2');kill('processing_wave3')
  176. end)
  177. kill('processing_wave1');kill('processing_wave2');kill('processing_wave3')
  178. assert(vars['ember.processing.phase']==4 and not vars['ember.darkness.enabled'])
  179. local before=#calls;region(56);assert(#calls==before,'processing restarted on revisit')
  180. region(40)
  181. assert(transition('TUMBLER_HATCH_DOOR_DEVICE').transition=='open','ready-room entrance cannot depend on enemies beyond it')
  182. kill('ready1_entry');kill('ready1_reinforce')
  183. trigger('TUMBLER_ENTRY_PLAYER_TRIGGER');kill('tumbler')
  184. trigger('DECK_EAST_ENTRY_PLAYER_TRIGGER_80B3C6E8');kill('sunburn_east')
  185. for _,n in ipairs({'sunburn_retreat_a','sunburn_retreat_b','sunburn_retreat_c'})do kill(n)end
  186. trigger('DECK_EAST_SECRET_PLAYER_TRIGGER');kill('sunburn_secret')
  187. trigger('DECK_BRIDGE_025_PERCENT_PLAYER_TRIGGER_80B3C6E8');kill('sunburn_bridge')
  188. trigger('DECK_BRIDGE_100_PERCENT_PLAYER_TRIGGER');kill('sunburn_west')
  189. trigger('DECK_WEST_EXIT_PLAYER_TRIGGER_80B3C6E8')
  190. assert(vars['ember.cinder.phase']==5 and not vars['ember.darkness.enabled'],'outdoor deck exit started indoor darkness')
  191. assert(not vars['ember.r.cue.28'] and not vars['ember.r.cue.20'],'indoor dialogue played outside')
  192. trigger('DIALOG_CINDER_READY_ROOM_02_001_PLAYER_TRIGGER')
  193. assert(vars['ember.cinder.phase']==6 and vars['ember.darkness.enabled'] and vars['ember.r.cue.28'])
  194. trigger('DIALOG_CINDER_READY_ROOM_02_002_PLAYER_TRIGGER')
  195. assert(vars['ember.r.cue.20'])
  196. trigger('READY_ROOM_02_SPAWN_SET_02_PLAYER_TRIGGER')
  197. reset_check('ready2',function()kill('ready2_entry');kill('ready2_reinforce');assert(not vars['ember.darkness.enabled'])end)
  198. kill('ready2_entry');kill('ready2_reinforce')
  199. assert(not vars['ember.darkness.enabled'])
  200. trigger('CHAMBER_SPAWN_SET_01_PLAYER_TRIGGER');kill('chamber')
  201. trigger('MEAT_GRINDER_SPAWN_SET_01_PLAYER_TRIGGER');kill('meat1')
  202. trigger('MEAT_GRINDER_SPAWN_SET_02_PLAYER_TRIGGER');kill('meat2')
  203. trigger('ASCENT_SPAWN_SET_01_PLAYER_TRIGGER');kill('ascent')
  204. for i,n in ipairs({'ascent_retreat_a','ascent_retreat_b','ascent_retreat_c'})do trigger('ASCENT_SPAWN_SET_0'..(i+1)..'_PLAYER_TRIGGER');kill(n)end
  205. trigger('FOUNDRY_SPAWN_SET_03_PLAYER_TRIGGER')
  206. reset_check('foundry',function()kill('foundry_entry');kill('foundry_mid');kill('foundry_final');assert(vars['ember.cinder.phase']==14)end)
  207. kill('foundry_entry');kill('foundry_mid');kill('foundry_final')
  208. assert(vars['ember.cinder.phase']==14 and not vars['ember.darkness.enabled'])
  209. region(0)
  210. assert(vars['ember.r.guidance']:sub(1,8)=='4E4862BB','apex must not show the grinder objective')
  211. assert(transition('SECURITY_DOOR_DEVICE').transition=='close')
  212. timer('ember.apex.setup.')
  213. for _,name in ipairs({'SPECOPS_APEX_RING_LASER_OBJECT','SPECOPS_APEX_RING_RING_OBJECT'}) do
  214. call(R.dispatch,'object',c,s,event(name,{generation=1,present=true,alive=true}))
  215. end
  216. assert(transition('SPECOPS_APEX_RING_LASER_DEVICE',true).transition=='close')
  217. assert(transition('SPECOPS_APEX_RING_LASER_DEVICE',true).snap==false, 'startup must run effect events instead of seeking past them')
  218. local primedCalls=#calls
  219. call(R.dispatch,'object',c,s,event('SPECOPS_APEX_RING_LASER_OBJECT',{generation=1,present=true,alive=true}))
  220. assert(#calls==primedCalls,'duplicate laser presence restarted its drive')
  221. assert(vars['ember.apex.beam'] and not vars['ember.apex.surge'],'entry must light the resting beam')
  222. assert(transition('CLAMSHELL_TO_COFFIN_EAST_BRIDGE_DEVICE', true).transition=='open')
  223. assert(transition('CLAMSHELL_TO_COFFIN_WEST_BRIDGE_DEVICE', true).transition=='open')
  224. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='lock')
  225. kill('access1');kill('access2');trigger('SECURITY_CENTER_PLAYER_TRIGGER')
  226. -- Boarding the vehicle is never a substitute for defeating the two controllers.
  227. use('SECURITY_PLACED_INTERCEPTOR_OBJECT',2)
  228. assert(not vars['ember.apex.interceptor_boarded'])
  229. use('SECURITY_PLACED_INTERCEPTOR_OBJECT',1)
  230. assert(vars['ember.apex.interceptor_boarded'])
  231. local used=#calls;use('SECURITY_PLACED_INTERCEPTOR_OBJECT',1);assert(#calls==used)
  232. local controllers=byname.electron_controllers.squads
  233. -- The dispenser squads hold the security room: both controller anchors are inside
  234. -- security_center_player_trigger, while every access volume lies east of x=-331. Under the
  235. -- access objective the native task selector walked them out through the security door, and
  236. -- no access group could hold them, so pinning a chosen pair of groups could not work either.
  237. assert(byname.electron_controllers.objective=='EMBER_APEX_SECURITY_OBJECTIVE'
  238. and byname.dispenser.objective=='EMBER_APEX_SECURITY_OBJECTIVE',
  239. 'dispenser squads must hold the security room, not the access approach')
  240. assert(byname.electron_controllers.fixed_tasks==nil,
  241. 'controllers pick their own task inside the security objective')
  242. local security=c:slot(m.Slot.EMBER_APEX_SECURITY_OBJECTIVE)
  243. for i,n in ipairs(controllers)do
  244. local values=c:squad(m.Squad[n]).default_counts
  245. local assigned
  246. for _, row in ipairs(calls) do
  247. if row[1]=='assign_combat_objective' and row[2]==slotDefs[m.Slot[n]].name then assigned=row[3] end
  248. end
  249. assert(assigned and assigned.objective.slot_type==security.slot_type
  250. and assigned.objective.slot_index==security.slot_index,
  251. 'controllers must be assigned the security objective')
  252. -- Cost selection runs inside that objective's seven authored groups.
  253. local beforeCosts=#calls
  254. call(R.squad,c,s,event(n,{objective_revision=1,task_costs={10,10,0,10,10,10,10}}))
  255. assert(#calls>beforeCosts,'controllers must follow their security task costs')
  256. call(R.squad,c,s,event(n,{alive_count=1,previous_alive_count=0,slot_counts=values}))
  257. call(R.squad,c,s,event(n,{alive_count=0,previous_alive_count=1,slot_counts=values}))
  258. assert((transition('SECURITY_DOOR_DEVICE').transition=='open')==(i==2), 'controller gate must require both deaths')
  259. end
  260. assert(vars['ember.r.dispenser.phase']==1 and vars['ember.r.security.phase']==1, 'supports must not gate the exit')
  261. assert(vars['ember.music.section']==21)
  262. kill('dispenser');kill('security')
  263. trigger('APEX_DIRECTIVE_REACTOR_GOTO_PLAYER_TRIGGER')
  264. reset_check('reactor',function()
  265. call(R.client,c,s,{held_region_index=0,spawn_state=0})
  266. assert(vars['ember.apex.phase']==3 and vars['ember.apex.generation']==3)
  267. damage('REACTOR_CLAMSHELL_EAST',1,1);damage('REACTOR_CLAMSHELL_EAST',0,1)
  268. assert(not vars['ember.apex.dead.EAST'],'stale damage crossed reset generation')
  269. end)
  270. for _,side in ipairs({'east','west'})do for _,wave in ipairs({'entry','reinforce','final'})do kill('reactor_'..side..'_'..wave)end end
  271. assert(vars['ember.music.section']==22)
  272. timer('ember.apex.explain.');assert(vars['ember.r.cue.41'] and vars['ember.music.section']==23)
  273. assert(timers['ember.apex.vents.1']==14000)
  274. assert(timers['ember.apex.surge_audio.1']==nil,'audio must have no independent pre-roll timer')
  275. local beforeAudio=#calls
  276. timer('ember.apex.vents.') -- Same callback starts the visible surge and its sound.
  277. local alarms=0
  278. local beamMoved=false
  279. for i=beforeAudio+1,#calls do
  280. local row=calls[i]
  281. if row[1]=='transition' and row[2]==slotDefs[m.Slot.SPECOPS_APEX_RING_LASER_DEVICE].name then
  282. beamMoved=row[3].transition=='open'
  283. end
  284. if row[1]=='play_sequence' then
  285. assert(beamMoved,'alarm was requested before the surge state changed')
  286. alarms=alarms+1
  287. end
  288. end
  289. assert(alarms==2,'both surviving clamshell alarms must start on the surge rising edge')
  290. assert(vars['ember.apex.vent_step']=='warning' and timers['ember.apex.vents.1']==6000)
  291. -- The weapon fires continuously through the fight; the exposure cycle must not blink it.
  292. -- The surge is the authored device drive of a beam that stays present and powered.
  293. assert(vars['ember.apex.beam']==true,'the weapon must keep firing across the cycle')
  294. -- Live-confirmed pose: close is normal; open is surge. Shutter timing stays independent.
  295. assert(vars['ember.apex.surge']==true,'the warning must drive the beam')
  296. assert(transition('SPECOPS_APEX_RING_LASER_DEVICE', true).transition=='open')
  297. assert(transition('SPECOPS_APEX_RING_RING_DEVICE', true).transition=='open')
  298. assert(transition('REACTOR_CLAMSHELL_EAST_DOOR_A_DEVICE').transition=='close')
  299. local coolingStart=#calls
  300. timer('ember.apex.vents.')
  301. assert(timers['ember.apex.vents.1']==10000)
  302. assert(vars['ember.apex.surge']==false,'cooling shutters opened during the surge')
  303. for i=coolingStart+1,#calls do assert(calls[i][1]~='play_sequence','opening the shutters must not restart surge audio') end
  304. local restored={}
  305. for i=coolingStart+1,#calls do
  306. local row=calls[i]
  307. if row[1]=='transition' then
  308. for _,name in ipairs({'SPECOPS_APEX_RING_LASER_DEVICE','SPECOPS_APEX_RING_RING_DEVICE'}) do
  309. if row[2]==slotDefs[m.Slot[name]].name and row[3].transition=='close' then restored[name]=true end
  310. end
  311. if row[2]==slotDefs[m.Slot.REACTOR_CLAMSHELL_EAST_DOOR_A_DEVICE].name and row[3].transition=='open' then
  312. assert(restored.SPECOPS_APEX_RING_LASER_DEVICE and restored.SPECOPS_APEX_RING_RING_DEVICE,
  313. 'both beam devices must end the surge before opening the cooling shutters')
  314. end
  315. end
  316. end
  317. assert(transition('REACTOR_CLAMSHELL_EAST_LIGHT_A_DEVICE').transition=='power_on')
  318. for _,side in ipairs({'EAST','WEST'})do
  319. local n='REACTOR_CLAMSHELL_'..side
  320. damage(n,0);assert(not vars['ember.apex.dead.'..side], 'initial zero autocompleted target')
  321. call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=true,alive=false}))
  322. assert(not vars['ember.apex.dead.'..side], 'initial dead object autocompleted target')
  323. call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=true,alive=true}))
  324. call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=false,alive=false}))
  325. assert(not vars['ember.apex.dead.'..side], 'unloaded object counted as a kill')
  326. if side=='EAST' then damage(n,1);damage(n,-1);assert(not vars['ember.apex.dead.EAST']);damage(n,0)
  327. else call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=true,alive=false})) end
  328. assert(vars['ember.apex.dead.'..side])
  329. if side=='EAST' then
  330. assert(vars['ember.apex.phase']==3 and vars['ember.music.section']==24)
  331. assert(transition('CLAMSHELL_TO_COFFIN_EAST_BRIDGE_DEVICE', true).transition=='open', 'one vent extended bridges')
  332. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='lock')
  333. end
  334. end
  335. assert(timers['ember.apex.vents.1']==10000, 'changing targets restarted the exposure clock')
  336. -- A core transition cannot publish into a different loaded area.
  337. do
  338. local before=#calls;vars['ember.region']=40
  339. call(R.dispatch,'timer',c,s,{timer_name='ember.apex.core.1'})
  340. assert(#calls==before and not vars['ember.apex.core_ready'])
  341. vars['ember.region']=0
  342. end
  343. timer('ember.apex.core.')
  344. do
  345. local before=#calls
  346. call(R.dispatch,'timer',c,s,{timer_name='ember.apex.core.1'})
  347. assert(#calls==before,'duplicate core timer replayed devices/objects')
  348. end
  349. assert(transition('CLAMSHELL_TO_COFFIN_EAST_BRIDGE_DEVICE', true).transition=='close')
  350. assert(transition('CLAMSHELL_TO_COFFIN_WEST_BRIDGE_DEVICE', true).transition=='close')
  351. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='open')
  352. assert(transition('REACTOR_COFFIN_DOOR_WEST_DEVICE').transition=='open')
  353. assert(transition('REACTOR_SHIELD_DEVICE').transition=='open')
  354. -- The new target is created inside its native housing, and cycles on the same clock.
  355. do
  356. local targetSpawn=false
  357. for _,v in ipairs(calls)do if v[1]=='set_interactable_object' and v[2]==slotDefs[m.Slot.REACTOR_COFFIN_TARGET_OBJECT].name then targetSpawn=true end end
  358. assert(targetSpawn, 'central reactor target was not created')
  359. end
  360. timer('ember.apex.vents.')
  361. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='close')
  362. assert(timers['ember.apex.vents.1']==14000)
  363. timer('ember.apex.vents.')
  364. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='close')
  365. timer('ember.apex.vents.')
  366. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='open' and vars['ember.r.cue.46'])
  367. assert(vars['ember.music.section']==26)
  368. for _,n in ipairs({'coffin_east','coffin_west','coffin_interior'})do kill(n)end
  369. damage('REACTOR_COFFIN',1);damage('REACTOR_COFFIN',0)
  370. assert(vars['ember.apex.phase']==5)
  371. assert(not timers['ember.apex.vents.1'] and vars['ember.r.cue.48'])
  372. assert(transition('MOTHER_BRAIN_DOOR_DEVICE').transition=='open')
  373. use('MOTHER_BRAIN_INTERACT_OBJECT');assert(vars['ember.apex.phase']==5,'empty deposit began escape')
  374. use('MOTHER_BRAIN_CARRY_OBJECT')
  375. assert(vars['ember.r.guidance']:find('EMBER_DIRECTIVE_REACTOR_MOTHER_BRAIN_DELIVERY_NAV_POINT',1,true))
  376. -- Expiry is recoverable here too, without resetting the core or its open doors.
  377. call(R.dispatch,'object',c,s,event('MOTHER_BRAIN_CARRY_OBJECT',{generation=1,present=false,alive=false}))
  378. timer('ember.carry.recover.apex.')
  379. assert(vars['ember.carry.apex.generation']==3 and vars['ember.apex.phase']==5)
  380. use('MOTHER_BRAIN_CARRY_OBJECT',1);use('MOTHER_BRAIN_INTERACT_OBJECT');assert(vars['ember.apex.phase']==5)
  381. local depositStart=#calls
  382. use('MOTHER_BRAIN_CARRY_OBJECT',3);use('MOTHER_BRAIN_INTERACT_OBJECT');assert(vars['ember.apex.phase']==6)
  383. -- Publish first; only native object presence starts the movement.
  384. assert(not vars['ember.apex.ship_started'])
  385. call(R.dispatch,'object',c,s,event('REACTOR_GETAWAY_SHIP_OBJECT',{generation=1,present=false,alive=false}))
  386. assert(not vars['ember.apex.ship_started'])
  387. call(R.dispatch,'object',c,s,event('REACTOR_GETAWAY_SHIP_OBJECT',{generation=1,present=true,alive=true}))
  388. assert(vars['ember.apex.ship_started'])
  389. local shipSteps={}
  390. for i=depositStart+1,#calls do
  391. local row=calls[i]
  392. if row[2]==slotDefs[m.Slot.REACTOR_GETAWAY_SHIP_OBJECT].name and row[1]=='set_object_active' and row[3].active then
  393. shipSteps[#shipSteps+1]='spawn'
  394. elseif row[2]==slotDefs[m.Slot.REACTOR_GETAWAY_SHIP_DEVICE].name and row[1]=='transition' then
  395. shipSteps[#shipSteps+1]=row[3].transition
  396. end
  397. end
  398. assert(table.concat(shipSteps,',')=='spawn,unlock,power_on,close,open', 'escape ship activation order')
  399. local shipStartedCalls=#calls
  400. call(R.dispatch,'object',c,s,event('REACTOR_GETAWAY_SHIP_OBJECT',{generation=1,present=true,alive=true}))
  401. assert(#calls==shipStartedCalls,'duplicate presence restarted the escape flight')
  402. assert(vars['ember.carry.apex.done'] and not vars['ember.carry.apex.held'])
  403. local before=#calls;use('MOTHER_BRAIN_INTERACT_OBJECT');assert(#calls==before)
  404. call(R.dispatch,'object',c,s,event('MOTHER_BRAIN_CARRY_OBJECT',{generation=3,present=false,alive=false}))
  405. assert(not timers['ember.carry.recover.apex.3'],'consumed final cell respawned')
  406. -- Deposit moves the ONE burn attachment from pipes to the escape rail.
  407. local burnSlot=slotDefs[m.Slot.REACTOR_COFFIN_INTERIOR_THERMAL_HOP_ON].name
  408. local escapeAttachments=0
  409. local sunburnState
  410. for i=depositStart+1,#calls do
  411. local row=calls[i]
  412. if row[1]=='set_mission_effect' and row[3].enabled then
  413. assert(row[2]==burnSlot,'escape created a second independent damage source')
  414. assert(row[3].filter==c:slot(m.Slot.AOD_REACTOR_RAIL_TOP_OBJECT_FILTER))
  415. escapeAttachments=escapeAttachments+1
  416. end
  417. if row[1]=='set_object_active' and row[2]==slotDefs[m.Slot.SUNBURN_DAMAGE_OBJECT].name then
  418. sunburnState=row[3].active
  419. end
  420. end
  421. assert(escapeAttachments==1,'deposit must move the existing burn to the escape filter exactly once')
  422. assert(sunburnState==false,'native damage volume would stack with the player attachment')
  423. local afterRail=#calls
  424. call(R.dispatch,'timer',c,s,{timer_name='ember.apex.hazards'})
  425. assert(#calls==afterRail,'duplicate hazard callback must not reattach scorch')
  426. -- The weapon is dead once the cell is in: powered off, but still installed. Deactivating the
  427. -- ring objects would take the beam and its surrounding structure out of the world entirely.
  428. assert(vars['ember.apex.beam']==false,'the beam must stop firing after the deposit')
  429. assert(vars['ember.apex.surge']==false,'the drive must return to its baseline with the power')
  430. assert(transition('SPECOPS_APEX_RING_LASER_DEVICE', true).transition=='close',
  431. 'a dark weapon rests in its normal pose, not the surge')
  432. local poweredOff=false
  433. for _,row in ipairs(calls)do
  434. if row[1]=='transition' and row[2]=='specops_apex_ring.laser_device'
  435. and row[3].transition=='power_off' then poweredOff=true end
  436. end
  437. assert(poweredOff,'the beam must be powered off at the deposit')
  438. -- Only the laser is the beam. Removing the core or ring would take the weapon's structure,
  439. -- and everything built around it, out of the world.
  440. for _,row in ipairs(calls)do
  441. if row[1]=='set_object_active' and row[3] and row[3].active==false then
  442. local n=tostring(row[2])
  443. assert(not (n:find('specops_apex_ring.core',1,true) or n:find('specops_apex_ring.ring',1,true)),
  444. 'the weapon structure must never be deactivated: '..n)
  445. end
  446. end
  447. -- Each authored explosion set is armed so it can fire as the player reaches it.
  448. for _,set in ipairs({'A','B','C','D'})do
  449. local name='ember_apex_explosion_sequence_prefab.explosion_set_'..set:lower()..'_player_trigger'
  450. local armed=false
  451. for _,row in ipairs(calls)do
  452. if row[1]=='fire_trigger' and row[2]==name then armed=true end
  453. end
  454. assert(armed,'explosion set '..set..' was never armed')
  455. end
  456. local explosionKeys={0x329EB106,0x633B82E9,0x15A78938,0xF9D55A83}
  457. local function explosionState()
  458. for i=#calls,1,-1 do if calls[i][1]=='set_scene_events' then return calls[i][3] end end
  459. end
  460. assert(#explosionState().events==0,'deposit must not detonate all four escape sets')
  461. local explosionGeneration=explosionState().generation
  462. for i,set in ipairs({'A','B','C','D'}) do
  463. local triggerName='EMBER_APEX_EXPLOSION_SEQUENCE_PREFAB_EXPLOSION_SET_'..set..'_PLAYER_TRIGGER'
  464. trigger(triggerName)
  465. local state=explosionState()
  466. assert(state.generation==explosionGeneration,'route events must not restart the whole scene')
  467. assert(#state.events==i and state.events[i]==explosionKeys[i],'wrong authored explosion event')
  468. local before=#calls
  469. trigger(triggerName)
  470. assert(#calls==before,'backtracking detonated an explosion twice')
  471. end
  472. reset_check('escape',function()
  473. assert(vars['ember.apex.phase']==6 and vars['ember.apex.dead.COFFIN'])
  474. assert(vars['ember.apex.beam']==false,'escape restart must leave the beam off')
  475. assert(explosionState().generation==explosionGeneration+1 and #explosionState().events==0,
  476. 'wipe must restart the explosion scene without replaying old events')
  477. end)
  478. trigger('APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER')
  479. local sunburnAfterEscape
  480. for i=#calls,1,-1 do
  481. if calls[i][1]=='set_object_active' and calls[i][2]==slotDefs[m.Slot.SUNBURN_DAMAGE_OBJECT].name then
  482. sunburnAfterEscape=calls[i][3].active;break
  483. end
  484. end
  485. assert(sunburnAfterEscape==false,'escape completion must disable native sunburn')
  486. -- Direct playback leaves Apex loaded and never selects a bookend world.
  487. assert(vars['ember.ending']==1 and movieStatus[1]=='queued')
  488. for i=depositStart+1,#calls do assert(calls[i][1]~='select','ending must not request world teardown') end
  489. local beforeStart=#calls
  490. call(R.client,c,s,{held_region_index=0})
  491. assert(#calls==beforeStart and not vars['ember.complete'],'queue receipt must not complete a movie')
  492. local first=event('PF_CINEMATIC_BOOKEND_STM_CINEMATIC',{runtime_object_id='9007199254740993'})
  493. call(R.terminated,c,s,first);call(R.started,c,s,first);call(R.skip,c,s,first)
  494. assert(vars['ember.ending']==1 and #calls==beforeStart,'type-6 receipts must not advance direct playback')
  495. movieStatus[1]='preparing';call(R.client,c,s,{held_region_index=0})
  496. assert(not vars['ember.ending.playing'])
  497. movieStatus[1]='playing';call(R.client,c,s,{held_region_index=0})
  498. assert(vars['ember.ending.playing']==1)
  499. call(R.skip,c,s,first)
  500. assert(vars['ember.ending']==1 and not vars['ember.complete'],'skip must wait for native completion')
  501. local stoppedCalls=#calls;call(R.skip,c,s,first);assert(#calls==stoppedCalls)
  502. movieStatus[1]='complete';timer('ember.ending.poll')
  503. assert(vars['ember.ending']==2 and movieStatus[2]=='queued')
  504. call(R.client,c,s,{held_region_index=0});call(R.terminated,c,s,first)
  505. assert(not vars['ember.complete'],'first movie completion must not complete the mission')
  506. movieStatus[2]='failed';call(R.client,c,s,{held_region_index=0})
  507. assert(vars['ember.ending.failed'] and not vars['ember.complete'],'playback failure must not award completion')
  508. movieStatus[2]='playing';call(R.client,c,s,{held_region_index=0})
  509. movieStatus[2]='complete';timer('ember.ending.poll')
  510. assert(vars['ember.complete'] and vars['ember.ending']==3)
  511. local completedCalls=#calls;call(R.client,c,s,{held_region_index=0});assert(#calls==completedCalls)
  512. local objective=vars['ember.r.guidance']
  513. region(56);region(40);assert(vars['ember.r.guidance']==objective,'backtracking reset the forward objective')
  514. local n=#calls;call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_CNN_CINEMATIC'));assert(#calls==n)
  515. print('Full authored route passed: '..peak..' variables, '..peakBurst..' intents/event, '..peakTimers..' timers peak; '..maxInstructions..' Lua instructions/event (including mock)' )