mission_ember_routes_test.lua 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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. c.lifetime = {set = function(_, args) record("lifetime",args.state) end}
  29. 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,
  30. fire_trigger=31,set_directive=68,set_darkness_zone=35,play_dialogue_cue=53,play_sequence=5,
  31. assign_combat_objective=1,reset_objectives=3,set_cinematic_active=6}
  32. function c:slot(id)
  33. assert(id, "nil SDK slot")
  34. if handles[id] then return handles[id] end
  35. local d=assert(slotDefs[id],id)
  36. local h={registry_key=id:sub(6,13),slot_type=d.type,slot_index=d.index}
  37. for method,t in pairs(types) do h[method]=function(_,args)
  38. assert(d.type==t,method.." wrong type "..d.type..":"..d.name)
  39. if method=='set_directive' and args.navpoint then assert(args.navpoint.slot_type==47) end
  40. if method=='watch_damage' then assert(args.target.slot_type==4 and args.revision>0) end
  41. if method=='assign_combat_objective' then assert(args.objective.slot_type==3) end
  42. record(method,d.name,args)
  43. end end
  44. handles[id]=h;return h
  45. end
  46. function c:squad(id)
  47. local d=assert(squadDefs[id],id); local values={}
  48. for _,member in ipairs(d.members) do values[#values+1]=member.count or member.default_count or 1 end
  49. local h={default_counts=values}
  50. function h:counts() local out={};function out:set(i,v) out[i]=v end;return out end
  51. function h:place(args) counts[id]=args.counts or values; record('squad',id,args) end
  52. return h
  53. end
  54. function c:scene(id) assert(id); return {activate=function() record('scene',id) end} end
  55. function c:restart_checkpoint(args) record('checkpoint',args.region,args);return{value='9007199254740993'}end
  56. local R=require('mission_ember.routes')(m)
  57. local roster=require('mission_ember.route_roster')
  58. local byname={};for _,g in ipairs(roster)do byname[g.name]=g end
  59. local function event(name,extra)
  60. 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}
  61. for k,v in pairs(extra or {})do e[k]=v end;return e
  62. end
  63. local maxInstructions = 0
  64. local function call(fn,...)
  65. burst=0;local instructions=0
  66. debug.sethook(function()instructions=instructions+1000;assert(instructions<=100000,'callback instruction budget exceeded')end,'',1000)
  67. -- Production removes pattern functions even from the string metatable.
  68. -- Restrict callbacks too: a normal system Lua interpreter hid a live foundry fault.
  69. local removed={}
  70. for _,name in ipairs({'dump','find','match','gmatch','gsub'})do removed[name]=string[name];string[name]=nil end
  71. local result=table.pack(pcall(fn,...))
  72. for name,value in pairs(removed)do string[name]=value end
  73. debug.sethook();maxInstructions=math.max(maxInstructions,instructions)
  74. assert(result[1], result[2])
  75. return table.unpack(result,2,result.n)
  76. end
  77. local function region(n) vars['ember.region']=n;call(R.client,c,s,{held_region_index=n}) end
  78. local function trigger(n) call(R.dispatch,'trigger',c,s,event(n)) end
  79. local function kill(name)
  80. for _,n in ipairs(assert(byname[name],name).squads)do
  81. local id=m.Squad[n];assert(counts[id], 'unspawned squad '..n)
  82. local values=c:squad(id).default_counts
  83. call(R.squad,c,s,event(n,{alive_count=1,previous_alive_count=0,slot_counts=values}))
  84. call(R.squad,c,s,event(n,{alive_count=0,previous_alive_count=1,slot_counts=values}))
  85. end
  86. assert(vars['ember.r.'..name..'.phase']==2,'uncleared '..name)
  87. end
  88. local function use(n,g) call(R.dispatch,'interaction',c,s,event(n,{generation=g or 1}))end
  89. 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
  90. local function timer(prefix)
  91. local key;for k in pairs(timers)do if k:find(prefix,1,true)==1 then key=k;break end end
  92. assert(key,'no timer '..prefix);timers[key]=nil;call(R.dispatch,'timer',c,s,{timer_name=key})
  93. end
  94. local function copy(t) local out={};for k,v in pairs(t)do out[k]=v end;return out end
  95. local function reset_check(name, verify)
  96. local savedVars, savedTimers, savedCounts=copy(vars),copy(timers),copy(counts)
  97. assert(vars['ember.checkpoint.name']==name)
  98. call(R.reset,c,s)
  99. verify()
  100. vars,timers,counts=savedVars,savedTimers,savedCounts
  101. end
  102. local function transition(name, position)
  103. for i=#calls,1,-1 do
  104. if calls[i][1]=='transition' and calls[i][2]==slotDefs[m.Slot[name]].name
  105. and (not position or calls[i][3].transition=='open' or calls[i][3].transition=='close') then return calls[i][3] end
  106. end
  107. end
  108. region(56)
  109. assert(transition('TUMBLER_DOOR_DEVICE').transition=='open','drill entrance must reveal the rock obstruction')
  110. kill('processing_entry');assert(vars['ember.processing.phase']==2)
  111. do
  112. local savedVars,savedTimers=copy(vars),copy(timers)
  113. -- Late first observation: expiry must recover without a preceding pickup receipt.
  114. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=1,present=false,alive=false}))
  115. timer('ember.carry.recover.processing.')
  116. assert(vars['ember.carry.processing.generation']==3)
  117. -- The expired carried cell may still have an entity awaiting native cleanup.
  118. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=3,present=true,alive=true,owner_known=true,has_owner=true}))
  119. assert(next(timers)==nil,'living carried cell must not recover')
  120. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=3,present=true,alive=false,owner_known=true,has_owner=false}))
  121. timer('ember.carry.recover.processing.')
  122. assert(vars['ember.carry.processing.generation']==5)
  123. use('CARRY_OBJECT',5);use('CARRY_RECEPTACLE_INTERACT_OBJECT',1)
  124. assert(vars['ember.carry.processing.done'])
  125. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=5,present=false,alive=false}))
  126. assert(next(timers)==nil,'inserted cell must not respawn')
  127. vars,timers=savedVars,savedTimers
  128. end
  129. do
  130. local savedVars,savedTimers,savedCounts=copy(vars),copy(timers),copy(counts)
  131. use('CARRY_RECEPTACLE_INTERACT_OBJECT');assert(vars['ember.processing.phase']==2,'empty-handed console advanced')
  132. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=1,present=true,owner_known=true,has_owner=true}))
  133. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=1,present=false}))
  134. region(40);timer('ember.carry.recover.processing.')
  135. assert(vars['ember.carry.processing.generation']==nil,'unloaded region respawned its cell')
  136. region(56);timer('ember.carry.recover.processing.')
  137. assert(vars['ember.carry.processing.generation']==3)
  138. use('CARRY_OBJECT',1);use('CARRY_RECEPTACLE_INTERACT_OBJECT',1);assert(vars['ember.processing.phase']==2)
  139. use('CARRY_OBJECT',3);use('CARRY_RECEPTACLE_INTERACT_OBJECT',1);assert(vars['ember.processing.phase']==3)
  140. vars,timers,counts=savedVars,savedTimers,savedCounts
  141. end
  142. -- Carry pickups publish ownership; they need not have an 80804FB7 use component.
  143. call(R.dispatch,'object',c,s,event('CARRY_OBJECT',{generation=1,present=true,alive=true,owner_known=true,has_owner=true}))
  144. assert(vars['ember.carry.processing.notified'] and vars['ember.carry.processing.held'])
  145. use('CARRY_RECEPTACLE_INTERACT_OBJECT')
  146. assert(vars['ember.processing.phase']==3 and vars['ember.carry.processing.done'])
  147. assert(not vars['ember.carry.processing.held'] and not vars['ember.carry.processing.present'])
  148. do
  149. local consumed
  150. for i=#calls,1,-1 do
  151. if calls[i][1]=='set_interactable_object' and calls[i][2]==slotDefs[m.Slot.CARRY_OBJECT].name then
  152. consumed=calls[i][3];break
  153. end
  154. end
  155. assert(consumed and consumed.active==false and consumed.generation==2,
  156. 'accepted deposit must consume the carried cell using a newer native generation')
  157. local before=#calls
  158. use('CARRY_RECEPTACLE_INTERACT_OBJECT')
  159. assert(#calls==before,'duplicate deposit repeated completion')
  160. end
  161. assert(transition('TUMBLER_DOOR_DEVICE').transition=='close' and not transition('TUMBLER_DOOR_DEVICE').snap)
  162. assert(vars['ember.darkness.enabled'])
  163. reset_check('processing',function()
  164. assert(vars['ember.processing.phase']==2 and not vars['ember.darkness.enabled'])
  165. assert(transition('TUMBLER_DOOR_DEVICE').transition=='open')
  166. assert(vars['ember.r.processing_entry.phase']==2)
  167. use('CARRY_RECEPTACLE_INTERACT_OBJECT',1);assert(vars['ember.processing.phase']==2)
  168. use('CARRY_OBJECT',3);use('CARRY_RECEPTACLE_INTERACT_OBJECT',3)
  169. kill('processing_wave1');kill('processing_wave2');kill('processing_wave3')
  170. end)
  171. kill('processing_wave1');kill('processing_wave2');kill('processing_wave3')
  172. assert(vars['ember.processing.phase']==4 and not vars['ember.darkness.enabled'])
  173. local before=#calls;region(56);assert(#calls==before,'processing restarted on revisit')
  174. region(40)
  175. assert(transition('TUMBLER_HATCH_DOOR_DEVICE').transition=='open','ready-room entrance cannot depend on enemies beyond it')
  176. kill('ready1_entry');kill('ready1_reinforce')
  177. trigger('TUMBLER_ENTRY_PLAYER_TRIGGER');kill('tumbler')
  178. trigger('DECK_EAST_ENTRY_PLAYER_TRIGGER_80B3C6E8');kill('sunburn_east')
  179. for _,n in ipairs({'sunburn_retreat_a','sunburn_retreat_b','sunburn_retreat_c'})do kill(n)end
  180. trigger('DECK_EAST_SECRET_PLAYER_TRIGGER');kill('sunburn_secret')
  181. trigger('DECK_BRIDGE_025_PERCENT_PLAYER_TRIGGER_80B3C6E8');kill('sunburn_bridge')
  182. trigger('DECK_BRIDGE_100_PERCENT_PLAYER_TRIGGER');kill('sunburn_west')
  183. trigger('DECK_WEST_EXIT_PLAYER_TRIGGER_80B3C6E8')
  184. assert(vars['ember.cinder.phase']==5 and not vars['ember.darkness.enabled'],'outdoor deck exit started indoor darkness')
  185. assert(not vars['ember.r.cue.28'] and not vars['ember.r.cue.20'],'indoor dialogue played outside')
  186. trigger('DIALOG_CINDER_READY_ROOM_02_001_PLAYER_TRIGGER')
  187. assert(vars['ember.cinder.phase']==6 and vars['ember.darkness.enabled'] and vars['ember.r.cue.28'])
  188. trigger('DIALOG_CINDER_READY_ROOM_02_002_PLAYER_TRIGGER')
  189. assert(vars['ember.r.cue.20'])
  190. trigger('READY_ROOM_02_SPAWN_SET_02_PLAYER_TRIGGER')
  191. reset_check('ready2',function()kill('ready2_entry');kill('ready2_reinforce');assert(not vars['ember.darkness.enabled'])end)
  192. kill('ready2_entry');kill('ready2_reinforce')
  193. assert(not vars['ember.darkness.enabled'])
  194. trigger('CHAMBER_SPAWN_SET_01_PLAYER_TRIGGER');kill('chamber')
  195. trigger('MEAT_GRINDER_SPAWN_SET_01_PLAYER_TRIGGER');kill('meat1')
  196. trigger('MEAT_GRINDER_SPAWN_SET_02_PLAYER_TRIGGER');kill('meat2')
  197. trigger('ASCENT_SPAWN_SET_01_PLAYER_TRIGGER');kill('ascent')
  198. 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
  199. trigger('FOUNDRY_SPAWN_SET_03_PLAYER_TRIGGER')
  200. reset_check('foundry',function()kill('foundry_entry');kill('foundry_mid');kill('foundry_final');assert(vars['ember.cinder.phase']==14)end)
  201. kill('foundry_entry');kill('foundry_mid');kill('foundry_final')
  202. assert(vars['ember.cinder.phase']==14 and not vars['ember.darkness.enabled'])
  203. region(0)
  204. assert(vars['ember.r.guidance']:sub(1,8)=='4E4862BB','apex must not show the grinder objective')
  205. assert(transition('SECURITY_DOOR_DEVICE').transition=='close')
  206. timer('ember.apex.setup.')
  207. for _,name in ipairs({'SPECOPS_APEX_RING_LASER_OBJECT','SPECOPS_APEX_RING_RING_OBJECT'}) do
  208. call(R.dispatch,'object',c,s,event(name,{generation=1,present=true,alive=true}))
  209. end
  210. assert(transition('SPECOPS_APEX_RING_LASER_DEVICE',true).transition=='open')
  211. assert(transition('SPECOPS_APEX_RING_LASER_DEVICE',true).snap==true)
  212. assert(vars['ember.apex.beam'] and not vars['ember.apex.surge'],'entry must light the resting beam')
  213. assert(transition('CLAMSHELL_TO_COFFIN_EAST_BRIDGE_DEVICE', true).transition=='open')
  214. assert(transition('CLAMSHELL_TO_COFFIN_WEST_BRIDGE_DEVICE', true).transition=='open')
  215. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='lock')
  216. kill('access1');kill('access2');trigger('SECURITY_CENTER_PLAYER_TRIGGER')
  217. -- Boarding the vehicle is never a substitute for defeating the two controllers.
  218. use('SECURITY_PLACED_INTERCEPTOR_OBJECT',2)
  219. assert(not vars['ember.apex.interceptor_boarded'])
  220. use('SECURITY_PLACED_INTERCEPTOR_OBJECT',1)
  221. assert(vars['ember.apex.interceptor_boarded'])
  222. local used=#calls;use('SECURITY_PLACED_INTERCEPTOR_OBJECT',1);assert(#calls==used)
  223. local controllers=byname.electron_controllers.squads
  224. -- The dispenser squads hold the security room: both controller anchors are inside
  225. -- security_center_player_trigger, while every access volume lies east of x=-331. Under the
  226. -- access objective the native task selector walked them out through the security door, and
  227. -- no access group could hold them, so pinning a chosen pair of groups could not work either.
  228. assert(byname.electron_controllers.objective=='EMBER_APEX_SECURITY_OBJECTIVE'
  229. and byname.dispenser.objective=='EMBER_APEX_SECURITY_OBJECTIVE',
  230. 'dispenser squads must hold the security room, not the access approach')
  231. assert(byname.electron_controllers.fixed_tasks==nil,
  232. 'controllers pick their own task inside the security objective')
  233. local security=c:slot(m.Slot.EMBER_APEX_SECURITY_OBJECTIVE)
  234. for i,n in ipairs(controllers)do
  235. local values=c:squad(m.Squad[n]).default_counts
  236. local assigned
  237. for _, row in ipairs(calls) do
  238. if row[1]=='assign_combat_objective' and row[2]==slotDefs[m.Slot[n]].name then assigned=row[3] end
  239. end
  240. assert(assigned and assigned.objective.slot_type==security.slot_type
  241. and assigned.objective.slot_index==security.slot_index,
  242. 'controllers must be assigned the security objective')
  243. -- Cost selection runs inside that objective's seven authored groups.
  244. local beforeCosts=#calls
  245. call(R.squad,c,s,event(n,{objective_revision=1,task_costs={10,10,0,10,10,10,10}}))
  246. assert(#calls>beforeCosts,'controllers must follow their security task costs')
  247. call(R.squad,c,s,event(n,{alive_count=1,previous_alive_count=0,slot_counts=values}))
  248. call(R.squad,c,s,event(n,{alive_count=0,previous_alive_count=1,slot_counts=values}))
  249. assert((transition('SECURITY_DOOR_DEVICE').transition=='open')==(i==2), 'controller gate must require both deaths')
  250. end
  251. assert(vars['ember.r.dispenser.phase']==1 and vars['ember.r.security.phase']==1, 'supports must not gate the exit')
  252. assert(vars['ember.music.section']==21)
  253. kill('dispenser');kill('security')
  254. trigger('APEX_DIRECTIVE_REACTOR_GOTO_PLAYER_TRIGGER')
  255. reset_check('reactor',function()
  256. call(R.client,c,s,{held_region_index=0,spawn_state=0})
  257. assert(vars['ember.apex.phase']==3 and vars['ember.apex.generation']==3)
  258. damage('REACTOR_CLAMSHELL_EAST',1,1);damage('REACTOR_CLAMSHELL_EAST',0,1)
  259. assert(not vars['ember.apex.dead.EAST'],'stale damage crossed reset generation')
  260. end)
  261. for _,side in ipairs({'east','west'})do for _,wave in ipairs({'entry','reinforce','final'})do kill('reactor_'..side..'_'..wave)end end
  262. assert(vars['ember.music.section']==22)
  263. timer('ember.apex.explain.');assert(vars['ember.r.cue.41'] and vars['ember.music.section']==23)
  264. assert(timers['ember.apex.vents.1']==14000)
  265. timer('ember.apex.vents.') -- Surge precedes any target exposure.
  266. assert(vars['ember.apex.vent_step']=='warning' and timers['ember.apex.vents.1']==6000)
  267. -- The weapon fires continuously through the fight; the exposure cycle must not blink it.
  268. -- The surge is the authored device drive of a beam that stays present and powered.
  269. assert(vars['ember.apex.beam']==true,'the weapon must keep firing across the cycle')
  270. -- Inverted pose, as on the landing and clamshell bridges: resting is open, the surge drives
  271. -- to close. The opposite mapping rendered the surge as the weapon's normal state.
  272. assert(vars['ember.apex.surge']==true,'the warning must drive the beam')
  273. assert(transition('SPECOPS_APEX_RING_LASER_DEVICE', true).transition=='close')
  274. assert(transition('SPECOPS_APEX_RING_RING_DEVICE', true).transition=='close')
  275. assert(transition('REACTOR_CLAMSHELL_EAST_DOOR_A_DEVICE').transition=='close')
  276. local coolingStart=#calls
  277. timer('ember.apex.vents.')
  278. assert(timers['ember.apex.vents.1']==10000)
  279. assert(vars['ember.apex.surge']==false,'cooling shutters opened during the surge')
  280. local restored={}
  281. for i=coolingStart+1,#calls do
  282. local row=calls[i]
  283. if row[1]=='transition' then
  284. for _,name in ipairs({'SPECOPS_APEX_RING_LASER_DEVICE','SPECOPS_APEX_RING_RING_DEVICE'}) do
  285. if row[2]==slotDefs[m.Slot[name]].name and row[3].transition=='open' then restored[name]=true end
  286. end
  287. if row[2]==slotDefs[m.Slot.REACTOR_CLAMSHELL_EAST_DOOR_A_DEVICE].name and row[3].transition=='open' then
  288. assert(restored.SPECOPS_APEX_RING_LASER_DEVICE and restored.SPECOPS_APEX_RING_RING_DEVICE,
  289. 'both beam devices must end the surge before opening the cooling shutters')
  290. end
  291. end
  292. end
  293. assert(transition('REACTOR_CLAMSHELL_EAST_LIGHT_A_DEVICE').transition=='power_on')
  294. for _,side in ipairs({'EAST','WEST'})do
  295. local n='REACTOR_CLAMSHELL_'..side
  296. damage(n,0);assert(not vars['ember.apex.dead.'..side], 'initial zero autocompleted target')
  297. call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=true,alive=false}))
  298. assert(not vars['ember.apex.dead.'..side], 'initial dead object autocompleted target')
  299. call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=true,alive=true}))
  300. call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=false,alive=false}))
  301. assert(not vars['ember.apex.dead.'..side], 'unloaded object counted as a kill')
  302. if side=='EAST' then damage(n,1);damage(n,-1);assert(not vars['ember.apex.dead.EAST']);damage(n,0)
  303. else call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=true,alive=false})) end
  304. assert(vars['ember.apex.dead.'..side])
  305. if side=='EAST' then
  306. assert(vars['ember.apex.phase']==3 and vars['ember.music.section']==24)
  307. assert(transition('CLAMSHELL_TO_COFFIN_EAST_BRIDGE_DEVICE', true).transition=='open', 'one vent extended bridges')
  308. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='lock')
  309. end
  310. end
  311. assert(timers['ember.apex.vents.1']==10000, 'changing targets restarted the exposure clock')
  312. -- A core transition cannot publish into a different loaded area.
  313. do
  314. local before=#calls;vars['ember.region']=40
  315. call(R.dispatch,'timer',c,s,{timer_name='ember.apex.core.1'})
  316. assert(#calls==before and not vars['ember.apex.core_ready'])
  317. vars['ember.region']=0
  318. end
  319. timer('ember.apex.core.')
  320. do
  321. local before=#calls
  322. call(R.dispatch,'timer',c,s,{timer_name='ember.apex.core.1'})
  323. assert(#calls==before,'duplicate core timer replayed devices/objects')
  324. end
  325. assert(transition('CLAMSHELL_TO_COFFIN_EAST_BRIDGE_DEVICE', true).transition=='close')
  326. assert(transition('CLAMSHELL_TO_COFFIN_WEST_BRIDGE_DEVICE', true).transition=='close')
  327. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='open')
  328. assert(transition('REACTOR_COFFIN_DOOR_WEST_DEVICE').transition=='open')
  329. assert(transition('REACTOR_SHIELD_DEVICE').transition=='open')
  330. -- The new target is created inside its native housing, and cycles on the same clock.
  331. do
  332. local targetSpawn=false
  333. 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
  334. assert(targetSpawn, 'central reactor target was not created')
  335. end
  336. timer('ember.apex.vents.')
  337. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='close')
  338. assert(timers['ember.apex.vents.1']==14000)
  339. timer('ember.apex.vents.')
  340. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='close')
  341. timer('ember.apex.vents.')
  342. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='open' and vars['ember.r.cue.46'])
  343. assert(vars['ember.music.section']==26)
  344. for _,n in ipairs({'coffin_east','coffin_west','coffin_interior'})do kill(n)end
  345. damage('REACTOR_COFFIN',1);damage('REACTOR_COFFIN',0)
  346. assert(vars['ember.apex.phase']==5)
  347. assert(not timers['ember.apex.vents.1'] and vars['ember.r.cue.48'])
  348. assert(transition('MOTHER_BRAIN_DOOR_DEVICE').transition=='open')
  349. use('MOTHER_BRAIN_INTERACT_OBJECT');assert(vars['ember.apex.phase']==5,'empty deposit began escape')
  350. use('MOTHER_BRAIN_CARRY_OBJECT')
  351. assert(vars['ember.r.guidance']:find('EMBER_DIRECTIVE_REACTOR_MOTHER_BRAIN_DELIVERY_NAV_POINT',1,true))
  352. -- Expiry is recoverable here too, without resetting the core or its open doors.
  353. call(R.dispatch,'object',c,s,event('MOTHER_BRAIN_CARRY_OBJECT',{generation=1,present=false,alive=false}))
  354. timer('ember.carry.recover.apex.')
  355. assert(vars['ember.carry.apex.generation']==3 and vars['ember.apex.phase']==5)
  356. use('MOTHER_BRAIN_CARRY_OBJECT',1);use('MOTHER_BRAIN_INTERACT_OBJECT');assert(vars['ember.apex.phase']==5)
  357. local depositStart=#calls
  358. use('MOTHER_BRAIN_CARRY_OBJECT',3);use('MOTHER_BRAIN_INTERACT_OBJECT');assert(vars['ember.apex.phase']==6)
  359. -- Publish first; only native object presence starts the movement.
  360. assert(not vars['ember.apex.ship_started'])
  361. call(R.dispatch,'object',c,s,event('REACTOR_GETAWAY_SHIP_OBJECT',{generation=1,present=false,alive=false}))
  362. assert(not vars['ember.apex.ship_started'])
  363. call(R.dispatch,'object',c,s,event('REACTOR_GETAWAY_SHIP_OBJECT',{generation=1,present=true,alive=true}))
  364. assert(vars['ember.apex.ship_started'])
  365. local shipSteps={}
  366. for i=depositStart+1,#calls do
  367. local row=calls[i]
  368. if row[2]==slotDefs[m.Slot.REACTOR_GETAWAY_SHIP_OBJECT].name and row[1]=='set_object_active' and row[3].active then
  369. shipSteps[#shipSteps+1]='spawn'
  370. elseif row[2]==slotDefs[m.Slot.REACTOR_GETAWAY_SHIP_DEVICE].name and row[1]=='transition' then
  371. shipSteps[#shipSteps+1]=row[3].transition
  372. end
  373. end
  374. assert(table.concat(shipSteps,',')=='spawn,unlock,power_on,close,open', 'escape ship activation order')
  375. local shipStartedCalls=#calls
  376. call(R.dispatch,'object',c,s,event('REACTOR_GETAWAY_SHIP_OBJECT',{generation=1,present=true,alive=true}))
  377. assert(#calls==shipStartedCalls,'duplicate presence restarted the escape flight')
  378. assert(vars['ember.carry.apex.done'] and not vars['ember.carry.apex.held'])
  379. local before=#calls;use('MOTHER_BRAIN_INTERACT_OBJECT');assert(#calls==before)
  380. call(R.dispatch,'object',c,s,event('MOTHER_BRAIN_CARRY_OBJECT',{generation=3,present=false,alive=false}))
  381. assert(not timers['ember.carry.recover.apex.3'],'consumed final cell respawned')
  382. -- Escape's native sunburn object must not stack with the scripted climb/rail burn.
  383. timer('ember.apex.hazards')
  384. local escapeEffect
  385. for i=#calls,1,-1 do
  386. if calls[i][1]=='set_mission_effect' then escapeEffect=calls[i][3];break end
  387. end
  388. assert(escapeEffect and escapeEffect.enabled==false and escapeEffect.filter==nil,
  389. 'escape must detach the added burn instead of stacking it with native sunburn')
  390. -- The weapon is dead once the cell is in: powered off, but still installed. Deactivating the
  391. -- ring objects would take the beam and its surrounding structure out of the world entirely.
  392. assert(vars['ember.apex.beam']==false,'the beam must stop firing after the deposit')
  393. assert(vars['ember.apex.surge']==false,'the drive must return to its baseline with the power')
  394. assert(transition('SPECOPS_APEX_RING_LASER_DEVICE', true).transition=='open',
  395. 'a dark weapon rests in its normal pose, not the surge')
  396. local poweredOff=false
  397. for _,row in ipairs(calls)do
  398. if row[1]=='transition' and row[2]=='specops_apex_ring.laser_device'
  399. and row[3].transition=='power_off' then poweredOff=true end
  400. end
  401. assert(poweredOff,'the beam must be powered off at the deposit')
  402. -- Only the laser is the beam. Removing the core or ring would take the weapon's structure,
  403. -- and everything built around it, out of the world.
  404. for _,row in ipairs(calls)do
  405. if row[1]=='set_object_active' and row[3] and row[3].active==false then
  406. local n=tostring(row[2])
  407. assert(not (n:find('specops_apex_ring.core',1,true) or n:find('specops_apex_ring.ring',1,true)),
  408. 'the weapon structure must never be deactivated: '..n)
  409. end
  410. end
  411. -- Each authored explosion set is armed so it can fire as the player reaches it.
  412. for _,set in ipairs({'A','B','C','D'})do
  413. local name='ember_apex_explosion_sequence_prefab.explosion_set_'..set:lower()..'_player_trigger'
  414. local armed=false
  415. for _,row in ipairs(calls)do
  416. if row[1]=='fire_trigger' and row[2]==name then armed=true end
  417. end
  418. assert(armed,'explosion set '..set..' was never armed')
  419. end
  420. reset_check('escape',function()
  421. assert(vars['ember.apex.phase']==6 and vars['ember.apex.dead.COFFIN'])
  422. assert(vars['ember.apex.beam']==false,'escape restart must leave the beam off')
  423. end)
  424. trigger('APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER')
  425. -- Regions 0, 1 and 2 are sibling states of one slice set, so the client never reports a new
  426. -- held region. Both the selection and activation must be queued in this callback.
  427. -- No teleport is armed for a sibling state, so no further client report arrives. The movie
  428. -- must be queued with its own selection or it never starts.
  429. assert(vars['ember.ending']==1 and vars['ember.ending.playing']==1,'first movie never started')
  430. local started=false
  431. for _,row in ipairs(calls)do
  432. if row[1]=='set_cinematic_active' and row[2]=='pf_cinematic_bookend_stm._cinematic'
  433. and row[3].active then started=true end
  434. end
  435. assert(started,'first bookend was never activated')
  436. call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_STM_CINEMATIC'))
  437. assert(vars['ember.ending']==2 and vars['ember.ending.playing']==2,'second movie never started')
  438. call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_CNN_CINEMATIC'))
  439. assert(vars['ember.complete'])
  440. local objective=vars['ember.r.guidance']
  441. region(56);region(40);assert(vars['ember.r.guidance']==objective,'backtracking reset the forward objective')
  442. local n=#calls;call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_CNN_CINEMATIC'));assert(#calls==n)
  443. print('Full authored route passed: '..peak..' variables, '..peakBurst..' intents/event, '..peakTimers..' timers peak; '..maxInstructions..' Lua instructions/event (including mock)' )