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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyY5qQB6XHRoBFNbsgdRQn
main
Amir Alexander Abdelbaki 2026-07-16 16:55:58 +02:00
parent 1099183788
commit ddf67ecb68
8 changed files with 276 additions and 2 deletions

View File

@ -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]

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

54
main.py
View File

@ -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)

View File

@ -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 ------------------------------------------------------------------------------------

View File

@ -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()