From 73c8246c8de35f85f7b0ad9742b0830b5473597b Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Sun, 5 Jul 2026 09:36:59 +0100 Subject: [PATCH] implements PLN-0163 --- .../src/firmware/firmware.rs | 58 +++++ .../src/firmware/firmware_step_hub_home.rs | 12 + .../src/programs/prometeu_hub/prometeu_hub.rs | 232 +++++++++++++++++- discussion/index.ndjson | 2 +- ...-render-hub-suspended-game-kill-control.md | 2 +- 5 files changed, 293 insertions(+), 13 deletions(-) diff --git a/crates/console/prometeu-firmware/src/firmware/firmware.rs b/crates/console/prometeu-firmware/src/firmware/firmware.rs index 960a58e2..3f56f8c1 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware.rs @@ -1099,6 +1099,64 @@ mod tests { ); } + #[test] + fn hub_kill_control_click_terminates_resident_game_and_stays_home() { + let library = discover_games_root(repository_test_cartridges_root()); + let (mut firmware, mut platform) = hub_home_firmware(); + firmware.hub.set_game_library(library); + firmware.tick(&InputSignals::default(), &mut platform); + + let stress_click = + InputSignals { f_signal: true, x_pos: 72, y_pos: 118, ..Default::default() }; + firmware.tick(&stress_click, &mut platform); + firmware.tick(&InputSignals::default(), &mut platform); + + let stress_task = match &firmware.state { + FirmwareState::GameRunning(step) => step.task_id, + other => panic!("expected Stress GameRunning state, got {:?}", other), + }; + + firmware.request_home_from_host(); + firmware.tick(&InputSignals::default(), &mut platform); + firmware.tick(&InputSignals::default(), &mut platform); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().resident_game_task(), Some(stress_task)); + + let kill_click = + InputSignals { f_signal: true, x_pos: 300, y_pos: 240, ..Default::default() }; + firmware.tick(&kill_click, &mut platform); + + assert!(matches!(firmware.state, FirmwareState::HubHome(_))); + assert_eq!(firmware.os.lifecycle().resident_game_task(), None); + assert_eq!(firmware.os.lifecycle().task_state(stress_task), Some(TaskState::Closed)); + assert_eq!( + firmware.os.lifecycle().process_state_for_task(stress_task), + Ok(ProcessState::Stopped) + ); + assert_eq!( + firmware.os.sessions().vm_session_for_task(stress_task).map(|session| session.task_id), + None + ); + assert!(!firmware.game_lifecycle_audio_paused()); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::GLYPH, index: 0 }).asset_id, + None + ); + assert_eq!( + platform.assets().slot_info(SlotRef { asset_type: BankType::SCENE, index: 0 }).asset_id, + None + ); + assert!( + firmware.os.recent_logs(32).iter().any(|event| { + event.level == LogLevel::Info + && event.source == LogSource::Hub + && event.msg == "Resident game killed from Hub: Stress Console" + }), + "Hub kill control should use the canonical manual kill log" + ); + } + #[test] fn failed_game_switch_returns_home_without_restoring_previous_game() { let root = TestGameRoot::new(); diff --git a/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs b/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs index 7f6740d3..3d852aa3 100644 --- a/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs +++ b/crates/console/prometeu-firmware/src/firmware/firmware_step_hub_home.rs @@ -106,6 +106,18 @@ impl HubHomeStep { } } } + Some(SystemProfileAction::KillResidentGame) => { + if let Err(error) = + resident_game_termination::terminate_resident_game(ctx.os, ctx.platform, true) + { + ctx.os.log( + LogLevel::Error, + LogSource::Hub, + 0, + format!("Failed to kill resident game from Hub: {error:?}"), + ); + } + } Some(SystemProfileAction::CloseShell) | None => {} } diff --git a/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs b/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs index b2f2ce26..10ed3418 100644 --- a/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs +++ b/crates/console/prometeu-system/src/programs/prometeu_hub/prometeu_hub.rs @@ -1,5 +1,6 @@ use crate::task::TaskId; use crate::{CrashReport, GameLibrary, GameSwitchTarget, SystemOS}; +use prometeu_hal::app_mode::AppMode; use prometeu_hal::color::Color; use prometeu_hal::primitives::Rect; use prometeu_hal::{ @@ -19,6 +20,7 @@ const GAME_ROW_W: i32 = 336; const GAME_ROW_H: i32 = 22; const GAME_ROW_GAP: i32 = 6; const MAX_HOME_GAME_ROWS: usize = 3; +const SUSPENDED_GAME_KILL_CONTROL: Rect = Rect { x: 288, y: 230, w: 168, h: 24 }; #[cfg(test)] const SHELL_FRAME: Rect = Rect { x: 40, y: 20, w: 400, h: 220 }; @@ -31,6 +33,8 @@ const COLOR_TEXT: Color = Color::rgb(230, 236, 224); const COLOR_MUTED: Color = Color::rgb(91, 112, 128); const COLOR_BUTTON: Color = Color::rgb(45, 59, 82); const COLOR_BUTTON_ACTIVE: Color = Color::rgb(72, 91, 122); +const COLOR_KILL: Color = Color::rgb(164, 32, 40); +const COLOR_KILL_ACTIVE: Color = Color::rgb(206, 46, 54); /// PrometeuHub: Launcher and system UI environment. pub struct PrometeuHub { @@ -83,6 +87,7 @@ impl NativeShellApp { pub enum SystemProfileAction { LaunchNativeShell(NativeShellApp), LaunchGame { target: GameSwitchTarget }, + KillResidentGame, CloseShell, } @@ -131,12 +136,14 @@ impl PrometeuHub { } let input = platform.input(); + let suspended_game = if in_shell { None } else { resident_game_indicator(os) }; if let Some(action) = action_for_click( in_shell, input.touch().x(), input.touch().y(), input.touch().f().pressed, self.game_library.entries(), + suspended_game.as_ref(), ) { return Some(action); } @@ -149,7 +156,13 @@ impl PrometeuHub { let pointer_y = platform.input().touch().y(); let packet = if os.windows().window_count() == 0 { - render_home_packet(pointer_x, pointer_y, self.game_library.entries()) + let suspended_game = resident_game_indicator(os); + render_home_packet( + pointer_x, + pointer_y, + self.game_library.entries(), + suspended_game.as_ref(), + ) } else { let mut commands = Vec::new(); for window in os.windows().windows() { @@ -232,6 +245,7 @@ fn action_for_click( y: i32, pressed: bool, game_entries: &[crate::GameLibraryEntry], + suspended_game: Option<&SuspendedGameIndicator>, ) -> Option { if !pressed { return None; @@ -241,6 +255,10 @@ fn action_for_click( return rect_contains(CLOSE_BUTTON, x, y).then_some(SystemProfileAction::CloseShell); } + if suspended_game.is_some() && rect_contains(SUSPENDED_GAME_KILL_CONTROL, x, y) { + return Some(SystemProfileAction::KillResidentGame); + } + if let Some(entry) = game_entries .iter() .take(MAX_HOME_GAME_ROWS) @@ -257,6 +275,23 @@ fn action_for_click( .map(|button| SystemProfileAction::LaunchNativeShell(button.app)) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct SuspendedGameIndicator { + title: String, +} + +fn resident_game_indicator(os: &mut SystemOS) -> Option { + let task_id = os.lifecycle().resident_game_task()?; + let sessions = os.sessions(); + let session = sessions.vm_session_for_task(task_id)?; + + if session.app_mode != AppMode::Game { + return None; + } + + Some(SuspendedGameIndicator { title: session.title.clone() }) +} + fn game_row_rect(index: usize) -> Rect { Rect { x: GAME_ROW_X, @@ -281,6 +316,7 @@ fn render_home_packet( pointer_x: i32, pointer_y: i32, game_entries: &[crate::GameLibraryEntry], + suspended_game: Option<&SuspendedGameIndicator>, ) -> ShellUiFramePacket { let mut commands = Vec::new(); commands.push(GfxUiCommand::Clear { color: COLOR_BG }); @@ -320,6 +356,11 @@ fn render_home_packet( draw_button(&mut commands, button.rect, button.label, hovered); } + if let Some(suspended_game) = suspended_game { + let hovered = rect_contains(SUSPENDED_GAME_KILL_CONTROL, pointer_x, pointer_y); + draw_suspended_game_kill_control(&mut commands, suspended_game, hovered); + } + ShellUiFramePacket::new(commands) } @@ -422,6 +463,39 @@ fn draw_game_row(commands: &mut Vec, rect: Rect, label: &str, hove draw_buffer_text(commands, rect.x + 8, rect.y + 7, label, COLOR_TEXT); } +fn draw_suspended_game_kill_control( + commands: &mut Vec, + suspended_game: &SuspendedGameIndicator, + hovered: bool, +) { + let fill = if hovered { COLOR_KILL_ACTIVE } else { COLOR_KILL }; + let border = if hovered { COLOR_HILITE } else { COLOR_BORDER }; + fill_rect(commands, SUSPENDED_GAME_KILL_CONTROL, fill); + draw_rect(commands, SUSPENDED_GAME_KILL_CONTROL, border); + draw_buffer_text( + commands, + SUSPENDED_GAME_KILL_CONTROL.x + 8, + SUSPENDED_GAME_KILL_CONTROL.y + 8, + &fit_text_to_control(&suspended_game.title, SUSPENDED_GAME_KILL_CONTROL.w - 16), + COLOR_TEXT, + ); +} + +fn fit_text_to_control(text: &str, max_width: i32) -> String { + let max_chars = (max_width / 8).max(0) as usize; + if text.chars().count() <= max_chars { + return text.to_string(); + } + + if max_chars <= 2 { + return text.chars().take(max_chars).collect(); + } + + let mut fitted: String = text.chars().take(max_chars - 2).collect(); + fitted.push_str(".."); + fitted +} + fn draw_buffer_text(commands: &mut Vec, x: i32, y: i32, text: &str, color: Color) { commands.push(GfxUiCommand::DrawText { x, y, text: text.to_string(), color }); } @@ -462,7 +536,14 @@ mod tests { #[test] fn shell_a_button_click_emits_launch_action() { assert_eq!( - action_for_click(false, HOME_BUTTONS[0].rect.x, HOME_BUTTONS[0].rect.y, true, &[]), + action_for_click( + false, + HOME_BUTTONS[0].rect.x, + HOME_BUTTONS[0].rect.y, + true, + &[], + None + ), Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellA)) ); } @@ -475,7 +556,8 @@ mod tests { SHELL_B_BUTTON.x + SHELL_B_BUTTON.w - 1, SHELL_B_BUTTON.y, true, - &[] + &[], + None ), Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellB)) ); @@ -489,7 +571,8 @@ mod tests { SHELL_A_BUTTON.x + SHELL_A_BUTTON.w, SHELL_A_BUTTON.y, true, - &[] + &[], + None ), None ); @@ -499,7 +582,8 @@ mod tests { SHELL_A_BUTTON.x, SHELL_A_BUTTON.y + SHELL_A_BUTTON.h, true, - &[] + &[], + None ), None ); @@ -507,7 +591,10 @@ mod tests { #[test] fn held_pointer_does_not_emit_click_action() { - assert_eq!(action_for_click(false, SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, false, &[]), None); + assert_eq!( + action_for_click(false, SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, false, &[], None), + None + ); } #[test] @@ -535,8 +622,14 @@ mod tests { discovered_at: std::time::SystemTime::UNIX_EPOCH, }; - let action = - action_for_click(false, GAME_ROW_X, GAME_ROW_Y, true, std::slice::from_ref(&entry)); + let action = action_for_click( + false, + GAME_ROW_X, + GAME_ROW_Y, + true, + std::slice::from_ref(&entry), + None, + ); assert_eq!( action, @@ -554,10 +647,10 @@ mod tests { #[test] fn close_button_click_emits_close_only_in_shell() { assert_eq!( - action_for_click(true, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[]), + action_for_click(true, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[], None), Some(SystemProfileAction::CloseShell) ); - assert_eq!(action_for_click(false, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[]), None); + assert_eq!(action_for_click(false, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[], None), None); } #[test] @@ -571,7 +664,7 @@ mod tests { #[test] fn home_render_produces_shell_ui_packet_commands() { - let packet = render_home_packet(SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, &[]); + let packet = render_home_packet(SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, &[], None); assert!(matches!(packet.commands.first(), Some(GfxUiCommand::Clear { .. }))); assert!(packet.commands.iter().any(|command| { @@ -579,6 +672,123 @@ mod tests { })); } + #[test] + fn resident_game_indicator_uses_only_resident_game_sessions() { + let mut os = SystemOS::new(None); + assert_eq!(resident_game_indicator(&mut os), None); + + let shell = os.sessions().create_vm_shell_task(7, "VM Shell"); + assert_eq!(resident_game_indicator(&mut os), None); + os.lifecycle().suspend_task(shell).expect("shell suspend should succeed"); + assert_eq!(resident_game_indicator(&mut os), None); + + let game = os.sessions().create_vm_game_task(42, "Stress Console"); + os.lifecycle().suspend_task(game).expect("game suspend should succeed"); + + assert_eq!( + resident_game_indicator(&mut os), + Some(SuspendedGameIndicator { title: "Stress Console".to_string() }) + ); + } + + #[test] + fn home_render_shows_suspended_game_kill_control_when_game_is_resident() { + let suspended_game = SuspendedGameIndicator { title: "Stress Console".to_string() }; + let packet = render_home_packet( + SUSPENDED_GAME_KILL_CONTROL.x, + SUSPENDED_GAME_KILL_CONTROL.y, + &[], + Some(&suspended_game), + ); + + assert!(packet.commands.iter().any(|command| { + matches!( + command, + GfxUiCommand::FillRect { rect, color } + if *rect == SUSPENDED_GAME_KILL_CONTROL && *color == COLOR_KILL_ACTIVE + ) + })); + assert!(packet.commands.iter().any(|command| { + matches!( + command, + GfxUiCommand::DrawText { x, y, text, .. } + if *x == SUSPENDED_GAME_KILL_CONTROL.x + 8 + && *y == SUSPENDED_GAME_KILL_CONTROL.y + 8 + && text == "Stress Console" + ) + })); + } + + #[test] + fn home_render_omits_suspended_game_kill_control_without_resident_game() { + let packet = render_home_packet( + SUSPENDED_GAME_KILL_CONTROL.x, + SUSPENDED_GAME_KILL_CONTROL.y, + &[], + None, + ); + + assert!(!packet.commands.iter().any(|command| { + matches!( + command, + GfxUiCommand::FillRect { rect, color } + if *rect == SUSPENDED_GAME_KILL_CONTROL + && (*color == COLOR_KILL || *color == COLOR_KILL_ACTIVE) + ) + })); + } + + #[test] + fn suspended_game_kill_control_click_emits_kill_action() { + let suspended_game = SuspendedGameIndicator { title: "Stress Console".to_string() }; + + assert_eq!( + action_for_click( + false, + SUSPENDED_GAME_KILL_CONTROL.x, + SUSPENDED_GAME_KILL_CONTROL.y, + true, + &[], + Some(&suspended_game), + ), + Some(SystemProfileAction::KillResidentGame) + ); + assert_eq!( + action_for_click( + false, + SUSPENDED_GAME_KILL_CONTROL.x, + SUSPENDED_GAME_KILL_CONTROL.y, + true, + &[], + None, + ), + None + ); + } + + #[test] + fn suspended_game_kill_control_truncates_long_titles() { + let long_title = + SuspendedGameIndicator { title: "Extremely Long Resident Game Title".to_string() }; + let packet = render_home_packet(0, 0, &[], Some(&long_title)); + let label = packet + .commands + .iter() + .find_map(|command| match command { + GfxUiCommand::DrawText { x, y, text, .. } + if *x == SUSPENDED_GAME_KILL_CONTROL.x + 8 + && *y == SUSPENDED_GAME_KILL_CONTROL.y + 8 => + { + Some(text) + } + _ => None, + }) + .expect("kill control text should render"); + + assert!(label.ends_with("..")); + assert!(label.chars().count() <= ((SUSPENDED_GAME_KILL_CONTROL.w - 16) / 8) as usize); + } + #[test] fn shell_close_button_sits_inside_shell_frame() { assert!(rect_contains(SHELL_FRAME, CLOSE_BUTTON.x, CLOSE_BUTTON.y)); diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 09a11462..bd44cb2b 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,5 +1,5 @@ {"type":"meta","next_id":{"DSC":45,"AGD":48,"DEC":41,"PLN":167,"LSN":53,"CLSN":1}} -{"type":"discussion","id":"DSC-0044","status":"in_progress","ticket":"hub-suspended-game-kill-affordance","title":"Hub Suspended Game Kill Affordance","created_at":"2026-07-05","updated_at":"2026-07-05","tags":["hub","lifecycle","game","ui"],"agendas":[{"id":"AGD-0047","file":"AGD-0047-hub-suspended-game-kill-affordance.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05"}],"decisions":[{"id":"DEC-0040","file":"DEC-0040-hub-suspended-game-kill-affordance.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05","ref_agenda":"AGD-0047"}],"plans":[{"id":"PLN-0163","file":"PLN-0163-render-hub-suspended-game-kill-control.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0164","file":"PLN-0164-expose-resident-game-termination-contract.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0165","file":"PLN-0165-validate-hub-kill-flow-end-to-end.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0166","file":"PLN-0166-publish-hub-kill-contract-spec-and-lesson-hooks.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0044","status":"in_progress","ticket":"hub-suspended-game-kill-affordance","title":"Hub Suspended Game Kill Affordance","created_at":"2026-07-05","updated_at":"2026-07-05","tags":["hub","lifecycle","game","ui"],"agendas":[{"id":"AGD-0047","file":"AGD-0047-hub-suspended-game-kill-affordance.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05"}],"decisions":[{"id":"DEC-0040","file":"DEC-0040-hub-suspended-game-kill-affordance.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05","ref_agenda":"AGD-0047"}],"plans":[{"id":"PLN-0163","file":"PLN-0163-render-hub-suspended-game-kill-control.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0164","file":"PLN-0164-expose-resident-game-termination-contract.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0165","file":"PLN-0165-validate-hub-kill-flow-end-to-end.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]},{"id":"PLN-0166","file":"PLN-0166-publish-hub-kill-contract-spec-and-lesson-hooks.md","status":"open","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0040"]}],"lessons":[]} {"type":"discussion","id":"DSC-0043","status":"in_progress","ticket":"system-os-cartridge-switch-orchestrator","title":"SystemOS Cartridge Switch Orchestrator","created_at":"2026-07-03","updated_at":"2026-07-05","tags":["runtime","os","lifecycle","game","cartridge","architecture"],"agendas":[{"id":"AGD-0044","file":"AGD-0044-systemos-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-03","updated_at":"2026-07-05"}],"decisions":[{"id":"DEC-0039","file":"DEC-0039-systemos-game-cartridge-switch-orchestrator.md","status":"accepted","created_at":"2026-07-05","updated_at":"2026-07-05","ref_agenda":"AGD-0044"}],"plans":[{"id":"PLN-0157","file":"PLN-0157-introduce-systemos-game-switch-target-contract.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0158","file":"PLN-0158-terminate-resident-game-before-switching-cartridges.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0159","file":"PLN-0159-recover-failed-game-switches-back-to-home.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0160","file":"PLN-0160-add-dummy-boy-visual-switch-test-cartridge.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0161","file":"PLN-0161-validate-stress-to-dummy-boy-game-switch-end-to-end.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]},{"id":"PLN-0162","file":"PLN-0162-specify-game-switch-contract-and-direct-boot-separation.md","status":"done","created_at":"2026-07-05","updated_at":"2026-07-05","ref_decisions":["DEC-0039"]}],"lessons":[]} {"type":"discussion","id":"DSC-0039","status":"abandoned","ticket":"render-pipeline-family-and-future-3d","title":"Render Pipeline Family and Future 3D","created_at":"2026-06-04","updated_at":"2026-06-04","tags":["gfx","renderer","runtime","architecture","pipeline"],"agendas":[{"id":"AGD-0039","file":"AGD-0039-render-pipeline-family-and-future-3d.md","status":"abandoned","created_at":"2026-06-04","updated_at":"2026-06-04","_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."}],"decisions":[],"plans":[],"lessons":[],"_override_reason":"User explicitly chose to close this agenda without a new decision because DSC-0038 already established enough architecture for future extension, and 3D is intentionally deferred."} {"type":"discussion","id":"DSC-0040","status":"done","ticket":"vm-render-parallel-execution-boundary","title":"VM and Render Parallel Execution Boundary","created_at":"2026-06-04","updated_at":"2026-06-06","tags":["runtime","renderer","vm","concurrency","architecture","perf"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0048","file":"discussion/lessons/DSC-0040-vm-render-parallel-execution-boundary/LSN-0048-render-workers-need-a-closed-packet-contract.md","status":"done","created_at":"2026-06-06","updated_at":"2026-06-06"}]} diff --git a/discussion/workflow/plans/PLN-0163-render-hub-suspended-game-kill-control.md b/discussion/workflow/plans/PLN-0163-render-hub-suspended-game-kill-control.md index 22ecdfc1..e4d0a707 100644 --- a/discussion/workflow/plans/PLN-0163-render-hub-suspended-game-kill-control.md +++ b/discussion/workflow/plans/PLN-0163-render-hub-suspended-game-kill-control.md @@ -2,7 +2,7 @@ id: PLN-0163 ticket: hub-suspended-game-kill-affordance title: Render Hub Suspended Game Kill Control -status: open +status: done created: 2026-07-05 ref_decisions: [DEC-0040] tags: [hub, lifecycle, game, ui]