mission_ember_controller_test.lua 29 KB

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