diff --git a/.github/labels.yaml b/.github/labels.yaml index 314743b..058d5ad 100644 --- a/.github/labels.yaml +++ b/.github/labels.yaml @@ -74,3 +74,95 @@ color: "0052cc" description: Stable escalated-lane Saffron worker. # Repo-specific labels can be appended below. Unknown labels are not pruned. +# Agents / Dispatch (mirrored from live repo labels, 2026-07-16) +- name: agent/foreman-coder + color: "ededed" + description: Foreman coding loop worker. +- name: agent/saffron + color: "ededed" + description: Saffron worker (unlaned). +- name: claimed + color: "85C653" + description: Issue is claimed by an active agent +- name: escalated + color: "FFA500" + description: Issue escalated to GPT review — not eligible for wishlist cron +- name: in-progress + color: "75C043" + description: Work has started +- name: needs-gpt + color: "9a3834" + description: This required gpt-5.5 model +# Areas +- name: area/ops + color: "5319E7" + description: Operations and release process +- name: area/product + color: "AFEEEE" + description: Product feature work +- name: area/security + color: "D93F0C" + description: Security related +- name: area/testing + color: "84B6E6" + description: Testing and QA +# Short-form priorities (legacy; prefer priority/*) +- name: p0 + color: "FF0000" + description: Critical priority +- name: p1 + color: "FBCA04" + description: High priority +- name: p2 + color: "0E8A16" + description: Normal priority +# Types +- name: type/bug + color: "ededed" + description: Bug fix. +- name: type/chore + color: "ededed" + description: Chore or maintenance. +- name: type/digest + color: "ededed" + description: Digest or summary work. +- name: type/feature + color: "ededed" + description: Feature work. +- name: type/major + color: "ededed" + description: Major change. +- name: type/minor + color: "ededed" + description: Minor change. +- name: type/patch + color: "ededed" + description: Patch-level change. +- name: tech-debt + color: "7056ff" + description: Technical debt from audits. +# GitHub defaults / misc +- name: duplicate + color: "cfd3d7" + description: This issue or pull request already exists +- name: good first issue + color: "7057ff" + description: Good for newcomers +- name: help wanted + color: "008672" + description: Extra attention is needed +- name: invalid + color: "e4e669" + description: This doesn't seem right +- name: question + color: "d876e3" + description: Further information is requested +- name: wontfix + color: "ffffff" + description: This will not be worked on +- name: renovate + color: "6B7280" + description: Automated Renovate dashboard +- name: renovate/github-action + color: "ededed" + description: Renovate GitHub Action updates. diff --git a/README.md b/README.md index 3bf4da0..05b1db6 100644 --- a/README.md +++ b/README.md @@ -84,4 +84,3 @@ Tooling is pinned with `.mise.toml`. Run `mise trust` once in the repo if mise p ## Contributors Windowstead is maintained by Miso Space with help from local and AI-assisted contributors. Contributions should preserve the desktop-companion dock UX: bottom strip first, temporary popup menus, and low-attention colony behavior. -# test fix: _assert_empty accepts Variant diff --git a/scripts/main.gd b/scripts/main.gd index 32bc32d..dbb4950 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -8,6 +8,7 @@ const MilestoneManager := preload("res://scripts/milestone_manager.gd") const ColonyStance := preload("res://scripts/colony_stance.gd") const TileRender := preload("res://scripts/tile_render.gd") const WorkerCapLogic := preload("res://scripts/worker_cap_logic.gd") +const WorkerRenderer := preload("res://scripts/worker_renderer.gd") const ColonySim := preload("res://scripts/colony_sim.gd") @@ -228,12 +229,21 @@ func apply_theme() -> void: option.add_theme_stylebox_override("pressed", make_panel_style(Color(0.13, 0.18, 0.24, 1.0), Color(0.42, 0.58, 0.71, 0.95), 10)) option.add_theme_color_override("font_color", Color(0.95, 0.97, 1.0, 0.98)) +# Button styleboxes are shared across every themed button — they are never +# mutated per-button, so one instance per state suffices (audit #279 item 4). +var _button_styles: Dictionary = {} + ## Shared button styling for every themed Button in the dock UI. func apply_button_theme(button: Button) -> void: - button.add_theme_stylebox_override("normal", make_panel_style(Color(0.18, 0.23, 0.3, 0.96), Color(0.36, 0.45, 0.55, 0.9), 10)) - button.add_theme_stylebox_override("hover", make_panel_style(Color(0.23, 0.3, 0.39, 1.0), Color(0.49, 0.64, 0.78, 1.0), 10)) - button.add_theme_stylebox_override("pressed", make_panel_style(Color(0.13, 0.18, 0.24, 1.0), Color(0.42, 0.58, 0.71, 0.95), 10)) - button.add_theme_stylebox_override("disabled", make_panel_style(Color(0.12, 0.15, 0.19, 0.65), Color(0.24, 0.28, 0.33, 0.45), 10)) + if _button_styles.is_empty(): + _button_styles = { + "normal": make_panel_style(Color(0.18, 0.23, 0.3, 0.96), Color(0.36, 0.45, 0.55, 0.9), 10), + "hover": make_panel_style(Color(0.23, 0.3, 0.39, 1.0), Color(0.49, 0.64, 0.78, 1.0), 10), + "pressed": make_panel_style(Color(0.13, 0.18, 0.24, 1.0), Color(0.42, 0.58, 0.71, 0.95), 10), + "disabled": make_panel_style(Color(0.12, 0.15, 0.19, 0.65), Color(0.24, 0.28, 0.33, 0.45), 10), + } + for state in _button_styles: + button.add_theme_stylebox_override(state, _button_styles[state]) button.add_theme_color_override("font_color", Color(0.95, 0.97, 1.0, 0.98)) button.add_theme_color_override("font_disabled_color", Color(0.72, 0.76, 0.82, 0.6)) @@ -1473,18 +1483,8 @@ func render_worker_overlay() -> void: sprite.position = draw_pos - sprite.custom_minimum_size * 0.5 func worker_collision_offset(slot: int, total: int) -> Vector2: - if total <= 1: - return Vector2.ZERO var spacing := maxf(8.0, float(tile_size.x) * 0.18) - var offsets := [ - Vector2(-spacing, -spacing), - Vector2(spacing, spacing), - Vector2(spacing, -spacing), - Vector2(-spacing, spacing), - Vector2(0, -spacing * 1.25), - Vector2(0, spacing * 1.25), - ] - return offsets[slot % offsets.size()] + return WorkerRenderer.collision_offset(slot, total, spacing) func tile_center(pos: Vector2i) -> Vector2: var index := pos.y * grid_w + pos.x @@ -1671,55 +1671,7 @@ func worker_texture(name: String, frame: int, carrying: String = "") -> Texture2 var cache_key := "%s:%d:%s" % [name, frame, carrying] if worker_texture_cache.has(cache_key): return worker_texture_cache[cache_key] - var accent: Color = Constants.WORKER_BADGE_COLORS.get(name, Color.WHITE) - var shadow := accent.darkened(0.45) - var skin := Color("#f2d0b1") - var image := Image.create(12, 14, false, Image.FORMAT_RGBA8) - image.fill(Color(0, 0, 0, 0)) - - # head - for y in range(0, 4): - for x in range(4, 8): - image.set_pixel(x, y, skin) - - # body - for y in range(4, 10): - for x in range(3, 9): - image.set_pixel(x, y, accent) - - # arms - for y in range(5, 9): - image.set_pixel(2, y, shadow) - image.set_pixel(9, y, shadow) - - if not carrying.is_empty(): - var cargo_color := Color("#9aa3aa") - if carrying == "wood": - cargo_color = Color("#8b5a2b") - elif carrying == "food": - cargo_color = Color("#6fbf73") - for y in range(5, 9): - for x in range(0, 3): - image.set_pixel(x, y, cargo_color) - image.set_pixel(1, 4, cargo_color.lightened(0.25)) - - # legs alternate per frame for a simple walk bob - if frame % 2 == 0: - image.set_pixel(4, 10, shadow) - image.set_pixel(4, 11, shadow) - image.set_pixel(7, 10, shadow) - image.set_pixel(7, 11, shadow) - else: - image.set_pixel(5, 10, shadow) - image.set_pixel(4, 11, shadow) - image.set_pixel(6, 10, shadow) - image.set_pixel(7, 11, shadow) - - # feet - image.set_pixel(3, 13, shadow) - image.set_pixel(7, 13, shadow) - - var texture := ImageTexture.create_from_image(image) + var texture := WorkerRenderer.create_texture(name, frame, carrying) worker_texture_cache[cache_key] = texture return texture diff --git a/scripts/worker_renderer.gd b/scripts/worker_renderer.gd new file mode 100644 index 0000000..d5b7075 --- /dev/null +++ b/scripts/worker_renderer.gd @@ -0,0 +1,69 @@ +## Pure worker sprite rendering — pixel-art texture generation and crowd +## offsets. No scene references; main.gd owns the texture cache and calls in. +class_name WorkerRenderer + +const Constants := preload("res://scripts/constants.gd") + + +## Build the 12x14 pixel-art texture for a worker. Deterministic per +## (name, frame, carrying) — callers should cache by that key. +static func create_texture(worker_name: String, frame: int, carrying: String = "") -> Texture2D: + var accent: Color = Constants.WORKER_BADGE_COLORS.get(worker_name, Color.WHITE) + var shadow := accent.darkened(0.45) + var skin := Color("#f2d0b1") + var image := Image.create(12, 14, false, Image.FORMAT_RGBA8) + image.fill(Color(0, 0, 0, 0)) + + # head + for y in range(0, 4): + for x in range(4, 8): + image.set_pixel(x, y, skin) + + # body + for y in range(4, 10): + for x in range(3, 9): + image.set_pixel(x, y, accent) + + # arms + for y in range(5, 9): + image.set_pixel(2, y, shadow) + image.set_pixel(9, y, shadow) + + if not carrying.is_empty(): + var cargo_color := Color("#9aa3aa") + if carrying == "wood": + cargo_color = Color("#8b5a2b") + elif carrying == "food": + cargo_color = Color("#6fbf73") + for y in range(5, 9): + for x in range(0, 3): + image.set_pixel(x, y, cargo_color) + image.set_pixel(1, 4, cargo_color.lightened(0.25)) + + # legs alternate per frame for a simple walk bob + if frame % 2 == 0: + image.set_pixel(4, 10, shadow) + image.set_pixel(4, 11, shadow) + image.set_pixel(7, 10, shadow) + image.set_pixel(7, 11, shadow) + else: + image.set_pixel(5, 10, shadow) + image.set_pixel(4, 11, shadow) + image.set_pixel(6, 10, shadow) + image.set_pixel(7, 11, shadow) + + # feet + image.set_pixel(3, 13, shadow) + image.set_pixel(7, 13, shadow) + + return ImageTexture.create_from_image(image) + + +## Offset for the slot-th of total workers sharing one tile: occupants are +## spread evenly around a ring, so any crowd size stays visually distinct +## (the old fixed six-offset table wrapped and overlapped above six). +static func collision_offset(slot: int, total: int, spacing: float) -> Vector2: + if total <= 1: + return Vector2.ZERO + var angle := TAU * float(slot % total) / float(total) + return Vector2(cos(angle), sin(angle)) * spacing * 1.25 diff --git a/tests/test_dock_layout.gd b/tests/test_dock_layout.gd new file mode 100644 index 0000000..43fbf53 --- /dev/null +++ b/tests/test_dock_layout.gd @@ -0,0 +1,107 @@ +extends "res://tests/test_case.gd" + +# ── Dock anchor layout orchestration tests (#279 item 11) ───────────────────── +# apply_anchor_layout() reparents the header labels, status column, and HUD +# row between the bottom-strip and side-dock families — the largest UX +# surface in the dock, previously untested. main.gd is instantiated WITHOUT +# entering the tree (so _ready never runs and no autoloads are needed); the +# node references it @onready-resolves in production are assigned manually. + +func run_tests() -> void: + var main = load("res://scripts/main.gd").new() + var refs := _build_layout_harness(main) + + # ── Bottom family ── + main.apply_anchor_geometry("bottom") + main.apply_anchor_layout("bottom") + assert_eq(main.anchor_family, "bottom", "bottom: anchor family applied") + assert_true(main.bottom_header_row != null, "bottom: header row created") + assert_eq(main.bottom_header_row.get_parent(), refs.left_column, "bottom: header row lives in left column") + assert_eq(refs.resource_label.get_parent(), main.bottom_header_row, "bottom: resource label in bottom header") + assert_eq(refs.status_label.get_parent(), main.bottom_status_column, "bottom: status label in bottom status column") + assert_eq(refs.hud_row.get_parent(), main.bottom_button_row, "bottom: HUD row in bottom button row") + assert_true(main.bottom_header_row.visible, "bottom: bottom header visible") + assert_eq(refs.status_label.horizontal_alignment, HORIZONTAL_ALIGNMENT_RIGHT, "bottom: status right-aligned") + assert_eq(refs.world_grid.columns, main.grid_w, "bottom: grid columns match bottom dims") + + # ── Switch to side family ── + main.apply_anchor_geometry("left") + main.apply_anchor_layout("left") + assert_eq(main.anchor_family, "vertical", "side: anchor family applied") + assert_true(main.side_header_row != null, "side: header row created") + assert_eq(main.side_header_row.get_parent(), refs.left_column, "side: header row lives in left column") + assert_eq(refs.resource_label.get_parent(), main.side_header_row, "side: resource label in side header") + assert_eq(refs.status_label.get_parent(), main.side_status_column, "side: status label in side status column") + assert_eq(refs.hud_row.get_parent(), main.side_button_row, "side: HUD row in side button row") + assert_false(main.bottom_header_row.visible, "side: bottom header hidden") + assert_true(main.side_header_row.visible, "side: side header visible") + assert_eq(refs.status_label.horizontal_alignment, HORIZONTAL_ALIGNMENT_LEFT, "side: status left-aligned") + assert_eq(refs.world_grid.columns, main.grid_w, "side: grid columns match side dims") + + # ── And back — parentage must be fully restored ── + main.apply_anchor_geometry("bottom") + main.apply_anchor_layout("bottom") + assert_eq(refs.resource_label.get_parent(), main.bottom_header_row, "return: resource label back in bottom header") + assert_eq(refs.status_label.get_parent(), main.bottom_status_column, "return: status label back in bottom column") + assert_eq(refs.hud_row.get_parent(), main.bottom_button_row, "return: HUD row back in bottom button row") + assert_true(main.bottom_header_row.visible, "return: bottom header visible again") + assert_false(main.side_header_row.visible, "return: side header hidden again") + # HUD row internal ordering is normalized by the shared tail. + assert_eq(refs.hud_row.get_child(0), refs.menu_button, "return: menu button first in HUD row") + assert_eq(refs.hud_row.get_child(2), refs.menu_hint, "return: menu hint last in HUD row") + + main.free() + + +## Wire up the minimal node graph apply_anchor_layout touches, returning the +## nodes the assertions need. Mirrors the scene structure without loading it. +func _build_layout_harness(main: Control) -> Dictionary: + var backdrop := Panel.new() + backdrop.name = "Backdrop" + main.add_child(backdrop) + + var root_box := BoxContainer.new() + backdrop.add_child(root_box) + var left_column := VBoxContainer.new() + root_box.add_child(left_column) + var world_panel := PanelContainer.new() + left_column.add_child(world_panel) + var world_grid := GridContainer.new() + world_panel.add_child(world_grid) + var sidebar_scroll := ScrollContainer.new() + root_box.add_child(sidebar_scroll) + + var resource_label := Label.new() + left_column.add_child(resource_label) + var status_label := Label.new() + left_column.add_child(status_label) + + var hud_row := HBoxContainer.new() + left_column.add_child(hud_row) + var menu_button := Button.new() + hud_row.add_child(menu_button) + var build_mode_button := Button.new() + hud_row.add_child(build_mode_button) + var menu_hint := Label.new() + hud_row.add_child(menu_hint) + + main.root_box = root_box + main.left_column = left_column + main.world_panel = world_panel + main.world_grid = world_grid + main.sidebar_scroll = sidebar_scroll + main.resource_label = resource_label + main.status_label = status_label + main.menu_button = menu_button + main.build_mode_button = build_mode_button + main.menu_hint = menu_hint + + return { + "left_column": left_column, + "world_grid": world_grid, + "resource_label": resource_label, + "status_label": status_label, + "hud_row": hud_row, + "menu_button": menu_button, + "menu_hint": menu_hint, + } diff --git a/tests/test_reservations.gd b/tests/test_reservations.gd index 2eb9beb..c537397 100644 --- a/tests/test_reservations.gd +++ b/tests/test_reservations.gd @@ -25,6 +25,7 @@ func run_tests() -> void: test_reserve_field_added_to_new_builds(gs) test_reserved_resources_save_load(gs) test_reserved_resources_resync_on_load(gs) + test_gather_reservation_balance_on_depleted_tile() # ────────────────────────────────────────────────────────────────────── @@ -477,3 +478,68 @@ func test_reserved_resources_resync_on_load(gs: Node) -> void: var reserved: Dictionary = loaded.get("reserved_resources", {}) assert_eq(int(reserved.get("wood", -1)), 1, "wood reservation rebuilt from gather worker") assert_eq(int(reserved.get("stone", -1)), 1, "stone reservation rebuilt from haul worker") + + +# ────────────────────────────────────────────────────────────────────── +# Reservation balance on the do_gather early-return path (#279 item 7): +# a worker arriving at a tile another worker already emptied must release +# its reservation exactly once, and the counter must never go negative. +# ────────────────────────────────────────────────────────────────────── + +const ColonySim := preload("res://scripts/colony_sim.gd") + +func test_gather_reservation_balance_on_depleted_tile() -> void: + print("") + print("--- reservation: balance on depleted-tile gather ---") + + var sim := ColonySim.new() + sim.grid_w = 5 + sim.grid_h = 5 + sim.stockpile_pos = Vector2i(0, 0) + sim.priority_order = ["gather"] as Array[String] + var tiles: Array = [] + for i in 25: + tiles.append({"kind": "ground", "amount": 0, "resource": "", "build_kind": ""}) + sim.state = { + "tick": 0, + "resources": {"wood": 0, "stone": 0, "food": 10}, + "harvested": {"wood": 0, "stone": 0, "food": 0}, + "priority_order": ["gather"], + "workers": [ + {"name": "Jun", "pos": {"x": 2, "y": 0}, "prev_pos": {"x": 2, "y": 0}, "carrying": {}, "task": {}, "break_ticks": 0}, + {"name": "Mara", "pos": {"x": 2, "y": 4}, "prev_pos": {"x": 2, "y": 4}, "carrying": {}, "task": {}, "break_ticks": 0}, + ], + "tiles": tiles, + "builds": [], + "next_build_id": 1, + "reserved_resources": {}, + "events": [], + } + # Two single-unit wood tiles so both workers can reserve wood. + sim.set_tile(Vector2i(3, 0), {"kind": "tree", "amount": 1, "resource": "wood", "build_kind": ""}) + sim.set_tile(Vector2i(3, 4), {"kind": "tree", "amount": 1, "resource": "wood", "build_kind": ""}) + + var task_a: Dictionary = sim.choose_task(sim.state.workers[0]) + sim.state.workers[0].task = task_a + var task_b: Dictionary = sim.choose_task(sim.state.workers[1]) + sim.state.workers[1].task = task_b + assert_eq(sim.get_reserved("wood"), 2, "balance: both workers hold a reservation") + + # Empty worker A's target tile out from under it (mirrors a race where + # the tile was consumed before arrival), then let A attempt the gather. + var target_a := ColonySim.data_to_vec(task_a.target) + sim.set_tile(target_a, {"kind": "ground", "amount": 0, "resource": "", "build_kind": ""}) + sim.state.workers[0].pos = task_a.target + sim.do_gather(sim.state.workers[0], task_a) + assert_eq(sim.get_reserved("wood"), 1, "balance: early-return released exactly one reservation") + assert_true(sim.state.workers[0].task.is_empty(), "balance: failed gather clears the task") + + # Worker B gathers normally — its reservation is released on success. + sim.state.workers[1].pos = task_b.target + sim.do_gather(sim.state.workers[1], task_b) + assert_eq(sim.get_reserved("wood"), 0, "balance: successful gather released the last reservation") + + # Pathological repeat on an empty tile with no reservation held: the + # counter clamps at zero instead of drifting negative. + sim.do_gather(sim.state.workers[0], task_a) + assert_eq(sim.get_reserved("wood"), 0, "balance: counter never goes negative")