mission_ember_routes_test.lua 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  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==false, 'startup must run effect events instead of seeking past them')
  212. local primedCalls=#calls
  213. call(R.dispatch,'object',c,s,event('SPECOPS_APEX_RING_LASER_OBJECT',{generation=1,present=true,alive=true}))
  214. assert(#calls==primedCalls,'duplicate laser presence restarted its drive')
  215. assert(vars['ember.apex.beam'] and not vars['ember.apex.surge'],'entry must light the resting beam')
  216. assert(transition('CLAMSHELL_TO_COFFIN_EAST_BRIDGE_DEVICE', true).transition=='open')
  217. assert(transition('CLAMSHELL_TO_COFFIN_WEST_BRIDGE_DEVICE', true).transition=='open')
  218. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='lock')
  219. kill('access1');kill('access2');trigger('SECURITY_CENTER_PLAYER_TRIGGER')
  220. -- Boarding the vehicle is never a substitute for defeating the two controllers.
  221. use('SECURITY_PLACED_INTERCEPTOR_OBJECT',2)
  222. assert(not vars['ember.apex.interceptor_boarded'])
  223. use('SECURITY_PLACED_INTERCEPTOR_OBJECT',1)
  224. assert(vars['ember.apex.interceptor_boarded'])
  225. local used=#calls;use('SECURITY_PLACED_INTERCEPTOR_OBJECT',1);assert(#calls==used)
  226. local controllers=byname.electron_controllers.squads
  227. -- The dispenser squads hold the security room: both controller anchors are inside
  228. -- security_center_player_trigger, while every access volume lies east of x=-331. Under the
  229. -- access objective the native task selector walked them out through the security door, and
  230. -- no access group could hold them, so pinning a chosen pair of groups could not work either.
  231. assert(byname.electron_controllers.objective=='EMBER_APEX_SECURITY_OBJECTIVE'
  232. and byname.dispenser.objective=='EMBER_APEX_SECURITY_OBJECTIVE',
  233. 'dispenser squads must hold the security room, not the access approach')
  234. assert(byname.electron_controllers.fixed_tasks==nil,
  235. 'controllers pick their own task inside the security objective')
  236. local security=c:slot(m.Slot.EMBER_APEX_SECURITY_OBJECTIVE)
  237. for i,n in ipairs(controllers)do
  238. local values=c:squad(m.Squad[n]).default_counts
  239. local assigned
  240. for _, row in ipairs(calls) do
  241. if row[1]=='assign_combat_objective' and row[2]==slotDefs[m.Slot[n]].name then assigned=row[3] end
  242. end
  243. assert(assigned and assigned.objective.slot_type==security.slot_type
  244. and assigned.objective.slot_index==security.slot_index,
  245. 'controllers must be assigned the security objective')
  246. -- Cost selection runs inside that objective's seven authored groups.
  247. local beforeCosts=#calls
  248. call(R.squad,c,s,event(n,{objective_revision=1,task_costs={10,10,0,10,10,10,10}}))
  249. assert(#calls>beforeCosts,'controllers must follow their security task costs')
  250. call(R.squad,c,s,event(n,{alive_count=1,previous_alive_count=0,slot_counts=values}))
  251. call(R.squad,c,s,event(n,{alive_count=0,previous_alive_count=1,slot_counts=values}))
  252. assert((transition('SECURITY_DOOR_DEVICE').transition=='open')==(i==2), 'controller gate must require both deaths')
  253. end
  254. assert(vars['ember.r.dispenser.phase']==1 and vars['ember.r.security.phase']==1, 'supports must not gate the exit')
  255. assert(vars['ember.music.section']==21)
  256. kill('dispenser');kill('security')
  257. trigger('APEX_DIRECTIVE_REACTOR_GOTO_PLAYER_TRIGGER')
  258. reset_check('reactor',function()
  259. call(R.client,c,s,{held_region_index=0,spawn_state=0})
  260. assert(vars['ember.apex.phase']==3 and vars['ember.apex.generation']==3)
  261. damage('REACTOR_CLAMSHELL_EAST',1,1);damage('REACTOR_CLAMSHELL_EAST',0,1)
  262. assert(not vars['ember.apex.dead.EAST'],'stale damage crossed reset generation')
  263. end)
  264. for _,side in ipairs({'east','west'})do for _,wave in ipairs({'entry','reinforce','final'})do kill('reactor_'..side..'_'..wave)end end
  265. assert(vars['ember.music.section']==22)
  266. timer('ember.apex.explain.');assert(vars['ember.r.cue.41'] and vars['ember.music.section']==23)
  267. assert(timers['ember.apex.vents.1']==14000)
  268. assert(timers['ember.apex.surge_audio.1']==8000,'audio pre-roll must lead the unchanged visual surge by six seconds')
  269. local beforeAudio=#calls
  270. timer('ember.apex.surge_audio.')
  271. assert(vars['ember.apex.vent_step']=='closed' and not vars['ember.apex.surge'],'audio pre-roll changed visual timing')
  272. local alarms=0
  273. for i=beforeAudio+1,#calls do if calls[i][1]=='play_sequence' then alarms=alarms+1 end end
  274. assert(alarms==2,'both surviving clamshells must pre-roll their authored audio')
  275. timer('ember.apex.vents.') -- Surge precedes any target exposure.
  276. assert(vars['ember.apex.vent_step']=='warning' and timers['ember.apex.vents.1']==6000)
  277. -- The weapon fires continuously through the fight; the exposure cycle must not blink it.
  278. -- The surge is the authored device drive of a beam that stays present and powered.
  279. assert(vars['ember.apex.beam']==true,'the weapon must keep firing across the cycle')
  280. -- Inverted pose, as on the landing and clamshell bridges: resting is open, the surge drives
  281. -- to close. The opposite mapping rendered the surge as the weapon's normal state.
  282. assert(vars['ember.apex.surge']==true,'the warning must drive the beam')
  283. assert(transition('SPECOPS_APEX_RING_LASER_DEVICE', true).transition=='close')
  284. assert(transition('SPECOPS_APEX_RING_RING_DEVICE', true).transition=='close')
  285. assert(transition('REACTOR_CLAMSHELL_EAST_DOOR_A_DEVICE').transition=='close')
  286. local coolingStart=#calls
  287. timer('ember.apex.vents.')
  288. assert(timers['ember.apex.vents.1']==10000)
  289. assert(vars['ember.apex.surge']==false,'cooling shutters opened during the surge')
  290. for i=coolingStart+1,#calls do assert(calls[i][1]~='play_sequence','opening the shutters must not restart surge audio') end
  291. local restored={}
  292. for i=coolingStart+1,#calls do
  293. local row=calls[i]
  294. if row[1]=='transition' then
  295. for _,name in ipairs({'SPECOPS_APEX_RING_LASER_DEVICE','SPECOPS_APEX_RING_RING_DEVICE'}) do
  296. if row[2]==slotDefs[m.Slot[name]].name and row[3].transition=='open' then restored[name]=true end
  297. end
  298. if row[2]==slotDefs[m.Slot.REACTOR_CLAMSHELL_EAST_DOOR_A_DEVICE].name and row[3].transition=='open' then
  299. assert(restored.SPECOPS_APEX_RING_LASER_DEVICE and restored.SPECOPS_APEX_RING_RING_DEVICE,
  300. 'both beam devices must end the surge before opening the cooling shutters')
  301. end
  302. end
  303. end
  304. assert(transition('REACTOR_CLAMSHELL_EAST_LIGHT_A_DEVICE').transition=='power_on')
  305. for _,side in ipairs({'EAST','WEST'})do
  306. local n='REACTOR_CLAMSHELL_'..side
  307. damage(n,0);assert(not vars['ember.apex.dead.'..side], 'initial zero autocompleted target')
  308. call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=true,alive=false}))
  309. assert(not vars['ember.apex.dead.'..side], 'initial dead object autocompleted target')
  310. call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=true,alive=true}))
  311. call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=false,alive=false}))
  312. assert(not vars['ember.apex.dead.'..side], 'unloaded object counted as a kill')
  313. if side=='EAST' then damage(n,1);damage(n,-1);assert(not vars['ember.apex.dead.EAST']);damage(n,0)
  314. else call(R.dispatch,'object',c,s,event(n..'_TARGET_OBJECT',{generation=1,present=true,alive=false})) end
  315. assert(vars['ember.apex.dead.'..side])
  316. if side=='EAST' then
  317. assert(vars['ember.apex.phase']==3 and vars['ember.music.section']==24)
  318. assert(transition('CLAMSHELL_TO_COFFIN_EAST_BRIDGE_DEVICE', true).transition=='open', 'one vent extended bridges')
  319. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='lock')
  320. end
  321. end
  322. assert(timers['ember.apex.vents.1']==10000, 'changing targets restarted the exposure clock')
  323. -- A core transition cannot publish into a different loaded area.
  324. do
  325. local before=#calls;vars['ember.region']=40
  326. call(R.dispatch,'timer',c,s,{timer_name='ember.apex.core.1'})
  327. assert(#calls==before and not vars['ember.apex.core_ready'])
  328. vars['ember.region']=0
  329. end
  330. timer('ember.apex.core.')
  331. do
  332. local before=#calls
  333. call(R.dispatch,'timer',c,s,{timer_name='ember.apex.core.1'})
  334. assert(#calls==before,'duplicate core timer replayed devices/objects')
  335. end
  336. assert(transition('CLAMSHELL_TO_COFFIN_EAST_BRIDGE_DEVICE', true).transition=='close')
  337. assert(transition('CLAMSHELL_TO_COFFIN_WEST_BRIDGE_DEVICE', true).transition=='close')
  338. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='open')
  339. assert(transition('REACTOR_COFFIN_DOOR_WEST_DEVICE').transition=='open')
  340. assert(transition('REACTOR_SHIELD_DEVICE').transition=='open')
  341. -- The new target is created inside its native housing, and cycles on the same clock.
  342. do
  343. local targetSpawn=false
  344. 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
  345. assert(targetSpawn, 'central reactor target was not created')
  346. end
  347. timer('ember.apex.vents.')
  348. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='close')
  349. assert(timers['ember.apex.vents.1']==14000)
  350. timer('ember.apex.vents.')
  351. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='close')
  352. timer('ember.apex.vents.')
  353. assert(transition('REACTOR_COFFIN_DOOR_EAST_DEVICE').transition=='open' and vars['ember.r.cue.46'])
  354. assert(vars['ember.music.section']==26)
  355. for _,n in ipairs({'coffin_east','coffin_west','coffin_interior'})do kill(n)end
  356. damage('REACTOR_COFFIN',1);damage('REACTOR_COFFIN',0)
  357. assert(vars['ember.apex.phase']==5)
  358. assert(not timers['ember.apex.vents.1'] and vars['ember.r.cue.48'])
  359. assert(transition('MOTHER_BRAIN_DOOR_DEVICE').transition=='open')
  360. use('MOTHER_BRAIN_INTERACT_OBJECT');assert(vars['ember.apex.phase']==5,'empty deposit began escape')
  361. use('MOTHER_BRAIN_CARRY_OBJECT')
  362. assert(vars['ember.r.guidance']:find('EMBER_DIRECTIVE_REACTOR_MOTHER_BRAIN_DELIVERY_NAV_POINT',1,true))
  363. -- Expiry is recoverable here too, without resetting the core or its open doors.
  364. call(R.dispatch,'object',c,s,event('MOTHER_BRAIN_CARRY_OBJECT',{generation=1,present=false,alive=false}))
  365. timer('ember.carry.recover.apex.')
  366. assert(vars['ember.carry.apex.generation']==3 and vars['ember.apex.phase']==5)
  367. use('MOTHER_BRAIN_CARRY_OBJECT',1);use('MOTHER_BRAIN_INTERACT_OBJECT');assert(vars['ember.apex.phase']==5)
  368. local depositStart=#calls
  369. use('MOTHER_BRAIN_CARRY_OBJECT',3);use('MOTHER_BRAIN_INTERACT_OBJECT');assert(vars['ember.apex.phase']==6)
  370. -- Publish first; only native object presence starts the movement.
  371. assert(not vars['ember.apex.ship_started'])
  372. call(R.dispatch,'object',c,s,event('REACTOR_GETAWAY_SHIP_OBJECT',{generation=1,present=false,alive=false}))
  373. assert(not vars['ember.apex.ship_started'])
  374. call(R.dispatch,'object',c,s,event('REACTOR_GETAWAY_SHIP_OBJECT',{generation=1,present=true,alive=true}))
  375. assert(vars['ember.apex.ship_started'])
  376. local shipSteps={}
  377. for i=depositStart+1,#calls do
  378. local row=calls[i]
  379. if row[2]==slotDefs[m.Slot.REACTOR_GETAWAY_SHIP_OBJECT].name and row[1]=='set_object_active' and row[3].active then
  380. shipSteps[#shipSteps+1]='spawn'
  381. elseif row[2]==slotDefs[m.Slot.REACTOR_GETAWAY_SHIP_DEVICE].name and row[1]=='transition' then
  382. shipSteps[#shipSteps+1]=row[3].transition
  383. end
  384. end
  385. assert(table.concat(shipSteps,',')=='spawn,unlock,power_on,close,open', 'escape ship activation order')
  386. local shipStartedCalls=#calls
  387. call(R.dispatch,'object',c,s,event('REACTOR_GETAWAY_SHIP_OBJECT',{generation=1,present=true,alive=true}))
  388. assert(#calls==shipStartedCalls,'duplicate presence restarted the escape flight')
  389. assert(vars['ember.carry.apex.done'] and not vars['ember.carry.apex.held'])
  390. local before=#calls;use('MOTHER_BRAIN_INTERACT_OBJECT');assert(#calls==before)
  391. call(R.dispatch,'object',c,s,event('MOTHER_BRAIN_CARRY_OBJECT',{generation=3,present=false,alive=false}))
  392. assert(not timers['ember.carry.recover.apex.3'],'consumed final cell respawned')
  393. -- Escape's native sunburn object must not stack with the scripted climb/rail burn.
  394. timer('ember.apex.hazards')
  395. local escapeEffect
  396. for i=#calls,1,-1 do
  397. if calls[i][1]=='set_mission_effect' then escapeEffect=calls[i][3];break end
  398. end
  399. assert(escapeEffect and escapeEffect.enabled==false and escapeEffect.filter==nil,
  400. 'escape must detach the added burn instead of stacking it with native sunburn')
  401. -- The weapon is dead once the cell is in: powered off, but still installed. Deactivating the
  402. -- ring objects would take the beam and its surrounding structure out of the world entirely.
  403. assert(vars['ember.apex.beam']==false,'the beam must stop firing after the deposit')
  404. assert(vars['ember.apex.surge']==false,'the drive must return to its baseline with the power')
  405. assert(transition('SPECOPS_APEX_RING_LASER_DEVICE', true).transition=='open',
  406. 'a dark weapon rests in its normal pose, not the surge')
  407. local poweredOff=false
  408. for _,row in ipairs(calls)do
  409. if row[1]=='transition' and row[2]=='specops_apex_ring.laser_device'
  410. and row[3].transition=='power_off' then poweredOff=true end
  411. end
  412. assert(poweredOff,'the beam must be powered off at the deposit')
  413. -- Only the laser is the beam. Removing the core or ring would take the weapon's structure,
  414. -- and everything built around it, out of the world.
  415. for _,row in ipairs(calls)do
  416. if row[1]=='set_object_active' and row[3] and row[3].active==false then
  417. local n=tostring(row[2])
  418. assert(not (n:find('specops_apex_ring.core',1,true) or n:find('specops_apex_ring.ring',1,true)),
  419. 'the weapon structure must never be deactivated: '..n)
  420. end
  421. end
  422. -- Each authored explosion set is armed so it can fire as the player reaches it.
  423. for _,set in ipairs({'A','B','C','D'})do
  424. local name='ember_apex_explosion_sequence_prefab.explosion_set_'..set:lower()..'_player_trigger'
  425. local armed=false
  426. for _,row in ipairs(calls)do
  427. if row[1]=='fire_trigger' and row[2]==name then armed=true end
  428. end
  429. assert(armed,'explosion set '..set..' was never armed')
  430. end
  431. reset_check('escape',function()
  432. assert(vars['ember.apex.phase']==6 and vars['ember.apex.dead.COFFIN'])
  433. assert(vars['ember.apex.beam']==false,'escape restart must leave the beam off')
  434. end)
  435. trigger('APEX_DIRECTIVE_REACTOR_RAILS_ESCAPE_PLAYER_TRIGGER')
  436. -- A selected bookend is not loaded merely because the player holds Apex's base world.
  437. local beforeArrival=#calls
  438. call(R.client,c,s,{held_region_index=0})
  439. call(R.client,c,s,{current_region_index=1})
  440. assert(#calls==beforeArrival and not vars['ember.ending.offered'],'pending/base world offered playback')
  441. region(1)
  442. assert(vars['ember.ending.offered']==1)
  443. local offeredCalls=#calls;region(1);assert(#calls==offeredCalls,'duplicate arrival replayed the movie offer')
  444. assert(vars['ember.ending']==1 and not vars['ember.ending.playing'],'offer is not native playback')
  445. local offered=false
  446. for _,row in ipairs(calls)do
  447. if row[1]=='set_cinematic_active' and row[2]=='pf_cinematic_bookend_stm._cinematic'
  448. and row[3].active then offered=true end
  449. end
  450. assert(offered,'first bookend was never offered')
  451. local first=event('PF_CINEMATIC_BOOKEND_STM_CINEMATIC',{runtime_object_id='9007199254740993'})
  452. local second=event('PF_CINEMATIC_BOOKEND_CNN_CINEMATIC',{runtime_object_id='9007199254740994'})
  453. local beforeStart=#calls
  454. call(R.terminated,c,s,first);call(R.skip,c,s,first)
  455. assert(#calls==beforeStart and vars['ember.ending']==1,'unstarted movie advanced the ending')
  456. call(R.started,c,s,second);assert(not vars['ember.ending.playing'],'wrong controller claimed playback')
  457. call(R.started,c,s,first);assert(vars['ember.ending.playing']==1)
  458. call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_STM_CINEMATIC',{runtime_object_id='stale'}))
  459. assert(vars['ember.ending']==1,'stale runtime object advanced the movie')
  460. call(R.skip,c,s,first)
  461. assert(vars['ember.ending']==1 and not vars['ember.complete'],'skip must wait for native termination')
  462. local stoppingCalls=#calls;call(R.skip,c,s,first);assert(#calls==stoppingCalls,'repeated skip republished stop')
  463. call(R.terminated,c,s,first)
  464. assert(vars['ember.ending']==2 and not vars['ember.ending.playing'],'second movie is only offered')
  465. call(R.terminated,c,s,first);assert(vars['ember.ending']==2,'old movie completion advanced its successor')
  466. call(R.started,c,s,second);assert(not vars['ember.ending.playing'],'unoffered second movie claimed playback')
  467. region(2)
  468. call(R.started,c,s,second);call(R.terminated,c,s,second)
  469. assert(vars['ember.complete'])
  470. local objective=vars['ember.r.guidance']
  471. region(56);region(40);assert(vars['ember.r.guidance']==objective,'backtracking reset the forward objective')
  472. local n=#calls;call(R.terminated,c,s,event('PF_CINEMATIC_BOOKEND_CNN_CINEMATIC'));assert(#calls==n)
  473. print('Full authored route passed: '..peak..' variables, '..peakBurst..' intents/event, '..peakTimers..' timers peak; '..maxInstructions..' Lua instructions/event (including mock)' )