From 01ddaf0bc374a40fa453d68d2d692244b027ca84 Mon Sep 17 00:00:00 2001 From: Mohammad Aloufi Date: Sat, 18 Apr 2026 02:23:03 +0300 Subject: [PATCH 1/9] Implement F1 shortcut for viewing game rules in tables --- clients/desktop/ui/main_window.py | 8 +++ .../game_utils/action_set_creation_mixin.py | 17 ++++++ server/game_utils/action_visibility_mixin.py | 4 ++ server/game_utils/menu_management_mixin.py | 58 +++++++++++++++++++ server/locales/en/games.ftl | 3 + 5 files changed, 90 insertions(+) diff --git a/clients/desktop/ui/main_window.py b/clients/desktop/ui/main_window.py index 0f090c6b..aa50e97f 100644 --- a/clients/desktop/ui/main_window.py +++ b/clients/desktop/ui/main_window.py @@ -263,6 +263,7 @@ def _setup_accelerators(self): self.ID_PING = wx.NewIdRef() self.ID_LIST_ONLINE = wx.NewIdRef() self.ID_LIST_ONLINE_WITH_GAMES = wx.NewIdRef() + self.ID_F1 = wx.NewIdRef() # Buffer system IDs self.ID_PREV_BUFFER = wx.NewIdRef() @@ -278,6 +279,7 @@ def _setup_accelerators(self): # Common accelerators that work everywhere common_entries = [ wx.AcceleratorEntry(wx.ACCEL_ALT, ord("M"), self.ID_FOCUS_MENU), + wx.AcceleratorEntry(wx.ACCEL_NORMAL, wx.WXK_F1, self.ID_F1), wx.AcceleratorEntry(wx.ACCEL_NORMAL, wx.WXK_F6, self.ID_TOGGLE_TABLE_CHAT), wx.AcceleratorEntry(wx.ACCEL_SHIFT, wx.WXK_F6, self.ID_TOGGLE_GLOBAL_CHAT), wx.AcceleratorEntry(wx.ACCEL_NORMAL, wx.WXK_F7, self.ID_AMBIENCE_DOWN), @@ -327,6 +329,7 @@ def _setup_accelerators(self): self.on_list_online_with_games, id=self.ID_LIST_ONLINE_WITH_GAMES, ) + self.Bind(wx.EVT_MENU, self.on_f1_keybind, id=self.ID_F1) # Buffer system event bindings self.Bind(wx.EVT_MENU, self.on_prev_buffer, id=self.ID_PREV_BUFFER) @@ -372,6 +375,11 @@ def on_menu_focus(self, event): self.SetAcceleratorTable(self.accel_table_with_buffers) event.Skip() + def on_f1_keybind(self, event): + """Handle F1 accelerator (global rules request).""" + if self.connected: + self.network.send_packet({"type": "keybind", "key": "f1"}) + def on_menu_unfocus(self, event): """Handle menu list losing focus - disable buffer navigation.""" try: diff --git a/server/game_utils/action_set_creation_mixin.py b/server/game_utils/action_set_creation_mixin.py index 5d6fdd42..44020764 100644 --- a/server/game_utils/action_set_creation_mixin.py +++ b/server/game_utils/action_set_creation_mixin.py @@ -107,6 +107,16 @@ def create_standard_action_set(self, player: "Player") -> ActionSet: show_in_actions_menu=False, ) ) + action_set.add( + Action( + id="show_rules", + label=Localization.get(locale, "show-rules"), + handler="_action_show_rules", + is_enabled="_is_always_enabled", + is_hidden="_is_always_hidden", + show_in_actions_menu=True, + ) + ) action_set.add( Action( id="save_table", @@ -223,6 +233,13 @@ def setup_keybinds(self) -> None: include_spectators=True, ) # Standard keybinds + self.define_keybind( + "f1", + "Show rules", + ["show_rules"], + state=KeybindState.ALWAYS, + include_spectators=True, + ) self.define_keybind( "escape", "Actions menu", diff --git a/server/game_utils/action_visibility_mixin.py b/server/game_utils/action_visibility_mixin.py index 6692eb30..7fbc96fc 100644 --- a/server/game_utils/action_visibility_mixin.py +++ b/server/game_utils/action_visibility_mixin.py @@ -167,6 +167,10 @@ def _is_show_actions_hidden(self, player: "Player") -> Visibility: return Visibility.VISIBLE return Visibility.HIDDEN + def _is_always_enabled(self, player: "Player") -> str | None: + """Always enable an action.""" + return None + def _is_always_hidden(self, player: "Player") -> Visibility: """Always hide an action from menus (keybind only).""" return Visibility.HIDDEN diff --git a/server/game_utils/menu_management_mixin.py b/server/game_utils/menu_management_mixin.py index d0550c71..074a7af2 100644 --- a/server/game_utils/menu_management_mixin.py +++ b/server/game_utils/menu_management_mixin.py @@ -230,3 +230,61 @@ def status_box(self, player: "Player", lines: list[str]) -> None: items=items, multiletter=False, ) + + def _action_show_rules(self, player: "Player", action_id: str) -> None: + """Show the rules document for the game.""" + user = self.get_user(player) + if not user: + return + + doc_manager = None + if hasattr(self, "_table") and self._table and hasattr(self._table, "_server") and self._table._server: + if hasattr(self._table._server, "_documents"): + doc_manager = self._table._server._documents + + if not doc_manager: + user.speak_l("action-not-available") + return + + folder_name = f"{self.get_type()}_rules" + meta = doc_manager.get_document_metadata(folder_name) + if not meta: + user.speak_l("documents-no-content") + return + + visible_locales = doc_manager._get_visible_locale_codes(meta, include_private=False) + allowed_private = set() + + source_locale = meta.get("source_locale", "en") + title_locale = None + content = None + + for locale_code in [user.locale, "en", source_locale, *visible_locales]: + if locale_code not in visible_locales or locale_code == title_locale: + continue + candidate_content = doc_manager.get_document_content_for_access( + folder_name, + locale_code, + include_private=False, + allowed_private_locales=allowed_private, + ) + if candidate_content is None: + continue + content = candidate_content + title_locale = locale_code + break + + if content is None: + user.speak_l("documents-no-content") + return + + title = meta.get("titles", {}).get(title_locale or user.locale) or folder_name + + user.show_editbox( + "game_rules", + title, + default_value=content, + multiline=True, + read_only=True, + content_format="markdown", + ) diff --git a/server/locales/en/games.ftl b/server/locales/en/games.ftl index e23fc09f..6be6bd3e 100644 --- a/server/locales/en/games.ftl +++ b/server/locales/en/games.ftl @@ -5,6 +5,9 @@ game-name-ninetynine = Ninety Nine game-name-humanitycards = Cards Against Humanity +# Generic game actions +show-rules = Show rules + # Game categories (shared) category-party-games = Party Games From 6b30fadde907520e5ce8ab7e556ca3a3ccfb390a Mon Sep 17 00:00:00 2001 From: Mohammad Aloufi Date: Sun, 26 Apr 2026 19:01:21 +0300 Subject: [PATCH 2/9] Reuse action-locked message for missing rules doc --- server/game_utils/menu_management_mixin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/game_utils/menu_management_mixin.py b/server/game_utils/menu_management_mixin.py index 074a7af2..91cd9f08 100644 --- a/server/game_utils/menu_management_mixin.py +++ b/server/game_utils/menu_management_mixin.py @@ -243,7 +243,7 @@ def _action_show_rules(self, player: "Player", action_id: str) -> None: doc_manager = self._table._server._documents if not doc_manager: - user.speak_l("action-not-available") + user.speak_l("action-locked") return folder_name = f"{self.get_type()}_rules" From b103282659076f7ffb15195f02f16748fa92076c Mon Sep 17 00:00:00 2001 From: Mohammad Aloufi Date: Sun, 26 Apr 2026 19:02:36 +0300 Subject: [PATCH 3/9] Remove dead hasattr guard for game rules document access --- server/game_utils/menu_management_mixin.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/server/game_utils/menu_management_mixin.py b/server/game_utils/menu_management_mixin.py index 91cd9f08..74364271 100644 --- a/server/game_utils/menu_management_mixin.py +++ b/server/game_utils/menu_management_mixin.py @@ -237,14 +237,7 @@ def _action_show_rules(self, player: "Player", action_id: str) -> None: if not user: return - doc_manager = None - if hasattr(self, "_table") and self._table and hasattr(self._table, "_server") and self._table._server: - if hasattr(self._table._server, "_documents"): - doc_manager = self._table._server._documents - - if not doc_manager: - user.speak_l("action-locked") - return + doc_manager = self._table._server._documents folder_name = f"{self.get_type()}_rules" meta = doc_manager.get_document_metadata(folder_name) From a6e5b27eacb0303539fa31437db9460eb4a6c2f9 Mon Sep 17 00:00:00 2001 From: Mohammad Aloufi Date: Sun, 26 Apr 2026 19:05:22 +0300 Subject: [PATCH 4/9] Use DocumentManager locale selection methods in game rules handler --- server/game_utils/menu_management_mixin.py | 40 ++++++++++++---------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/server/game_utils/menu_management_mixin.py b/server/game_utils/menu_management_mixin.py index 74364271..a157c212 100644 --- a/server/game_utils/menu_management_mixin.py +++ b/server/game_utils/menu_management_mixin.py @@ -246,32 +246,34 @@ def _action_show_rules(self, player: "Player", action_id: str) -> None: return visible_locales = doc_manager._get_visible_locale_codes(meta, include_private=False) - allowed_private = set() - + if not visible_locales: + user.speak_l("documents-no-content") + return + source_locale = meta.get("source_locale", "en") - title_locale = None - content = None + title_locale = doc_manager._select_display_title_locale(visible_locales, user.locale, source_locale) - for locale_code in [user.locale, "en", source_locale, *visible_locales]: - if locale_code not in visible_locales or locale_code == title_locale: - continue - candidate_content = doc_manager.get_document_content_for_access( - folder_name, - locale_code, - include_private=False, - allowed_private_locales=allowed_private, - ) - if candidate_content is None: - continue - content = candidate_content - title_locale = locale_code - break + if not title_locale: + user.speak_l("documents-no-content") + return + content = doc_manager.get_document_content_for_access( + folder_name, + title_locale, + include_private=False, + ) if content is None: user.speak_l("documents-no-content") return - title = meta.get("titles", {}).get(title_locale or user.locale) or folder_name + titles = meta.get("titles", {}) + title = doc_manager._select_visible_title( + titles, + visible_locales, + title_locale, + source_locale, + folder_name, + ) user.show_editbox( "game_rules", From b402fe1889f3e5ff3e4237ca9d5f85d75144ec88 Mon Sep 17 00:00:00 2001 From: Mohammad Aloufi Date: Sun, 26 Apr 2026 19:14:53 +0300 Subject: [PATCH 5/9] Migrate redundant is_enabled callbacks to _is_always_enabled --- server/game_utils/action_set_creation_mixin.py | 4 ++-- server/game_utils/action_visibility_mixin.py | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/server/game_utils/action_set_creation_mixin.py b/server/game_utils/action_set_creation_mixin.py index 44020764..7b1929ce 100644 --- a/server/game_utils/action_set_creation_mixin.py +++ b/server/game_utils/action_set_creation_mixin.py @@ -102,7 +102,7 @@ def create_standard_action_set(self, player: "Player") -> ActionSet: id="show_actions", label=Localization.get(locale, "actions-menu"), handler="_action_show_actions_menu", - is_enabled="_is_show_actions_enabled", + is_enabled="_is_always_enabled", is_hidden="_is_always_hidden", show_in_actions_menu=False, ) @@ -187,7 +187,7 @@ def create_standard_action_set(self, player: "Player") -> ActionSet: id="leave_game", label=Localization.get(locale, "leave-table"), handler="_action_leave_game", - is_enabled="_is_leave_game_enabled", + is_enabled="_is_always_enabled", is_hidden="_is_leave_game_hidden", ) ) diff --git a/server/game_utils/action_visibility_mixin.py b/server/game_utils/action_visibility_mixin.py index 7fbc96fc..7cbefed3 100644 --- a/server/game_utils/action_visibility_mixin.py +++ b/server/game_utils/action_visibility_mixin.py @@ -109,10 +109,6 @@ def _get_toggle_spectator_label(self, player: "Player", action_id: str) -> str: return Localization.get(locale, "play") return Localization.get(locale, "spectate") - def _is_leave_game_enabled(self, player: "Player") -> str | None: - """Leave game is always enabled.""" - return None - def _is_leave_game_hidden(self, player: "Player") -> Visibility: """Leave game is always hidden (F5/keybind only).""" return Visibility.HIDDEN @@ -157,10 +153,6 @@ def _is_estimate_duration_hidden(self, player: "Player") -> Visibility: # --- Standard actions --- - def _is_show_actions_enabled(self, player: "Player") -> str | None: - """Show actions menu is always enabled.""" - return None - def _is_show_actions_hidden(self, player: "Player") -> Visibility: """Show actions is hidden for players but visible to spectators.""" if player.is_spectator: From e78d9b54ade75806a525e53681a20c3b547e2014 Mon Sep 17 00:00:00 2001 From: Mohammad Aloufi Date: Sun, 26 Apr 2026 19:22:46 +0300 Subject: [PATCH 6/9] Add get_documents accessor to Table to decouple game logic from server --- server/core/tables/table.py | 4 ++++ server/game_utils/menu_management_mixin.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/server/core/tables/table.py b/server/core/tables/table.py index 8832f8db..27aa0b44 100644 --- a/server/core/tables/table.py +++ b/server/core/tables/table.py @@ -98,6 +98,10 @@ def attach_user(self, username: str, user: "User") -> None: """Attach a user to a member (e.g., after deserialization).""" self._users[username] = user + def get_documents(self) -> Any: + """Get the server's document manager.""" + return self._server._documents + def get_players(self) -> list[TableMember]: """Get all non-spectator members.""" return [m for m in self.members if not m.is_spectator] diff --git a/server/game_utils/menu_management_mixin.py b/server/game_utils/menu_management_mixin.py index a157c212..fdb9a5b4 100644 --- a/server/game_utils/menu_management_mixin.py +++ b/server/game_utils/menu_management_mixin.py @@ -237,7 +237,7 @@ def _action_show_rules(self, player: "Player", action_id: str) -> None: if not user: return - doc_manager = self._table._server._documents + doc_manager = self._table.get_documents() folder_name = f"{self.get_type()}_rules" meta = doc_manager.get_document_metadata(folder_name) From c2e4615bd7d853ff23f566fbd367c4da311ffd61 Mon Sep 17 00:00:00 2001 From: Mohammad Aloufi Date: Sun, 26 Apr 2026 19:25:02 +0300 Subject: [PATCH 7/9] Remove trailing whitespace in _action_show_rules --- server/game_utils/menu_management_mixin.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/server/game_utils/menu_management_mixin.py b/server/game_utils/menu_management_mixin.py index fdb9a5b4..3893b9e1 100644 --- a/server/game_utils/menu_management_mixin.py +++ b/server/game_utils/menu_management_mixin.py @@ -236,27 +236,27 @@ def _action_show_rules(self, player: "Player", action_id: str) -> None: user = self.get_user(player) if not user: return - + doc_manager = self._table.get_documents() - + folder_name = f"{self.get_type()}_rules" meta = doc_manager.get_document_metadata(folder_name) if not meta: user.speak_l("documents-no-content") return - + visible_locales = doc_manager._get_visible_locale_codes(meta, include_private=False) if not visible_locales: user.speak_l("documents-no-content") return - + source_locale = meta.get("source_locale", "en") title_locale = doc_manager._select_display_title_locale(visible_locales, user.locale, source_locale) - + if not title_locale: user.speak_l("documents-no-content") return - + content = doc_manager.get_document_content_for_access( folder_name, title_locale, @@ -265,7 +265,7 @@ def _action_show_rules(self, player: "Player", action_id: str) -> None: if content is None: user.speak_l("documents-no-content") return - + titles = meta.get("titles", {}) title = doc_manager._select_visible_title( titles, @@ -274,7 +274,7 @@ def _action_show_rules(self, player: "Player", action_id: str) -> None: source_locale, folder_name, ) - + user.show_editbox( "game_rules", title, From de3f72cdaf6f6f619954273a00cbfe7edb31c0a1 Mon Sep 17 00:00:00 2001 From: Mohammad Aloufi Date: Sun, 26 Apr 2026 19:31:09 +0300 Subject: [PATCH 8/9] Provide feedback when pressing F1 outside of a game table --- server/core/server.py | 5 ++++- server/locales/en/main.ftl | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/server/core/server.py b/server/core/server.py index 311dc1ea..21d31895 100644 --- a/server/core/server.py +++ b/server/core/server.py @@ -4114,7 +4114,8 @@ async def _handle_keybind(self, client: ClientConnection, packet: dict) -> None: user = self._users.get(username) table = self._tables.find_user_table(username) if not table and user: - if packet.get("key") == "w" and packet.get("control"): + keybind_key = packet.get("key") + if keybind_key == "w" and packet.get("control"): players = [ u.username for u in self._users.values() @@ -4126,6 +4127,8 @@ async def _handle_keybind(self, client: ClientConnection, packet: dict) -> None: names = Localization.format_list_and(user.locale, players) key = "online-users-one" if len(players) == 1 else "online-users-many" user.speak_l(key, count=len(players), users=names) + elif keybind_key == "f1": + user.speak_l("action-must-be-at-table") return if table and table.game and user: player = table.game.get_player_by_id(user.uuid) diff --git a/server/locales/en/main.ftl b/server/locales/en/main.ftl index e82e4ea6..a211bed1 100644 --- a/server/locales/en/main.ftl +++ b/server/locales/en/main.ftl @@ -189,6 +189,7 @@ action-table-full = The table is full. action-no-bots = There are no bots to remove. action-bots-cannot = Bots cannot do this. action-no-scores = No scores available yet. +action-must-be-at-table = You must be at a table to view rules. # Dice actions dice-not-rolled = You haven't rolled yet. From 4abdda8590e0b6997a8fb11d773fa018fb68a3fc Mon Sep 17 00:00:00 2001 From: Mohammad Aloufi Date: Sun, 26 Apr 2026 19:41:33 +0300 Subject: [PATCH 9/9] Fix possible duplicate F1 keybind packet on desktop client --- clients/desktop/ui/main_window.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/clients/desktop/ui/main_window.py b/clients/desktop/ui/main_window.py index 4a1bc588..5a48a80b 100644 --- a/clients/desktop/ui/main_window.py +++ b/clients/desktop/ui/main_window.py @@ -701,11 +701,10 @@ def _map_direction_key(self, event, key_code: int, menu_is_empty: bool) -> str | def _map_function_key(self, event, key_code: int) -> str | None: key_map = { - wx.WXK_F1: "f1", wx.WXK_F3: "f3", wx.WXK_F5: "f5", } - if key_code in (wx.WXK_F2, wx.WXK_F4): + if key_code in (wx.WXK_F1, wx.WXK_F2, wx.WXK_F4): event.Skip() return None return key_map.get(key_code) @@ -738,7 +737,6 @@ def _send_keybind_if_allowed(self, event, key_name: str) -> bool: has_shift = (modifiers & wx.MOD_SHIFT) != 0 is_function_key = key_name in [ - "f1", "f2", "f3", "f5",