From ddf67ecb68d49bc65ccc940a2354b1111d1808a3 Mon Sep 17 00:00:00 2001 From: The_miro Date: Thu, 16 Jul 2026 16:55:58 +0200 Subject: [PATCH] Add dev-mode faction tag editor F7 opens it targeting the player, F8 targets whichever character is directly ahead. Type + Enter adds a tag, Up/Down selects an existing one, Delete removes it. Backed by a new DevFactionMenu (mirrors the existing spawn/turret dev menus) and Mode.FACTION_EDIT. Note: Character.factions isn't persisted through WorldRepository save/load yet - same pre-existing gap as implants/ai/held_items. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FyY5qQB6XHRoBFNbsgdRQn --- config/keybindings.conf | 2 + engine/config.py | 2 + engine/dev_tools.py | 50 +++++++++++++++++ engine/game_modes.py | 1 + engine/render/menu_draw.py | 32 +++++++++++ main.py | 54 +++++++++++++++++- tests/test_dev_tools.py | 110 ++++++++++++++++++++++++++++++++++++- tests/test_menu_draw.py | 27 +++++++++ 8 files changed, 276 insertions(+), 2 deletions(-) diff --git a/config/keybindings.conf b/config/keybindings.conf index 6f8af3b..1f5154b 100644 --- a/config/keybindings.conf +++ b/config/keybindings.conf @@ -21,6 +21,8 @@ dev_spawn_object = F3 dev_spawn_npc_menu = F4 dev_cycle_turret = F5 dev_place_turret = F6 +dev_edit_player_factions = F7 +dev_edit_target_factions = F8 pickup = f [hands] diff --git a/engine/config.py b/engine/config.py index 856a3dc..c1dde4d 100644 --- a/engine/config.py +++ b/engine/config.py @@ -36,6 +36,8 @@ DEFAULT_KEYBINDINGS: dict[str, list[str]] = { "dev_spawn_npc_menu": ["F4"], "dev_cycle_turret": ["F5"], "dev_place_turret": ["F6"], + "dev_edit_player_factions": ["F7"], + "dev_edit_target_factions": ["F8"], } # Human-friendly config names for keys that don't have a printable representation. diff --git a/engine/dev_tools.py b/engine/dev_tools.py index e675453..6fe9afd 100644 --- a/engine/dev_tools.py +++ b/engine/dev_tools.py @@ -81,3 +81,53 @@ class DevTurretMenu: if not self.entity_def_ids: return None return self.entity_def_ids[self.selected_index] + + +MAX_FACTION_TAG_LENGTH = 24 + + +@dataclass +class DevFactionMenu: + """Dev-mode-only cheat tool: view and edit a Character's faction tags (see + Character.factions - the general tag system resources/logic/turrets.py::is_hostile reads to + tell allies from enemies). Not itself aware of which Character it's editing - main.py + resolves that fresh each time it's opened (the player, or whichever Character is directly + ahead - see main.py's dev_edit_player_factions/dev_edit_target_factions) and passes it into + every method here, the same way the backpack popup doesn't own the Character it displays. + """ + + input_text: str = "" + selected_index: int = 0 + + def type_char(self, ch: str) -> None: + if ch.isprintable() and len(self.input_text) < MAX_FACTION_TAG_LENGTH: + self.input_text += ch + + def backspace(self) -> None: + self.input_text = self.input_text[:-1] + + def add_tag(self, character: Character) -> bool: + """Adds the currently-typed text as a new faction tag and clears the input field. + No-op (returns False) for a blank tag or one the character already has. + """ + tag = self.input_text.strip() + if not tag or tag in character.factions: + return False + character.factions.append(tag) + self.input_text = "" + return True + + def cycle_selected(self, character: Character, delta: int) -> None: + if character.factions: + self.selected_index = (self.selected_index + delta) % len(character.factions) + + def remove_selected(self, character: Character) -> bool: + """Removes whichever existing tag is currently selected. No-op (returns False) if the + character has no tags to remove. + """ + if not character.factions: + return False + index = self.selected_index % len(character.factions) + character.factions.pop(index) + self.selected_index = max(0, min(self.selected_index, len(character.factions) - 1)) + return True diff --git a/engine/game_modes.py b/engine/game_modes.py index 069105f..7b43d30 100644 --- a/engine/game_modes.py +++ b/engine/game_modes.py @@ -21,6 +21,7 @@ class Mode(Enum): PAUSED = "paused" PLAYING = "playing" NPC_SPAWN = "npc_spawn" # dev-mode-only: reuses CharacterCreationFlow to build and place an NPC + FACTION_EDIT = "faction_edit" # dev-mode-only: view/add/remove a Character's faction tags @dataclass diff --git a/engine/render/menu_draw.py b/engine/render/menu_draw.py index 07095bb..281da47 100644 --- a/engine/render/menu_draw.py +++ b/engine/render/menu_draw.py @@ -1,6 +1,7 @@ from __future__ import annotations from engine.controls_menu import ControlsMenu +from engine.dev_tools import DevFactionMenu from engine.game_modes import CharacterCreationFlow, MainMenu, PauseMenu from engine.render.ui_draw import LINE_HEIGHT, draw_panel, draw_text, text_width from engine.ui import AbilityBar, CharacterMenu @@ -118,6 +119,37 @@ def draw_controls_menu(buckets, menu: ControlsMenu, viewport_w: float, viewport_ draw_text(buckets, "Enter to rebind, Esc to go back", x + PADDING, footer_y) +def draw_faction_editor(buckets, menu: DevFactionMenu, character, viewport_w: float, viewport_h: float) -> None: + """Dev-mode-only: lists `character`'s current faction tags (see Character.factions) with + the selected one highlighted, plus the tag currently being typed - see main.py's + dev_edit_player_factions/dev_edit_target_factions and DevFactionMenu itself for the + add/remove/select logic this only renders. + """ + tag_count = len(character.factions) + panel_w = 380.0 + panel_h = PADDING * 2 + ROW_H * (tag_count + 4.5) + x = (viewport_w - panel_w) / 2 + y = max(20.0, (viewport_h - panel_h) / 2) + draw_panel(buckets, "background", x, y, panel_w, panel_h) + draw_text(buckets, f"Factions: {character.name or character.entity_id}", x + PADDING, y + PADDING) + + if not character.factions: + draw_text(buckets, "(no tags yet)", x + PADDING, y + PADDING + ROW_H * 1.5) + for i, tag in enumerate(character.factions): + row_y = y + PADDING + ROW_H * (1.5 + i) + label = f"> {tag}" if i == menu.selected_index else f" {tag}" + if i == menu.selected_index: + draw_panel(buckets, "highlight", x + PADDING - 6, row_y - 2, text_width(label) + 12, ROW_H) + draw_text(buckets, label, x + PADDING, row_y) + + input_row_y = y + PADDING + ROW_H * (tag_count + 2.0) + draw_text(buckets, f"New tag: {menu.input_text}_", x + PADDING, input_row_y) + footer_y = y + panel_h - PADDING - ROW_H + draw_text( + buckets, "Type + Enter to add, Up/Down to select, Delete to remove, Esc to close", x + PADDING, footer_y + ) + + CHARACTER_CARD_W = 260.0 CHARACTER_CARD_H = 200.0 diff --git a/main.py b/main.py index b758339..0eaa26b 100644 --- a/main.py +++ b/main.py @@ -17,7 +17,7 @@ from engine.controls_menu import ControlsMenu from engine.db import WorldRepository from engine.defs import DefRegistry from engine.dev_config import DevConfig -from engine.dev_tools import DevSpawnMenu, DevTurretMenu +from engine.dev_tools import DevFactionMenu, DevSpawnMenu, DevTurretMenu from engine.entity import EntityPosition from engine.gamepad import GamepadBindings, GamepadButtonEdgeTracker, GamepadState from engine.game_modes import CharacterCreationFlow, MainMenu, Mode, PauseMenu @@ -37,6 +37,7 @@ from engine.render.menu_draw import ( draw_character_creation, draw_controls_menu, draw_drag_ghost, + draw_faction_editor, draw_main_menu, draw_pause_menu, draw_world_creation, @@ -137,6 +138,8 @@ class AppState: self.char_flow: CharacterCreationFlow | None = None self.pending_character: Character | None = None # built, awaiting a world to spawn into self.npc_spawn_flow: CharacterCreationFlow | None = None # dev-mode NPC spawner (see Mode.NPC_SPAWN) + self.faction_edit_menu = DevFactionMenu() # dev-mode faction tag editor (see Mode.FACTION_EDIT) + self.faction_edit_target: Character | None = None # whose factions faction_edit_menu is currently showing self.world_creation_menu: WorldCreationMenu | None = None self.controls_menu: ControlsMenu | None = None self.pause_menu: PauseMenu | None = None @@ -292,6 +295,30 @@ def main() -> None: elif len(key) == 1: flow.type_char(key) + def handle_faction_edit_key(key: str) -> None: + """Dev-mode-only faction tag editor - see DevFactionMenu (engine/dev_tools.py) for the + add/remove/select logic, main.py's dev_edit_player_factions/dev_edit_target_factions + for how this mode gets entered (and which Character it's pointed at). + """ + menu = state.faction_edit_menu + target = state.faction_edit_target + assert target is not None + if key == "ArrowUp": + menu.cycle_selected(target, -1) + elif key == "ArrowDown": + menu.cycle_selected(target, 1) + elif key == "Enter": + menu.add_tag(target) + elif key == "Delete": + menu.remove_selected(target) + elif key == "Backspace": + menu.backspace() + elif key == "Escape": + state.faction_edit_target = None + enter_playing() + elif len(key) == 1: + menu.type_char(key) + def handle_world_create_key(key: str) -> None: menu = state.world_creation_menu assert menu is not None @@ -376,6 +403,8 @@ def main() -> None: handle_pause_key(key) elif state.mode == Mode.NPC_SPAWN: handle_npc_spawn_key(key) + elif state.mode == Mode.FACTION_EDIT: + handle_faction_edit_key(key) def handle_playing_key_down(event: dict) -> None: key = event.get("key", "") @@ -427,6 +456,26 @@ def main() -> None: turret = spawn_turret(registry, entity_def_id, position, hostile_entity_ids=(player.entity_id,)) state.world.add_entity(turret) return + if dev_config.enabled and action == "dev_edit_player_factions": + player = state.world.player if state.world is not None else None + if isinstance(player, Character): + leave_playing() + state.faction_edit_target = player + state.faction_edit_menu = DevFactionMenu() + state.mode = Mode.FACTION_EDIT + return + if dev_config.enabled and action == "dev_edit_target_factions": + player = state.world.player if state.world is not None else None + if isinstance(player, Character) and player.position is not None and state.world is not None: + dx, dy, dz = current_aim_direction(player) + ahead = (player.position.x + dx, player.position.y + dy, player.position.z + dz) + target = state.world.entity_at(player.position.map_id, *ahead) + if isinstance(target, Character): + leave_playing() + state.faction_edit_target = target + state.faction_edit_menu = DevFactionMenu() + state.mode = Mode.FACTION_EDIT + return input_state.handle_event(event) def handle_character_card_pointer_event(event_type: str, event: dict) -> bool: @@ -674,6 +723,9 @@ def main() -> None: elif state.mode == Mode.NPC_SPAWN: assert state.npc_spawn_flow is not None draw_character_creation(ui_buckets, state.npc_spawn_flow, viewport_w, viewport_h, title="Spawn NPC") + elif state.mode == Mode.FACTION_EDIT: + assert state.faction_edit_target is not None + draw_faction_editor(ui_buckets, state.faction_edit_menu, state.faction_edit_target, viewport_w, viewport_h) renderer.render_frame(context, backdrop, camera, ui_buckets) window.canvas.request_draw(draw_frame) diff --git a/tests/test_dev_tools.py b/tests/test_dev_tools.py index d85a26c..b97645a 100644 --- a/tests/test_dev_tools.py +++ b/tests/test_dev_tools.py @@ -5,7 +5,7 @@ import pytest from engine.character import Character from engine.defs import DefRegistry from engine.dev_config import DevConfig -from engine.dev_tools import DevSpawnMenu, DevTurretMenu +from engine.dev_tools import DevFactionMenu, DevSpawnMenu, DevTurretMenu from engine.inventory import Inventory DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" @@ -94,6 +94,114 @@ def test_turret_menu_selected_entity_def_id_is_none_when_empty(registry): assert menu.selected_entity_def_id() is None +# --- DevFactionMenu -------------------------------------------------------------------------------- + + +def test_type_char_builds_up_the_input_text(): + menu = DevFactionMenu() + menu.type_char("h") + menu.type_char("i") + assert menu.input_text == "hi" + + +def test_backspace_removes_the_last_typed_character(): + menu = DevFactionMenu() + menu.type_char("h") + menu.type_char("i") + menu.backspace() + assert menu.input_text == "h" + + +def test_typing_is_capped_at_max_faction_tag_length(): + menu = DevFactionMenu() + for ch in "x" * 40: + menu.type_char(ch) + assert len(menu.input_text) == 24 + + +def test_add_tag_appends_the_typed_text_and_clears_the_input(registry): + menu = DevFactionMenu() + character = make_human(registry) + menu.type_char("h") + menu.type_char("o") + menu.type_char("s") + menu.type_char("t") + menu.type_char("i") + menu.type_char("l") + menu.type_char("e") + + assert menu.add_tag(character) is True + + assert character.factions == ["hostile"] + assert menu.input_text == "" + + +def test_add_tag_trims_whitespace(registry): + menu = DevFactionMenu() + character = make_human(registry) + for ch in " crew ": + menu.type_char(ch) + menu.add_tag(character) + assert character.factions == ["crew"] + + +def test_add_tag_is_a_noop_for_blank_input(registry): + menu = DevFactionMenu() + character = make_human(registry) + for ch in " ": + menu.type_char(ch) + assert menu.add_tag(character) is False + assert character.factions == [] + + +def test_add_tag_is_a_noop_for_a_duplicate(registry): + menu = DevFactionMenu() + character = make_human(registry, factions=["crew"]) + for ch in "crew": + menu.type_char(ch) + assert menu.add_tag(character) is False + assert character.factions == ["crew"] + + +def test_cycle_selected_wraps_around(registry): + menu = DevFactionMenu() + character = make_human(registry, factions=["a", "b", "c"]) + menu.cycle_selected(character, -1) + assert menu.selected_index == 2 + + +def test_cycle_selected_is_a_noop_without_any_tags(registry): + menu = DevFactionMenu() + character = make_human(registry) + menu.cycle_selected(character, 1) + assert menu.selected_index == 0 + + +def test_remove_selected_removes_the_highlighted_tag(registry): + menu = DevFactionMenu() + character = make_human(registry, factions=["a", "b", "c"]) + menu.selected_index = 1 + assert menu.remove_selected(character) is True + assert character.factions == ["a", "c"] + + +def test_remove_selected_clamps_the_index_after_removing_the_last_tag(registry): + menu = DevFactionMenu() + character = make_human(registry, factions=["a", "b"]) + menu.selected_index = 1 + menu.remove_selected(character) + assert character.factions == ["a"] + assert menu.remove_selected(character) is True + assert character.factions == [] + assert menu.selected_index == 0 + + +def test_remove_selected_is_a_noop_without_any_tags(registry): + menu = DevFactionMenu() + character = make_human(registry) + assert menu.remove_selected(character) is False + + # --- DevConfig ------------------------------------------------------------------------------------ diff --git a/tests/test_menu_draw.py b/tests/test_menu_draw.py index 7877c68..c5a9d75 100644 --- a/tests/test_menu_draw.py +++ b/tests/test_menu_draw.py @@ -7,6 +7,7 @@ from engine.character import Character from engine.character_creator import CharacterCreator from engine.controls_menu import ControlsMenu from engine.defs import DefRegistry +from engine.dev_tools import DevFactionMenu from engine.game_modes import CharacterCreationFlow, MainMenu, PauseMenu from engine.inventory import Inventory from engine.item import ItemInstance @@ -115,6 +116,32 @@ def test_draw_controls_menu_highlights_selected_row(): assert buckets["ui/panel/highlight.png"] +def test_draw_faction_editor_lists_existing_tags_with_the_selected_one_highlighted(registry): + buckets = new_buckets() + character = Character( + name="Vex", species_id="human", default_body_parts=registry.new_default_body_parts("human"), + factions=["player", "crew"], + ) + menu = DevFactionMenu() + + menu_draw.draw_faction_editor(buckets, menu, character, 800, 600) + + assert buckets["ui/panel/background.png"] + assert buckets["ui/panel/highlight.png"] + assert any(k.startswith("ui/font/") for k in buckets) + + +def test_draw_faction_editor_handles_a_character_with_no_tags_yet(registry): + buckets = new_buckets() + character = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human")) + menu = DevFactionMenu() + + menu_draw.draw_faction_editor(buckets, menu, character, 800, 600) + + assert buckets["ui/panel/background.png"] + assert "ui/panel/highlight.png" not in buckets + + def test_draw_ability_bar_highlights_the_selected_slot(): buckets = new_buckets() bar = AbilityBar()