mission_ember_controller_test.lua 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. package.path = "scripts/?.lua;" .. package.path
  2. local roster = require("mission_ember.roster")
  3. local order = {"catwalk_entry", "catwalk_mid", "pipe", "mercury", "mercury_bonus", "bridge", "dropships", "passengers", "sun", "helipad"}
  4. local names = {}
  5. local unique = {}
  6. for _, group in ipairs(order) do
  7. for _, name in ipairs(roster[group]) do
  8. assert(not unique[name], "duplicate authored squad")
  9. unique[name] = true
  10. names[#names + 1] = name
  11. end
  12. end
  13. assert(#names == 42)
  14. local triggers = {
  15. "CATWALK_015_PERCENT_PLAYER_TRIGGER", "CATWALK_035_PERCENT_PLAYER_TRIGGER_80B3CC33",
  16. "CATWALK_100_PERCENT_PLAYER_TRIGGER", "PIPE_CROSSING_040_PERCENT_PLAYER_TRIGGER",
  17. "LANDING_MERCURY_040_PERCENT_PLAYER_TRIGGER",
  18. "BRIDGE_025_PERCENT_PLAYER_TRIGGER", "BRIDGE_075_PERCENT_PLAYER_TRIGGER", "BRIDGE_100_PERCENT_PLAYER_TRIGGER",
  19. }
  20. local mission = {
  21. Squad = {}, Slot = {M_ENGAGEMENT_SENSOR_80B3CB5E = "engagement", M_DIRECTIVE_SENSOR_80B3C90A = "directive", M_MUSIC_SENSOR_80B3C90A = "music"},
  22. states = {
  23. STATE_80B3C09E_0008_0000_80B3C09C = {region_index = 64},
  24. STATE_80B3C09E_0006_0001_80B3C09A = {region_index = 49},
  25. },
  26. DialogueCue = {M_DIALOG_SENSOR_80B3C90A = {CUE_0 = 0, CUE_2 = 2, CUE_3 = 3}},
  27. Directive = {
  28. FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_4EA80049 = "follow",
  29. FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_A70DA4A6 = "clear",
  30. FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_2700C0C5 = "controls",
  31. FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_AFBF882A = "cross",
  32. },
  33. }
  34. for _, name in ipairs(names) do
  35. mission.Squad[name] = name
  36. mission.Slot[name] = name
  37. end
  38. for _, name in ipairs(triggers) do mission.Slot[name] = name end
  39. local markerNames = {"POWERHOUSE_DIRECTIVE_LANDING_MERCURY_CLEAR_NAV_POINT",
  40. "POWERHOUSE_DIRECTIVE_LANDING_MERCURY_INTERACT_NAV_POINT", "HELIPAD_SUN_SQUAD_FORMATION_NAV_POINT"}
  41. for index, name in ipairs(markerNames) do
  42. mission.Slot[name] = {registry_key = 77, slot_type = 47, slot_index = index}
  43. end
  44. local navpoints = {}
  45. local flights, deliveries, retiredShips = {}, {}, 0
  46. local exitActions = {}
  47. local darkness = {}
  48. mission.Slot.HARD_WIPE_GLOBALS = "darkness"
  49. local timers, timerStarts, now = {}, 0, 0
  50. for index, letter in ipairs({"A", "B", "C", "D"}) do
  51. local prefix = "BRIDGE_CROSSING_DROPSHIP_" .. letter
  52. for _, suffix in ipairs({"_SQUAD_HARVESTER", "_ENTRY_SEQUENCE", "_EXIT_SEQUENCE"}) do
  53. mission.Slot[prefix .. suffix] = {registry_key = 7, slot_type = suffix == "_SQUAD_HARVESTER" and 2 or 58,
  54. slot_index = index, play_actor_path = function(_, args) flights[#flights + 1] = args end,
  55. deliver_squads = function(_, args)
  56. assert(index <= 2, "off-bridge ships cannot receive troops")
  57. assert(args.generation % 2 == 1 and args.revision == 1 and #args.squads == 2)
  58. assert(args.squads[1].slot_index == 33 + index and args.squads[2].slot_index == 35 + index,
  59. "each bridge ship carries its support and melee squad")
  60. deliveries[#deliveries + 1] = args
  61. end,
  62. play_actor_action = function(_, args)
  63. assert(args.generation % 2 == 1 and args.revision == 3,
  64. "exit action must preserve the ship and advance only the command revision")
  65. assert(args.group == 0x811C9DC5 and args.action == 0x7B0D3643)
  66. exitActions[#exitActions + 1] = args
  67. end,
  68. retire_actor = function(_, args)
  69. assert(args.generation % 2 == 0, "retirement must advance the native lifecycle"); retiredShips = retiredShips + 1
  70. end}
  71. end
  72. end
  73. local assignments = {}
  74. for _, area in ipairs({"CATWALK", "PIPE_CROSSING", "LANDING_MERCURY", "BRIDGE", "LANDING_SUN", "HELIPAD_SUN"}) do
  75. local key = "EMBER_POWERHOUSE_" .. area .. "_OBJECTIVE"
  76. mission.Slot[key] = {objective_name = key}
  77. end
  78. local armed = {}
  79. local devices = {
  80. "POWERHOUSE_BRIDGE_ARM_MERCURY_DEVICE", "POWERHOUSE_BRIDGE_GEAR_MERCURY_BOTTOM_DEVICE",
  81. "POWERHOUSE_BRIDGE_GEAR_MERCURY_TOP_DEVICE", "POWERHOUSE_BRIDGE_ARM_SUN_DEVICE",
  82. "POWERHOUSE_BRIDGE_GEAR_SUN_BOTTOM_DEVICE", "POWERHOUSE_BRIDGE_GEAR_SUN_TOP_DEVICE",
  83. }
  84. for _, name in ipairs(devices) do mission.Slot[name] = name end
  85. mission.Slot.EMBER_POWERHOUSE_BRIDGE_OBJECTIVE = "bridge_objective"
  86. mission.Slot.LANDING_MERCURY_CONSOLE_BUTTON_GHOST_LINK = "ghost"
  87. mission.Slot.LANDING_MERCURY_DOOR_BUTTON_OBJECT = "lever"
  88. mission.Slot.POWERHOUSE_LANDING_MERCURY_DOOR_DEVICE = "door"
  89. mission.Slot.POWERHOUSE_LANDING_MERCURY_DOOR_LEVER_DEVICE = "lever_device"
  90. mission.Slot.PF_CINEMATIC_BOOKEND_CINEMATIC = "cinematic"
  91. mission.Slot.M_DIALOG_SENSOR_80B3C90A = "dialogue"
  92. package.preload.missions = function() return {MISSION_EMBER = "test_mission"} end
  93. package.preload.test_mission = function() return mission end
  94. -- Match the production sandbox: unordered iteration is deliberately unavailable.
  95. pairs = nil
  96. next = nil
  97. -- This fixture deliberately supplies only the 42 Powerhouse squads. Later controllers
  98. -- are exercised separately against the complete generated SDK.
  99. package.preload["mission_ember.routes"] = function() return function() return {
  100. client = function() end, dispatch = function() end, squad = function() end, terminated = function() end,
  101. } end end
  102. local program = require("mission_ember")
  103. assert(program.initial_state == mission.states.STATE_80B3C09E_0006_0001_80B3C09A,
  104. "fresh launches must declare the authored cinematic state")
  105. local vars, directives, placements, seeds = {}, {}, 0, 0
  106. local retirements = 0
  107. local variable_count, peak_variables = 0, 0
  108. local state = {variable = function(_, key) return vars[key] end}
  109. local closed, resets, movies, cues = {}, 0, {}, {}
  110. local ghost, door, extended = {}, {}, 0
  111. local leverAnimation = {}
  112. local context = {sdk = {squad_modes = {replace = "replace", reserve = "reserve"}, device_transitions = {close = "close", open = "open"}}}
  113. function context:start_timer(name, delay)
  114. assert(delay == (name:find("depart", 1, true) and 4000 or 6000),
  115. "unload adds three seconds to the prior three-second settle; departure holds four seconds")
  116. timers[name] = now + delay
  117. timerStarts = timerStarts + 1
  118. end
  119. local function tick(time)
  120. now = time
  121. for _, stage in ipairs({"unload", "depart"}) do
  122. for index = 1, 4 do
  123. local name = "ember.transport." .. stage .. "." .. index
  124. if timers[name] and now >= timers[name] then
  125. timers[name] = nil
  126. program.on_event_timer_elapsed(context, state, {timer_name = name})
  127. end
  128. end
  129. end
  130. end
  131. function context:set_phase(value) vars.phase = value end
  132. function context:set_variable(key, value)
  133. if vars[key] == nil then variable_count = variable_count + 1 end
  134. peak_variables = math.max(peak_variables, variable_count)
  135. assert(variable_count <= 512, "mission variable capacity exceeded")
  136. vars[key] = value
  137. end
  138. function context:clear_variable(key)
  139. if vars[key] ~= nil then variable_count = variable_count - 1 end
  140. vars[key] = nil
  141. end
  142. function context:select_state(entry)
  143. assert(entry.region_index == 64)
  144. seeds = seeds + 1
  145. end
  146. function context:squad(name)
  147. assert(mission.Squad[name])
  148. return {default_counts = {1},
  149. counts = function() return {set = function(self, index, value) assert(index == 1 and value == 0); self.zero = true end} end,
  150. place = function(_, args)
  151. if name:find("DROPSHIP_[ABCD]_SQUAD$") then assert(args.counts.zero) end
  152. if name:find("DROPSHIP_A_SUPPORT") or name:find("DROPSHIP_A_MELEE") then
  153. assert(args.mode == "reserve", "cargo cannot use ordinary ground placement")
  154. else assert(args.mode == "replace") end
  155. if args.counts and args.counts.zero and not name:find("DROPSHIP_[ABCD]_SQUAD$") then
  156. retirements = retirements + 1
  157. else placements = placements + 1 end
  158. end}
  159. end
  160. function context:slot(name)
  161. if name == "music" then return {set_music_section = function(_, args) assert(args.section >= 0 and args.section <= 28) end} end
  162. if name == "darkness" then return {set_darkness_zone = function(_, args) darkness[#darkness + 1] = args.enabled end} end
  163. if type(name) == "table" then return name end
  164. for index, trigger in ipairs(triggers) do
  165. if name == trigger then
  166. return {registry_key = 10, slot_type = 31, slot_index = index,
  167. fire_trigger = function() armed[index] = (armed[index] or 0) + 1 end}
  168. end
  169. end
  170. if name == "cinematic" then
  171. return {registry_key = 9, slot_type = 6, slot_index = 0,
  172. set_cinematic_active = function(_, args) movies[#movies + 1] = args.active end}
  173. end
  174. if name == "dialogue" then
  175. return {play_dialogue_cue = function(_, args) cues[#cues + 1] = args.cue end}
  176. end
  177. for index, device in ipairs(devices) do
  178. if name == device then
  179. return {registry_key = 13, slot_type = 23, slot_index = index, transition = function(_, args)
  180. if args.snap then
  181. assert(args.transition == "open")
  182. closed[#closed + 1] = name
  183. else
  184. assert(args.transition == "close")
  185. extended = extended + 1
  186. end
  187. end}
  188. end
  189. end
  190. if name == "lever" then
  191. return {registry_key = 12, slot_type = 4, slot_index = 19,
  192. set_interactable_object = function(_, args) assert(args.generation == 1) end}
  193. end
  194. if name == "ghost" then
  195. return {registry_key = 12, slot_type = 65, slot_index = 60,
  196. set_ghost_link = function(_, args) ghost[#ghost + 1] = args end}
  197. end
  198. if name == "lever_device" then
  199. return {transition = function(_, args) leverAnimation[#leverAnimation+1] = args end}
  200. end
  201. if name == "door" then
  202. return {transition = function(_, args) door[#door + 1] = args end}
  203. end
  204. if name == "bridge_objective" then
  205. return {objective_name = "bridge_objective", reset_objectives = function() resets = resets + 1 end}
  206. end
  207. if name == "engagement" then
  208. return {set_engagement_state = function(_, args) assert(args.flags == 0 and args.revision == 1) end}
  209. end
  210. if name == "directive" then
  211. return {set_directive = function(_, args) if directives[#directives] ~= args.directive then directives[#directives + 1] = args.directive end; navpoints[#navpoints + 1] = args.navpoint or false end}
  212. end
  213. for index, value in ipairs(names) do
  214. if name == value then return {registry_key = 7, slot_type = 1, slot_index = index,
  215. assign_combat_objective = function(_, args)
  216. assert((args.task_group == -1 or (name:find("DROPSHIP_[ABCD]_SQUAD$") and args.task_group == 1)) and args.revision == 1)
  217. assert(args.reserved == (name:find("DROPSHIP_A_SUPPORT") ~= nil or name:find("DROPSHIP_A_MELEE") ~= nil),
  218. "objective updates must preserve the passenger reservation")
  219. assignments[name] = args.objective
  220. end} end
  221. end
  222. error("unknown slot " .. name)
  223. end
  224. local function region(index)
  225. program.on_event_client_state_changed(context, state, {current_region_index = index})
  226. end
  227. local function cleared(first, last)
  228. for index = first or 12, last or 22 do
  229. program.on_event_squad_state(context, state, {
  230. registry_key = 7, slot_type = 1, slot_index = index,
  231. alive_count = 0, previous_alive_count = 1, removal_flag = true, slot_counts = {1},
  232. })
  233. end
  234. end
  235. assert(#closed == 0 and resets == 0 and placements == 0)
  236. program.on_event_client_state_changed(context, state, {region_index = 49})
  237. assert(#movies == 0, "a requested region is not a held cinematic")
  238. region(64)
  239. assert(placements == 0 and #closed == 0 and #cues == 0,
  240. "playable-region reports cannot start the landing before cinematic completion")
  241. region(49); region(49)
  242. assert(#movies == 1 and movies[1] == true and placements == 0)
  243. program.on_event_cinematic_terminated(context, state, {registry_key = 8, slot_type = 6, slot_index = 0})
  244. assert(seeds == 0, "unrelated movie termination cannot advance the mission")
  245. local termination = {registry_key = 9, slot_type = 6, slot_index = 0}
  246. -- A skip request must match the current movie, and late completion must not spawn twice.
  247. program.on_event_cinematic_skip_requested(context, state, {registry_key = 8, slot_type = 6, slot_index = 0})
  248. assert(seeds == 0, "an unrelated skip cannot advance the mission")
  249. program.on_event_cinematic_skip_requested(context, state, termination)
  250. program.on_event_cinematic_skip_requested(context, state, termination)
  251. program.on_event_cinematic_terminated(context, state, termination)
  252. program.on_event_cinematic_terminated(context, state, termination)
  253. assert(seeds == 1 and #movies == 2 and movies[2] == false)
  254. region(40)
  255. assert(placements == 0 and #closed == 0)
  256. region(64); region(64)
  257. assert(placements == 5 and directives[1] == "follow")
  258. assert(#darkness == 1 and darkness[1] == false, "arrival must leave darkness disabled")
  259. for index = 1, #triggers do assert(armed[index] == (index < 6 and 1 or nil)) end
  260. assert(#closed == 6 and resets == 1, "initialize devices only after playable region is held")
  261. program.on_event_client_state_changed(context, state, {teleport_state = 0})
  262. assert(#cues == 0)
  263. region(nil); region(nil)
  264. assert(#cues == 1 and cues[1] == 0, "arrival dialogue follows spawn settlement once")
  265. -- Initial zero and unrelated trigger reports must not complete or populate later fights.
  266. for index = 12, 22 do
  267. program.on_event_squad_state(context, state, {registry_key = 7, slot_type = 1,
  268. slot_index = index, alive_count = 0, previous_alive_count = 0})
  269. end
  270. assert(#directives == 1)
  271. local function trigger(index, registry)
  272. program.on_event_player_trigger(context, state, {
  273. registry_key = registry or 10, slot_type = 31, slot_index = index,
  274. })
  275. end
  276. trigger(1, 99); assert(placements == 5)
  277. trigger(1); trigger(1); assert(placements == 9)
  278. trigger(2); assert(placements == 11)
  279. trigger(3); assert(placements == 19 and directives[2] == "clear")
  280. for _, name in ipairs({"BRIDGE_VIGNETTE_SUPPORT_A_SQUAD", "BRIDGE_VIGNETTE_SUPPORT_B_SQUAD"}) do
  281. assert(assignments[name].objective_name == "bridge_objective",
  282. "near-side squads retain their authored bridge AI objective")
  283. end
  284. trigger(4); trigger(4); assert(#cues == 2 and cues[2] == 2)
  285. assert(#ghost == 1 and ghost[1].generation == 1 and not ghost[1].enabled)
  286. assert(#door == 1 and door[1].transition == "close" and door[1].snap)
  287. assert(#leverAnimation == 1 and leverAnimation[1].transition == "close" and leverAnimation[1].snap)
  288. trigger(5); trigger(5)
  289. trigger(8); trigger(6); trigger(7)
  290. assert(placements == 19, "overlapping bridge receipts must not spawn waves before the scan")
  291. assert(#door == 1, "proximity cannot open the lever door or spawn its group")
  292. local function console_complete(generation, progress, active, registry)
  293. program.on_event_ghost_link_state(context, state, {registry_key = registry or 12,
  294. slot_type = 65, slot_index = 60, generation = generation, progress = progress, active = active})
  295. end
  296. console_complete(2, 1, false)
  297. assert(extended == 0, "console cannot complete before combat")
  298. cleared(12,17)
  299. assert(#directives == 2, "near-side bridge squads must also clear before Ghost becomes usable")
  300. cleared(18,18); assert(#directives == 2)
  301. cleared(19,19); cleared()
  302. assert(#directives == 3 and directives[3] == "controls" and retirements == 8)
  303. assert(#cues == 3 and cues[3] == 3)
  304. assert(#closed == 6 and extended == 0, "combat clear cannot move the bridge")
  305. assert(#ghost == 2 and ghost[2].generation == 2 and ghost[2].enabled)
  306. assert(not vars["ember.mercury_bonus.phase"], "Crimson Shadow cannot block Ghost interaction")
  307. local function use_lever(registry, slot_type, slot_index)
  308. program.on_event_object_interacted(context, state, {
  309. registry_key = registry or 12, slot_type = slot_type or 4, slot_index = slot_index or 19})
  310. end
  311. use_lever(99); use_lever(12,65); use_lever(12,4,20)
  312. assert(#door == 1 and not vars["ember.mercury_bonus.phase"], "unrelated objects cannot use the lever")
  313. use_lever(); use_lever()
  314. assert(#door == 2 and door[2].transition == "open" and not door[2].snap)
  315. assert(#leverAnimation == 2 and leverAnimation[2].transition == "open" and not leverAnimation[2].snap,
  316. "lever use must animate the matching authored device once")
  317. assert(vars["ember.mercury_bonus.phase"] == 1, "lever use starts the optional group exactly once")
  318. cleared(20,24)
  319. assert(#directives == 3 and #ghost == 2, "bonus completion cannot replace the bridge instruction")
  320. console_complete(1, 1, false); console_complete(2, 1, false, 99)
  321. console_complete(2, .5, false); console_complete(2, 1, true)
  322. assert(extended == 0, "only matching completed native interaction can extend the bridge")
  323. assert(#darkness == 1 and not darkness[1], "combat clear and incomplete scans cannot enable darkness")
  324. -- Prepare reserved cargo and empty parents before Ghost is usable, without starting ships.
  325. assert(placements == 32 and #flights == 0 and #deliveries == 0)
  326. program.on_event_squad_state(context, state, {registry_key = 7, slot_type = 1, slot_index = 30,
  327. objective_revision = 1, task_costs = {2040, 10, 20}})
  328. assert(vars["ember.transport.ai"] == 2, "Harvester chooses its native minimum-cost task")
  329. assert(placements == 32 and #flights == 0 and #deliveries == 0,
  330. "ship objective assignment must preserve empty requests and deferred flight")
  331. console_complete(2, 1, false); console_complete(2, 1, false)
  332. assert(extended == 6 and directives[4] == "cross" and vars.phase == 4)
  333. assert(#darkness == 2 and darkness[2], "console completion enables darkness once with bridge extension")
  334. assert(placements == 37 and #flights == 4 and #deliveries == 0,
  335. "bridge activation starts ships and all five far-side hangar squads exactly once")
  336. for _, group in ipairs({"sun", "helipad"}) do
  337. assert(vars["ember." .. group .. ".phase"] == 1, "hangar waves cannot wait for the crossing")
  338. end
  339. -- Crossing stages the seven deck defenders; Sun and helipad remain one-shot.
  340. trigger(6); assert(placements == 42)
  341. trigger(6); assert(placements == 42)
  342. trigger(7); assert(placements == 42)
  343. trigger(8); assert(placements == 42)
  344. trigger(6); trigger(7); assert(placements == 42)
  345. package.loaded.mission_ember = nil
  346. program = require("mission_ember")
  347. region(64); cleared(); trigger(8)
  348. assert(#darkness == 2 and darkness[2], "crossings and reload cannot restart darkness")
  349. assert(seeds == 1 and placements == 42 and #directives == 4 and #cues == 3)
  350. for index = 1, #triggers do assert(armed[index] == 1) end
  351. assert(#closed == 6 and resets == 1, "transit and reload must not reset the bridge")
  352. local function flight(index, revision, status, generation, dead, delivery_revision, delivery_state)
  353. program.on_event_actor_path_state(context, state, {registry_key = 7, slot_type = 2, slot_index = index,
  354. generation = generation or 1, revision = revision, path_state = status, dead = dead or false,
  355. delivery_revision = delivery_revision, delivery_state = delivery_state})
  356. end
  357. flight(1, 1, 0); flight(1, 1, 1, 2, false, 1, 0)
  358. assert(#flights == 4 and #deliveries == 0, "initial and stale member reports cannot begin unloading")
  359. for index = 1, 3 do flight(index, 1, 1, 1, false, 0, 0) end
  360. assert(#deliveries == 0 and timerStarts == 3)
  361. flight(1, 1, 1, 1, false, 0, 0)
  362. assert(timerStarts == 3, "duplicate arrival cannot restart the unload delay")
  363. tick(5999)
  364. assert(#deliveries == 0, "hold cargo for six seconds after arrival")
  365. package.loaded.mission_ember = nil
  366. program = require("mission_ember")
  367. region(56)
  368. assert(darkness[#darkness] == false, "leaving the encounter must release respawn restrictions")
  369. tick(6000)
  370. assert(#deliveries == 2 and #flights == 4 and timerStarts == 4, "A/B unload; empty C begins its departure hold")
  371. program.on_event_timer_elapsed(context, state, {timer_name = "ember.transport.unload.1"})
  372. assert(#deliveries == 2, "duplicate timer receipt cannot deliver twice")
  373. flight(4, 1, 0, 1, false, 0, 0)
  374. assert(timerStarts == 4)
  375. flight(4, 1, 1, 1, false, 0, 0)
  376. tick(9999); assert(#flights == 4)
  377. tick(10000); assert(#flights == 5, "C leaves after its separate four-second departure hold")
  378. tick(11999); assert(#flights == 5)
  379. tick(12000); assert(#deliveries == 2 and #flights == 5 and timerStarts == 6)
  380. for index = 1, 2 do flight(index, 1, 1, 1, false, 1, 1) end
  381. assert(placements == 42, "manifests must not add ground spawn requests")
  382. flight(1, 1, 1, 1, false, 1, 0)
  383. assert(#flights == 5)
  384. program.on_event_squad_state(context, state, {registry_key = 7, slot_type = 1, slot_index = 34,
  385. alive_count = 1, previous_alive_count = 0})
  386. assert(#flights == 5, "A waits for both of its passenger squads")
  387. program.on_event_squad_state(context, state, {registry_key = 7, slot_type = 1, slot_index = 36,
  388. alive_count = 1, previous_alive_count = 0})
  389. assert(#flights == 5 and timerStarts == 7, "A begins its hold after both squads and detach completed")
  390. for _, index in ipairs({35,37}) do
  391. program.on_event_squad_state(context, state, {registry_key = 7, slot_type = 1, slot_index = index,
  392. alive_count = 1, previous_alive_count = 0})
  393. end
  394. assert(#flights == 5 and timerStarts == 7, "B still waits for native detach completion")
  395. flight(2, 1, 1, 1, false, 1, 0)
  396. assert(#flights == 5 and timerStarts == 8)
  397. -- Reload and duplicate receipts cannot restart or bypass a hold.
  398. package.loaded.mission_ember = nil
  399. program = require("mission_ember")
  400. flight(2, 1, 1, 1, false, 1, 0)
  401. assert(timerStarts == 8)
  402. tick(15999); assert(#flights == 5)
  403. tick(16000)
  404. assert(#flights == 8 and flights[5].revision == 2 and flights[5].generation == 1)
  405. for index = 1,4 do
  406. program.on_event_timer_elapsed(context,state,{timer_name="ember.transport.depart."..index})
  407. end
  408. assert(#flights == 8 and timerStarts == 8, "duplicate departure timers cannot repeat paths")
  409. for index = 1, 4 do flight(index, 1, 1, 1, false, 1, 0); flight(index, 2, 0) end
  410. assert(retiredShips == 0, "exit requests cannot retire a ship before it finishes flying")
  411. for index = 1, 4 do flight(index, 2, 1); flight(index, 2, 1) end
  412. assert(retiredShips == 0 and #exitActions == 4, "path end starts one native exit action per ship")
  413. -- Stale path receipts, module reload and running-action receipts cannot retire or restart ships.
  414. package.loaded.mission_ember = nil
  415. program = require("mission_ember")
  416. for index = 1,4 do flight(index, 2, 1); flight(index, 3, 0) end
  417. assert(retiredShips == 0 and #exitActions == 4)
  418. for index = 1,4 do flight(index, 3, 1); flight(index, 3, 1) end
  419. assert(retiredShips == 4 and #exitActions == 4, "native exit completion retires each ship once")
  420. console_complete(2, 1, false)
  421. assert(placements == 42 and #flights == 8 and #deliveries == 2,
  422. "duplicate path and scan receipts cannot respawn ships or passengers")
  423. region(64); cleared()
  424. assert(#ghost == 2 and extended == 6)
  425. print("cinematic handoff, 42-squad route, skipped volumes, exact triggers, dialogue and reload checks passed")
  426. for _, group in ipairs({"catwalk_entry", "catwalk_mid", "pipe", "mercury", "mercury_bonus", "bridge", "passengers", "sun", "helipad"}) do
  427. for _, name in ipairs(roster[group]) do assert(assignments[name], "missing objective: " .. name) end
  428. end
  429. for _, name in ipairs(roster.dropships) do
  430. assert(assignments[name], "each ship parent needs bridge objective membership")
  431. end
  432. print("full-route peak durable variables: " .. peak_variables .. "/512")
  433. -- Completed native requests are zeroed once. Returning from the refinery, including a
  434. -- module reload and late trigger receipts, cannot replay any opening side effect.
  435. cleared(25,29); cleared(34,36); cleared(38,39)
  436. assert(darkness[#darkness] == true, "one remaining passenger must hold darkness")
  437. cleared(37,37)
  438. assert(darkness[#darkness] == false, "the final bridge passenger clears darkness without a load zone")
  439. assert(vars["ember.helipad.phase"] == 1, "later helipad combat cannot hold bridge darkness")
  440. program.on_event_squad_state(context,state,{registry_key=7,slot_type=1,slot_index=40,
  441. alive_count=1,previous_alive_count=0})
  442. assert(darkness[#darkness] == false, "live helipad enemies cannot reactivate the cleared bridge")
  443. cleared(40,42)
  444. assert(vars["ember.catwalk_entry.phase"] == 1, "earlier encounters are outside bridge darkness completion")
  445. cleared(1,11)
  446. assert(retirements == 38)
  447. local darknessCount = #darkness
  448. local objectiveCount, cueCount, doorCount = #directives, #cues, #door
  449. local movieCount, flightCount = #movies, #flights
  450. -- A completed checkpoint may never have crossed these narrow early volumes.
  451. context:clear_variable("ember.route.PIPE_CROSSING_040_PERCENT_PLAYER_TRIGGER")
  452. context:clear_variable("ember.route.LANDING_MERCURY_040_PERCENT_PLAYER_TRIGGER")
  453. region(56)
  454. package.loaded.mission_ember = nil
  455. program = require("mission_ember")
  456. region(49); region(64)
  457. for index = #triggers, 1, -1 do trigger(index) end
  458. cleared(1,42)
  459. console_complete(2,1,false)
  460. use_lever()
  461. assert(#darkness == darknessCount, "completed-area return cannot restore restrictions")
  462. assert(retirements == 38 and placements == 42 and retiredShips == 4)
  463. assert(#directives == objectiveCount and #cues == cueCount and #door == doorCount and #leverAnimation == 2)
  464. assert(#movies == movieCount and #flights == flightCount and #closed == 6 and extended == 6)
  465. print("completed-area return preserves objectives, devices, cinematic and zero native spawn requests")
  466. -- Guidance follows route progress and never leaves a completed approach pointing backward.
  467. local landing = require("mission_ember.landing")(mission)
  468. local function guidance() landing.update_guidance(context,state); return navpoints[#navpoints] end
  469. vars["ember.bridge.extended"]=false;vars["ember.console.enabled"]=false
  470. vars["ember.route.progress"]=0;vars["ember.navpoint"]=nil
  471. assert(guidance().slot_index==1, "cleared approach points toward Mercury")
  472. vars["ember.landing.phase"]=1
  473. assert(guidance()==false, "combat removes navigation")
  474. vars["ember.landing.phase"]=2;vars["ember.console.enabled"]=true
  475. assert(guidance().slot_index==2, "cleared landing points to Ghost")
  476. local markers=#navpoints;guidance();assert(#navpoints==markers,"unchanged target is idempotent")
  477. vars["ember.bridge.extended"]=true;vars["ember.sun.phase"]=1
  478. assert(guidance()==false)
  479. vars["ember.sun.phase"]=2
  480. assert(guidance().slot_index==3,"completed bridge points forward to the helipad")
  481. vars["ember.route.progress"]=8;assert(guidance()==false,"reaching helipad retires the approach marker")
  482. -- Reset the bridge and scan again without replaying the completed approach or lever.
  483. function context:cancel_timer(name) timers[name]=nil end
  484. local beforeMovies,beforeLever=#movies,#leverAnimation
  485. local beforeFlights=#flights
  486. landing.reset_checkpoint(context,state)
  487. assert(not vars["ember.bridge.extended"] and not vars["ember.darkness.enabled"])
  488. assert(vars["ember.route.progress"]==5 and vars["ember.console.generation"]==4)
  489. assert(vars["ember.transport.generation"]==3 and not vars["ember.transport.started"])
  490. assert(vars["ember.catwalk_entry.phase"]==2 and vars["ember.landing.phase"]==2)
  491. assert(vars["ember.lever.used"] and #leverAnimation==beforeLever and #movies==beforeMovies)
  492. assert(ghost[#ghost].enabled and ghost[#ghost].generation==4)
  493. assert(#closed==12 and #flights==beforeFlights)
  494. console_complete(2,1,false);assert(#flights==beforeFlights,"prior Ghost completion cannot rescan")
  495. console_complete(4,1,false);assert(#flights==beforeFlights+4)
  496. assert(flights[#flights].generation==3 and darkness[#darkness])
  497. local deliveryCount=#deliveries
  498. program.on_event_actor_path_state(context,state,{registry_key=7,slot_type=2,slot_index=1,
  499. generation=1,revision=1,path_state=1})
  500. program.on_event_timer_elapsed(context,state,{timer_name="ember.transport.unload.1"})
  501. assert(#deliveries==deliveryCount,"old flight/timer cannot unload next attempt")
  502. program.on_event_actor_path_state(context,state,{registry_key=7,slot_type=2,slot_index=1,
  503. generation=3,revision=1,path_state=1})
  504. assert(timers["ember.transport.unload.1.3"],"next flight has its own unload timer")
  505. program.on_event_timer_elapsed(context,state,{timer_name="ember.transport.unload.1"})
  506. assert(#deliveries==deliveryCount)
  507. program.on_event_timer_elapsed(context,state,{timer_name="ember.transport.unload.1.3"})
  508. assert(#deliveries==deliveryCount+1 and deliveries[#deliveries].generation==3)
  509. print("navpoint combat gating, checkpoint rescan and second-flight stale-event checks passed")