From 3b2f3a7e350a6b8f82aabaaaffea24e1c3d97b43 Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Tue, 31 Mar 2026 07:05:38 +0300 Subject: [PATCH 01/11] Refactor HUD and bot rendering in Village AI War - Introduced a minimum height for the legend HUD to ensure consistent display across game states. - Updated the bot rendering logic to include health bars above units, enhancing visual feedback on bot status. - Improved the drawing of bot figures with distinct colors for team and role, and added a helmet representation for roles. - Enhanced the rendering of unit roles and health indicators in the Pygame renderer for better clarity and user experience. --- .../rendering/moderngl_3d_renderer.py | 169 +++++++++++++++--- .../rendering/pygame_renderer.py | 63 ++++++- 2 files changed, 201 insertions(+), 31 deletions(-) diff --git a/src/village_ai_war/rendering/moderngl_3d_renderer.py b/src/village_ai_war/rendering/moderngl_3d_renderer.py index 84022fe..4149385 100644 --- a/src/village_ai_war/rendering/moderngl_3d_renderer.py +++ b/src/village_ai_war/rendering/moderngl_3d_renderer.py @@ -39,7 +39,7 @@ _LEGEND_WIDTH_PX = 268 # Khronos GL_SCISSOR_TEST (moderngl.Context has no SCISSOR_TEST flag alias) _GL_SCISSOR_TEST = 0x0C11 -_LEGEND_HUD_HEIGHT = 102 +_LEGEND_HUD_HEIGHT_MIN = 102 _TEAM_HUD_RGB = ((255, 130, 130), (150, 185, 255)) _HUD_ROW_BG = (30, 33, 44) _ACCENT_HUD = (92, 140, 220) @@ -407,6 +407,16 @@ def _mode_label_ru(m: GlobalRewardMode) -> str: }.get(m, "?") +def _legend_hud_height_for_state( + _pygame: Any, _small: Any, _body: Any, _state: GameState, _legend_w: int +) -> int: + """Высота нижней панели: тик + две строки команд (HP рисуется над юнитами на карте).""" + tick_block = 6 + 16 + 4 + row_h = 32 + gap = 3 + return max(_LEGEND_HUD_HEIGHT_MIN, tick_block + 2 * (row_h + gap) + 10) + + def _ortho_pixel(win_w: float, win_h: float) -> np.ndarray: m = np.zeros((4, 4), dtype=np.float32) m[0, 0] = 2.0 / win_w @@ -426,10 +436,11 @@ def _draw_legend_hud( w: int, h: int, state: GameState, + hud_h: int, ) -> None: - """Нижняя зона панели: тик, ресурсы и живые боты по командам.""" - y0 = h - _LEGEND_HUD_HEIGHT - pygame.draw.rect(surf, (22, 24, 32), pygame.Rect(0, y0, w, _LEGEND_HUD_HEIGHT)) + """Нижняя зона панели: тик и ресурсы по командам (HP — на модели юнита над клеткой).""" + y0 = h - hud_h + pygame.draw.rect(surf, (22, 24, 32), pygame.Rect(0, y0, w, hud_h)) pygame.draw.line(surf, _ACCENT_HUD, (0, y0), (w, y0), 2) win = state.winner @@ -453,13 +464,13 @@ def _draw_legend_hud( col = _TEAM_HUD_RGB[i] if i < 2 else (200, 200, 210) name = names[i] if i < len(names) else f"команда {v.team}" r = v.resources - alive = sum(1 for b in v.bots if b.is_alive) + alive_n = sum(1 for b in v.bots if b.is_alive) mode = _mode_label_ru(v.global_reward_mode) panel = pygame.Rect(6, yy, w - 12, row_h) pygame.draw.rect(surf, _HUD_ROW_BG, panel) pygame.draw.rect(surf, _lerp_rgb_int(col, (255, 255, 255), 0.65), panel, 1) pygame.draw.rect(surf, col, pygame.Rect(panel.left, panel.top, 3, panel.height)) - line1 = f"{name} боты {alive}/{v.pop_cap} AI: {mode}" + line1 = f"{name} боты {alive_n}/{v.pop_cap} AI: {mode}" line2 = f"дер. {r.wood} кам. {r.stone} еда {r.food}" surf.blit(small.render(line1, True, col), (panel.left + 8, panel.top + 4)) surf.blit(body.render(line2, True, (210, 212, 220)), (panel.left + 8, panel.top + 17)) @@ -524,6 +535,118 @@ def _add_building_variant( _add_cuboid(buf, wx, base_h + h * 0.5 + 0.02, wz, 0.72, h, 0.72, team) +def _add_bot_figure( + buf: list[float], + wx: float, + base_h: float, + wz: float, + team_rgb: tuple[float, float, float], + role_rgb: tuple[float, float, float], + role: Role, +) -> None: + """Силуэт: ноги и туловище — цвет команды; шлем и аксессуары роли — цвет роли.""" + y = float(base_h) + tr, tg, tb = team_rgb + rr, rg, rb = role_rgb + leg_tint = (tr * 0.52 + 0.04, tg * 0.52 + 0.04, tb * 0.52 + 0.04) + torso = (min(tr * 1.05, 0.99), min(tg * 1.05, 0.99), min(tb * 1.05, 0.99)) + arm_col = (tr * 0.78, tg * 0.78, tb * 0.78) + + leg_h, leg_w = 0.13, 0.062 + spread = 0.092 + for sx in (-spread, spread): + _add_cuboid( + buf, + wx + sx, + y + leg_h * 0.5, + wz, + leg_w, + leg_h, + leg_w * 1.08, + leg_tint, + ) + y += leg_h + + tw, th, td = 0.24, 0.30, 0.13 + ty = y + th * 0.5 + _add_cuboid(buf, wx, ty, wz, tw, th, td, torso) + + arm_y = y + th * 0.52 + arm_l, aw, ad = 0.13, 0.052, 0.052 + ox = tw * 0.5 + aw * 0.48 + for sx in (-ox, ox): + _add_cuboid(buf, wx + sx, arm_y, wz + 0.018, aw, arm_l, ad, arm_col) + + y += th + head_y = y + 0.095 + skin = (0.91, 0.79, 0.64) + _add_sphere(buf, wx, head_y, wz, 0.092, skin, stacks=4, slices=8) + helm = (min(rr * 1.08, 0.98), min(rg * 1.08, 0.98), min(rb * 1.08, 0.98)) + _add_cuboid(buf, wx, head_y + 0.105, wz, 0.15, 0.048, 0.15, helm) + + if role == Role.WARRIOR: + blade = (0.75, 0.76, 0.82) + _add_cuboid(buf, wx + ox * 0.85, arm_y + 0.02, wz + 0.16, 0.035, 0.045, 0.20, blade) + elif role == Role.GATHERER: + bag = (rr * 0.42 + 0.32, rg * 0.38 + 0.28, rb * 0.32 + 0.22) + _add_cuboid(buf, wx - ox * 0.85, arm_y, wz + 0.12, 0.08, 0.06, 0.08, bag) + elif role == Role.FARMER: + hat = (min(rr * 0.72 + 0.2, 0.96), min(rg * 0.68 + 0.18, 0.92), min(rb * 0.35 + 0.12, 0.85)) + _add_cylinder_y( + buf, + wx, + head_y + 0.14, + wz, + 0.11, + 0.04, + 8, + hat, + cap_top=True, + cap_bottom=True, + ) + elif role == Role.BUILDER: + brick = (rr * 0.45 + 0.35, rg * 0.42 + 0.33, rb * 0.4 + 0.32) + _add_cuboid(buf, wx - ox * 0.9, arm_y - 0.02, wz + 0.1, 0.05, 0.08, 0.05, brick) + + +def _add_bot_hp_bar( + buf: list[float], + wx: float, + wz: float, + base_h: float, + hp_frac: float, +) -> None: + """Полоска HP над головой: светлый трек + яркая заливка слоем выше (без z-fight с треком).""" + f = float(np.clip(hp_frac, 0.0, 1.0)) + bar_w, bar_d = 0.46, 0.075 + h_track = 0.048 + h_fill = 0.024 + # Центр трека — над шлемом/шляпой + cy_track = float(base_h) + 0.79 + rim = (0.28, 0.30, 0.36) + track = (0.48, 0.50, 0.56) + _add_cuboid( + buf, wx, cy_track, wz, bar_w + 0.024, h_track + 0.012, bar_d + 0.018, rim + ) + _add_cuboid(buf, wx, cy_track, wz, bar_w, h_track, bar_d, track) + if f <= 0.001: + return + margin = bar_w * 0.08 + inner = bar_w - 2.0 * margin + fw = max(inner * f, 0.02) + left = wx - bar_w * 0.5 + margin + fx = left + fw * 0.5 + if f > 0.35: + fill = (0.22, 0.98, 0.42) + elif f > 0.15: + fill = (1.0, 0.78, 0.18) + else: + fill = (1.0, 0.32, 0.28) + # Заливка целиком над верхней гранью трека — всегда видна при типичном свете (amb+dif) + cy_fill = cy_track + h_track * 0.5 + h_fill * 0.5 + 0.004 + _add_cuboid(buf, fx, cy_fill, wz, fw, h_fill, bar_d * 0.88, fill) + + def _install_linux_gl_soname_patch() -> Any: """moderngl loads ``libGL.so`` / ``libEGL.so``; Debian/Ubuntu ship ``libGL.so.1`` only. @@ -702,8 +825,9 @@ def _upload_legend_texture(self, state: GameState) -> None: title = self._f_leg_title body = self._f_leg_body small = self._f_leg_small + hud_h = _legend_hud_height_for_state(pygame, small, body, state, self._legend_w) y = 10 - hud_top = self._win_h - _LEGEND_HUD_HEIGHT + hud_top = self._win_h - hud_h def bl(txt: str, dy: int, color: tuple[int, int, int], font: Any = body) -> None: nonlocal y @@ -714,20 +838,20 @@ def bl(txt: str, dy: int, color: tuple[int, int, int], font: Any = body) -> None bl("Легенда (3D)", 22, (245, 248, 255), title) bl("Боты", 16, (120, 170, 230), small) - bl("Низ — команда (диск)", 16, (200, 200, 210)) - bl("Сфера — роль (W/G/F/B)", 16, (200, 200, 210)) - bl("Кольцо — снова команда", 16, (200, 200, 210)) + bl("Фигурки: тело — команда", 16, (200, 200, 210)) + bl("шлем/убор — роль W/G/F/B", 16, (200, 200, 210)) + bl("HP — полоска над юнитом", 16, (175, 205, 235)) y += 4 for i, label in enumerate(("RED (0)", "BLUE (1)")): if y > hud_top - 24: break c = tuple(int(255 * x) for x in _TEAM_NEON[i]) - pygame.draw.ellipse(surf, c, pygame.Rect(12, y, 22, 14)) - pygame.draw.rect(surf, (60, 65, 80), pygame.Rect(12, y, 22, 14), 1) - surf.blit(body.render(label, True, (220, 222, 230)), (40, y - 1)) + pygame.draw.rect(surf, c, pygame.Rect(12, y, 10, 14)) + pygame.draw.rect(surf, (60, 65, 80), pygame.Rect(12, y, 10, 14), 1) + surf.blit(body.render(label, True, (220, 222, 230)), (28, y - 1)) y += 20 y += 6 - bl("Роли (сфера)", 16, (120, 170, 230), small) + bl("Роли (цвет шлема)", 16, (120, 170, 230), small) for role, lab in ( (Role.WARRIOR, "W воин (оранж.)"), (Role.GATHERER, "G сборщик"), @@ -738,9 +862,9 @@ def bl(txt: str, dy: int, color: tuple[int, int, int], font: Any = body) -> None break rr, rg, rb = _ROLE_RGB[role] c = (int(rr * 255), int(rg * 255), int(rb * 255)) - pygame.draw.circle(surf, c, (23, y + 7), 7) - pygame.draw.circle(surf, (40, 44, 55), (23, y + 7), 7, 1) - surf.blit(body.render(lab, True, (210, 212, 220)), (38, y)) + pygame.draw.rect(surf, c, pygame.Rect(15, y + 1, 14, 12)) + pygame.draw.rect(surf, (40, 44, 55), pygame.Rect(15, y + 1, 14, 12), 1) + surf.blit(body.render(lab, True, (210, 212, 220)), (34, y)) y += 20 y += 6 bl("Ландшафт", 16, (120, 170, 230), small) @@ -773,7 +897,7 @@ def bl(txt: str, dy: int, color: tuple[int, int, int], font: Any = body) -> None bl("На карте: жёлтый маркер — ресурс", 16, (170, 175, 190), small) _draw_legend_hud( - surf, pygame, small, body, self._legend_w, self._win_h, state + surf, pygame, small, body, self._legend_w, self._win_h, state, hud_h ) self._tex_legend.write(pygame.image.tobytes(surf, "RGBA")) @@ -843,12 +967,9 @@ def _build_dynamic_geometry(self, state: GameState) -> np.ndarray: team_idx = v.team tn = _TEAM_NEON[team_idx] if team_idx < 2 else (0.55, 0.55, 0.58) rr, rg, rb = _ROLE_RGB.get(bot.role, (0.85, 0.85, 0.88)) - pad = base_h - _add_cylinder_y(buf, wx, pad, wz, 0.33, 0.09, 12, tn, cap_top=True, cap_bottom=True) - body_y = pad + 0.09 + 0.15 - _add_sphere(buf, wx, body_y, wz, 0.155, (rr, rg, rb), stacks=4, slices=8) - ring_y = pad + 0.09 + 0.13 - _add_cylinder_y(buf, wx, ring_y, wz, 0.23, 0.04, 16, tn, cap_top=True, cap_bottom=True) + _add_bot_figure(buf, wx, base_h, wz, tn, (rr, rg, rb), bot.role) + hp_f = bot.hp / max(bot.max_hp, 1) + _add_bot_hp_bar(buf, wx, wz, base_h, hp_f) return np.asarray(buf, dtype=np.float32) diff --git a/src/village_ai_war/rendering/pygame_renderer.py b/src/village_ai_war/rendering/pygame_renderer.py index 8898c35..f52ae60 100644 --- a/src/village_ai_war/rendering/pygame_renderer.py +++ b/src/village_ai_war/rendering/pygame_renderer.py @@ -285,14 +285,61 @@ def team_dark(team: int) -> tuple[int, int, int]: cy = by * cell + cell // 2 r = max(cell // 4, 4) role_col = _ROLE_FILL.get(bot.role, (200, 200, 200)) - pygame.draw.circle(surf, (18, 20, 26), (cx + 1, cy + 2), r + 1) - pygame.draw.circle(surf, role_col, (cx, cy), r) - pygame.draw.circle(surf, team_dark(v.team), (cx, cy), r, 2) - pygame.draw.circle(surf, _lerp_rgb(role_col, (255, 255, 255), 0.4), (cx, cy), r, 1) + team_col = _TEAM_FILL[v.team] if v.team < len(_TEAM_FILL) else (160, 160, 160) + skin = (228, 198, 168) + head_r = max(3, int(r * 0.44)) + body_w = max(6, int(r * 1.65)) + body_h = max(5, int(r * 1.05)) + foot_y = cy + max(2, r // 3) + body = pygame.Rect(cx - body_w // 2, foot_y - body_h, body_w, body_h) + hc_y = foot_y - body_h - head_r - 1 + frac = bot.hp / max(bot.max_hp, 1) + bar_w = min(cell - 4, max(body_w + 4, 18)) + bar_h = max(3, min(5, cell // 6)) + bar_y = hc_y - head_r - bar_h - 3 + bar_x = cx - bar_w // 2 + pygame.draw.rect(surf, _HP_BG, pygame.Rect(bar_x, bar_y, bar_w, bar_h)) + hp_color = _HP_OK if frac > 0.35 else _HP_LOW + pygame.draw.rect( + surf, + hp_color, + pygame.Rect(bar_x, bar_y, max(0, int(bar_w * frac)), bar_h), + ) + pygame.draw.rect(surf, (0, 0, 0), pygame.Rect(bar_x, bar_y, bar_w, bar_h), 1) + if self._font_hint is not None and bar_w >= 14: + hs = f"{bot.hp}" + ht = self._font_hint.render(hs, True, (248, 250, 255)) + ho = self._font_hint.render(hs, True, (12, 14, 18)) + tx = cx - ht.get_width() // 2 + ty = bar_y + (bar_h - ht.get_height()) // 2 + for ox, oy in ((-1, 0), (1, 0), (0, -1), (0, 1)): + surf.blit(ho, (tx + ox, ty + oy)) + surf.blit(ht, (tx, ty)) + pygame.draw.ellipse(surf, (18, 20, 26), body.move(1, 2)) + pygame.draw.ellipse(surf, team_col, body) + pygame.draw.ellipse(surf, team_dark(v.team), body, 2) + pygame.draw.ellipse( + surf, _lerp_rgb(team_col, (255, 255, 255), 0.35), body, 1 + ) + pygame.draw.circle(surf, (14, 16, 20), (cx + 1, hc_y + 2), head_r + 1) + pygame.draw.circle(surf, skin, (cx, hc_y), head_r) + pygame.draw.circle(surf, team_dark(v.team), (cx, hc_y), head_r, 2) + hw = max(4, int(head_r * 1.9)) + hh = max(3, int(head_r * 0.88)) + helmet = pygame.Rect(cx - hw // 2, hc_y - head_r - 1, hw, hh) + pygame.draw.ellipse(surf, role_col, helmet) + pygame.draw.ellipse( + surf, _lerp_rgb(role_col, (12, 14, 18), 0.5), helmet, 1 + ) if self._font_role is not None: rn = bot.role.name[:1] - t = self._font_role.render(rn, True, (16, 18, 22)) - surf.blit(t, (cx - t.get_width() // 2, cy - t.get_height() // 2)) + t = self._font_role.render(rn, True, (248, 250, 255)) + to = self._font_role.render(rn, True, (16, 18, 22)) + bx = cx - t.get_width() // 2 + by = foot_y - body_h // 2 - t.get_height() // 2 + for ox, oy in ((-1, 0), (1, 0), (0, -1), (0, 1)): + surf.blit(to, (bx + ox, by + oy)) + surf.blit(t, (bx, by)) def _render_human_window(self, state: GameState, gw: int, gh: int) -> None: pygame = self._pygame @@ -399,6 +446,7 @@ def line(text: str, color: tuple[int, int, int] = fg, fnt: Any = body) -> None: screen.blit(small.render("UNITS", True, _ACCENT), (x + 12, cy)) cy += line_h - 2 + line("Swatch = role cap (torso = team)", muted, small) for role, name in ( (Role.WARRIOR, "Warrior"), (Role.GATHERER, "Gatherer"), @@ -410,7 +458,8 @@ def line(text: str, color: tuple[int, int, int] = fg, fnt: Any = body) -> None: pygame.draw.circle(screen, (24, 26, 32), (x + 21, cy + 8), 7, 1) screen.blit(body.render(name, True, fg), (x + 36, cy)) cy += line_h - line("Ring: team color (red / blue)", muted, small) + line("Torso = team; cap = role color", muted, small) + line("Bar above unit = HP (number = current)", muted, small) cy += 6 screen.blit(small.render("BUILDINGS", True, _ACCENT), (x + 12, cy)) From 87a2900df1eb12b2cd5d769889952ae6c45bbf2e Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Tue, 31 Mar 2026 07:14:05 +0300 Subject: [PATCH 02/11] Update game configurations and enhance bot observation in Village AI War - Increased building health points and adjusted construction costs for various structures to improve gameplay balance. - Modified combat statistics for units, including health and damage values, to refine combat dynamics. - Enhanced economy settings, including resource collection rates and bot costs, to better align with game mechanics. - Updated map configurations to increase size and resource density, promoting more strategic gameplay. - Improved bot observation logic to incorporate resource capacities and enhance decision-making during gameplay. - Added new reward metrics for village performance based on food security, encouraging resource management strategies. --- configs/buildings.yaml | 26 ++- configs/combat.yaml | 16 +- configs/default.yaml | 10 +- configs/economy.yaml | 14 +- configs/map.yaml | 10 +- configs/rewards/bot_rewards.yaml | 51 +++-- configs/rewards/village_rewards.yaml | 14 +- src/village_ai_war.egg-info/PKG-INFO | 157 ++++++++++++-- src/village_ai_war.egg-info/SOURCES.txt | 8 +- src/village_ai_war/agents/bot_obs_builder.py | 106 +++++++++- src/village_ai_war/env/building_system.py | 38 +++- src/village_ai_war/env/economy_system.py | 11 + src/village_ai_war/env/game_env.py | 204 ++++++++++++++++--- src/village_ai_war/rewards/village_reward.py | 6 + src/village_ai_war/training/self_play_env.py | 5 +- tests/test_building_construction.py | 73 +++++++ tests/test_economy_system.py | 1 + tests/test_reward_village.py | 15 ++ uv.lock | 184 +++++++++++++++++ 19 files changed, 829 insertions(+), 120 deletions(-) create mode 100644 tests/test_building_construction.py create mode 100644 uv.lock diff --git a/configs/buildings.yaml b/configs/buildings.yaml index 74f6e5b..bab2946 100644 --- a/configs/buildings.yaml +++ b/configs/buildings.yaml @@ -1,31 +1,37 @@ # @package _global_ buildings: townhall: - hp: 1000 + hp: 1500 cost: {} barracks: hp: 300 cost: - wood: 100 + wood: 80 + construction_ticks: 24 storage: hp: 200 cost: - wood: 50 + wood: 40 + construction_ticks: 16 farm: hp: 200 cost: - wood: 80 + wood: 60 + construction_ticks: 20 tower: - hp: 400 + hp: 350 cost: - stone: 100 + stone: 80 + construction_ticks: 28 wall: - hp: 500 + hp: 400 cost: - stone: 30 + stone: 25 + construction_ticks: 22 citadel: hp: 800 cost: - stone: 200 - wood: 150 + stone: 150 + wood: 100 + construction_ticks: 40 citadel_pop_bonus: 5 diff --git a/configs/combat.yaml b/configs/combat.yaml index 9769f8c..51344b1 100644 --- a/configs/combat.yaml +++ b/configs/combat.yaml @@ -3,19 +3,19 @@ combat: stats: warrior: hp: 100 - damage: 25 + damage: 20 attack_range: 1 gatherer: - hp: 80 - damage: 8 - attack_range: 1 - farmer: hp: 70 damage: 5 attack_range: 1 + farmer: + hp: 60 + damage: 3 + attack_range: 1 builder: - hp: 80 - damage: 8 + hp: 75 + damage: 5 attack_range: 1 - tower_damage: 15 + tower_damage: 12 tower_range: 3 diff --git a/configs/default.yaml b/configs/default.yaml index 6e79b58..25bd10b 100644 --- a/configs/default.yaml +++ b/configs/default.yaml @@ -9,13 +9,13 @@ defaults: - _self_ game: - max_ticks: 2000 + max_ticks: 3000 manager_interval: 5 initial_resources: - wood: 200 - stone: 100 - food: 500 - initial_bots: 10 + wood: 150 + stone: 80 + food: 400 + initial_bots: 8 initial_buildings: - barracks - storage diff --git a/configs/economy.yaml b/configs/economy.yaml index fa7f3f9..d0da7d5 100644 --- a/configs/economy.yaml +++ b/configs/economy.yaml @@ -1,11 +1,11 @@ # @package _global_ economy: - harvest_interval: 2 - harvest_amount: 8 + harvest_interval: 3 + harvest_amount: 10 food_consumption: 1 - hunger_damage: 0.5 + hunger_damage: 5 bot_cost: - wood: 25 - food: 50 - bot_spawn_delay: 3 - farm_food_bonus: 0.65 + wood: 30 + food: 60 + bot_spawn_delay: 5 + farm_food_bonus: 0.5 diff --git a/configs/map.yaml b/configs/map.yaml index 68762f7..5cde4a8 100644 --- a/configs/map.yaml +++ b/configs/map.yaml @@ -1,10 +1,10 @@ # @package _global_ map: - size: 12 + size: 20 seed: 42 - resource_density: 0.15 - mountain_density: 0.05 + resource_density: 0.18 + mountain_density: 0.04 resource_capacity: - forest: 500 - stone: 300 + forest: 800 + stone: 500 field: 999999 diff --git a/configs/rewards/bot_rewards.yaml b/configs/rewards/bot_rewards.yaml index f5ae7b8..a4e96e3 100644 --- a/configs/rewards/bot_rewards.yaml +++ b/configs/rewards/bot_rewards.yaml @@ -1,30 +1,37 @@ # @package _global_ rewards: bot: - alpha: 0.7 + alpha: 0.6 warrior: - damage_dealt: 0.1 - kill: 5.0 - damage_taken: -0.05 - death: -10.0 - noop: -0.01 + damage_dealt: 0.15 + kill: 8.0 + damage_taken: -0.08 + death: -15.0 + noop: -0.02 + approach_enemy: 0.02 + retreat_penalty: -0.03 gatherer: - resource_collected: 1.0 - damage_taken: -0.05 - death: -10.0 - noop: -0.01 + resource_collected: 2.0 + damage_taken: -0.1 + death: -15.0 + noop: -0.02 + approach_resource: 0.01 + idle_at_resource: 0.05 farmer: - food_produced: 1.0 - damage_taken: -0.05 - death: -10.0 - noop: -0.01 + food_produced: 2.0 + damage_taken: -0.1 + death: -15.0 + noop: -0.02 + approach_field: 0.01 + idle_at_field: 0.05 builder: - block_placed: 2.0 - repair_pct: 0.1 - damage_taken: -0.05 - death: -10.0 - noop: -0.01 + block_placed: 5.0 + repair_pct: 0.2 + damage_taken: -0.1 + death: -15.0 + noop: -0.02 + approach_blueprint: 0.02 global_modes: - defend_coeff: -0.05 - attack_coeff: 0.05 - gather_coeff: 0.1 + defend_coeff: -0.04 + attack_coeff: 0.06 + gather_coeff: 0.15 diff --git a/configs/rewards/village_rewards.yaml b/configs/rewards/village_rewards.yaml index 0ac1dd7..95adca9 100644 --- a/configs/rewards/village_rewards.yaml +++ b/configs/rewards/village_rewards.yaml @@ -1,11 +1,13 @@ # @package _global_ rewards: village: - economy_coeff: 0.01 - kill_reward: 5.0 - loss_penalty: -3.0 - building_reward: 10.0 - stagnation_penalty: -0.05 - stagnation_threshold: 50 + economy_coeff: 0.005 + kill_reward: 3.0 + loss_penalty: -5.0 + building_reward: 15.0 + food_security_bonus: 0.02 + food_security_threshold: 100 + stagnation_penalty: -0.1 + stagnation_threshold: 30 win: 1000.0 loss: -1000.0 diff --git a/src/village_ai_war.egg-info/PKG-INFO b/src/village_ai_war.egg-info/PKG-INFO index 35396f6..357900b 100644 --- a/src/village_ai_war.egg-info/PKG-INFO +++ b/src/village_ai_war.egg-info/PKG-INFO @@ -10,45 +10,162 @@ Requires-Dist: ruff>=0.1; extra == "dev" # Village AI War — 2D Hierarchical RL Environment -Custom Gymnasium environment for hierarchical multi-agent reinforcement learning. +Custom Gymnasium environment for hierarchical multi-agent reinforcement learning. The **baseline is fully RL-driven**: low-level units are controlled by a learned policy (no movement heuristics), and both teams can be trained with **self-play** against pools of past checkpoints. ## Architecture -- **Bot Agents (Low Level):** Warriors, Gatherers, Farmers, Builders — PPO -- **Village Agent (High Level):** Strategic manager — MaskablePPO -- **Reward Shaping:** Dynamic global reward modes controlled by village agent +- **Bot agents (low level):** One **role-conditioned** PPO policy (`RoleConditionedPolicy`) — shared backbone plus a learned role embedding from the observation one-hot (see `BotObsBuilder`). All roles share weights. +- **Village agent (high level):** Strategic manager — **MaskablePPO** with invalid-action masking (`MultiInputPolicy` on dict observations). +- **Self-play:** Stage 1 samples opponents from `checkpoints/pool/bots/`; stage 2 samples village opponents from `checkpoints/pool/village/`. Empty pools fall back to random opponent actions. +- **Unified training:** One Hydra entry point (`training=train_unified`, `training.stage=0`) alternates PPO on bots and MaskablePPO on the red manager. Each environment step matches village self-play order (all bots act, then both managers). Blue bots and blue manager are sampled from the same pools; the non-training partner on red is frozen from the last saved checkpoint until the next phase. +- **Reward shaping:** Dynamic global reward modes controlled by the village agent (unchanged). ## Quick Start ```bash cd VillageAI_War -python3 -m venv .venv && source .venv/bin/activate +python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -r requirements.txt pip install -e . python scripts/run_game.py +``` + +### Watching trained policies + +[`scripts/run_game.py`](scripts/run_game.py) can load the same checkpoints produced by training. If a path is missing or fails to load, that component falls back to random valid actions (same as the old demo). + +```bash +# Defaults: checkpoints/village/village_final.zip, checkpoints/bots/bot_final.zip +python scripts/run_game.py + +# Explicit paths and deterministic manager actions +python scripts/run_game.py \ + --village-checkpoint checkpoints/village/village_final.zip \ + --opponent-village-checkpoint checkpoints/pool/village/village_iter5.zip \ + --bot-checkpoint checkpoints/bots/bot_final.zip \ + --deterministic --seed 42 --max-steps 2000 + +# Artifacts from unified training +python scripts/run_game.py \ + --village-checkpoint checkpoints/unified/village_final.zip \ + --bot-checkpoint checkpoints/unified/bot_final.zip \ + --deterministic --seed 42 +``` + +When any village, opponent, or bot checkpoint is loaded (or you pass a path that exists), the viewer runs **both** village managers each tick (trained or random per side), then resolves the tick in the same order as self-play training (`run_bots_then_village_decisions` in [`GameEnv`](src/village_ai_war/env/game_env.py)). If **no** checkpoints load, behavior matches the legacy script: one random manager action per step for team 0 only. + +The pygame **human** window includes numeric **row/column axes**, a **legend** (terrain colors, harvest hints `w`/`s`/`f`, unit roles and team rings, building abbreviations), and a **bottom HUD** (tick, winner, resources, population, global mode per team). `rgb_array` mode is still the raw map only for headless frames. + +**3D view** (OpenGL via [moderngl](https://github.com/moderngl/moderngl)): run `python scripts/run_game.py --human-3d` for an extruded terrain board, **building silhouettes** (town hall roof, tower spire, farm silo, etc.), **bots as team disk + role sphere + team ring**, and a **Russian legend panel** on the right (including a **live HUD**: tick, wood/stone/food, alive bots / pop cap, village AI mode per team). **Camera:** left-drag on the map to orbit (yaw/pitch); arrow keys nudge the camera; optional idle spin via `auto_rotate_deg_per_sec` (default `0`). Tuning: `window_width_3d`, `legend_width_3d`, `camera_dist_scale`, `orbit_mouse_sensitivity`, `orbit_key_deg_per_sec`, … in [`configs/default.yaml`](configs/default.yaml). In code, use `GameEnv(..., render_mode="human_3d")` or `render_mode="rgb_array_3d"` for full-window RGB captures (map + legend). + +On **Linux**, Mesa packages expose `libGL.so.1` (not `libGL.so`); this project patches library loading so moderngl finds them. If you still see GL errors, install dri drivers: `sudo apt install -y libgl1-mesa-dri libegl1`. On **WSL2** you need a working GUI stack (WSLg); without it, use 2D or run from native Windows. If 3D fails, `run_game.py` falls back to the 2D pygame window and logs a warning. + +### Training (Hydra) + +The default config composes `training: train_bots_selfplay` (see [`configs/default.yaml`](configs/default.yaml)). Stages are selected with `training.stage` (`0` = unified, `1`–`3` = legacy pipeline). + +**Stage 1 — bot self-play (role-conditioned PPO)** + +```bash python scripts/run_training.py training.stage=1 -python scripts/run_training.py training.stage=2 +``` + +Uses `game.initial_bots: 1` in [`configs/training/train_bots_selfplay.yaml`](configs/training/train_bots_selfplay.yaml) so each team has one controllable unit per episode (reproducible with `SubprocVecEnv`; multi-bot stage 1 would need extra machinery such as checkpoint sync or `DummyVecEnv`-only runs). + +**Stage 2 — village self-play (MaskablePPO, frozen bot policy)** + +Requires a stage-1 artifact at `checkpoints/bots/bot_final.zip`. + +```bash +python scripts/run_training.py training=train_village_selfplay training.stage=2 +``` + +**Stage 3 — joint fine-tuning** + +Loads `checkpoints/village/village_final.zip` when present; bots use `checkpoints/bots/bot_final.zip` when present (otherwise random bot actions). + +```bash +python scripts/run_training.py training=train_joint training.stage=3 +``` + +**Unified training (recommended)** — `training.stage=0`, single process alternating two learners + +No prerequisite checkpoints. Team 0’s bot policy (PPO) and red manager (MaskablePPO) are updated in turns; after each phase the trainer saves so the other phase loads a frozen partner from disk. Opponents use the same self-play pools as stages 1–2; empty pools still fall back to random valid actions. + +```bash +python scripts/run_training.py training=train_unified +``` + +You can also set `training.stage=0` with another `training=` group; built-in defaults for `unified.*` still apply, but prefer `training=train_unified` so values in [`configs/training/train_unified.yaml`](configs/training/train_unified.yaml) are loaded. + +Configure macro steps with `unified.bot_steps_per_turn`, `unified.village_steps_per_turn`, `unified.n_cycles`, optional `unified.first_phase` (`bot` or `village`), and `unified.push_to_pool` (append snapshots to `checkpoints/pool/bots` and `.../village`) in [`configs/training/train_unified.yaml`](configs/training/train_unified.yaml). Each phase logs an estimated **SB3 iteration count** (`n_steps × n_envs` env-steps per iteration). `unified.progress_bar: true` enables Stable-Baselines’ tqdm progress bar for the current `learn()` (requires `tqdm` and `rich` in [`requirements.txt`](requirements.txt)). `unified.sb3_verbose` controls SB3’s stdout tables (`0` by default when using the progress bar). `unified.progress_log_interval_sec` throttles **Loguru** lines with progress, ETA, env-steps/s, and wall time of the previous full SB3 iteration (see `run_training.log` under the Hydra run directory). Set `unified.plot_metrics_on_finish: true` to write PNG grids of TensorBoard scalars into the same Hydra output folder when the run finishes (or run [`scripts/plot_tensorboard_scalars.py`](scripts/plot_tensorboard_scalars.py) manually). Outputs live under `checkpoints/unified/` (`bot_final.zip`, `village_final.zip`, plus per-cycle checkpoints and `bot_latest` / `village_latest` stems used between phases). + +The bot phase uses `DummyVecEnv` only so every sub-env shares the in-process `bot_policy_holder`; do not use `SubprocVecEnv` for that phase. Default `game.initial_bots: 1` matches stage 1; more red bots require a live model in the holder for the extra units. + +Stages 1–3 remain a supported alternative pipeline. + +**Useful overrides** + +```bash +python scripts/run_training.py training.total_timesteps=2000 training.n_envs=1 +``` + +### Metrics (TensorBoard) + +With `logging.use_tensorboard: true` (default) and `tensorboard` installed, stage 1 and 2 write training scalars under `logs/bots/` and `logs/village/`; unified training writes under `logs/unified_bots/` and `logs/unified_village/`. Periodic evaluation logs `eval/mean_reward` (and related fields) under `logs/bots_eval/` and `logs/village_eval/` when `training.eval_freq > 0` (default `10000` **environment timesteps** between evals; internally scaled by `n_envs` per Stable-Baselines3). The unified config sets `eval_freq: 0` by default (no separate eval pass yet). Tune with `training.n_eval_episodes`. + +```bash +tensorboard --logdir logs/ +``` + +After unified training (or anytime), generate static PNG grids from the latest run in each subfolder: + +```bash +python scripts/plot_tensorboard_scalars.py --log-root logs +``` + +Default outputs: `logs/plots/unified_bots_scalars.png` and `logs/plots/unified_village_scalars.png` (or under the Hydra run directory when `unified.plot_metrics_on_finish` is enabled). + +**Best vs last checkpoint:** when evaluation is enabled and at least one eval run produced a best model, `checkpoints/bots/bot_final.zip` and `checkpoints/village/village_final.zip` are copies of the best eval checkpoint (also saved as `bot_best.zip` / `village_best.zip`). The last weights after all self-play iterations are kept as `bot_last.zip` / `village_last.zip`. Set `training.eval_freq=0` to keep the previous behavior (final = last iteration only). Stage 3 joint training does not add a separate eval pass yet; it still saves `checkpoints/joint/joint_final.zip` from the end of the run. Unified training always ends with `bot_final.zip` / `village_final.zip` as the last full save after all cycles (no best-model selection unless you add eval later). + +**Evaluation** + +```bash python scripts/evaluate.py ``` -Training entrypoint uses Hydra; override configs from the CLI (for example ``training.total_timesteps=50000``). Stage 2/3 configs can be composed via ``--config-name=training/train_village``. +## Checkpoints (typical layout) + +| Path | Contents | +|------|-----------| +| `checkpoints/bots/bot_final.zip` | Stage 1 policy (best eval mean reward when `training.eval_freq > 0`, else last iteration) | +| `checkpoints/pool/bots/*.zip` | Historical bot policies for self-play | +| `checkpoints/village/village_final.zip` | Stage 2 manager (same best-vs-last rule as bots) | +| `checkpoints/pool/village/*.zip` | Historical village policies for self-play | +| `checkpoints/joint/joint_final.zip` | Stage 3 output | +| `checkpoints/unified/bot_final.zip` | Unified loop bot policy (last save after all cycles) | +| `checkpoints/unified/village_final.zip` | Unified loop village policy (last save after all cycles) | +| `checkpoints/unified/bot_latest.zip` / `village_latest.zip` | Latest weights exchanged between alternating phases | +| `checkpoints/unified/bot_cycle*.zip` / `village_cycle*.zip` | Periodic `CheckpointCallback` snapshots during unified runs | + +## Project layout (RL baseline) -## Training Stages +- [`src/village_ai_war/env/game_env.py`](src/village_ai_war/env/game_env.py) — `step`, `step_with_opponent`, `step_village_only`, optional `game.bot_rl_checkpoint` / `training.bot_checkpoint` for frozen bot PPO +- [`src/village_ai_war/models/role_conditioned_policy.py`](src/village_ai_war/models/role_conditioned_policy.py) +- [`src/village_ai_war/training/self_play_env.py`](src/village_ai_war/training/self_play_env.py) — `SelfPlayBotEnv`, `SelfPlayVillageEnv`, `UnifiedBotSelfPlayEnv` +- [`src/village_ai_war/training/train_bots_selfplay.py`](src/village_ai_war/training/train_bots_selfplay.py), [`train_village_selfplay.py`](src/village_ai_war/training/train_village_selfplay.py), [`train_joint.py`](src/village_ai_war/training/train_joint.py), [`train_unified.py`](src/village_ai_war/training/train_unified.py), [`tensorboard_plots.py`](src/village_ai_war/training/tensorboard_plots.py), [`plot_tensorboard_scalars.py`](scripts/plot_tensorboard_scalars.py) -- **Stage 1:** Train bot policies in isolation -- **Stage 2:** Train village manager with frozen bots -- **Stage 3:** Joint fine-tuning +Legacy trainers [`train_bots.py`](src/village_ai_war/training/train_bots.py) and [`train_village.py`](src/village_ai_war/training/train_village.py) are not used by [`scripts/run_training.py`](scripts/run_training.py). -## Project Status +## Project status - [x] Data structures (Pydantic) - [x] Map generator -- [x] Economy system -- [x] Combat system -- [x] Building system -- [x] Observation builders -- [x] Reward calculators -- [x] Action masker -- [x] GameEnv (Gymnasium) +- [x] Economy / combat / building systems +- [x] Observation builders and action masker +- [x] GameEnv (Gymnasium), no bot heuristics +- [x] Role-conditioned bot policy + PPO self-play (stage 1) +- [x] Village MaskablePPO self-play with RL bots (stage 2) +- [x] Joint fine-tuning with RL bots (stage 3) +- [x] Unified training loop (alternating bot PPO + village MaskablePPO) - [x] Pygame renderer -- [x] Training scripts diff --git a/src/village_ai_war.egg-info/SOURCES.txt b/src/village_ai_war.egg-info/SOURCES.txt index 5096f41..3ba5e46 100644 --- a/src/village_ai_war.egg-info/SOURCES.txt +++ b/src/village_ai_war.egg-info/SOURCES.txt @@ -22,6 +22,7 @@ src/village_ai_war/env/map_generator.py src/village_ai_war/models/__init__.py src/village_ai_war/models/role_conditioned_policy.py src/village_ai_war/rendering/__init__.py +src/village_ai_war/rendering/moderngl_3d_renderer.py src/village_ai_war/rendering/pygame_renderer.py src/village_ai_war/rewards/__init__.py src/village_ai_war/rewards/bot_reward.py @@ -34,10 +35,13 @@ src/village_ai_war/state/game_state.py src/village_ai_war/state/village_state.py src/village_ai_war/training/__init__.py src/village_ai_war/training/pool_manager.py +src/village_ai_war/training/progress_callback.py src/village_ai_war/training/self_play_env.py +src/village_ai_war/training/tensorboard_plots.py src/village_ai_war/training/train_bots.py src/village_ai_war/training/train_bots_selfplay.py src/village_ai_war/training/train_joint.py +src/village_ai_war/training/train_unified.py src/village_ai_war/training/train_village.py src/village_ai_war/training/train_village_selfplay.py tests/test_action_masker.py @@ -45,7 +49,9 @@ tests/test_combat_system.py tests/test_economy_system.py tests/test_game_state.py tests/test_pool_manager.py +tests/test_render_import.py tests/test_reward_bot.py tests/test_reward_village.py tests/test_role_conditioned_policy.py -tests/test_smoke_game_env.py \ No newline at end of file +tests/test_smoke_game_env.py +tests/test_unified_training_smoke.py \ No newline at end of file diff --git a/src/village_ai_war/agents/bot_obs_builder.py b/src/village_ai_war/agents/bot_obs_builder.py index a87051b..d2fd739 100644 --- a/src/village_ai_war/agents/bot_obs_builder.py +++ b/src/village_ai_war/agents/bot_obs_builder.py @@ -6,7 +6,7 @@ import numpy as np -from village_ai_war.state import GameState, GlobalRewardMode, Role, TerrainType +from village_ai_war.state import GameState, GlobalRewardMode, ResourceLayer, Role, TerrainType class BotObsBuilder: @@ -24,22 +24,36 @@ class BotObsBuilder: - ``106:110`` — one-hot ``GlobalRewardMode`` for own village (4). - ``110:114`` — ally alive count / ``pop_cap``, enemy alive / ``pop_cap``, ally TH HP fraction, enemy TH HP fraction (4 scalars). - - ``114:181`` — padded zeros (reserved for future local patches / path hints). + - ``114:116`` — role-specific hints (dist / map_size, secondary norm). + - ``116:181`` — reserved (zeros). Args: map_size: Side length of the square map. max_terrain: Normalizer for terrain enum (default ``max(TerrainType)``). + config: Optional merged game config (for ``map.resource_capacity``). """ OBS_DIM = 181 PATCH = 7 - def __init__(self, map_size: int, max_terrain: float | None = None) -> None: + def __init__( + self, + map_size: int, + max_terrain: float | None = None, + config: Mapping[str, Any] | None = None, + ) -> None: self.map_size = map_size self.max_terrain = float(max(TerrainType)) if max_terrain is None else float(max_terrain) - - def build(self, state: GameState, bot_id: int) -> np.ndarray: + self.config = config + + def build( + self, + state: GameState, + bot_id: int, + config: Mapping[str, Any] | None = None, + ) -> np.ndarray: """Return observation vector for ``bot_id``.""" + cfg = config if config is not None else self.config bot = next( (b for v in state.villages for b in v.bots if b.bot_id == bot_id), None, @@ -50,6 +64,8 @@ def build(self, state: GameState, bot_id: int) -> np.ndarray: n = state.map_size terrain = np.asarray(state.terrain, dtype=np.float32) / self.max_terrain resources = np.asarray(state.resources, dtype=np.float32) / 4.0 + amounts = np.asarray(state.resource_amounts, dtype=np.int32) + res_layer = np.asarray(state.resources, dtype=np.int32) cx, cy = bot.position r = self.PATCH // 2 @@ -101,4 +117,84 @@ def th_hp(team: int) -> float: out[112] = th_hp(bot.team) out[113] = th_hp(enemy_team) + cap_forest = 800 + cap_stone = 500 + cap_field = 999999 + if cfg is not None: + rcap = cfg.get("map", {}).get("resource_capacity", {}) + if isinstance(rcap, Mapping): + cap_forest = int(rcap.get("forest", cap_forest)) + cap_stone = int(rcap.get("stone", cap_stone)) + cap_field = int(rcap.get("field", cap_field)) + + def nearest_dist(pos: tuple[int, int], cells: list[tuple[int, int]]) -> float: + if not cells: + return float(n + n) + return float(min(abs(pos[0] - x) + abs(pos[1] - y) for x, y in cells)) + + enemy_cells = [ + tuple(b.position) + for b in state.villages[enemy_team].bots + if b.is_alive + ] + res_cells: list[tuple[int, int]] = [] + field_cells: list[tuple[int, int]] = [] + for y in range(n): + for x in range(n): + if amounts[y, x] <= 0: + continue + layer = int(res_layer[y, x]) + if layer == int(ResourceLayer.NONE): + continue + res_cells.append((x, y)) + if layer == int(ResourceLayer.FIELD): + field_cells.append((x, y)) + + bp_team: list[tuple[tuple[int, int], float]] = [ + ( + (int(bp["position"][0]), int(bp["position"][1])), + float(bp.get("progress", 0.0)), + ) + for bp in state.blueprints + if int(bp["team"]) == bot.team + ] + + ms = float(max(n, 1)) + if bot.role == Role.WARRIOR: + d = nearest_dist((cx, cy), enemy_cells) + out[114] = float(np.clip(d / ms, 0.0, 1.0)) + near = sum( + 1 + for ex, ey in enemy_cells + if abs(ex - cx) + abs(ey - cy) <= 1 + ) + out[115] = float(np.clip(near / 5.0, 0.0, 1.0)) + elif bot.role == Role.GATHERER: + d = nearest_dist((cx, cy), res_cells) + out[114] = float(np.clip(d / ms, 0.0, 1.0)) + layer_here = int(res_layer[cy, cx]) if 0 <= cy < n and 0 <= cx < n else int(ResourceLayer.NONE) + cap_here = cap_forest + if layer_here == int(ResourceLayer.STONE): + cap_here = cap_stone + elif layer_here == int(ResourceLayer.FIELD): + cap_here = cap_field + amt_here = int(amounts[cy, cx]) if 0 <= cy < n and 0 <= cx < n else 0 + out[115] = float(np.clip(amt_here / max(cap_here, 1), 0.0, 1.0)) + elif bot.role == Role.FARMER: + d = nearest_dist((cx, cy), field_cells) + out[114] = float(np.clip(d / ms, 0.0, 1.0)) + out[115] = float(np.clip(village.resources.food / 1000.0, 0.0, 1.0)) + elif bot.role == Role.BUILDER: + if not bp_team: + out[114] = 1.0 + out[115] = 0.0 + else: + best_pos, best_prog = min( + bp_team, + key=lambda t: abs(cx - t[0][0]) + abs(cy - t[0][1]), + ) + best_d = abs(cx - best_pos[0]) + abs(cy - best_pos[1]) + out[114] = float(np.clip(best_d / ms, 0.0, 1.0)) + out[115] = float(np.clip(best_prog, 0.0, 1.0)) + return out diff --git a/src/village_ai_war/env/building_system.py b/src/village_ai_war/env/building_system.py index 339ee83..dce1827 100644 --- a/src/village_ai_war/env/building_system.py +++ b/src/village_ai_war/env/building_system.py @@ -7,7 +7,7 @@ import numpy as np from village_ai_war.exceptions import InsufficientResourcesError, InvalidActionError -from village_ai_war.state import BuildingState, BuildingType, GameState, TerrainType +from village_ai_war.state import BuildingState, BuildingType, GameState, Role, TerrainType class BuildingSystem: @@ -15,22 +15,48 @@ class BuildingSystem: @staticmethod def construction_tick(state: GameState, config: Mapping[str, Any]) -> dict[str, Any]: - """Advance blueprint progress; complete buildings. + """Advance blueprint progress when an ally builder is adjacent; complete buildings. Returns: - Events with ``buildings_completed`` list of ``(team, building_id)``. + Events with ``buildings_completed`` and ``block_placed_by_bot`` (progress share). """ bcfg = config["buildings"] - events: dict[str, Any] = {"buildings_completed": []} - progress_per_tick = 0.05 + events: dict[str, Any] = {"buildings_completed": [], "block_placed_by_bot": {}} + block_by_bot: dict[int, float] = events["block_placed_by_bot"] still: list[dict[str, Any]] = [] for bp in state.blueprints: team = int(bp["team"]) btype = BuildingType(int(bp["building_type"])) pos = tuple(bp["position"]) + px, py = int(pos[0]), int(pos[1]) + key = btype.name.lower() + bdef = bcfg.get(key) + if not isinstance(bdef, Mapping): + bdef = {} + ticks = int(bdef.get("construction_ticks", 20)) + step = 1.0 / max(ticks, 1) + + adjacent_builders: list[int] = [] + for v in state.villages: + if v.team != team: + continue + for bot in v.bots: + if not bot.is_alive or bot.role != Role.BUILDER: + continue + bx, by = bot.position + if abs(bx - px) + abs(by - py) == 1: + adjacent_builders.append(bot.bot_id) + prog = float(bp.get("progress", 0.0)) - prog = min(1.0, prog + progress_per_tick) + if adjacent_builders: + room = 1.0 - prog + delta = min(step, room) + prog = prog + delta + share = delta / len(adjacent_builders) + for bid in adjacent_builders: + block_by_bot[bid] = block_by_bot.get(bid, 0.0) + share + bp["progress"] = prog if prog >= 1.0: hp = BuildingSystem._max_hp(btype, bcfg) diff --git a/src/village_ai_war/env/economy_system.py b/src/village_ai_war/env/economy_system.py index e2433de..7e3e465 100644 --- a/src/village_ai_war/env/economy_system.py +++ b/src/village_ai_war/env/economy_system.py @@ -39,8 +39,16 @@ def step(state: GameState, config: Mapping[str, Any]) -> dict[str, Any]: bot_cost_food = int(ecfg["bot_cost"]["food"]) farm_bonus = float(ecfg.get("farm_food_bonus", 0.5)) + resource_by_bot: dict[int, int] = {} + + def add_resource_bot(bot_id: int, amt: int) -> None: + if amt <= 0: + return + resource_by_bot[bot_id] = resource_by_bot.get(bot_id, 0) + amt + events: dict[str, Any] = { "resource_collected": {0: 0, 1: 0}, + "resource_collected_by_bot": resource_by_bot, "wood_delta": {0: 0, 1: 0}, "stone_delta": {0: 0, 1: 0}, "food_delta": {0: 0, 1: 0}, @@ -83,15 +91,18 @@ def step(state: GameState, config: Mapping[str, Any]) -> dict[str, Any]: village.resources.wood += take events["resource_collected"][team] += take events["wood_delta"][team] += take + add_resource_bot(bot.bot_id, take) elif layer == int(ResourceLayer.STONE): village.resources.stone += take events["resource_collected"][team] += take events["stone_delta"][team] += take + add_resource_bot(bot.bot_id, take) elif layer == int(ResourceLayer.FIELD): prod = int(take * food_mult) village.resources.food += prod events["food_produced"][team] += prod events["food_delta"][team] += prod + add_resource_bot(bot.bot_id, prod) alive = [b for b in village.bots if b.is_alive] need = food_per_bot * len(alive) diff --git a/src/village_ai_war/env/game_env.py b/src/village_ai_war/env/game_env.py index 5be79b2..6183070 100644 --- a/src/village_ai_war/env/game_env.py +++ b/src/village_ai_war/env/game_env.py @@ -70,11 +70,16 @@ def __init__( self._last_tick_merged: dict[str, Any] = {} self._loaded_bot_policy: Any = None self._bot_policy_load_attempted: bool = False + self._tick_start_positions: dict[int, tuple[int, int]] = {} + self._prev_distances: dict[int, float] = {} + self._tick_food_by_bot: dict[int, int] = {} + self._tick_builder_repair_pct: dict[int, float] = {} + self._shaping_snapshot: dict[str, Any] = {} n = int(config["map"]["size"]) max_bots = int(config["game"].get("max_bots_for_role_change", 32)) self._village_space = VillageActionSpace(n, max_bots=max_bots) - self._bot_obs = BotObsBuilder(n) + self._bot_obs = BotObsBuilder(n, config=config) self._vil_obs = VillageObsBuilder(n) if mode == "bot": @@ -113,6 +118,8 @@ def reset( cfg["map"] = dict(cfg["map"]) cfg["map"]["seed"] = map_seed self._state = generate_initial_state(cfg, self._rng) + self._prev_distances.clear() + self._tick_start_positions.clear() # Pick controlled bot for bot mode (optional role filter for stage-1 training) if self.mode == "bot": @@ -137,6 +144,7 @@ def reset( def step(self, action: Any) -> tuple[Any, SupportsFloat, bool, bool, dict[str, Any]]: assert self._state is not None and self._rng is not None + self.snapshot_bot_positions_for_tick() state = self._state manager_action: dict[str, Any] | None = None @@ -177,6 +185,7 @@ def step_with_opponent( if self.mode != "bot": raise ValueError("step_with_opponent requires mode='bot'") assert self._state is not None and self._rng is not None + self.snapshot_bot_positions_for_tick() melee_intents: list[tuple[int, int, tuple[int, int]]] = [] self._apply_bot_action(0, self._controlled_bot_id, int(red_action), melee_intents) self._apply_bot_action(1, self._opponent_controlled_bot_id, int(blue_action), melee_intents) @@ -253,6 +262,7 @@ def run_bots_then_village_decisions( """ if self.mode not in ("village", "full"): raise ValueError("run_bots_then_village_decisions requires mode='village' or 'full'") + self.snapshot_bot_positions_for_tick() melee_intents: list[tuple[int, int, tuple[int, int]]] = [] self._step_all_bots_with_policy(bot_policy, melee_intents, exclude=None) return self.step_village_only( @@ -304,7 +314,7 @@ def close(self) -> None: def _build_obs(self) -> Any: assert self._state is not None if self.mode == "bot": - return self._bot_obs.build(self._state, self._controlled_bot_id) + return self._bot_obs.build(self._state, self._controlled_bot_id, self.config) return self._vil_obs.build(self._state, self.team) def _info_dict(self) -> dict[str, Any]: @@ -355,7 +365,7 @@ def _ensure_bot_policy_loaded(self) -> None: def _get_single_bot_obs(self, bot_id: int) -> np.ndarray: assert self._state is not None - return self._bot_obs.build(self._state, bot_id) + return self._bot_obs.build(self._state, bot_id, self.config) def _get_bot_obs(self, team: int) -> np.ndarray | None: """Observation for the first alive bot on ``team`` (opponent policy input).""" @@ -391,6 +401,18 @@ def _step_all_bots_with_policy( act_int = int(self._rng.integers(0, self.BOT_ACTIONS)) self._apply_bot_action(team, bot.bot_id, act_int, melee_intents) + def snapshot_bot_positions_for_tick(self) -> None: + """Call before bot movement each tick (movement shaping + per-tick counters).""" + assert self._state is not None + self._tick_start_positions = { + b.bot_id: tuple(b.position) + for v in self._state.villages + for b in v.bots + if b.is_alive + } + self._tick_food_by_bot = {} + self._tick_builder_repair_pct = {} + def _advance_tick_after_bots( self, melee_intents: list[tuple[int, int, tuple[int, int]]], @@ -403,6 +425,8 @@ def _advance_tick_after_bots( terminated = False truncated = False + self._shaping_snapshot = GameEnv._build_shaping_snapshot(state) + cmb = CombatSystem.apply_melee_intents(state, self.config, melee_intents) eco = EconomySystem.step(state, self.config) bld = BuildingSystem.construction_tick(state, self.config) @@ -414,6 +438,13 @@ def _advance_tick_after_bots( merged["resource_collected"] = eco.get("resource_collected", {}) merged["food_produced"] = eco.get("food_produced", {}) merged["building_completed"] = bld.get("buildings_completed", []) + merged["resource_collected_by_bot"] = dict(eco.get("resource_collected_by_bot", {})) + fpb: dict[int, int] = {} + for bid, amt in self._tick_food_by_bot.items(): + fpb[bid] = fpb.get(bid, 0) + amt + merged["food_produced_by_bot"] = fpb + merged["block_placed_by_bot"] = dict(bld.get("block_placed_by_bot", {})) + merged["repair_pct_by_bot"] = dict(self._tick_builder_repair_pct) kills_this_tick = int(cmb["kills"].get(self.team, 0)) + int(tw["kills"].get(self.team, 0)) resources_delta = { @@ -463,7 +494,7 @@ def _advance_tick_after_bots( ) if bot is not None: mode = state.villages[self.team].global_reward_mode - bev = self._bot_events_for(bot, merged, learner_bot_action) + bev = self._bot_events_for(bot, merged, learner_bot_action, state) reward = float(BotRewardCalculator.compute(bev, bot, mode, self.config)) else: reward = 0.0 @@ -579,6 +610,7 @@ def _apply_bot_action( elif action == 10 and bot.role == Role.FARMER: village = self._state.villages[team] village.resources.food += 1 + self._tick_food_by_bot[bot_id] = self._tick_food_by_bot.get(bot_id, 0) + 1 elif action == 11 and bot.role == Role.BUILDER: for v in self._state.villages: for b in v.buildings: @@ -586,7 +618,15 @@ def _apply_bot_action( continue bx, by = b.position if abs(bx - ax) + abs(by - ay) == 1: - b.hp = min(b.max_hp, b.hp + max(1, int(0.1 * b.max_hp))) + before = b.hp + delta = max(1, int(0.1 * b.max_hp)) + b.hp = min(b.max_hp, b.hp + delta) + gained = b.hp - before + if gained > 0 and b.max_hp > 0: + self._tick_builder_repair_pct[bot_id] = ( + self._tick_builder_repair_pct.get(bot_id, 0.0) + + float(gained) / float(b.max_hp) + ) @staticmethod def _unit_at(state: GameState, x: int, y: int, exclude: BotState | None) -> bool: @@ -616,6 +656,141 @@ def _merge_combat_events( ) return out + @staticmethod + def _build_shaping_snapshot(state: GameState) -> dict[str, Any]: + n = state.map_size + amounts = np.asarray(state.resource_amounts, dtype=np.int32) + res = np.asarray(state.resources, dtype=np.int32) + res_cells: list[tuple[int, int]] = [] + field_cells: list[tuple[int, int]] = [] + for y in range(n): + for x in range(n): + if amounts[y, x] <= 0: + continue + layer = int(res[y, x]) + if layer == int(ResourceLayer.NONE): + continue + res_cells.append((x, y)) + if layer == int(ResourceLayer.FIELD): + field_cells.append((x, y)) + enemy_for: dict[int, list[tuple[int, int]]] = {} + for t in (0, 1): + et = 1 - t + enemy_for[t] = [ + tuple(b.position) for b in state.villages[et].bots if b.is_alive + ] + bp_by_team: dict[int, list[tuple[int, int]]] = {0: [], 1: []} + for bp in state.blueprints: + tm = int(bp["team"]) + bp_by_team.setdefault(tm, []).append( + (int(bp["position"][0]), int(bp["position"][1])) + ) + return { + "enemy_for": enemy_for, + "res_cells": res_cells, + "field_cells": field_cells, + "bp_by_team": bp_by_team, + } + + @staticmethod + def _nearest_dist(pos: tuple[int, int], targets: list[tuple[int, int]]) -> float: + if not targets: + return float("inf") + px, py = pos + return float(min(abs(px - tx) + abs(py - ty) for tx, ty in targets)) + + def _bot_events_for( + self, + bot: BotState, + merged: Mapping[str, Any], + action: int, + state: GameState, + ) -> dict[str, Any]: + snap = self._shaping_snapshot + ev: dict[str, Any] = {"global_scale": 1.0} + if action == 0: + ev["noop"] = 1.0 + if merged.get("kills", {}).get(bot.team, 0) > 0: + ev["kill"] = 1.0 + if merged.get("damage_taken", {}).get(bot.team, 0) > 0: + ev["damage_taken"] = float(merged["damage_taken"][bot.team]) + if merged.get("damage_dealt", {}).get(bot.team, 0) > 0: + ev["damage_dealt"] = float(merged["damage_dealt"][bot.team]) + if not bot.is_alive: + ev["death"] = 1.0 + + rcb = merged.get("resource_collected_by_bot") or {} + if isinstance(rcb, Mapping) and bot.bot_id in rcb: + ev["resource_collected"] = float(rcb[bot.bot_id]) + + fpb = merged.get("food_produced_by_bot") or {} + if isinstance(fpb, Mapping) and bot.bot_id in fpb: + ev["food_produced"] = float(fpb[bot.bot_id]) + + bpb = merged.get("block_placed_by_bot") or {} + if isinstance(bpb, Mapping) and bot.bot_id in bpb: + ev["block_placed"] = float(bpb[bot.bot_id]) + + rpr = merged.get("repair_pct_by_bot") or {} + if isinstance(rpr, Mapping) and bot.bot_id in rpr: + ev["repair_pct"] = float(rpr[bot.bot_id]) + + pos0 = self._tick_start_positions.get(bot.bot_id, bot.position) + pos1 = bot.position + t0 = (int(pos0[0]), int(pos0[1])) + t1 = (int(pos1[0]), int(pos1[1])) + + if bot.role == Role.WARRIOR: + enemies = list(snap.get("enemy_for", {}).get(bot.team, [])) + if enemies: + d0 = GameEnv._nearest_dist(t0, enemies) + d1 = GameEnv._nearest_dist(t1, enemies) + if d1 < d0: + ev["approach_enemy"] = 1.0 + elif d1 > d0: + ev["retreat_penalty"] = 1.0 + self._prev_distances[bot.bot_id] = d1 + elif bot.role == Role.GATHERER: + cells = list(snap.get("res_cells", [])) + if cells: + d0 = GameEnv._nearest_dist(t0, cells) + d1 = GameEnv._nearest_dist(t1, cells) + if d1 < d0: + ev["approach_resource"] = 1.0 + self._prev_distances[bot.bot_id] = d1 + x, y = t1 + n = state.map_size + if 0 <= x < n and 0 <= y < n: + amt = int(np.asarray(state.resource_amounts, dtype=np.int32)[y, x]) + layer = int(np.asarray(state.resources, dtype=np.int32)[y, x]) + if amt > 0 and layer != int(ResourceLayer.NONE): + ev["idle_at_resource"] = 1.0 + elif bot.role == Role.FARMER: + fields = list(snap.get("field_cells", [])) + if fields: + d0 = GameEnv._nearest_dist(t0, fields) + d1 = GameEnv._nearest_dist(t1, fields) + if d1 < d0: + ev["approach_field"] = 1.0 + self._prev_distances[bot.bot_id] = d1 + x, y = t1 + n = state.map_size + if 0 <= x < n and 0 <= y < n: + amt = int(np.asarray(state.resource_amounts, dtype=np.int32)[y, x]) + layer = int(np.asarray(state.resources, dtype=np.int32)[y, x]) + if amt > 0 and layer == int(ResourceLayer.FIELD): + ev["idle_at_field"] = 1.0 + elif bot.role == Role.BUILDER: + bps = list(snap.get("bp_by_team", {}).get(bot.team, [])) + if bps: + d0 = GameEnv._nearest_dist(t0, bps) + d1 = GameEnv._nearest_dist(t1, bps) + if d1 < d0: + ev["approach_blueprint"] = 1.0 + self._prev_distances[bot.bot_id] = d1 + + return ev + @staticmethod def _terminal_update(state: GameState, config: Mapping[str, Any]) -> int | None: """Set ``is_done``/``winner`` if TH destroyed or stalemate.""" @@ -648,22 +823,3 @@ def _terminal_update(state: GameState, config: Mapping[str, Any]) -> int | None: state.winner = None return None return None - - @staticmethod - def _bot_events_for( - bot: BotState, - merged: Mapping[str, Any], - action: int, - ) -> dict[str, Any]: - ev: dict[str, Any] = {"global_scale": 1.0} - if action == 0: - ev["noop"] = 1.0 - if merged.get("kills", {}).get(bot.team, 0) > 0: - ev["kill"] = 1.0 - if merged.get("damage_taken", {}).get(bot.team, 0) > 0: - ev["damage_taken"] = float(merged["damage_taken"][bot.team]) - if merged.get("damage_dealt", {}).get(bot.team, 0) > 0: - ev["damage_dealt"] = float(merged["damage_dealt"][bot.team]) - if not bot.is_alive: - ev["death"] = 1.0 - return ev diff --git a/src/village_ai_war/rewards/village_reward.py b/src/village_ai_war/rewards/village_reward.py index bb22d61..6f22562 100644 --- a/src/village_ai_war/rewards/village_reward.py +++ b/src/village_ai_war/rewards/village_reward.py @@ -29,6 +29,12 @@ def compute( ) / 1000.0 r += eco + food = int(village_state.resources.food) + fthresh = int(rcfg.get("food_security_threshold", 100)) + fbonus = float(rcfg.get("food_security_bonus", 0.0)) + if food > fthresh and fbonus != 0.0: + r += float(food - fthresh) * fbonus + if "kills" in events and isinstance(events["kills"], dict): r += float(rcfg["kill_reward"]) * float(events["kills"].get(team, 0)) if "losses" in events and isinstance(events["losses"], dict): diff --git a/src/village_ai_war/training/self_play_env.py b/src/village_ai_war/training/self_play_env.py index b4201de..3cbcd7e 100644 --- a/src/village_ai_war/training/self_play_env.py +++ b/src/village_ai_war/training/self_play_env.py @@ -355,6 +355,7 @@ def reset( def step(self, action: Any) -> tuple[np.ndarray, SupportsFloat, bool, bool, dict[str, Any]]: assert self.inner._state is not None and self.inner._rng is not None + self.inner.snapshot_bot_positions_for_tick() melee_intents: list[tuple[int, int, tuple[int, int]]] = [] # --- bot phase --- @@ -421,7 +422,9 @@ def step(self, action: Any) -> tuple[np.ndarray, SupportsFloat, bool, bool, dict ) if bot_state is not None: mode = self.inner._state.villages[0].global_reward_mode - bev = GameEnv._bot_events_for(bot_state, self.inner._last_tick_merged, learner_action) + bev = self.inner._bot_events_for( + bot_state, self.inner._last_tick_merged, learner_action, self.inner._state + ) reward = float(BotRewardCalculator.compute(bev, bot_state, mode, self.inner.config)) else: reward = 0.0 diff --git a/tests/test_building_construction.py b/tests/test_building_construction.py new file mode 100644 index 0000000..b060730 --- /dev/null +++ b/tests/test_building_construction.py @@ -0,0 +1,73 @@ +"""Blueprint progress requires adjacent builder and scales with construction_ticks.""" + +from __future__ import annotations + +from typing import Any + +from village_ai_war.env.building_system import BuildingSystem +from village_ai_war.state import BotState, BuildingType, GameState, ResourceStock, Role, VillageState + + +def _bcfg() -> dict[str, Any]: + return { + "buildings": { + "townhall": {"hp": 100, "cost": {}}, + "barracks": {"hp": 50, "cost": {"wood": 1}, "construction_ticks": 10}, + "storage": {"hp": 50, "cost": {"wood": 1}}, + "farm": {"hp": 50, "cost": {"wood": 1}}, + "tower": {"hp": 50, "cost": {"stone": 1}}, + "wall": {"hp": 50, "cost": {"stone": 1}}, + "citadel": {"hp": 50, "cost": {"stone": 1}}, + } + } + + +def _state_with_blueprint(bp_pos: tuple[int, int], builder_pos: tuple[int, int] | None) -> GameState: + bots: list[BotState] = [] + if builder_pos is not None: + bots.append( + BotState( + bot_id=1, + team=0, + role=Role.BUILDER, + position=builder_pos, + hp=10, + max_hp=10, + ) + ) + return GameState( + map_size=8, + max_ticks=100, + terrain=[[0] * 8 for _ in range(8)], + resources=[[0] * 8 for _ in range(8)], + resource_amounts=[[0] * 8 for _ in range(8)], + villages=[ + VillageState(team=0, resources=ResourceStock(), bots=bots), + VillageState(team=1), + ], + blueprints=[ + { + "team": 0, + "building_type": int(BuildingType.BARRACKS), + "position": [bp_pos[0], bp_pos[1]], + "progress": 0.0, + } + ], + next_bot_id=2, + ) + + +def test_no_progress_without_adjacent_builder() -> None: + cfg = _bcfg() + g = _state_with_blueprint((3, 3), builder_pos=(0, 0)) + ev = BuildingSystem.construction_tick(g, cfg) + assert not ev["buildings_completed"] + assert g.blueprints and float(g.blueprints[0]["progress"]) == 0.0 + + +def test_progress_and_block_placed_with_adjacent_builder() -> None: + cfg = _bcfg() + g = _state_with_blueprint((3, 3), builder_pos=(3, 4)) + ev = BuildingSystem.construction_tick(g, cfg) + assert float(g.blueprints[0]["progress"]) > 0.0 + assert ev["block_placed_by_bot"].get(1, 0.0) > 0.0 diff --git a/tests/test_economy_system.py b/tests/test_economy_system.py index dbf420f..46dffd2 100644 --- a/tests/test_economy_system.py +++ b/tests/test_economy_system.py @@ -86,6 +86,7 @@ def test_gatherer_collects_wood() -> None: ev = EconomySystem.step(g, cfg) assert g.villages[0].resources.wood > 0 assert ev["wood_delta"][0] > 0 + assert ev["resource_collected_by_bot"].get(0, 0) > 0 def test_hunger_when_no_food() -> None: diff --git a/tests/test_reward_village.py b/tests/test_reward_village.py index f777de6..51a0d44 100644 --- a/tests/test_reward_village.py +++ b/tests/test_reward_village.py @@ -45,3 +45,18 @@ def test_win_terminal() -> None: won=True, ) assert r >= 1000.0 + + +def test_food_security_bonus() -> None: + cfg = { + "rewards": { + "village": { + **_cfg()["rewards"]["village"], + "food_security_bonus": 0.02, + "food_security_threshold": 100, + } + } + } + v = VillageState(team=0, resources=ResourceStock(food=150)) + r = VillageRewardCalculator.compute({}, v, cfg, terminated=False, won=None) + assert r >= (150 - 100) * 0.02 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..21a1fe7 --- /dev/null +++ b/uv.lock @@ -0,0 +1,184 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "village-ai-war" +version = "0.1.0" +source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1" }, +] +provides-extras = ["dev"] From 4b0a9773976449569f157778034f717a5adf5ab6 Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Tue, 31 Mar 2026 07:40:12 +0300 Subject: [PATCH 03/11] Enhance camera controls and rendering settings in Village AI War - Added new camera zoom parameters in default.yaml to improve user control over the 3D view. - Introduced minimum and maximum zoom levels, along with a zoom wheel step for finer adjustments. - Updated camera distance settings to enhance the overall viewing experience in the game. --- configs/default.yaml | 5 + .../rendering/building_models_3d.py | 297 +++++++++++++ .../rendering/mesh_primitives.py | 311 ++++++++++++++ .../rendering/moderngl_3d_renderer.py | 389 +++--------------- .../rendering/world_scenery_3d.py | 226 ++++++++++ 5 files changed, 902 insertions(+), 326 deletions(-) create mode 100644 src/village_ai_war/rendering/building_models_3d.py create mode 100644 src/village_ai_war/rendering/mesh_primitives.py create mode 100644 src/village_ai_war/rendering/world_scenery_3d.py diff --git a/configs/default.yaml b/configs/default.yaml index 25bd10b..333d0b9 100644 --- a/configs/default.yaml +++ b/configs/default.yaml @@ -34,6 +34,11 @@ rendering: legend_width_3d: 268 camera_fov_deg: 50 camera_dist_scale: 1.4 + # zoom: distance *= camera_zoom_mul (wheel over map, +/- / PgUp/PgDn) + camera_zoom_mul_min: 0.22 + camera_zoom_mul_max: 2.85 + camera_zoom_wheel_step: 0.11 + camera_dist_min: 3.5 camera_pitch_deg: 32 camera_yaw_deg: 40 # 0 = fixed / mouse+keys only; set e.g. 10 for idle spin diff --git a/src/village_ai_war/rendering/building_models_3d.py b/src/village_ai_war/rendering/building_models_3d.py new file mode 100644 index 0000000..1cbe37d --- /dev/null +++ b/src/village_ai_war/rendering/building_models_3d.py @@ -0,0 +1,297 @@ +"""Detailed procedural building meshes for the 3D board (team-tinted stone/wood).""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np + +from village_ai_war.rendering.mesh_primitives import ( + add_cuboid, + add_cylinder_y, + add_prism_y, + add_pyramid, + add_quad, + add_sphere, + add_tri, +) +from village_ai_war.state import BuildingType + + +def _mix( + a: tuple[float, float, float], b: tuple[float, float, float], t: float +) -> tuple[float, float, float]: + t = min(1.0, max(0.0, t)) + return ( + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ) + + +def _stone(team: tuple[float, float, float]) -> tuple[float, float, float]: + base = (0.52, 0.54, 0.58) + return _mix(base, team, 0.22) + + +def _wood() -> tuple[float, float, float]: + return (0.62, 0.46, 0.30) + + +def _wood_dark() -> tuple[float, float, float]: + return (0.42, 0.30, 0.20) + + +def _window() -> tuple[float, float, float]: + return (0.18, 0.22, 0.32) + + +def _roof_tile(team: tuple[float, float, float]) -> tuple[float, float, float]: + return (min(team[0] * 0.55 + 0.25, 0.95), min(team[1] * 0.55 + 0.22, 0.9), min(team[2] * 0.6 + 0.2, 0.92)) + + +def _trim_gold(team: tuple[float, float, float]) -> tuple[float, float, float]: + return _mix((0.92, 0.78, 0.42), team, 0.15) + + +def _add_merlons_ring( + buf: list[float], + cx: float, + cy: float, + cz: float, + radius: float, + n: int, + w: float, + h: float, + rgb: tuple[float, float, float], +) -> None: + for i in range(n): + a = 2 * math.pi * (i + 0.5) / n + ox = float(cx + radius * math.cos(a)) + oz = float(cz + radius * math.sin(a)) + add_cuboid(buf, ox, cy + h * 0.5, oz, w, h, w, rgb) + + +def _pitched_roof_along_z( + buf: list[float], + cx: float, + cz0: float, + cz1: float, + y0: float, + half_w: float, + ridge_h: float, + rgb: tuple[float, float, float], +) -> None: + """Двускатная крыша: конёк вдоль Z на x=cx, карнизы на x=cx±half_w.""" + r, g, b = rgb + ridge_y = y0 + ridge_h + # Левый скат (-X) + p0 = (cx - half_w, y0, cz0) + p1 = (cx - half_w, y0, cz1) + p2 = (cx, ridge_y, cz1) + p3 = (cx, ridge_y, cz0) + ex = p1[0] - p0[0] + ey = p1[1] - p0[1] + ez = p1[2] - p0[2] + fx = p3[0] - p0[0] + fy = p3[1] - p0[1] + fz = p3[2] - p0[2] + nx = ey * fz - ez * fy + ny = ez * fx - ex * fz + nz = ex * fy - ey * fx + ln = float(np.sqrt(nx * nx + ny * ny + nz * nz) + 1e-8) + nl = (nx / ln, ny / ln, nz / ln) + add_quad(buf, p0, p1, p2, p3, nl, (r * 0.9, g * 0.9, b * 0.9)) + # Правый скат (+X) + q0 = (cx + half_w, y0, cz0) + q1 = (cx + half_w, y0, cz1) + q2 = (cx, ridge_y, cz1) + q3 = (cx, ridge_y, cz0) + ex2 = q1[0] - q0[0] + ey2 = q1[1] - q0[1] + ez2 = q1[2] - q0[2] + fx2 = q3[0] - q0[0] + fy2 = q3[1] - q0[1] + fz2 = q3[2] - q0[2] + nx2 = ey2 * fz2 - ez2 * fy2 + ny2 = ez2 * fx2 - ex2 * fz2 + nz2 = ex2 * fy2 - ey2 * fx2 + ln2 = float(np.sqrt(nx2 * nx2 + ny2 * ny2 + nz2 * nz2) + 1e-8) + nr = (nx2 / ln2, ny2 / ln2, nz2 / ln2) + add_quad(buf, q1, q0, q3, q2, nr, (r * 0.82, g * 0.82, b * 0.82)) + # Фронтон z = cz1 + add_tri( + buf, + (cx - half_w, y0, cz1), + (cx + half_w, y0, cz1), + (cx, ridge_y, cz1), + (0.0, 0.0, 1.0), + (r * 0.88, g * 0.88, b * 0.88), + ) + # Фронтон z = cz0 + add_tri( + buf, + (cx + half_w, y0, cz0), + (cx - half_w, y0, cz0), + (cx, ridge_y, cz0), + (0.0, 0.0, -1.0), + (r * 0.78, g * 0.78, b * 0.78), + ) + + +def add_building_variant( + buf: list[float], + wx: float, + wz: float, + base_h: float, + b: Any, + tr: float, + tg: float, + tb: float, +) -> None: + hp_f = b.hp / max(b.max_hp, 1) + team = (tr, tg, tb) + roof = (min(tr * 1.12, 0.98), min(tg * 1.12, 0.98), min(tb * 1.12, 0.98)) + st = _stone(team) + bt = b.building_type + y = base_h + + if bt == BuildingType.TOWNHALL: + h_main = 0.38 + 0.42 * hp_f + # Ступенчатый цоколь + add_cuboid(buf, wx, y + 0.04, wz, 0.92, 0.08, 0.92, _mix(st, (0.35, 0.36, 0.4), 0.5)) + y += 0.08 + add_prism_y(buf, wx, y, wz, 0.40, h_main, 10, st, cap_bottom=False, cap_top=True) + y += h_main + # Второй ярус — уже + add_prism_y(buf, wx, y, wz, 0.30, 0.18, 10, _mix(team, st, 0.35), cap_bottom=False, cap_top=True) + y += 0.18 + # Колонны по углам вписанного квадрата + col_y = base_h + 0.08 + h_main * 0.35 + col_h = h_main * 0.55 + off = 0.34 + for dx, dz in ((-off, -off), (off, -off), (-off, off), (off, off)): + add_cylinder_y(buf, wx + dx, col_y, wz + dz, 0.045, col_h, 10, _trim_gold(team)) + # Купол + add_sphere(buf, wx, y + 0.14, wz, 0.20, roof, stacks=6, slices=14) + add_cylinder_y(buf, wx, y, wz, 0.22, 0.06, 16, _trim_gold(team)) + # Флагшток и полотнище + pole_y = y + 0.28 + add_cylinder_y(buf, wx + 0.14, pole_y, wz - 0.06, 0.014, 0.26, 6, (0.55, 0.52, 0.5)) + fy = pole_y + 0.22 + add_quad( + buf, + (wx + 0.16, fy - 0.08, wz - 0.04), + (wx + 0.16, fy + 0.02, wz - 0.04), + (wx + 0.28, fy + 0.02, wz - 0.04), + (wx + 0.28, fy - 0.08, wz - 0.04), + (0.0, 0.0, 1.0), + team, + ) + + elif bt == BuildingType.TOWER: + h0, h1, h2 = 0.18 + 0.12 * hp_f, 0.28 + 0.22 * hp_f, 0.16 + 0.1 * hp_f + r0, r1, r2 = 0.24, 0.19, 0.14 + add_cylinder_y(buf, wx, y, wz, r0 + 0.02, 0.06, 12, _mix(st, (0.4, 0.4, 0.44), 0.6)) + y += 0.05 + add_cylinder_y(buf, wx, y, wz, r0, h0, 12, st) + y += h0 + add_cylinder_y(buf, wx, y, wz, r1, h1, 12, _mix(st, team, 0.12)) + y += h1 + add_cylinder_y(buf, wx, y, wz, r2, h2, 10, st) + y += h2 + _add_merlons_ring(buf, wx, y, wz, r2 - 0.02, 8, 0.07, 0.08, st) + y += 0.08 + add_pyramid(buf, wx, y, wz, 0.11, y + 0.38, _roof_tile(team)) + # Стрельницы — малые выступы + for i in range(4): + a = math.pi * 0.25 + i * math.pi * 0.5 + ox = wx + (r1 - 0.02) * math.cos(a) + oz = wz + (r1 - 0.02) * math.sin(a) + add_cuboid(buf, ox, base_h + h0 * 0.5, oz, 0.1, h0 * 0.6, 0.1, _mix(st, _window(), 0.25)) + + elif bt == BuildingType.BARRACKS: + h = 0.34 + 0.38 * hp_f + hw, hd = 0.44, 0.34 + add_cuboid(buf, wx, y + 0.035, wz, hw * 2 + 0.08, 0.07, hd * 2 + 0.08, _mix(st, (0.4, 0.42, 0.45), 0.55)) + y += 0.07 + add_cuboid(buf, wx, y + h * 0.5, wz, hw * 2, h, hd * 2, _mix(team, st, 0.18)) + # Окна (тёмные вставки на фасаде +Z) + win_y = y + h * 0.55 + for ox in (-0.28, 0.0, 0.28): + add_cuboid(buf, wx + ox, win_y, wz + hd - 0.02, 0.1, 0.12, 0.04, _window()) + add_cuboid(buf, wx, y + h * 0.22, wz + hd - 0.02, 0.14, 0.2, 0.05, _wood_dark()) + # Двускатная крыша + cz0, cz1 = wz - hd, wz + hd + _pitched_roof_along_z(buf, wx, cz0, cz1, y + h, hw, 0.22, _roof_tile(team)) + # Труба + add_cylinder_y( + buf, + wx + hw * 0.45, + y + h + 0.12, + wz - hd * 0.3, + 0.05, + 0.16, + 8, + (0.45, 0.44, 0.48), + ) + + elif bt == BuildingType.FARM: + h_wall = 0.16 + 0.1 * hp_f + hw_b, hd_b = 0.40, 0.32 + add_cuboid(buf, wx, y + h_wall * 0.5, wz, hw_b * 2, h_wall, hd_b * 2, _wood_dark()) + y2 = y + h_wall + _pitched_roof_along_z(buf, wx, wz - hd_b, wz + hd_b, y2, hw_b, 0.2, _roof_tile(team)) + # Силос + silo_x = wx + 0.34 + add_cylinder_y(buf, silo_x, y, wz + 0.08, 0.11, 0.52 + 0.15 * hp_f, 14, (0.78, 0.76, 0.72)) + sy = y + 0.52 + 0.15 * hp_f + add_pyramid(buf, silo_x, sy, wz + 0.08, 0.09, sy + 0.16, (0.72, 0.58, 0.38)) + # Стог / навес + add_cuboid(buf, wx - 0.32, y + 0.08, wz - 0.22, 0.22, 0.12, 0.2, (0.82, 0.72, 0.28)) + add_cuboid(buf, wx - 0.32, y + 0.18, wz - 0.22, 0.24, 0.03, 0.22, _wood()) + + elif bt == BuildingType.WALL: + h = 0.16 + 0.22 * hp_f + add_cuboid(buf, wx, y + h * 0.5, wz, 0.9, h, 0.2, st) + top = y + h + n_m = 5 + for i in range(n_m): + t = (i + 0.5) / n_m - 0.5 + mx = wx + t * 0.72 + if i % 2 == 0: + add_cuboid(buf, mx, top + 0.06, wz, 0.12, 0.12, 0.16, _mix(st, team, 0.08)) + else: + add_cuboid(buf, mx, top + 0.03, wz, 0.14, 0.06, 0.18, _mix(st, (0.45, 0.46, 0.5), 0.4)) + + elif bt == BuildingType.CITADEL: + h_base = 0.36 + 0.35 * hp_f + add_prism_y(buf, wx, y, wz, 0.39, h_base, 8, st, cap_bottom=False, cap_top=True) + y += h_base + add_cuboid(buf, wx, y + 0.16, wz, 0.52, 0.32, 0.52, _mix(team, st, 0.2)) + add_prism_y(buf, wx, y + 0.32, wz, 0.22, 0.2, 8, _roof_tile(team), cap_bottom=False, cap_top=True) + for i in range(4): + a = math.pi * 0.25 + i * math.pi * 0.5 + ox = wx + 0.33 * math.cos(a) + oz = wz + 0.33 * math.sin(a) + add_cylinder_y(buf, ox, base_h, oz, 0.08, h_base + 0.1, 10, st) + add_pyramid(buf, ox, base_h + h_base + 0.1, oz, 0.06, base_h + h_base + 0.28, roof) + add_cuboid(buf, wx, y + 0.34, wz, 0.56, 0.08, 0.56, _trim_gold(team)) + + elif bt == BuildingType.STORAGE: + h = 0.26 + 0.32 * hp_f + add_cuboid(buf, wx, y + 0.04, wz, 0.88, 0.08, 0.76, _mix(st, (0.48, 0.5, 0.54), 0.45)) + y += 0.08 + add_cuboid(buf, wx, y + h * 0.5, wz, 0.82, h, 0.70, _mix(st, team, 0.12)) + # Навес погрузки + add_cuboid(buf, wx + 0.36, y + h * 0.72, wz + 0.38, 0.34, 0.06, 0.42, _wood()) + add_cuboid(buf, wx + 0.36, y + h * 0.45, wz + 0.52, 0.06, h * 0.5, 0.06, _wood_dark()) + add_cuboid(buf, wx + 0.36, y + h * 0.45, wz + 0.24, 0.06, h * 0.5, 0.06, _wood_dark()) + add_cuboid(buf, wx - 0.12, y + h * 0.35, wz - 0.18, 0.22, 0.18, 0.2, _wood_dark()) + add_cuboid(buf, wx - 0.22, y + h * 0.2, wz + 0.2, 0.16, 0.14, 0.16, (0.55, 0.5, 0.42)) + add_cuboid(buf, wx - 0.28, y + h * 0.52, wz - 0.22, 0.2, 0.12, 0.18, (0.58, 0.52, 0.4)) + + else: + h = 0.35 + 0.45 * hp_f + add_cuboid(buf, wx, y + h * 0.5 + 0.02, wz, 0.72, h, 0.72, team) diff --git a/src/village_ai_war/rendering/mesh_primitives.py b/src/village_ai_war/rendering/mesh_primitives.py new file mode 100644 index 0000000..b4606e4 --- /dev/null +++ b/src/village_ai_war/rendering/mesh_primitives.py @@ -0,0 +1,311 @@ +"""Shared mesh helpers for 3D rendering (interleaved pos, normal, rgb per vertex).""" + +from __future__ import annotations + +import math + +import numpy as np + + +def add_quad( + buf: list[float], + p0: tuple[float, float, float], + p1: tuple[float, float, float], + p2: tuple[float, float, float], + p3: tuple[float, float, float], + n: tuple[float, float, float], + rgb: tuple[float, float, float], +) -> None: + nx, ny, nz = n + r, g, b = rgb + for px, py, pz in (p0, p1, p2): + buf.extend([px, py, pz, nx, ny, nz, r, g, b]) + for px, py, pz in (p0, p2, p3): + buf.extend([px, py, pz, nx, ny, nz, r, g, b]) + + +def add_tri( + buf: list[float], + p0: tuple[float, float, float], + p1: tuple[float, float, float], + p2: tuple[float, float, float], + n: tuple[float, float, float], + rgb: tuple[float, float, float], +) -> None: + nx, ny, nz = n + r, g, b = rgb + for px, py, pz in (p0, p1, p2): + buf.extend([px, py, pz, nx, ny, nz, r, g, b]) + + +def add_cuboid( + buf: list[float], + cx: float, + cy: float, + cz: float, + sx: float, + sy: float, + sz: float, + rgb: tuple[float, float, float], +) -> None: + hx, hy, hz = sx * 0.5, sy * 0.5, sz * 0.5 + r, g, b = rgb + add_quad( + buf, + (cx - hx, cy + hy, cz - hz), + (cx + hx, cy + hy, cz - hz), + (cx + hx, cy + hy, cz + hz), + (cx - hx, cy + hy, cz + hz), + (0, 1, 0), + (r * 1.1, g * 1.1, b * 1.1), + ) + add_quad( + buf, + (cx - hx, cy - hy, cz + hz), + (cx + hx, cy - hy, cz + hz), + (cx + hx, cy - hy, cz - hz), + (cx - hx, cy - hy, cz - hz), + (0, -1, 0), + (r * 0.55, g * 0.55, b * 0.55), + ) + add_quad( + buf, + (cx + hx, cy - hy, cz - hz), + (cx + hx, cy + hy, cz - hz), + (cx + hx, cy + hy, cz + hz), + (cx + hx, cy - hy, cz + hz), + (1, 0, 0), + (r * 0.85, g * 0.85, b * 0.85), + ) + add_quad( + buf, + (cx - hx, cy - hy, cz + hz), + (cx - hx, cy + hy, cz + hz), + (cx - hx, cy + hy, cz - hz), + (cx - hx, cy - hy, cz - hz), + (-1, 0, 0), + (r * 0.75, g * 0.75, b * 0.75), + ) + add_quad( + buf, + (cx - hx, cy - hy, cz + hz), + (cx + hx, cy - hy, cz + hz), + (cx + hx, cy + hy, cz + hz), + (cx - hx, cy + hy, cz + hz), + (0, 0, 1), + (r * 0.9, g * 0.9, b * 0.9), + ) + add_quad( + buf, + (cx + hx, cy - hy, cz - hz), + (cx - hx, cy - hy, cz - hz), + (cx - hx, cy + hy, cz - hz), + (cx + hx, cy + hy, cz - hz), + (0, 0, -1), + (r * 0.7, g * 0.7, b * 0.7), + ) + + +def add_sphere( + buf: list[float], + cx: float, + cy: float, + cz: float, + radius: float, + rgb: tuple[float, float, float], + stacks: int = 5, + slices: int = 8, +) -> None: + r, g, b = rgb + for i in range(stacks): + lat0 = np.pi * (-0.5 + i / stacks) + lat1 = np.pi * (-0.5 + (i + 1) / stacks) + z0, zr0 = np.sin(lat0) * radius, np.cos(lat0) * radius + z1, zr1 = np.sin(lat1) * radius, np.cos(lat1) * radius + for j in range(slices): + lng0 = 2 * np.pi * j / slices + lng1 = 2 * np.pi * (j + 1) / slices + p00 = ( + float(cx + zr0 * np.cos(lng0)), + float(cy + z0), + float(cz + zr0 * np.sin(lng0)), + ) + p01 = ( + float(cx + zr0 * np.cos(lng1)), + float(cy + z0), + float(cz + zr0 * np.sin(lng1)), + ) + p10 = ( + float(cx + zr1 * np.cos(lng0)), + float(cy + z1), + float(cz + zr1 * np.sin(lng0)), + ) + p11 = ( + float(cx + zr1 * np.cos(lng1)), + float(cy + z1), + float(cz + zr1 * np.sin(lng1)), + ) + ex = p01[0] - p00[0] + ey = p01[1] - p00[1] + ez = p01[2] - p00[2] + fx = p10[0] - p00[0] + fy = p10[1] - p00[1] + fz = p10[2] - p00[2] + nx = ey * fz - ez * fy + ny = ez * fx - ex * fz + nz = ex * fy - ey * fx + ln = float(np.sqrt(nx * nx + ny * ny + nz * nz) + 1e-8) + nn = (nx / ln, ny / ln, nz / ln) + add_quad(buf, p00, p01, p11, p10, nn, (r, g, b)) + + +def add_cylinder_y( + buf: list[float], + cx: float, + cy_bottom: float, + cz: float, + radius: float, + height: float, + segs: int, + rgb: tuple[float, float, float], + *, + cap_top: bool = True, + cap_bottom: bool = True, +) -> None: + r, g, b = rgb + y0, y1 = cy_bottom, cy_bottom + height + for i in range(segs): + a0 = 2 * np.pi * i / segs + a1 = 2 * np.pi * (i + 1) / segs + x0 = float(cx + radius * np.cos(a0)) + z0 = float(cz + radius * np.sin(a0)) + x1 = float(cx + radius * np.cos(a1)) + z1 = float(cz + radius * np.sin(a1)) + am = (a0 + a1) * 0.5 + nn = (float(np.cos(am)), 0.0, float(np.sin(am))) + add_quad(buf, (x0, y0, z0), (x1, y0, z1), (x1, y1, z1), (x0, y1, z0), nn, (r, g, b)) + if cap_top: + for i in range(segs): + a0 = 2 * np.pi * i / segs + a1 = 2 * np.pi * (i + 1) / segs + x0 = float(cx + radius * np.cos(a0)) + z0 = float(cz + radius * np.sin(a0)) + x1 = float(cx + radius * np.cos(a1)) + z1 = float(cz + radius * np.sin(a1)) + add_tri( + buf, + (cx, y1, cz), + (x0, y1, z0), + (x1, y1, z1), + (0, 1, 0), + (r * 1.08, g * 1.08, b * 1.08), + ) + if cap_bottom: + for i in range(segs): + a0 = 2 * np.pi * i / segs + a1 = 2 * np.pi * (i + 1) / segs + x0 = float(cx + radius * np.cos(a0)) + z0 = float(cz + radius * np.sin(a0)) + x1 = float(cx + radius * np.cos(a1)) + z1 = float(cz + radius * np.sin(a1)) + add_tri( + buf, + (cx, y0, cz), + (x1, y0, z1), + (x0, y0, z0), + (0, -1, 0), + (r * 0.65, g * 0.65, b * 0.65), + ) + + +def add_pyramid( + buf: list[float], + cx: float, + cy_base: float, + cz: float, + half_w: float, + cy_apex: float, + rgb: tuple[float, float, float], +) -> None: + r, g, b = rgb + hw = half_w + yb, ya = cy_base, cy_apex + nw = (cx - hw, yb, cz - hw) + ne = (cx + hw, yb, cz - hw) + se = (cx + hw, yb, cz + hw) + sw = (cx - hw, yb, cz + hw) + apex = (cx, ya, cz) + + for p0, p1, p2 in ((nw, ne, apex), (ne, se, apex), (se, sw, apex), (sw, nw, apex)): + ex = p1[0] - p0[0] + ey = p1[1] - p0[1] + ez = p1[2] - p0[2] + fx = p2[0] - p0[0] + fy = p2[1] - p0[1] + fz = p2[2] - p0[2] + nx = ey * fz - ez * fy + ny = ez * fx - ex * fz + nz = ex * fy - ey * fx + ln = float(np.sqrt(nx * nx + ny * ny + nz * nz) + 1e-8) + nn = (nx / ln, ny / ln, nz / ln) + add_tri(buf, p0, p1, p2, nn, (r, g, b)) + + +def add_prism_y( + buf: list[float], + cx: float, + cy_bot: float, + cz: float, + radius: float, + height: float, + sides: int, + rgb: tuple[float, float, float], + *, + cap_bottom: bool = True, + cap_top: bool = True, +) -> None: + """Вертикальная призма с многоугольным основанием в плоскости XZ.""" + y_top = cy_bot + height + r, g, b = rgb + for i in range(sides): + a0 = 2 * math.pi * i / sides + a1 = 2 * math.pi * (i + 1) / sides + x0 = float(cx + radius * math.cos(a0)) + z0 = float(cz + radius * math.sin(a0)) + x1 = float(cx + radius * math.cos(a1)) + z1 = float(cz + radius * math.sin(a1)) + am = (a0 + a1) * 0.5 + nn = (float(math.cos(am)), 0.0, float(math.sin(am))) + add_quad(buf, (x0, cy_bot, z0), (x1, cy_bot, z1), (x1, y_top, z1), (x0, y_top, z0), nn, (r, g, b)) + if cap_bottom: + for i in range(sides): + a0 = 2 * math.pi * i / sides + a1 = 2 * math.pi * (i + 1) / sides + x0 = float(cx + radius * math.cos(a0)) + z0 = float(cz + radius * math.sin(a0)) + x1 = float(cx + radius * math.cos(a1)) + z1 = float(cz + radius * math.sin(a1)) + add_tri( + buf, + (cx, cy_bot, cz), + (x1, cy_bot, z1), + (x0, cy_bot, z0), + (0, -1, 0), + (r * 0.62, g * 0.62, b * 0.62), + ) + if cap_top: + for i in range(sides): + a0 = 2 * math.pi * i / sides + a1 = 2 * math.pi * (i + 1) / sides + x0 = float(cx + radius * math.cos(a0)) + z0 = float(cz + radius * math.sin(a0)) + x1 = float(cx + radius * math.cos(a1)) + z1 = float(cz + radius * math.sin(a1)) + add_tri( + buf, + (cx, y_top, cz), + (x0, y_top, z0), + (x1, y_top, z1), + (0, 1, 0), + (r * 1.05, g * 1.05, b * 1.05), + ) diff --git a/src/village_ai_war/rendering/moderngl_3d_renderer.py b/src/village_ai_war/rendering/moderngl_3d_renderer.py index 4149385..4003d33 100644 --- a/src/village_ai_war/rendering/moderngl_3d_renderer.py +++ b/src/village_ai_war/rendering/moderngl_3d_renderer.py @@ -7,8 +7,21 @@ import numpy as np +from village_ai_war.rendering.building_models_3d import add_building_variant as _add_building_variant +from village_ai_war.rendering.world_scenery_3d import ( + add_resource_prop as _add_resource_prop, + add_terrain_cell as _add_terrain_cell, + terrain_height as _terrain_height, +) +from village_ai_war.rendering.mesh_primitives import ( + add_cuboid as _add_cuboid, + add_cylinder_y as _add_cylinder_y, + add_pyramid as _add_pyramid, + add_quad as _add_quad, + add_sphere as _add_sphere, + add_tri as _add_tri, +) from village_ai_war.state import ( - BuildingType, GameState, GlobalRewardMode, ResourceLayer, @@ -45,16 +58,6 @@ _ACCENT_HUD = (92, 140, 220) -def _terrain_height(t: int) -> float: - return { - int(TerrainType.GRASS): 0.14, - int(TerrainType.MOUNTAIN): 0.62, - int(TerrainType.FOREST): 0.22, - int(TerrainType.STONE_DEPOSIT): 0.18, - int(TerrainType.FIELD): 0.10, - }.get(t, 0.12) - - def _mul4(a: np.ndarray, b: np.ndarray) -> np.ndarray: return (a @ b).astype(np.float32) @@ -88,253 +91,6 @@ def look_at(eye: np.ndarray, center: np.ndarray, world_up: np.ndarray) -> np.nda return v -def _add_quad( - buf: list[float], - p0: tuple[float, float, float], - p1: tuple[float, float, float], - p2: tuple[float, float, float], - p3: tuple[float, float, float], - n: tuple[float, float, float], - rgb: tuple[float, float, float], -) -> None: - nx, ny, nz = n - r, g, b = rgb - for px, py, pz in (p0, p1, p2): - buf.extend([px, py, pz, nx, ny, nz, r, g, b]) - for px, py, pz in (p0, p2, p3): - buf.extend([px, py, pz, nx, ny, nz, r, g, b]) - - -def _add_tri( - buf: list[float], - p0: tuple[float, float, float], - p1: tuple[float, float, float], - p2: tuple[float, float, float], - n: tuple[float, float, float], - rgb: tuple[float, float, float], -) -> None: - nx, ny, nz = n - r, g, b = rgb - for px, py, pz in (p0, p1, p2): - buf.extend([px, py, pz, nx, ny, nz, r, g, b]) - - -def _add_cuboid( - buf: list[float], - cx: float, - cy: float, - cz: float, - sx: float, - sy: float, - sz: float, - rgb: tuple[float, float, float], -) -> None: - hx, hy, hz = sx * 0.5, sy * 0.5, sz * 0.5 - r, g, b = rgb - # Six faces; +Y is up - _add_quad( - buf, - (cx - hx, cy + hy, cz - hz), - (cx + hx, cy + hy, cz - hz), - (cx + hx, cy + hy, cz + hz), - (cx - hx, cy + hy, cz + hz), - (0, 1, 0), - (r * 1.1, g * 1.1, b * 1.1), - ) - _add_quad( - buf, - (cx - hx, cy - hy, cz + hz), - (cx + hx, cy - hy, cz + hz), - (cx + hx, cy - hy, cz - hz), - (cx - hx, cy - hy, cz - hz), - (0, -1, 0), - (r * 0.55, g * 0.55, b * 0.55), - ) - _add_quad( - buf, - (cx + hx, cy - hy, cz - hz), - (cx + hx, cy + hy, cz - hz), - (cx + hx, cy + hy, cz + hz), - (cx + hx, cy - hy, cz + hz), - (1, 0, 0), - (r * 0.85, g * 0.85, b * 0.85), - ) - _add_quad( - buf, - (cx - hx, cy - hy, cz + hz), - (cx - hx, cy + hy, cz + hz), - (cx - hx, cy + hy, cz - hz), - (cx - hx, cy - hy, cz - hz), - (-1, 0, 0), - (r * 0.75, g * 0.75, b * 0.75), - ) - _add_quad( - buf, - (cx - hx, cy - hy, cz + hz), - (cx + hx, cy - hy, cz + hz), - (cx + hx, cy + hy, cz + hz), - (cx - hx, cy + hy, cz + hz), - (0, 0, 1), - (r * 0.9, g * 0.9, b * 0.9), - ) - _add_quad( - buf, - (cx + hx, cy - hy, cz - hz), - (cx - hx, cy - hy, cz - hz), - (cx - hx, cy + hy, cz - hz), - (cx + hx, cy + hy, cz - hz), - (0, 0, -1), - (r * 0.7, g * 0.7, b * 0.7), - ) - - -def _add_sphere( - buf: list[float], - cx: float, - cy: float, - cz: float, - radius: float, - rgb: tuple[float, float, float], - stacks: int = 5, - slices: int = 8, -) -> None: - r, g, b = rgb - for i in range(stacks): - lat0 = np.pi * (-0.5 + i / stacks) - lat1 = np.pi * (-0.5 + (i + 1) / stacks) - z0, zr0 = np.sin(lat0) * radius, np.cos(lat0) * radius - z1, zr1 = np.sin(lat1) * radius, np.cos(lat1) * radius - for j in range(slices): - lng0 = 2 * np.pi * j / slices - lng1 = 2 * np.pi * (j + 1) / slices - p00 = ( - float(cx + zr0 * np.cos(lng0)), - float(cy + z0), - float(cz + zr0 * np.sin(lng0)), - ) - p01 = ( - float(cx + zr0 * np.cos(lng1)), - float(cy + z0), - float(cz + zr0 * np.sin(lng1)), - ) - p10 = ( - float(cx + zr1 * np.cos(lng0)), - float(cy + z1), - float(cz + zr1 * np.sin(lng0)), - ) - p11 = ( - float(cx + zr1 * np.cos(lng1)), - float(cy + z1), - float(cz + zr1 * np.sin(lng1)), - ) - ex = p01[0] - p00[0] - ey = p01[1] - p00[1] - ez = p01[2] - p00[2] - fx = p10[0] - p00[0] - fy = p10[1] - p00[1] - fz = p10[2] - p00[2] - nx = ey * fz - ez * fy - ny = ez * fx - ex * fz - nz = ex * fy - ey * fx - ln = float(np.sqrt(nx * nx + ny * ny + nz * nz) + 1e-8) - nn = (nx / ln, ny / ln, nz / ln) - _add_quad(buf, p00, p01, p11, p10, nn, (r, g, b)) - - -def _add_cylinder_y( - buf: list[float], - cx: float, - cy_bottom: float, - cz: float, - radius: float, - height: float, - segs: int, - rgb: tuple[float, float, float], - *, - cap_top: bool = True, - cap_bottom: bool = True, -) -> None: - r, g, b = rgb - y0, y1 = cy_bottom, cy_bottom + height - for i in range(segs): - a0 = 2 * np.pi * i / segs - a1 = 2 * np.pi * (i + 1) / segs - x0 = float(cx + radius * np.cos(a0)) - z0 = float(cz + radius * np.sin(a0)) - x1 = float(cx + radius * np.cos(a1)) - z1 = float(cz + radius * np.sin(a1)) - am = (a0 + a1) * 0.5 - nn = (float(np.cos(am)), 0.0, float(np.sin(am))) - _add_quad(buf, (x0, y0, z0), (x1, y0, z1), (x1, y1, z1), (x0, y1, z0), nn, (r, g, b)) - if cap_top: - for i in range(segs): - a0 = 2 * np.pi * i / segs - a1 = 2 * np.pi * (i + 1) / segs - x0 = float(cx + radius * np.cos(a0)) - z0 = float(cz + radius * np.sin(a0)) - x1 = float(cx + radius * np.cos(a1)) - z1 = float(cz + radius * np.sin(a1)) - _add_tri( - buf, - (cx, y1, cz), - (x0, y1, z0), - (x1, y1, z1), - (0, 1, 0), - (r * 1.08, g * 1.08, b * 1.08), - ) - if cap_bottom: - for i in range(segs): - a0 = 2 * np.pi * i / segs - a1 = 2 * np.pi * (i + 1) / segs - x0 = float(cx + radius * np.cos(a0)) - z0 = float(cz + radius * np.sin(a0)) - x1 = float(cx + radius * np.cos(a1)) - z1 = float(cz + radius * np.sin(a1)) - _add_tri( - buf, - (cx, y0, cz), - (x1, y0, z1), - (x0, y0, z0), - (0, -1, 0), - (r * 0.65, g * 0.65, b * 0.65), - ) - - -def _add_pyramid( - buf: list[float], - cx: float, - cy_base: float, - cz: float, - half_w: float, - cy_apex: float, - rgb: tuple[float, float, float], -) -> None: - """Квадратное основание в плоскости XZ, вершина на оси Y.""" - r, g, b = rgb - hw = half_w - yb, ya = cy_base, cy_apex - # углы: NW, NE, SE, SW (вид сверху, Z+ «вниз» как строки карты) - nw = (cx - hw, yb, cz - hw) - ne = (cx + hw, yb, cz - hw) - se = (cx + hw, yb, cz + hw) - sw = (cx - hw, yb, cz + hw) - apex = (cx, ya, cz) - - for p0, p1, p2 in ((nw, ne, apex), (ne, se, apex), (se, sw, apex), (sw, nw, apex)): - ex = p1[0] - p0[0] - ey = p1[1] - p0[1] - ez = p1[2] - p0[2] - fx = p2[0] - p0[0] - fy = p2[1] - p0[1] - fz = p2[2] - p0[2] - nx = ey * fz - ez * fy - ny = ez * fx - ex * fz - nz = ex * fy - ey * fx - ln = float(np.sqrt(nx * nx + ny * ny + nz * nz) + 1e-8) - nn = (nx / ln, ny / ln, nz / ln) - _add_tri(buf, p0, p1, p2, nn, (r, g, b)) - - _VS = """ #version 330 uniform mat4 vp; @@ -477,64 +233,6 @@ def _draw_legend_hud( yy += row_h + 3 -def _add_building_variant( - buf: list[float], - wx: float, - wz: float, - base_h: float, - b: Any, - tr: float, - tg: float, - tb: float, -) -> None: - hp_f = b.hp / max(b.max_hp, 1) - team = (tr, tg, tb) - roof = (min(tr * 1.12, 0.98), min(tg * 1.12, 0.98), min(tb * 1.12, 0.98)) - bt = b.building_type - - if bt == BuildingType.TOWNHALL: - h = 0.42 + 0.5 * hp_f - _add_cuboid(buf, wx, base_h + h * 0.5, wz, 0.78, h, 0.78, team) - _add_pyramid(buf, wx, base_h + h + 0.02, wz, 0.48, base_h + h + 0.42, roof) - elif bt == BuildingType.TOWER: - h = 0.55 + 0.5 * hp_f - _add_cuboid(buf, wx, base_h + h * 0.5, wz, 0.34, h, 0.34, team) - _add_pyramid(buf, wx, base_h + h + 0.02, wz, 0.12, base_h + h + 0.62, roof) - elif bt == BuildingType.BARRACKS: - h = 0.32 + 0.4 * hp_f - _add_cuboid(buf, wx, base_h + h * 0.48, wz, 0.88, h * 0.92, 0.68, team) - _add_cuboid(buf, wx, base_h + h + 0.1, wz, 0.55, 0.14, 0.42, roof) - elif bt == BuildingType.FARM: - h = 0.22 + 0.25 * hp_f - _add_cuboid(buf, wx, base_h + h * 0.5, wz, 0.85, h, 0.72, team) - _add_cylinder_y( - buf, - wx + 0.32, - base_h + h + 0.08, - wz, - 0.12, - 0.28, - 10, - (0.85, 0.72, 0.42), - cap_top=True, - cap_bottom=True, - ) - elif bt == BuildingType.WALL: - h = 0.18 + 0.2 * hp_f - _add_cuboid(buf, wx, base_h + h * 0.5, wz, 0.88, h, 0.22, team) - elif bt == BuildingType.CITADEL: - h = 0.5 + 0.45 * hp_f - _add_cuboid(buf, wx, base_h + h * 0.5, wz, 0.82, h, 0.82, team) - _add_pyramid(buf, wx, base_h + h + 0.02, wz, 0.52, base_h + h + 0.5, roof) - elif bt == BuildingType.STORAGE: - h = 0.3 + 0.35 * hp_f - _add_cuboid(buf, wx, base_h + h * 0.5, wz, 0.72, h, 0.72, team) - _add_cuboid(buf, wx - 0.08, base_h + h + 0.12, wz + 0.1, 0.38, 0.2, 0.38, roof) - else: - h = 0.35 + 0.45 * hp_f - _add_cuboid(buf, wx, base_h + h * 0.5 + 0.02, wz, 0.72, h, 0.72, team) - - def _add_bot_figure( buf: list[float], wx: float, @@ -715,6 +413,11 @@ def __init__(self, config: Mapping[str, Any], state: GameState | None) -> None: self._map_vp_w = max(120, self._win_w - self._legend_w) self._fov = float(rend.get("camera_fov_deg", 50.0)) self._dist_scale = float(rend.get("camera_dist_scale", 1.4)) + self._zoom_mul = 1.0 + self._zoom_mul_min = float(rend.get("camera_zoom_mul_min", 0.22)) + self._zoom_mul_max = float(rend.get("camera_zoom_mul_max", 2.85)) + self._zoom_wheel_step = float(rend.get("camera_zoom_wheel_step", 0.11)) + self._dist_min = float(rend.get("camera_dist_min", 3.5)) self._auto_rotate = float(rend.get("auto_rotate_deg_per_sec", 0.0)) self._pitch_deg = float(rend.get("camera_pitch_deg", 32.0)) self._yaw_deg = float(rend.get("camera_yaw_deg", 40.0)) @@ -892,9 +595,10 @@ def bl(txt: str, dy: int, color: tuple[int, int, int], font: Any = body) -> None surf.blit(body.render(names[int(tt)], True, (210, 212, 220)), (36, y - 1)) y += 18 y += 6 - bl("Здания — цвет команды", 16, (120, 170, 230), small) - bl("TH купол, Tw шпиль, Fm силос", 16, (170, 175, 190), small) - bl("На карте: жёлтый маркер — ресурс", 16, (170, 175, 190), small) + bl("Здания — детальные модели", 16, (120, 170, 230), small) + bl("TH ратуша, казармы, ферма, склады…", 16, (170, 175, 190), small) + bl("Ресурсы: 3D (лес/руда/урожай)", 16, (170, 175, 190), small) + bl("Колёсико / +/- : масштаб", 16, (170, 175, 190), small) _draw_legend_hud( surf, pygame, small, body, self._legend_w, self._win_h, state, hud_h @@ -918,9 +622,7 @@ def _build_static_terrain(self, state: GameState) -> None: for gx in range(n): wx, wz = self._grid_to_world(gx, gz) t = int(terrain[gz, gx]) - h = _terrain_height(t) - rgb = _TERRAIN_COLOR.get(t, (0.3, 0.3, 0.35)) - _add_cuboid(buf, wx, h * 0.5, wz, cell, h, cell, rgb) + _add_terrain_cell(buf, wx, wz, t, gx, gz, cell) arr = np.asarray(buf, dtype=np.float32) if self._vbo is not None: self._vbo.release() @@ -933,17 +635,20 @@ def _build_static_terrain(self, state: GameState) -> None: def _build_dynamic_geometry(self, state: GameState) -> np.ndarray: buf: list[float] = [] res_layer = np.asarray(state.resources, dtype=np.int32) + amounts = np.asarray(state.resource_amounts, dtype=np.int32) n = self._n for gz in range(n): for gx in range(n): layer = int(res_layer[gz, gx]) if layer == int(ResourceLayer.NONE): continue + amt = int(amounts[gz, gx]) + if amt <= 0: + continue wx, wz = self._grid_to_world(gx, gz) t = int(state.terrain[gz][gx]) base_h = _terrain_height(t) - tint = (0.95, 0.92, 0.55) if layer == int(ResourceLayer.FOREST) else (0.85, 0.88, 0.95) - _add_cuboid(buf, wx, base_h + 0.08, wz, 0.25, 0.06, 0.25, tint) + _add_resource_prop(buf, wx, wz, base_h, layer, amt, gx, gz) for v in state.villages: tr, tg, tb = _TEAM_RGB[v.team] if v.team < 2 else (0.6, 0.6, 0.6) @@ -976,7 +681,7 @@ def _build_dynamic_geometry(self, state: GameState) -> np.ndarray: def _view_proj(self) -> np.ndarray: n = self._n center = np.array([0.0, n * 0.12, 0.0], dtype=np.float32) - dist = max(n * self._dist_scale, 8.0) + dist = max(n * self._dist_scale * self._zoom_mul, self._dist_min) yaw = np.radians(self._yaw_deg) pitch = np.radians(self._pitch_deg) eye = center + np.array( @@ -1033,6 +738,20 @@ def render(self, state: GameState, mode: str) -> np.ndarray | None: dt_ms = self._clock.tick(self._fps) dt = dt_ms / 1000.0 + def _apply_zoom(direction: float) -> None: + """direction > 0 — приблизить (меньше расстояние), < 0 — отдалить.""" + if direction == 0.0: + return + step = self._zoom_wheel_step + if direction > 0: + self._zoom_mul *= max(0.5, 1.0 - step) + else: + self._zoom_mul *= 1.0 + step + self._zoom_mul = max( + self._zoom_mul_min, min(self._zoom_mul_max, self._zoom_mul) + ) + + _mw = getattr(pygame, "MOUSEWHEEL", None) for event in pygame.event.get(): if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: if event.pos[0] < self._map_vp_w: @@ -1040,6 +759,24 @@ def render(self, state: GameState, mode: str) -> np.ndarray | None: self._orbit_last = (event.pos[0], event.pos[1]) elif event.type == pygame.MOUSEBUTTONUP and event.button == 1: self._orbit_drag = False + elif event.type == pygame.MOUSEBUTTONDOWN and event.pos[0] < self._map_vp_w: + if event.button == 4: + _apply_zoom(1.0) + elif event.button == 5: + _apply_zoom(-1.0) + elif _mw is not None and event.type == _mw: + mx, _my = pygame.mouse.get_pos() + if mx < self._map_vp_w: + y = float(getattr(event, "y", 0)) + if y > 0: + _apply_zoom(1.0) + elif y < 0: + _apply_zoom(-1.0) + elif event.type == pygame.KEYDOWN: + if event.key in (pygame.K_EQUALS, pygame.K_PLUS, pygame.K_PAGEUP): + _apply_zoom(1.0) + elif event.key in (pygame.K_MINUS, pygame.K_PAGEDOWN): + _apply_zoom(-1.0) elif event.type == pygame.MOUSEMOTION and self._orbit_drag: x, y = event.pos[0], event.pos[1] lx, ly = self._orbit_last @@ -1067,7 +804,7 @@ def render(self, state: GameState, mode: str) -> np.ndarray | None: win_s = "…" if win is None else ("Red" if win == 0 else "Blue" if win == 1 else "draw") pygame.display.set_caption( f"Village AI War — 3D | tick {state.tick}/{state.max_ticks} | {win_s} | " - "LMB drag: orbit · arrows: camera" + "LMB: orbit · wheel/±: zoom · arrows: tilt" ) if mode == "rgb_array_3d": diff --git a/src/village_ai_war/rendering/world_scenery_3d.py b/src/village_ai_war/rendering/world_scenery_3d.py new file mode 100644 index 0000000..c09e9de --- /dev/null +++ b/src/village_ai_war/rendering/world_scenery_3d.py @@ -0,0 +1,226 @@ +"""Процедурные 3D-модели ландшафта и добываемых ресурсов для доски moderngl.""" + +from __future__ import annotations + +import math + +from village_ai_war.rendering.mesh_primitives import ( + add_cuboid, + add_cylinder_y, + add_pyramid, + add_sphere, +) +from village_ai_war.state import ResourceLayer, TerrainType + + +def terrain_height(terrain_t: int) -> float: + """Верх клетки по Y (как раньше в moderngl) — здания и юниты опираются на это значение.""" + return { + int(TerrainType.GRASS): 0.14, + int(TerrainType.MOUNTAIN): 0.62, + int(TerrainType.FOREST): 0.22, + int(TerrainType.STONE_DEPOSIT): 0.18, + int(TerrainType.FIELD): 0.10, + }.get(int(terrain_t), 0.12) + + +def _j(gx: int, gz: int, salt: int) -> float: + """Детерминированный [0,1) для вариаций внутри клетки.""" + v = (gx * 374761393 + gz * 668265263 + salt * 1442695041) & 0x7FFFFFFF + return v / 2147483647.0 + + +def _tree( + buf: list[float], + tx: float, + tz: float, + y0: float, + trunk_h: float, + crown_r: float, + trunk_rgb: tuple[float, float, float], + leaf_rgb: tuple[float, float, float], +) -> None: + add_cylinder_y(buf, tx, y0, tz, 0.038, trunk_h, 8, trunk_rgb) + add_sphere( + buf, tx, y0 + trunk_h + crown_r * 0.55, tz, crown_r, leaf_rgb, stacks=4, slices=8 + ) + + +def add_terrain_cell( + buf: list[float], + wx: float, + wz: float, + terrain_t: int, + gx: int, + gz: int, + cell: float = 0.92, +) -> None: + """Один тайл карты: объёмный ландшафт под тип местности.""" + t = int(terrain_t) + h = terrain_height(t) + + if t == int(TerrainType.GRASS): + c0 = (0.14, 0.36, 0.24) + c1 = (0.22, 0.50, 0.34) + c2 = (0.28, 0.58, 0.38) + add_cuboid(buf, wx, h * 0.38, wz, cell, h * 0.76, cell, c0) + add_cuboid(buf, wx, h - 0.025, wz, cell * 0.94, 0.05, cell * 0.94, c1) + for i in range(6): + a = 2 * math.pi * i / 6 + _j(gx, gz, i) * 0.4 + rr = 0.22 + 0.12 * _j(gx, gz, i + 40) + ox = wx + rr * math.cos(a) + oz = wz + rr * math.sin(a) + blade_h = 0.05 + 0.04 * _j(gx, gz, i + 80) + add_cylinder_y( + buf, + ox, + h * 0.88, + oz, + 0.014 + 0.006 * _j(gx, gz, i + 10), + blade_h, + 5, + _mix3(c2, (0.1, 0.45, 0.2), _j(gx, gz, i + 20)), + ) + + elif t == int(TerrainType.FOREST): + soil = (0.12, 0.28, 0.18) + moss = (0.16, 0.38, 0.26) + add_cuboid(buf, wx, h * 0.42, wz, cell, h * 0.84, cell, soil) + add_cuboid(buf, wx, h - 0.03, wz, cell * 0.92, 0.06, cell * 0.92, moss) + trunk = (0.38, 0.28, 0.18) + leaf = (0.1, 0.42, 0.22) + n_trees = 3 if _j(gx, gz, 1) > 0.25 else 2 + for i in range(n_trees): + ang = 2 * math.pi * (i / max(n_trees, 1)) + _j(gx, gz, 50 + i) * 0.7 + rad = 0.18 + 0.14 * _j(gx, gz, 60 + i) + tx = wx + rad * math.cos(ang) + tz = wz + rad * math.sin(ang) + th = 0.11 + 0.06 * _j(gx, gz, 70 + i) + cr = 0.09 + 0.04 * _j(gx, gz, 90 + i) + _tree(buf, tx, tz, h * 0.92, th, cr, trunk, leaf) + + elif t == int(TerrainType.MOUNTAIN): + rock_lo = (0.32, 0.34, 0.38) + rock_hi = (0.48, 0.50, 0.55) + snow = (0.82, 0.86, 0.90) + add_cuboid(buf, wx, h * 0.22, wz, cell * 0.95, h * 0.44, cell * 0.95, rock_lo) + yb = h * 0.38 + add_pyramid(buf, wx, yb, wz, 0.40, h * 0.88, rock_hi) + ox = wx + 0.22 * (1 if (gx + gz) % 2 == 0 else -1) + oz = wz + 0.18 * (1 if gz % 3 else -1) + add_pyramid(buf, ox, yb + h * 0.08, oz, 0.22, h * 0.72, _mix3(rock_hi, rock_lo, 0.4)) + add_sphere(buf, wx, h * 0.82, wz, 0.12, snow, stacks=3, slices=6) + + elif t == int(TerrainType.STONE_DEPOSIT): + base = (0.42, 0.44, 0.48) + rock = (0.55, 0.56, 0.62) + add_cuboid(buf, wx, h * 0.45, wz, cell, h * 0.9, cell, base) + for k in range(5): + jr = 0.05 + 0.05 * _j(gx, gz, 100 + k) + ja = 2 * math.pi * _j(gx, gz, 110 + k) + ox = wx + (0.12 + 0.22 * _j(gx, gz, 120 + k)) * math.cos(ja) + oz = wz + (0.12 + 0.22 * _j(gx, gz, 130 + k)) * math.sin(ja) + add_sphere(buf, ox, h - jr * 0.3, oz, jr, rock, stacks=3, slices=6) + add_cuboid(buf, wx + 0.18, h * 0.55, wz - 0.12, 0.16, h * 0.5, 0.14, _mix3(rock, base, 0.3)) + + elif t == int(TerrainType.FIELD): + earth = (0.52, 0.44, 0.28) + crop = (0.72, 0.62, 0.32) + add_cuboid(buf, wx, h * 0.48, wz, cell, h * 0.96, cell, earth) + for row in range(4): + zz = wz + (row - 1.5) * 0.18 + add_cuboid(buf, wx, h - 0.02, zz, cell * 0.88, 0.035, 0.06, crop) + for i in range(8): + sx = wx + (i - 3.5) * 0.1 + (_j(gx, gz, i) - 0.5) * 0.04 + add_cuboid( + buf, + sx, + h + 0.04 * _j(gx, gz, 200 + i), + wz + (_j(gx, gz, 210 + i) - 0.5) * 0.35, + 0.04, + 0.06 + 0.05 * _j(gx, gz, 220 + i), + 0.04, + (0.58, 0.72, 0.28), + ) + + else: + rgb = (0.32, 0.34, 0.36) + add_cuboid(buf, wx, h * 0.5, wz, cell, h, cell, rgb) + + +def _mix3( + a: tuple[float, float, float], b: tuple[float, float, float], t: float +) -> tuple[float, float, float]: + t = min(1.0, max(0.0, t)) + return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) + + +def add_resource_prop( + buf: list[float], + wx: float, + wz: float, + base_h: float, + layer: int, + amount: int, + gx: int, + gz: int, +) -> None: + """Над поверхностью клетки (base_h): модель запаса леса / камня / поля.""" + if int(layer) == int(ResourceLayer.NONE) or amount <= 0: + return + sc = min(1.0, 0.2 + 0.8 * (amount / (amount + 30.0))) + y = float(base_h) + 0.02 + + if int(layer) == int(ResourceLayer.FOREST): + wood = (0.55, 0.40, 0.26) + wood2 = (0.42, 0.32, 0.22) + for k in range(max(2, int(4 * sc))): + ox = wx + (k - 1.5) * 0.12 * sc + (_j(gx, gz, 300 + k) - 0.5) * 0.06 + oz = wz + (_j(gx, gz, 310 + k) - 0.5) * 0.1 + ly = y + k * 0.045 * sc + add_cuboid( + buf, + ox, + ly + 0.03 * sc, + oz, + 0.22 * sc, + 0.055 * sc, + 0.07 * sc, + wood if k % 2 == 0 else wood2, + ) + add_cuboid(buf, wx, y + 0.02, wz, 0.12 * sc, 0.04 * sc, 0.12 * sc, (0.22, 0.38, 0.18)) + + elif int(layer) == int(ResourceLayer.STONE): + ore = (0.62, 0.58, 0.72) + ore_d = (0.42, 0.38, 0.52) + cry = (0.75, 0.72, 0.88) + add_sphere(buf, wx, y + 0.08 * sc, wz, 0.1 * sc, ore_d, stacks=4, slices=7) + add_pyramid(buf, wx, y, wz, 0.12 * sc, y + 0.22 * sc, ore) + add_pyramid( + buf, + wx - 0.1 * sc, + y + 0.04, + wz + 0.08 * sc, + 0.06 * sc, + y + 0.18 * sc, + cry, + ) + add_pyramid( + buf, + wx + 0.11 * sc, + y + 0.03, + wz - 0.07 * sc, + 0.05 * sc, + y + 0.16 * sc, + _mix3(ore, cry, 0.5), + ) + + elif int(layer) == int(ResourceLayer.FIELD): + gold = (0.88, 0.72, 0.22) + gold2 = (0.72, 0.58, 0.18) + n = max(3, int(7 * sc)) + for i in range(n): + sx = wx + (i - n * 0.5 + 0.5) * 0.09 * sc + sh = (0.1 + 0.12 * _j(gx, gz, 400 + i)) * sc + add_cuboid(buf, sx, y + sh * 0.5, wz + (_j(gx, gz, 410 + i) - 0.5) * 0.12, 0.03, sh, 0.03, gold if i % 2 == 0 else gold2) + add_cuboid(buf, wx, y - 0.01, wz, 0.35 * sc, 0.025, 0.28 * sc, (0.62, 0.52, 0.28)) From 61aeffcb306775b69ea826c2129d974693575489 Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Tue, 31 Mar 2026 09:53:55 +0300 Subject: [PATCH 04/11] Implement MAPPO training framework and enhance bot observation in Village AI War - Introduced a new MAPPO training stage with decentralized actor and centralized critic for improved multi-agent coordination. - Added MAPPO-specific configurations and training scripts to support self-play and opponent pool management. - Enhanced bot observation logic to include both local and global state information for better decision-making. - Updated README to reflect new MAPPO features and usage instructions. - Refactored existing code to accommodate the new training environment and policies. - Added tests for MAPPO layout, environment, and policy to ensure functionality and performance. --- .gitignore | 2 + README.md | 52 +++- configs/training/train_mappo_bots.yaml | 31 +++ scripts/run_training.py | 5 +- .../agents/village_obs_builder.py | 23 +- src/village_ai_war/env/game_env.py | 62 ++++- src/village_ai_war/models/mappo_actor.py | 62 +++++ src/village_ai_war/models/mappo_critic.py | 53 +++++ src/village_ai_war/models/mappo_layout.py | 40 ++++ src/village_ai_war/models/mappo_policy.py | 108 +++++++++ .../training/global_state_callback.py | 22 ++ src/village_ai_war/training/mappo_env.py | 223 ++++++++++++++++++ .../training/train_mappo_bots.py | 124 ++++++++++ tests/test_mappo_baseline.py | 219 +++++++++++++++++ 14 files changed, 1008 insertions(+), 18 deletions(-) create mode 100644 configs/training/train_mappo_bots.yaml create mode 100644 src/village_ai_war/models/mappo_actor.py create mode 100644 src/village_ai_war/models/mappo_critic.py create mode 100644 src/village_ai_war/models/mappo_layout.py create mode 100644 src/village_ai_war/models/mappo_policy.py create mode 100644 src/village_ai_war/training/global_state_callback.py create mode 100644 src/village_ai_war/training/mappo_env.py create mode 100644 src/village_ai_war/training/train_mappo_bots.py create mode 100644 tests/test_mappo_baseline.py diff --git a/.gitignore b/.gitignore index afca9d2..26a25f1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ checkpoints/ wandb/ outputs/ .hydra/ +*.egg-info +.cursor/ \ No newline at end of file diff --git a/README.md b/README.md index dddcdba..1a3207f 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,21 @@ Custom Gymnasium environment for hierarchical multi-agent reinforcement learning. The **baseline is fully RL-driven**: low-level units are controlled by a learned policy (no movement heuristics), and both teams can be trained with **self-play** against pools of past checkpoints. +### Research baselines (bots) + +| | Baseline v0 (legacy stage 1) | Baseline v1 (MAPPO, stage 4) | +|---|------------------------------|--------------------------------| +| Algorithm | PPO + `RoleConditionedPolicy` (shared actor–critic on local obs) | PPO + `MAPPOPolicy`: decentralized actor, **centralized critic** on global map + both villages | +| Coordination | Each bot optimizes its own value signal | Shared critic sees full state (CTDE-style training) | +| Observation | `BotObsBuilder` only (181-dim) | Concatenation: local bot vector + flattened global map (team-0 POV) + both village vectors (see `MAPPOBotEnv`) | + +**Baseline v0 (failed for the full game):** villages often **starved before the economy phase** because local critics did not coordinate gatherers/farmers; other issues included reward plumbing and map scale. That stack remains available as **stage 1** for ablations, but **v1 (MAPPO)** is the recommended bot baseline for comparing new architectures. + ## Architecture -- **Bot agents (low level):** One **role-conditioned** PPO policy (`RoleConditionedPolicy`) — shared backbone plus a learned role embedding from the observation one-hot (see `BotObsBuilder`). All roles share weights. +- **Bot agents (low level):** Default legacy path uses one **role-conditioned** PPO policy (`RoleConditionedPolicy`) — shared backbone plus a learned role embedding from the observation one-hot (see `BotObsBuilder`). All roles share weights. **MAPPO path (stage 4):** `MAPPOPolicy` with `MAPPOActorExtractor` on the local slice and `MAPPOCentralizedCritic` on the global tail of the observation. The self-play pool still loads standard **181-dim** PPO checkpoints for the opponent; the trained MAPPO policy expects the **extended** observation from `MAPPOBotEnv`. - **Village agent (high level):** Strategic manager — **MaskablePPO** with invalid-action masking (`MultiInputPolicy` on dict observations). -- **Self-play:** Stage 1 samples opponents from `checkpoints/pool/bots/`; stage 2 samples village opponents from `checkpoints/pool/village/`. Empty pools fall back to random opponent actions. +- **Self-play:** Stages 1 and 4 sample bot opponents from `checkpoints/pool/bots/`; stage 2 samples village opponents from `checkpoints/pool/village/`. Empty pools fall back to random opponent actions. - **Unified training:** One Hydra entry point (`training=train_unified`, `training.stage=0`) alternates PPO on bots and MaskablePPO on the red manager. Each environment step matches village self-play order (all bots act, then both managers). Blue bots and blue manager are sampled from the same pools; the non-training partner on red is frozen from the last saved checkpoint until the next phase. - **Reward shaping:** Dynamic global reward modes controlled by the village agent (unchanged). @@ -52,9 +62,26 @@ On **Linux**, Mesa packages expose `libGL.so.1` (not `libGL.so`); this project p ### Training (Hydra) -The default config composes `training: train_bots_selfplay` (see [`configs/default.yaml`](configs/default.yaml)). Stages are selected with `training.stage` (`0` = unified, `1`–`3` = legacy pipeline). +The default config composes `training: train_bots_selfplay` (see [`configs/default.yaml`](configs/default.yaml)). Stages are selected with `training.stage` (`0` = unified, `1`–`3` = legacy pipeline, `4` = MAPPO bots). + +**Stage 4 — MAPPO bot self-play (Baseline v1)** + +Centralized critic + parameter sharing with round-robin control of allied bots per env step (`MAPPOBotEnv`). Checkpoints under `checkpoints/bots_mappo/`; pool snapshots `checkpoints/pool/bots/mappo_bot_iter*.zip`. Uses **TensorBoard** only (`logging.use_tensorboard` in [`configs/training/train_mappo_bots.yaml`](configs/training/train_mappo_bots.yaml); scalars under `logs/mappo_bots/`). + +Opponent `PPO` policies must use the **same 181-dim** `Box` as plain bot mode; MAPPO learner zips (extended observation) are **skipped** when sampling the pool so `predict` never sees a shape mismatch. If nothing matches, opponents act at random until a compatible checkpoint appears (e.g. stage 1 / unified bot zips). + +```bash +python scripts/run_training.py training=train_mappo_bots +``` + +Smoke run: + +```bash +python scripts/run_training.py training=train_mappo_bots \ + training.total_timesteps=2000 training.n_envs=1 training.selfplay_iterations=2 +``` -**Stage 1 — bot self-play (role-conditioned PPO)** +**Stage 1 — bot self-play (role-conditioned PPO, Baseline v0)** ```bash python scripts/run_training.py training.stage=1 @@ -92,7 +119,7 @@ Configure macro steps with `unified.bot_steps_per_turn`, `unified.village_steps_ The bot phase uses `DummyVecEnv` only so every sub-env shares the in-process `bot_policy_holder`; do not use `SubprocVecEnv` for that phase. Default `game.initial_bots: 1` matches stage 1; more red bots require a live model in the holder for the extra units. -Stages 1–3 remain a supported alternative pipeline. +Stages 1–3 remain a supported alternative pipeline; stage 4 is the MAPPO baseline for multi-bot coordination research. **Useful overrides** @@ -102,7 +129,7 @@ python scripts/run_training.py training.total_timesteps=2000 training.n_envs=1 ### Metrics (TensorBoard) -With `logging.use_tensorboard: true` (default) and `tensorboard` installed, stage 1 and 2 write training scalars under `logs/bots/` and `logs/village/`; unified training writes under `logs/unified_bots/` and `logs/unified_village/`. Periodic evaluation logs `eval/mean_reward` (and related fields) under `logs/bots_eval/` and `logs/village_eval/` when `training.eval_freq > 0` (default `10000` **environment timesteps** between evals; internally scaled by `n_envs` per Stable-Baselines3). The unified config sets `eval_freq: 0` by default (no separate eval pass yet). Tune with `training.n_eval_episodes`. +With `logging.use_tensorboard: true` (default) and `tensorboard` installed, stage 1 and 2 write training scalars under `logs/bots/` and `logs/village/`; stage 4 (MAPPO) under `logs/mappo_bots/`; unified training writes under `logs/unified_bots/` and `logs/unified_village/`. Periodic evaluation logs `eval/mean_reward` (and related fields) under `logs/bots_eval/` and `logs/village_eval/` when `training.eval_freq > 0` (default `10000` **environment timesteps** between evals; internally scaled by `n_envs` per Stable-Baselines3). The unified config sets `eval_freq: 0` by default (no separate eval pass yet). Tune with `training.n_eval_episodes`. ```bash tensorboard --logdir logs/ @@ -129,7 +156,8 @@ python scripts/evaluate.py | Path | Contents | |------|-----------| | `checkpoints/bots/bot_final.zip` | Stage 1 policy (best eval mean reward when `training.eval_freq > 0`, else last iteration) | -| `checkpoints/pool/bots/*.zip` | Historical bot policies for self-play | +| `checkpoints/bots_mappo/mappo_bot_final.zip` | Stage 4 MAPPO policy (last save after all self-play iterations) | +| `checkpoints/pool/bots/*.zip` | Historical bot policies for self-play (includes `mappo_bot_iter*.zip` when using stage 4) | | `checkpoints/village/village_final.zip` | Stage 2 manager (same best-vs-last rule as bots) | | `checkpoints/pool/village/*.zip` | Historical village policies for self-play | | `checkpoints/joint/joint_final.zip` | Stage 3 output | @@ -140,10 +168,13 @@ python scripts/evaluate.py ## Project layout (RL baseline) -- [`src/village_ai_war/env/game_env.py`](src/village_ai_war/env/game_env.py) — `step`, `step_with_opponent`, `step_village_only`, optional `game.bot_rl_checkpoint` / `training.bot_checkpoint` for frozen bot PPO +- [`src/village_ai_war/env/game_env.py`](src/village_ai_war/env/game_env.py) — `step`, `step_with_opponent`, `step_village_only`, `queue_bot_action`, `_simulation_tick` (MAPPO tick path), optional `game.bot_rl_checkpoint` / `training.bot_checkpoint` for frozen bot PPO +- [`src/village_ai_war/agents/village_obs_builder.py`](src/village_ai_war/agents/village_obs_builder.py) — `build_map`, `build_village_vec` (global critic / MAPPO) - [`src/village_ai_war/models/role_conditioned_policy.py`](src/village_ai_war/models/role_conditioned_policy.py) +- [`src/village_ai_war/models/mappo_actor.py`](src/village_ai_war/models/mappo_actor.py), [`mappo_critic.py`](src/village_ai_war/models/mappo_critic.py), [`mappo_policy.py`](src/village_ai_war/models/mappo_policy.py), [`mappo_layout.py`](src/village_ai_war/models/mappo_layout.py) - [`src/village_ai_war/training/self_play_env.py`](src/village_ai_war/training/self_play_env.py) — `SelfPlayBotEnv`, `SelfPlayVillageEnv`, `UnifiedBotSelfPlayEnv` -- [`src/village_ai_war/training/train_bots_selfplay.py`](src/village_ai_war/training/train_bots_selfplay.py), [`train_village_selfplay.py`](src/village_ai_war/training/train_village_selfplay.py), [`train_joint.py`](src/village_ai_war/training/train_joint.py), [`train_unified.py`](src/village_ai_war/training/train_unified.py), [`tensorboard_plots.py`](src/village_ai_war/training/tensorboard_plots.py), [`plot_tensorboard_scalars.py`](scripts/plot_tensorboard_scalars.py) +- [`src/village_ai_war/training/mappo_env.py`](src/village_ai_war/training/mappo_env.py) — `MAPPOBotEnv`; [`global_state_callback.py`](src/village_ai_war/training/global_state_callback.py) (optional diagnostics from `info`) +- [`src/village_ai_war/training/train_bots_selfplay.py`](src/village_ai_war/training/train_bots_selfplay.py), [`train_mappo_bots.py`](src/village_ai_war/training/train_mappo_bots.py), [`train_village_selfplay.py`](src/village_ai_war/training/train_village_selfplay.py), [`train_joint.py`](src/village_ai_war/training/train_joint.py), [`train_unified.py`](src/village_ai_war/training/train_unified.py), [`tensorboard_plots.py`](src/village_ai_war/training/tensorboard_plots.py), [`plot_tensorboard_scalars.py`](scripts/plot_tensorboard_scalars.py) Legacy trainers [`train_bots.py`](src/village_ai_war/training/train_bots.py) and [`train_village.py`](src/village_ai_war/training/train_village.py) are not used by [`scripts/run_training.py`](scripts/run_training.py). @@ -154,7 +185,8 @@ Legacy trainers [`train_bots.py`](src/village_ai_war/training/train_bots.py) and - [x] Economy / combat / building systems - [x] Observation builders and action masker - [x] GameEnv (Gymnasium), no bot heuristics -- [x] Role-conditioned bot policy + PPO self-play (stage 1) +- [x] Role-conditioned bot policy + PPO self-play (stage 1, Baseline v0) +- [x] MAPPO bot baseline — centralized critic, concat global obs, stage 4 (`train_mappo_bots`) - [x] Village MaskablePPO self-play with RL bots (stage 2) - [x] Joint fine-tuning with RL bots (stage 3) - [x] Unified training loop (alternating bot PPO + village MaskablePPO) diff --git a/configs/training/train_mappo_bots.yaml b/configs/training/train_mappo_bots.yaml new file mode 100644 index 0000000..7d859a2 --- /dev/null +++ b/configs/training/train_mappo_bots.yaml @@ -0,0 +1,31 @@ +# @package _global_ +# MAPPO baseline (stage 4): centralized critic + concat global obs. +# Use: python scripts/run_training.py training=train_mappo_bots +training: + stage: 4 + algorithm: mappo + total_timesteps: 3000000 + selfplay_iterations: 30 + n_envs: 8 + n_steps: 512 + learning_rate: 3.0e-4 + batch_size: 256 + n_epochs: 10 + gamma: 0.99 + gae_lambda: 0.95 + clip_range: 0.2 + ent_coef: 0.01 + vf_coef: 0.5 + critic_hidden_dim: 256 + pool_max_size: 15 + checkpoint_interval: 100000 + opponent_sampling: uniform + pool_dir: checkpoints/pool + checkpoint_dir: checkpoints + log_dir: logs + +game: + initial_bots: 4 + +logging: + use_tensorboard: true diff --git a/scripts/run_training.py b/scripts/run_training.py index 33bf63e..c370332 100644 --- a/scripts/run_training.py +++ b/scripts/run_training.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Launch training stages via Hydra (``training.stage`` 0 unified, 1–3 legacy).""" +"""Launch training stages via Hydra (``training.stage``: 0 unified, 1–3 legacy, 4 MAPPO bots).""" from __future__ import annotations @@ -18,6 +18,7 @@ from village_ai_war.training.train_bots_selfplay import run_bots_selfplay_training from village_ai_war.training.train_joint import run_joint_training +from village_ai_war.training.train_mappo_bots import run_mappo_bots_training from village_ai_war.training.train_unified import run_unified_training from village_ai_war.training.train_village_selfplay import run_village_selfplay_training @@ -68,6 +69,8 @@ def main(cfg: DictConfig) -> None: run_village_selfplay_training(cfg) elif stage == 3: run_joint_training(cfg) + elif stage == 4: + run_mappo_bots_training(cfg) else: raise ValueError(f"Unknown training.stage={stage}") diff --git a/src/village_ai_war/agents/village_obs_builder.py b/src/village_ai_war/agents/village_obs_builder.py index a8a8bc8..6d9471b 100644 --- a/src/village_ai_war/agents/village_obs_builder.py +++ b/src/village_ai_war/agents/village_obs_builder.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Any - import numpy as np from village_ai_war.state import BuildingType, GameState, TerrainType @@ -39,8 +37,8 @@ class VillageObsBuilder: def __init__(self, map_size: int) -> None: self.map_size = map_size - def build(self, state: GameState, team: int) -> dict[str, np.ndarray]: - """Return dict with ``"map"`` and ``"village"`` arrays.""" + def build_map(self, state: GameState, team: int) -> np.ndarray: + """Map tensor ``(N, N, C)`` in ``[0, 1]``; ally/enemy are relative to ``team``.""" n = state.map_size terr = np.asarray(state.terrain, dtype=np.float32) / float(max(TerrainType)) res = np.asarray(state.resources, dtype=np.float32) / 4.0 @@ -76,6 +74,14 @@ def build(self, state: GameState, team: int) -> dict[str, np.ndarray]: en_u = np.clip(en_u / pop_cap, 0.0, 1.0) mp = np.stack([terr, res, ally_b, en_b, ally_u, en_u], axis=-1) + return mp.astype(np.float32) + + def build_village_vec(self, state: GameState, team: int) -> np.ndarray: + """Village state vector (``VEC_DIM``,) in ``[0, 1]`` for ``team``.""" + n = state.map_size + village = state.villages[team] + enemy = state.villages[1 - team] + pop_cap = max(village.pop_cap, 1) vec = np.zeros((self.VEC_DIM,), dtype=np.float32) vec[0] = float(np.clip(village.resources.wood / 500.0, 0.0, 1.0)) @@ -98,4 +104,11 @@ def build(self, state: GameState, team: int) -> dict[str, np.ndarray]: vec[16] = 1.0 - float(village.spawn_queue_ticks_remaining) / 10.0 vec[16] = float(np.clip(vec[16], 0.0, 1.0)) - return {"map": mp.astype(np.float32), "village": vec} + return vec + + def build(self, state: GameState, team: int) -> dict[str, np.ndarray]: + """Return dict with ``"map"`` and ``"village"`` arrays.""" + return { + "map": self.build_map(state, team), + "village": self.build_village_vec(state, team), + } diff --git a/src/village_ai_war/env/game_env.py b/src/village_ai_war/env/game_env.py index 6183070..9b01cd0 100644 --- a/src/village_ai_war/env/game_env.py +++ b/src/village_ai_war/env/game_env.py @@ -75,6 +75,7 @@ def __init__( self._tick_food_by_bot: dict[int, int] = {} self._tick_builder_repair_pct: dict[int, float] = {} self._shaping_snapshot: dict[str, Any] = {} + self._melee_intents: list[tuple[int, int, tuple[int, int]]] = [] n = int(config["map"]["size"]) max_bots = int(config["game"].get("max_bots_for_role_change", 32)) @@ -103,6 +104,15 @@ def __init__( else: raise ValueError(f"Unknown mode {mode}") + @property + def game_state(self) -> GameState | None: + """Current simulation state (``None`` before ``reset``).""" + return self._state + + def begin_mappo_tick(self) -> None: + """Clear pending melee intents before MAPPO queues bot actions for one simulation tick.""" + self._melee_intents.clear() + def reset( self, *, @@ -176,6 +186,21 @@ def step(self, action: Any) -> tuple[Any, SupportsFloat, bool, bool, dict[str, A learner_bot_action=learner_bot_action, ) + def _simulation_tick( + self, + *, + manager_action: dict[str, Any] | None, + learner_bot_action: int | None, + ) -> tuple[Any, float, bool, bool, dict[str, Any]]: + """Run combat/economy/buildings after bot actions queued via :meth:`queue_bot_action`.""" + out = self._run_simulation_phase( + self._melee_intents, + manager_action=manager_action, + learner_bot_action=learner_bot_action, + ) + self._melee_intents.clear() + return out + def step_with_opponent( self, red_action: int, @@ -203,6 +228,10 @@ def step_with_opponent( learner_bot_action=int(red_action), ) + def queue_bot_action(self, team: int, bot_id: int, action: int) -> None: + """Queue one bot action; melee intent goes to the instance buffer (MAPPO tick).""" + self._apply_bot_action_to_list(team, bot_id, int(action), self._melee_intents) + def step_village_only( self, red_village_action: int, @@ -363,8 +392,15 @@ def _ensure_bot_policy_loaded(self) -> None: except Exception as e: # noqa: BLE001 logger.warning("Failed to load bot policy from {}: {}", path, e) - def _get_single_bot_obs(self, bot_id: int) -> np.ndarray: + def _get_single_bot_obs(self, bot_id: int, team: int | None = None) -> np.ndarray: assert self._state is not None + if team is not None: + bot = next( + (b for b in self._state.villages[team].bots if b.bot_id == bot_id), + None, + ) + if bot is None: + return np.zeros((BotObsBuilder.OBS_DIM,), dtype=np.float32) return self._bot_obs.build(self._state, bot_id, self.config) def _get_bot_obs(self, team: int) -> np.ndarray | None: @@ -419,6 +455,19 @@ def _advance_tick_after_bots( *, manager_action: dict[str, Any] | None, learner_bot_action: int | None, + ) -> tuple[Any, float, bool, bool, dict[str, Any]]: + return self._run_simulation_phase( + melee_intents, + manager_action=manager_action, + learner_bot_action=learner_bot_action, + ) + + def _run_simulation_phase( + self, + melee_intents: list[tuple[int, int, tuple[int, int]]], + *, + manager_action: dict[str, Any] | None, + learner_bot_action: int | None, ) -> tuple[Any, float, bool, bool, dict[str, Any]]: assert self._state is not None state = self._state @@ -427,7 +476,7 @@ def _advance_tick_after_bots( self._shaping_snapshot = GameEnv._build_shaping_snapshot(state) - cmb = CombatSystem.apply_melee_intents(state, self.config, melee_intents) + cmb = CombatSystem.apply_melee_intents(state, self.config, list(melee_intents)) eco = EconomySystem.step(state, self.config) bld = BuildingSystem.construction_tick(state, self.config) tw = CombatSystem.apply_tower_fire(state, self.config) @@ -579,6 +628,15 @@ def _apply_bot_action( bot_id: int, action: int, melee_intents: list[tuple[int, int, tuple[int, int]]], + ) -> None: + self._apply_bot_action_to_list(team, bot_id, action, melee_intents) + + def _apply_bot_action_to_list( + self, + team: int, + bot_id: int, + action: int, + melee_intents: list[tuple[int, int, tuple[int, int]]], ) -> None: assert self._state is not None bot = next( diff --git a/src/village_ai_war/models/mappo_actor.py b/src/village_ai_war/models/mappo_actor.py new file mode 100644 index 0000000..8f7da99 --- /dev/null +++ b/src/village_ai_war/models/mappo_actor.py @@ -0,0 +1,62 @@ +"""MAPPO actor feature extractor (local bot obs only; ignores concatenated global tail).""" + +from __future__ import annotations + +import gymnasium as gym +import numpy as np +import torch +import torch.nn as nn +from stable_baselines3.common.torch_layers import BaseFeaturesExtractor + +from village_ai_war.agents.bot_obs_builder import BotObsBuilder +from village_ai_war.models.mappo_layout import mappo_local_dim + +# Role one-hot in BotObsBuilder: indices 98:102 (same as RoleConditionedExtractor). +_ROLE_START = 98 +_ROLE_END = 102 +_BACKBONE_DIM = _ROLE_START + (BotObsBuilder.OBS_DIM - _ROLE_END) + + +class MAPPOActorExtractor(BaseFeaturesExtractor): + """Role-conditioned trunk on the first ``local_dim`` components of the observation vector.""" + + def __init__( + self, + observation_space: gym.spaces.Box, + features_dim: int = 128, + backbone_hidden: int = 128, + role_embed_dim: int = 16, + n_roles: int = 4, + local_dim: int | None = None, + ) -> None: + obs_dim = int(np.prod(observation_space.shape)) + ld = int(local_dim) if local_dim is not None else mappo_local_dim() + if obs_dim < ld: + raise ValueError(f"Expected observation dim >= {ld}, got {obs_dim}") + super().__init__(observation_space, features_dim) + self._local_dim = ld + + self.backbone = nn.Sequential( + nn.Linear(_BACKBONE_DIM, backbone_hidden), + nn.LayerNorm(backbone_hidden), + nn.ReLU(), + nn.Linear(backbone_hidden, backbone_hidden), + nn.LayerNorm(backbone_hidden), + nn.ReLU(), + ) + self.role_embedding = nn.Embedding(n_roles, role_embed_dim) + self.head = nn.Sequential( + nn.Linear(backbone_hidden + role_embed_dim, features_dim), + nn.ReLU(), + ) + + def forward(self, observations: torch.Tensor) -> torch.Tensor: + local = observations[:, : self._local_dim] + role_oh = local[:, _ROLE_START:_ROLE_END] + role_ids = role_oh.argmax(dim=1).clamp(0, self.role_embedding.num_embeddings - 1) + rest_before = local[:, :_ROLE_START] + rest_after = local[:, _ROLE_END:] + obs_no_role = torch.cat([rest_before, rest_after], dim=1) + hidden = self.backbone(obs_no_role) + role_vec = self.role_embedding(role_ids) + return self.head(torch.cat([hidden, role_vec], dim=1)) diff --git a/src/village_ai_war/models/mappo_critic.py b/src/village_ai_war/models/mappo_critic.py new file mode 100644 index 0000000..2d1cffc --- /dev/null +++ b/src/village_ai_war/models/mappo_critic.py @@ -0,0 +1,53 @@ +"""Centralized MAPPO critic: global map tensor + both village vectors.""" + +from __future__ import annotations + +import torch +import torch.nn as nn + + +class MAPPOCentralizedCritic(nn.Module): + """Value network over full map (CNN) and concatenated village state (MLP).""" + + def __init__( + self, + map_shape: tuple[int, int, int], + village_vec_dim: int, + hidden_dim: int = 256, + ) -> None: + super().__init__() + n, n2, c = map_shape + if n != n2: + raise ValueError("Map must be square") + self.map_shape = map_shape + + self.map_encoder = nn.Sequential( + nn.Conv2d(c, 32, kernel_size=3, padding=1), + nn.ReLU(), + nn.Conv2d(32, 64, kernel_size=3, padding=1), + nn.ReLU(), + nn.AdaptiveAvgPool2d((4, 4)), + nn.Flatten(), + ) + map_out_dim = 64 * 4 * 4 + + self.village_encoder = nn.Sequential( + nn.Linear(village_vec_dim, hidden_dim // 2), + nn.ReLU(), + ) + + self.value_head = nn.Sequential( + nn.Linear(map_out_dim + hidden_dim // 2, hidden_dim), + nn.ReLU(), + nn.Linear(hidden_dim, hidden_dim // 2), + nn.ReLU(), + nn.Linear(hidden_dim // 2, 1), + ) + + def forward(self, map_obs: torch.Tensor, village_obs: torch.Tensor) -> torch.Tensor: + # map_obs: (B, H, W, C) -> (B, C, H, W) + x = map_obs.permute(0, 3, 1, 2) + map_features = self.map_encoder(x) + village_features = self.village_encoder(village_obs) + combined = torch.cat([map_features, village_features], dim=1) + return self.value_head(combined) diff --git a/src/village_ai_war/models/mappo_layout.py b/src/village_ai_war/models/mappo_layout.py new file mode 100644 index 0000000..d22c79c --- /dev/null +++ b/src/village_ai_war/models/mappo_layout.py @@ -0,0 +1,40 @@ +"""Observation layout for MAPPO: local bot vector + flattened global map + two village vectors.""" + +from __future__ import annotations + +import numpy as np + +from village_ai_war.agents.bot_obs_builder import BotObsBuilder +from village_ai_war.agents.village_obs_builder import VillageObsBuilder + + +def mappo_local_dim() -> int: + return int(BotObsBuilder.OBS_DIM) + + +def mappo_map_flat(map_size: int) -> int: + return int(map_size) * int(map_size) * int(VillageObsBuilder.N_CHANNELS) + + +def mappo_village_total() -> int: + return int(VillageObsBuilder.VEC_DIM) * 2 + + +def mappo_obs_dim(map_size: int) -> int: + return mappo_local_dim() + mappo_map_flat(map_size) + mappo_village_total() + + +def pack_mappo_obs( + local_obs: np.ndarray, + map_obs: np.ndarray, + village0: np.ndarray, + village1: np.ndarray, +) -> np.ndarray: + """Concatenate local bot obs, flattened map (team-0 POV), and both village vectors.""" + flat_map = np.asarray(map_obs, dtype=np.float32).reshape(-1) + v = np.concatenate( + [np.asarray(village0, dtype=np.float32), np.asarray(village1, dtype=np.float32)], + axis=0, + ) + loc = np.asarray(local_obs, dtype=np.float32).reshape(-1) + return np.concatenate([loc, flat_map, v], axis=0).astype(np.float32, copy=False) diff --git a/src/village_ai_war/models/mappo_policy.py b/src/village_ai_war/models/mappo_policy.py new file mode 100644 index 0000000..25120b4 --- /dev/null +++ b/src/village_ai_war/models/mappo_policy.py @@ -0,0 +1,108 @@ +"""MAPPO policy: decentralized actor features + centralized critic on global tail.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import torch as th +from gymnasium import spaces +from stable_baselines3.common.policies import ActorCriticPolicy +from stable_baselines3.common.type_aliases import PyTorchObs, Schedule + +from village_ai_war.models.mappo_actor import MAPPOActorExtractor +from village_ai_war.models.mappo_critic import MAPPOCentralizedCritic +from village_ai_war.models.mappo_layout import mappo_local_dim, mappo_map_flat, mappo_village_total + + +class MAPPOPolicy(ActorCriticPolicy): + """PPO policy with actor on local bot obs and critic on global map + villages.""" + + def __init__( + self, + observation_space: spaces.Space, + action_space: spaces.Space, + lr_schedule: Schedule, + map_size: int, + critic_hidden_dim: int = 256, + *args: Any, + **kwargs: Any, + ) -> None: + self._mappo_map_size = int(map_size) + self._local_dim = mappo_local_dim() + self._map_flat = mappo_map_flat(self._mappo_map_size) + self._village_total = mappo_village_total() + tail = self._map_flat + self._village_total + expected = self._local_dim + tail + if isinstance(observation_space, spaces.Box): + od = int(np.prod(observation_space.shape)) + if od != expected: + raise ValueError( + f"MAPPOPolicy expects observation dim {expected} " + f"(local {self._local_dim} + global {tail}), got {od}" + ) + + kwargs.setdefault("features_extractor_class", MAPPOActorExtractor) + kwargs.setdefault( + "features_extractor_kwargs", + { + "features_dim": 128, + "backbone_hidden": 128, + "role_embed_dim": 16, + "n_roles": 4, + "local_dim": self._local_dim, + }, + ) + super().__init__(observation_space, action_space, lr_schedule, *args, **kwargs) + + map_shape = (self._mappo_map_size, self._mappo_map_size, 6) + self.centralized_critic = MAPPOCentralizedCritic( + map_shape=map_shape, + village_vec_dim=self._village_total, + hidden_dim=int(critic_hidden_dim), + ) + self.centralized_critic.to(self.device) + + def _global_tensors(self, obs: th.Tensor) -> tuple[th.Tensor, th.Tensor]: + rest = obs[:, self._local_dim :] + map_flat = rest[:, : self._map_flat] + vil = rest[:, self._map_flat :] + n = self._mappo_map_size + map_b = map_flat.reshape(-1, n, n, 6) + return map_b, vil + + def _centralized_values(self, obs: th.Tensor) -> th.Tensor: + map_b, vil = self._global_tensors(obs) + return self.centralized_critic(map_b, vil) + + def forward( + self, + obs: th.Tensor, + deterministic: bool = False, + ) -> tuple[th.Tensor, th.Tensor, th.Tensor]: + features = self.extract_features(obs) + latent_pi = self.mlp_extractor.forward_actor(features) + distribution = self._get_action_dist_from_latent(latent_pi) + actions = distribution.get_actions(deterministic=deterministic) + log_prob = distribution.log_prob(actions) + actions = actions.reshape((-1, *self.action_space.shape)) # type: ignore[union-attr] + values = self._centralized_values(obs) + return actions, values, log_prob + + def evaluate_actions( + self, + obs: PyTorchObs, + actions: th.Tensor, + ) -> tuple[th.Tensor, th.Tensor, th.Tensor | None]: + obs_t = obs if isinstance(obs, th.Tensor) else th.as_tensor(obs, device=self.device) + features = self.extract_features(obs_t) + latent_pi = self.mlp_extractor.forward_actor(features) + distribution = self._get_action_dist_from_latent(latent_pi) + log_prob = distribution.log_prob(actions) + entropy = distribution.entropy() + values = self._centralized_values(obs_t) + return values, log_prob, entropy + + def predict_values(self, obs: PyTorchObs) -> th.Tensor: + obs_t = obs if isinstance(obs, th.Tensor) else th.as_tensor(obs, device=self.device) + return self._centralized_values(obs_t) diff --git a/src/village_ai_war/training/global_state_callback.py b/src/village_ai_war/training/global_state_callback.py new file mode 100644 index 0000000..e65c59d --- /dev/null +++ b/src/village_ai_war/training/global_state_callback.py @@ -0,0 +1,22 @@ +"""Optional callback: last-step global_state from vectorized env infos (logging only).""" + +from __future__ import annotations + +from typing import Any + +from stable_baselines3.common.callbacks import BaseCallback + + +class GlobalStateCallback(BaseCallback): + """Stores ``global_state`` from each sub-env ``info`` (debugging; not used for critic).""" + + def __init__(self, verbose: int = 0) -> None: + super().__init__(verbose) + self.last_global_states: list[dict[str, Any] | None] = [] + + def _on_step(self) -> bool: + infos = self.locals.get("infos", []) + self.last_global_states = [ + inf.get("global_state") if isinstance(inf, dict) else None for inf in infos + ] + return True diff --git a/src/village_ai_war/training/mappo_env.py b/src/village_ai_war/training/mappo_env.py new file mode 100644 index 0000000..60865f9 --- /dev/null +++ b/src/village_ai_war/training/mappo_env.py @@ -0,0 +1,223 @@ +""" +MAPPO training env: one allied bot per PPO step (round-robin), all opponent bots, then one sim tick. + +Unlike :class:`GameEnv` bot mode, not every allied bot moves on every environment step. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any, SupportsFloat + +import gymnasium as gym +import numpy as np +from gymnasium import spaces +from loguru import logger +from stable_baselines3 import PPO + +from village_ai_war.agents.village_obs_builder import VillageObsBuilder +from village_ai_war.env.game_env import GameEnv +from village_ai_war.models.mappo_layout import mappo_obs_dim, pack_mappo_obs +from village_ai_war.state.game_state import GameState + + +def _as_plain_config(config: Mapping[str, Any] | Any) -> dict[str, Any]: + from omegaconf import OmegaConf + + if OmegaConf.is_config(config): + return OmegaConf.to_container(config, resolve=True) # type: ignore[return-value] + return dict(config) + + +class MAPPOBotEnv(gym.Env): + """MAPPO with global state concatenated to each bot observation for the centralized critic.""" + + def __init__( + self, + config: Mapping[str, Any] | Any, + team: int = 0, + opponent_pool_dir: str = "checkpoints/pool/bots", + opponent_sampling: str = "uniform", + ) -> None: + super().__init__() + self._flat_cfg = _as_plain_config(config) + n = int(self._flat_cfg["map"]["size"]) + self.inner = GameEnv(self._flat_cfg, mode="bot", team=team, render_mode=None) + self.team = int(team) + self.opponent_team = 1 - self.team + + self._local_dim = int(self.inner.observation_space.shape[0]) # BotObsBuilder.OBS_DIM + self._obs_dim = mappo_obs_dim(n) + self.observation_space = spaces.Box( + low=0.0, + high=1.0, + shape=(self._obs_dim,), + dtype=np.float32, + ) + self.action_space = self.inner.action_space + + self.opponent_pool_dir = Path(opponent_pool_dir) + self.opponent_sampling = opponent_sampling + self._opponent_policy: PPO | None = None + self._current_bot_idx: int = 0 + self._alive_bot_ids: list[int] = [] + + self.village_obs_builder = VillageObsBuilder(n) + self._load_opponent() + + def _opponent_obs_compatible(self, model: PPO) -> bool: + """Opponent policy must match ``inner`` bot Box (181-dim), not MAPPO extended obs.""" + pe = model.observation_space + ee = self.inner.observation_space + if not isinstance(pe, spaces.Box) or not isinstance(ee, spaces.Box): + return False + return tuple(int(x) for x in pe.shape) == tuple(int(x) for x in ee.shape) + + def _load_opponent(self) -> None: + self.opponent_pool_dir.mkdir(parents=True, exist_ok=True) + checkpoints = sorted(self.opponent_pool_dir.glob("*.zip")) + if not checkpoints: + self._opponent_policy = None + return + + rng = np.random.default_rng() + if self.opponent_sampling == "latest": + order = list(reversed(checkpoints)) + else: + perm = rng.permutation(len(checkpoints)) + order = [checkpoints[int(i)] for i in perm] + + self._opponent_policy = None + for ckpt in order: + try: + model = PPO.load(str(ckpt)) + except Exception as e: # noqa: BLE001 + logger.debug("Skip opponent {}: {}", ckpt.name, e) + continue + if not self._opponent_obs_compatible(model): + logger.debug( + "Skip opponent {}: policy obs {} != inner bot obs {}", + ckpt.name, + getattr(model.observation_space, "shape", None), + self.inner.observation_space.shape, + ) + continue + self._opponent_policy = model + logger.debug("Loaded opponent: {}", ckpt.name) + return + + logger.warning( + "No compatible opponent in {} (need Box shape {}); using random opponent moves", + self.opponent_pool_dir, + self.inner.observation_space.shape, + ) + + def _global_state(self, state: GameState) -> dict[str, np.ndarray]: + """Canonical team-0 map POV plus both village vectors (for critic and logging).""" + mp = self.village_obs_builder.build_map(state, team=0) + v0 = self.village_obs_builder.build_village_vec(state, team=0) + v1 = self.village_obs_builder.build_village_vec(state, team=1) + return {"map": mp, "village": np.concatenate([v0, v1], axis=0).astype(np.float32)} + + def _pack(self, local: np.ndarray, gs: dict[str, np.ndarray]) -> np.ndarray: + return pack_mappo_obs(local, gs["map"], gs["village"][:20], gs["village"][20:]) + + def _get_global_from_state(self) -> dict[str, np.ndarray]: + st = self.inner.game_state + assert st is not None + return self._global_state(st) + + def _get_current_bot_local_obs(self) -> np.ndarray | None: + st = self.inner.game_state + assert st is not None + village = st.villages[self.team] + alive = [b for b in village.bots if b.is_alive] + if not alive: + return None + self._alive_bot_ids = [b.bot_id for b in alive] + self._current_bot_idx = self._current_bot_idx % len(alive) + bot = alive[self._current_bot_idx] + return self.inner._get_single_bot_obs(bot.bot_id, self.team) + + def _step_opponent_bots(self) -> None: + st = self.inner.game_state + assert st is not None + village = st.villages[self.opponent_team] + for bot in village.bots: + if not bot.is_alive: + continue + obs_local = self.inner._get_single_bot_obs(bot.bot_id, self.opponent_team) + if self._opponent_policy is not None: + act, _ = self._opponent_policy.predict(obs_local, deterministic=False) + act_int = int(np.asarray(act).reshape(-1)[0]) + else: + act_int = int(self.inner.action_space.sample()) + self.inner.queue_bot_action(self.opponent_team, bot.bot_id, act_int) + + def reset( + self, + *, + seed: int | None = None, + options: dict[str, Any] | None = None, + ) -> tuple[np.ndarray, dict[str, Any]]: + super().reset(seed=seed) + self._current_bot_idx = 0 + _, info = self.inner.reset(seed=seed, options=options) + if self.np_random.random() < 0.1: + self._load_opponent() + gs = self._get_global_from_state() + info = dict(info) + info["global_state"] = gs + local = self._get_current_bot_local_obs() + if local is None: + local = np.zeros((self._local_dim,), dtype=np.float32) + packed = self._pack(local, gs) + return packed, info + + def step(self, action: Any) -> tuple[np.ndarray, SupportsFloat, bool, bool, dict[str, Any]]: + st0 = self.inner.game_state + assert st0 is not None + village = st0.villages[self.team] + alive = [b for b in village.bots if b.is_alive] + learner_action = int(action) + + if not alive: + gs = self._get_global_from_state() + info = {**self.inner._info_dict(), "global_state": gs} + z = np.zeros((self._local_dim,), dtype=np.float32) + return self._pack(z, gs), 0.0, True, False, info + + self.inner.snapshot_bot_positions_for_tick() + self.inner.begin_mappo_tick() + + idx = self._current_bot_idx % len(alive) + bot = alive[idx] + self.inner._controlled_bot_id = bot.bot_id + self.inner.queue_bot_action(self.team, bot.bot_id, learner_action) + self._current_bot_idx = (idx + 1) % len(alive) + + self._step_opponent_bots() + + _, reward, terminated, truncated, info = self.inner._simulation_tick( + manager_action=None, + learner_bot_action=learner_action, + ) + + gs = self._get_global_from_state() + info = dict(info) + info["global_state"] = gs + + next_local = self._get_current_bot_local_obs() + if next_local is None: + next_local = np.zeros((self._local_dim,), dtype=np.float32) + terminated = True + + packed = self._pack(next_local, gs) + return packed, reward, terminated, truncated, info + + def render(self) -> Any: + return self.inner.render() + + def close(self) -> None: + self.inner.close() diff --git a/src/village_ai_war/training/train_mappo_bots.py b/src/village_ai_war/training/train_mappo_bots.py new file mode 100644 index 0000000..a084d39 --- /dev/null +++ b/src/village_ai_war/training/train_mappo_bots.py @@ -0,0 +1,124 @@ +"""Stage 4 (MAPPO): decentralized actor + centralized critic, bot self-play pool.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any + +from loguru import logger +from omegaconf import OmegaConf +from stable_baselines3 import PPO +from stable_baselines3.common.callbacks import CheckpointCallback +from stable_baselines3.common.vec_env import DummyVecEnv, SubprocVecEnv, VecMonitor + +from village_ai_war.models.mappo_policy import MAPPOPolicy +from village_ai_war.training.mappo_env import MAPPOBotEnv +from village_ai_war.training.pool_manager import PoolManager + + +def _flat_cfg(cfg: Any) -> dict[str, Any]: + if OmegaConf.is_config(cfg): + return OmegaConf.to_container(cfg, resolve=True) # type: ignore[return-value] + return dict(cfg) + + +def _tensorboard_log_dir(flat: dict[str, Any], tcfg: dict[str, Any], subdir: str) -> str | None: + if not bool(flat.get("logging", {}).get("use_tensorboard", True)): + return None + if importlib.util.find_spec("tensorboard") is None: + logger.warning( + "logging.use_tensorboard is true but tensorboard is not installed; " + "training without tensorboard_log" + ) + return None + return str(Path(tcfg["log_dir"]) / subdir) + + +def run_mappo_bots_training(cfg: Any) -> None: + """MAPPO (PPO + centralized critic) with growing opponent checkpoint pool.""" + flat = _flat_cfg(cfg) + tcfg = flat["training"] + pool_dir = Path(tcfg["pool_dir"]) / "bots" + pool_dir.mkdir(parents=True, exist_ok=True) + checkpoint_dir = Path(tcfg["checkpoint_dir"]) / "bots_mappo" + checkpoint_dir.mkdir(parents=True, exist_ok=True) + pool_manager = PoolManager(pool_dir, max_size=int(tcfg.get("pool_max_size", 15))) + + n = int(flat["map"]["size"]) + + n_envs = int(tcfg["n_envs"]) + total = int(tcfg["total_timesteps"]) + iterations = int(tcfg.get("selfplay_iterations", 1)) + steps_per_iter = max(total // max(iterations, 1), n_envs) + + def make_env(_rank: int) -> Any: + def _init() -> MAPPOBotEnv: + return MAPPOBotEnv( + flat, + team=0, + opponent_pool_dir=str(pool_dir), + opponent_sampling=str(tcfg.get("opponent_sampling", "uniform")), + ) + + return _init + + use_subproc = n_envs > 1 + vec_env: DummyVecEnv | SubprocVecEnv = ( + SubprocVecEnv([make_env(i) for i in range(n_envs)]) + if use_subproc + else DummyVecEnv([make_env(0)]) + ) + vec_env = VecMonitor(vec_env) + + gae_lambda = float(tcfg.get("gae_lambda", 0.95)) + clip_range = float(tcfg.get("clip_range", 0.2)) + ent_coef = float(tcfg.get("ent_coef", 0.01)) + vf_coef = float(tcfg.get("vf_coef", 0.5)) + critic_h = int(tcfg.get("critic_hidden_dim", 256)) + + tb_log = _tensorboard_log_dir(flat, tcfg, "mappo_bots") + + model = PPO( + MAPPOPolicy, + vec_env, + verbose=1, + learning_rate=float(tcfg["learning_rate"]), + n_steps=int(tcfg.get("n_steps", 512)), + batch_size=int(tcfg["batch_size"]), + n_epochs=int(tcfg["n_epochs"]), + gamma=float(tcfg["gamma"]), + gae_lambda=gae_lambda, + clip_range=clip_range, + ent_coef=ent_coef, + vf_coef=vf_coef, + tensorboard_log=tb_log, + policy_kwargs={ + "map_size": n, + "critic_hidden_dim": critic_h, + }, + ) + + save_freq = max(int(tcfg.get("checkpoint_interval", 100_000)) // max(n_envs, 1), 1) + + for iteration in range(iterations): + logger.info("MAPPO self-play iteration {} / {}", iteration + 1, iterations) + checkpoint_callback = CheckpointCallback( + save_freq=save_freq, + save_path=str(checkpoint_dir), + name_prefix=f"mappo_bot_iter{iteration}", + ) + model.learn( + total_timesteps=steps_per_iter, + callback=checkpoint_callback, + reset_num_timesteps=(iteration == 0), + tb_log_name="mappo_bot_selfplay", + ) + stem = pool_dir / f"mappo_bot_iter{iteration}" + model.save(str(stem)) + pool_manager.add(Path(str(stem) + ".zip")) + + model.save(str(checkpoint_dir / "mappo_bot_final")) + logger.info("Saved MAPPO policy to {}.zip", checkpoint_dir / "mappo_bot_final") + + vec_env.close() diff --git a/tests/test_mappo_baseline.py b/tests/test_mappo_baseline.py new file mode 100644 index 0000000..720cfd5 --- /dev/null +++ b/tests/test_mappo_baseline.py @@ -0,0 +1,219 @@ +"""MAPPO layout, GameEnv MAPPO helpers, MAPPOBotEnv, and MAPPOPolicy smoke tests.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np +import pytest + +pytest.importorskip("gymnasium") +pytest.importorskip("torch") +pytest.importorskip("stable_baselines3") + +from stable_baselines3 import PPO +from stable_baselines3.common.vec_env import DummyVecEnv + +from village_ai_war.agents.village_obs_builder import VillageObsBuilder +from village_ai_war.env.game_env import GameEnv +from village_ai_war.env.map_generator import generate_initial_state +from village_ai_war.models.mappo_critic import MAPPOCentralizedCritic +from village_ai_war.models.mappo_layout import mappo_local_dim, mappo_obs_dim, pack_mappo_obs +from village_ai_war.models.mappo_policy import MAPPOPolicy +from village_ai_war.training.mappo_env import MAPPOBotEnv + + +def _tiny() -> dict[str, Any]: + return { + "map": { + "size": 12, + "seed": 0, + "resource_density": 0.1, + "mountain_density": 0.02, + "resource_capacity": {"forest": 100, "stone": 50, "field": 999}, + }, + "economy": { + "harvest_interval": 3, + "harvest_amount": 5, + "food_consumption": 1, + "hunger_damage": 5, + "bot_cost": {"wood": 50, "food": 100}, + "bot_spawn_delay": 2, + "farm_food_bonus": 0.5, + }, + "combat": { + "stats": { + "warrior": {"hp": 100, "damage": 10, "attack_range": 1}, + "gatherer": {"hp": 80, "damage": 8, "attack_range": 1}, + "farmer": {"hp": 70, "damage": 5, "attack_range": 1}, + "builder": {"hp": 80, "damage": 8, "attack_range": 1}, + }, + "tower_damage": 15, + "tower_range": 3, + }, + "buildings": { + "townhall": {"hp": 500, "cost": {}}, + "barracks": {"hp": 100, "cost": {"wood": 100}}, + "storage": {"hp": 100, "cost": {"wood": 50}}, + "farm": {"hp": 100, "cost": {"wood": 80}}, + "tower": {"hp": 100, "cost": {"stone": 100}}, + "wall": {"hp": 100, "cost": {"stone": 30}}, + "citadel": {"hp": 100, "cost": {"stone": 200, "wood": 150}}, + "citadel_pop_bonus": 5, + }, + "game": { + "max_ticks": 50, + "manager_interval": 5, + "initial_resources": {"wood": 200, "stone": 100, "food": 500}, + "initial_bots": 1, + "initial_buildings": ["barracks", "storage"], + "blueprint_adjacent_to_townhall": True, + "max_bots_for_role_change": 16, + }, + "rewards": { + "bot": { + "alpha": 0.7, + "warrior": { + "damage_dealt": 0.1, + "kill": 5.0, + "damage_taken": -0.05, + "death": -10.0, + "noop": -0.01, + }, + "gatherer": { + "resource_collected": 0.5, + "damage_taken": -0.05, + "death": -10.0, + "noop": -0.01, + }, + "farmer": { + "food_produced": 0.5, + "damage_taken": -0.05, + "death": -10.0, + "noop": -0.01, + }, + "builder": { + "block_placed": 2.0, + "repair_pct": 0.1, + "damage_taken": -0.05, + "death": -10.0, + "noop": -0.01, + }, + "global_modes": {"defend_coeff": -0.05, "attack_coeff": 0.05, "gather_coeff": 0.1}, + }, + "village": { + "economy_coeff": 0.01, + "kill_reward": 5.0, + "loss_penalty": -3.0, + "building_reward": 10.0, + "stagnation_penalty": -0.05, + "stagnation_threshold": 50, + "win": 1000.0, + "loss": -1000.0, + }, + }, + "rendering": {"cell_size": 16, "fps": 60}, + } + + +def test_village_obs_build_map_and_vec_match_build() -> None: + cfg = _tiny() + rng = np.random.default_rng(0) + st = generate_initial_state(cfg, rng) + vb = VillageObsBuilder(int(cfg["map"]["size"])) + full = vb.build(st, team=0) + mp = vb.build_map(st, team=0) + vec = vb.build_village_vec(st, team=0) + np.testing.assert_array_equal(full["map"], mp) + np.testing.assert_array_equal(full["village"], vec) + + +def test_bot_step_deterministic_regression() -> None: + """Two fresh envs with the same seed should match for several bot-mode steps.""" + cfg = _tiny() + a = GameEnv(cfg, mode="bot", team=0, render_mode=None) + b = GameEnv(cfg, mode="bot", team=0, render_mode=None) + o0, _ = a.reset(seed=99) + o1, _ = b.reset(seed=99) + np.testing.assert_array_equal(o0, o1) + for _ in range(5): + act = 0 + o0, r0, t0, tr0, i0 = a.step(act) + o1, r1, t1, tr1, i1 = b.step(act) + np.testing.assert_array_equal(o0, o1) + assert r0 == r1 and t0 == t1 and tr0 == tr1 + assert i0["tick"] == i1["tick"] + + +def test_queue_and_simulation_tick_matches_step_with_opponent() -> None: + cfg = _tiny() + cfg["game"]["initial_bots"] = 1 + e1 = GameEnv(cfg, mode="bot", team=0, render_mode=None) + e2 = GameEnv(cfg, mode="bot", team=0, render_mode=None) + e1.reset(seed=7) + e2.reset(seed=7) + o_a, r_a, t_a, tr_a, i_a = e1.step_with_opponent(0, 0) + + e2.snapshot_bot_positions_for_tick() + e2.begin_mappo_tick() + e2.queue_bot_action(0, e2._controlled_bot_id, 0) + e2.queue_bot_action(1, e2._opponent_controlled_bot_id, 0) + o_b, r_b, t_b, tr_b, i_b = e2._simulation_tick(manager_action=None, learner_bot_action=0) + + np.testing.assert_array_equal(o_a, o_b) + assert r_a == r_b and t_a == t_b and tr_a == tr_b + assert i_a["tick"] == i_b["tick"] + + +def test_mappo_layout_and_critic_shapes() -> None: + n = 12 + assert mappo_obs_dim(n) == mappo_local_dim() + n * n * 6 + 40 + import torch + + crit = MAPPOCentralizedCritic(map_shape=(n, n, 6), village_vec_dim=40, hidden_dim=64) + b = 3 + m = torch.zeros(b, n, n, 6) + v = torch.zeros(b, 40) + out = crit(m, v) + assert out.shape == (b, 1) + + +def test_mappo_bot_env_and_policy(tmp_path: Path) -> None: + cfg = _tiny() + pool = tmp_path / "bot_pool" + env = MAPPOBotEnv(cfg, team=0, opponent_pool_dir=str(pool)) + obs, info = env.reset(seed=0) + assert obs.shape == (mappo_obs_dim(12),) + assert "global_state" in info + assert info["global_state"]["map"].shape == (12, 12, 6) + obs2, _r, _t, _tr, info2 = env.step(0) + assert obs2.shape == obs.shape + assert "global_state" in info2 + env.close() + + pool2 = tmp_path / "bot_pool2" + pool2.mkdir() + venv = DummyVecEnv([lambda: MAPPOBotEnv(_tiny(), opponent_pool_dir=str(pool2))]) + model = PPO( + MAPPOPolicy, + venv, + n_steps=64, + batch_size=32, + verbose=0, + policy_kwargs={"map_size": 12, "critic_hidden_dim": 64}, + ) + obs = venv.reset() + act, _ = model.predict(obs, deterministic=True) + assert act.shape == (1,) + venv.close() + + +def test_pack_mappo_obs_roundtrip_dims() -> None: + n = 12 + loc = np.zeros((mappo_local_dim(),), dtype=np.float32) + mp = np.zeros((n, n, 6), dtype=np.float32) + v0 = np.zeros((20,), dtype=np.float32) + v1 = np.zeros((20,), dtype=np.float32) + p = pack_mappo_obs(loc, mp, v0, v1) + assert p.shape == (mappo_obs_dim(n),) From dd686098a7db7949007dd6831185e65ec1320261 Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Tue, 31 Mar 2026 10:02:44 +0300 Subject: [PATCH 05/11] Update README and training configurations for MAPPO enhancements - Clarified opponent sampling and checkpoint management in the README, specifying the need for 181-dim opponents and the organization of MAPPO training snapshots. - Adjusted training configuration to separate opponent and MAPPO-specific checkpoint directories, ensuring proper management of bot policies. - Improved logging in the MAPPO environment to provide clearer warnings about incompatible opponents and their handling during training. --- README.md | 7 +-- configs/training/train_mappo_bots.yaml | 2 + src/village_ai_war/training/mappo_env.py | 45 +++++++++++++++---- .../training/train_mappo_bots.py | 15 ++++--- 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1a3207f..7fbeb3e 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,9 @@ The default config composes `training: train_bots_selfplay` (see [`configs/defau **Stage 4 — MAPPO bot self-play (Baseline v1)** -Centralized critic + parameter sharing with round-robin control of allied bots per env step (`MAPPOBotEnv`). Checkpoints under `checkpoints/bots_mappo/`; pool snapshots `checkpoints/pool/bots/mappo_bot_iter*.zip`. Uses **TensorBoard** only (`logging.use_tensorboard` in [`configs/training/train_mappo_bots.yaml`](configs/training/train_mappo_bots.yaml); scalars under `logs/mappo_bots/`). +Centralized critic + parameter sharing with round-robin control of allied bots per env step (`MAPPOBotEnv`). Checkpoints under `checkpoints/bots_mappo/`; self-play **snapshots** go to `checkpoints/pool/bots_mappo/mappo_bot_iter*.zip` (extended obs). **Opponents** are sampled only from `checkpoints/pool/bots/*.zip` and must be **181-dim** (stage 1 / unified bot). Uses **TensorBoard** only (`logging.use_tensorboard` in [`configs/training/train_mappo_bots.yaml`](configs/training/train_mappo_bots.yaml); scalars under `logs/mappo_bots/`). -Opponent `PPO` policies must use the **same 181-dim** `Box` as plain bot mode; MAPPO learner zips (extended observation) are **skipped** when sampling the pool so `predict` never sees a shape mismatch. If nothing matches, opponents act at random until a compatible checkpoint appears (e.g. stage 1 / unified bot zips). +Files named `mappo_bot*.zip` under `pool/bots/` are **ignored** (treat as misplaced MAPPO saves). If there is no usable 181-dim checkpoint, only **SubprocVecEnv worker 0** logs a single WARNING (other workers stay silent). Add at least one stage-1-style bot zip in `pool/bots/` for real self-play. ```bash python scripts/run_training.py training=train_mappo_bots @@ -157,7 +157,8 @@ python scripts/evaluate.py |------|-----------| | `checkpoints/bots/bot_final.zip` | Stage 1 policy (best eval mean reward when `training.eval_freq > 0`, else last iteration) | | `checkpoints/bots_mappo/mappo_bot_final.zip` | Stage 4 MAPPO policy (last save after all self-play iterations) | -| `checkpoints/pool/bots/*.zip` | Historical bot policies for self-play (includes `mappo_bot_iter*.zip` when using stage 4) | +| `checkpoints/pool/bots/*.zip` | 181-dim bot policies (opponents for MAPPO and stage 1) | +| `checkpoints/pool/bots_mappo/*.zip` | MAPPO training snapshots (extended obs; not used as opponents) | | `checkpoints/village/village_final.zip` | Stage 2 manager (same best-vs-last rule as bots) | | `checkpoints/pool/village/*.zip` | Historical village policies for self-play | | `checkpoints/joint/joint_final.zip` | Stage 3 output | diff --git a/configs/training/train_mappo_bots.yaml b/configs/training/train_mappo_bots.yaml index 7d859a2..4f48426 100644 --- a/configs/training/train_mappo_bots.yaml +++ b/configs/training/train_mappo_bots.yaml @@ -20,6 +20,8 @@ training: pool_max_size: 15 checkpoint_interval: 100000 opponent_sampling: uniform + # MAPPO iter*.zip are stored here (2621-dim); opponents load from pool_dir/bots/ only (181-dim). + mappo_pool_subdir: bots_mappo pool_dir: checkpoints/pool checkpoint_dir: checkpoints log_dir: logs diff --git a/src/village_ai_war/training/mappo_env.py b/src/village_ai_war/training/mappo_env.py index 60865f9..d701367 100644 --- a/src/village_ai_war/training/mappo_env.py +++ b/src/village_ai_war/training/mappo_env.py @@ -39,6 +39,8 @@ def __init__( team: int = 0, opponent_pool_dir: str = "checkpoints/pool/bots", opponent_sampling: str = "uniform", + *, + vec_env_index: int = 0, ) -> None: super().__init__() self._flat_cfg = _as_plain_config(config) @@ -59,9 +61,12 @@ def __init__( self.opponent_pool_dir = Path(opponent_pool_dir) self.opponent_sampling = opponent_sampling + self._vec_env_index = int(vec_env_index) self._opponent_policy: PPO | None = None self._current_bot_idx: int = 0 self._alive_bot_ids: list[int] = [] + self._skip_opponent_logged: set[str] = set() + self._warned_no_compatible_opponent: bool = False self.village_obs_builder = VillageObsBuilder(n) self._load_opponent() @@ -76,9 +81,18 @@ def _opponent_obs_compatible(self, model: PPO) -> bool: def _load_opponent(self) -> None: self.opponent_pool_dir.mkdir(parents=True, exist_ok=True) - checkpoints = sorted(self.opponent_pool_dir.glob("*.zip")) + all_zips = sorted(self.opponent_pool_dir.glob("*.zip")) + # MAPPO zips live in pool/bots_mappo/; skip mappo_bot* here to avoid loading them. + checkpoints = [p for p in all_zips if not p.name.startswith("mappo_bot")] + if not all_zips: + self._opponent_policy = None + return if not checkpoints: self._opponent_policy = None + self._emit_no_opponent_message( + "opponent pool has only mappo_bot_*.zip (skipped); add 181-dim zips or use " + "pool/bots_mappo/ for MAPPO — random opponent moves", + ) return rng = np.random.default_rng() @@ -96,23 +110,36 @@ def _load_opponent(self) -> None: logger.debug("Skip opponent {}: {}", ckpt.name, e) continue if not self._opponent_obs_compatible(model): - logger.debug( - "Skip opponent {}: policy obs {} != inner bot obs {}", - ckpt.name, - getattr(model.observation_space, "shape", None), - self.inner.observation_space.shape, - ) + key = str(ckpt.resolve()) + if key not in self._skip_opponent_logged: + self._skip_opponent_logged.add(key) + logger.debug( + "Skip opponent {}: policy obs {} != inner bot obs {}", + ckpt.name, + getattr(model.observation_space, "shape", None), + self.inner.observation_space.shape, + ) continue self._opponent_policy = model + self._warned_no_compatible_opponent = False logger.debug("Loaded opponent: {}", ckpt.name) return - logger.warning( - "No compatible opponent in {} (need Box shape {}); using random opponent moves", + self._emit_no_opponent_message( + "no compatible opponent in {} (need Box shape {}); using random opponent moves", self.opponent_pool_dir, self.inner.observation_space.shape, ) + def _emit_no_opponent_message(self, msg: str, *args: Any) -> None: + """SubprocVecEnv: each process has its own memory — log only from worker 0, once (no DEBUG spam).""" + if self._vec_env_index != 0: + return + if self._warned_no_compatible_opponent: + return + self._warned_no_compatible_opponent = True + logger.warning(msg, *args) + def _global_state(self, state: GameState) -> dict[str, np.ndarray]: """Canonical team-0 map POV plus both village vectors (for critic and logging).""" mp = self.village_obs_builder.build_map(state, team=0) diff --git a/src/village_ai_war/training/train_mappo_bots.py b/src/village_ai_war/training/train_mappo_bots.py index a084d39..6cafd24 100644 --- a/src/village_ai_war/training/train_mappo_bots.py +++ b/src/village_ai_war/training/train_mappo_bots.py @@ -39,11 +39,15 @@ def run_mappo_bots_training(cfg: Any) -> None: """MAPPO (PPO + centralized critic) with growing opponent checkpoint pool.""" flat = _flat_cfg(cfg) tcfg = flat["training"] - pool_dir = Path(tcfg["pool_dir"]) / "bots" - pool_dir.mkdir(parents=True, exist_ok=True) + pool_root = Path(tcfg["pool_dir"]) + # Opponents must be 181-dim (stage 1 / unified bot); MAPPO zips go elsewhere. + opponent_pool_dir = pool_root / "bots" + opponent_pool_dir.mkdir(parents=True, exist_ok=True) + mappo_pool_dir = pool_root / str(tcfg.get("mappo_pool_subdir", "bots_mappo")) + mappo_pool_dir.mkdir(parents=True, exist_ok=True) checkpoint_dir = Path(tcfg["checkpoint_dir"]) / "bots_mappo" checkpoint_dir.mkdir(parents=True, exist_ok=True) - pool_manager = PoolManager(pool_dir, max_size=int(tcfg.get("pool_max_size", 15))) + pool_manager = PoolManager(mappo_pool_dir, max_size=int(tcfg.get("pool_max_size", 15))) n = int(flat["map"]["size"]) @@ -57,8 +61,9 @@ def _init() -> MAPPOBotEnv: return MAPPOBotEnv( flat, team=0, - opponent_pool_dir=str(pool_dir), + opponent_pool_dir=str(opponent_pool_dir), opponent_sampling=str(tcfg.get("opponent_sampling", "uniform")), + vec_env_index=_rank, ) return _init @@ -114,7 +119,7 @@ def _init() -> MAPPOBotEnv: reset_num_timesteps=(iteration == 0), tb_log_name="mappo_bot_selfplay", ) - stem = pool_dir / f"mappo_bot_iter{iteration}" + stem = mappo_pool_dir / f"mappo_bot_iter{iteration}" model.save(str(stem)) pool_manager.add(Path(str(stem) + ".zip")) From 3ecab6b47bc0e4d22f182c0b91fbcf3f9a141294 Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Tue, 31 Mar 2026 10:32:25 +0300 Subject: [PATCH 06/11] Enhance MAPPO training environment and metrics tracking - Updated the MAPPO training environment to allow all allied bots to act each tick, improving coordination and decision-making. - Introduced a new callback for tracking episode metrics, including win/loss/draw outcomes and terminal reasons, for better performance analysis. - Adjusted observation packing to support multiple local bot slots, enhancing the observation structure for the centralized critic. - Updated README and training configurations to reflect changes in observation dimensions and metrics logging. - Added tests to ensure the functionality of new features and maintain code integrity. --- README.md | 6 +- configs/training/train_mappo_bots.yaml | 4 +- src/village_ai_war/env/game_env.py | 61 +++++++++--- src/village_ai_war/models/mappo_actor.py | 26 ++++-- src/village_ai_war/models/mappo_layout.py | 33 +++++-- src/village_ai_war/models/mappo_policy.py | 10 +- src/village_ai_war/training/mappo_env.py | 93 +++++++++++-------- .../mappo_episode_metrics_callback.py | 42 +++++++++ .../training/train_mappo_bots.py | 10 +- tests/test_mappo_baseline.py | 41 +++++++- 10 files changed, 244 insertions(+), 82 deletions(-) create mode 100644 src/village_ai_war/training/mappo_episode_metrics_callback.py diff --git a/README.md b/README.md index 7fbeb3e..1336db7 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ Custom Gymnasium environment for hierarchical multi-agent reinforcement learning |---|------------------------------|--------------------------------| | Algorithm | PPO + `RoleConditionedPolicy` (shared actor–critic on local obs) | PPO + `MAPPOPolicy`: decentralized actor, **centralized critic** on global map + both villages | | Coordination | Each bot optimizes its own value signal | Shared critic sees full state (CTDE-style training) | -| Observation | `BotObsBuilder` only (181-dim) | Concatenation: local bot vector + flattened global map (team-0 POV) + both village vectors (see `MAPPOBotEnv`) | +| Observation | `BotObsBuilder` only (181-dim) | **K** stacked local bot vectors (`K = game.max_bots_for_role_change`) + flattened global map (team-0 POV) + both village vectors (see `MAPPOBotEnv`) | **Baseline v0 (failed for the full game):** villages often **starved before the economy phase** because local critics did not coordinate gatherers/farmers; other issues included reward plumbing and map scale. That stack remains available as **stage 1** for ablations, but **v1 (MAPPO)** is the recommended bot baseline for comparing new architectures. ## Architecture -- **Bot agents (low level):** Default legacy path uses one **role-conditioned** PPO policy (`RoleConditionedPolicy`) — shared backbone plus a learned role embedding from the observation one-hot (see `BotObsBuilder`). All roles share weights. **MAPPO path (stage 4):** `MAPPOPolicy` with `MAPPOActorExtractor` on the local slice and `MAPPOCentralizedCritic` on the global tail of the observation. The self-play pool still loads standard **181-dim** PPO checkpoints for the opponent; the trained MAPPO policy expects the **extended** observation from `MAPPOBotEnv`. +- **Bot agents (low level):** Default legacy path uses one **role-conditioned** PPO policy (`RoleConditionedPolicy`) — shared backbone plus a learned role embedding from the observation one-hot (see `BotObsBuilder`). All roles share weights. **MAPPO path (stage 4):** `MAPPOPolicy` with `MAPPOActorExtractor` over **K local slots** (shared trunk per slot) and `MAPPOCentralizedCritic` on the global tail. One RL step = one game tick with **all allied bots** acting (`MultiDiscrete` of size **K**); opponents still act every tick via the pool policy or random. The self-play pool loads **181-dim** PPO checkpoints for the opponent; the trained MAPPO policy expects the **extended** observation from `MAPPOBotEnv`. **Older MAPPO checkpoints** from the single-bot-per-tick layout are **not** compatible with the current stacked-obs + `MultiDiscrete` policy. - **Village agent (high level):** Strategic manager — **MaskablePPO** with invalid-action masking (`MultiInputPolicy` on dict observations). - **Self-play:** Stages 1 and 4 sample bot opponents from `checkpoints/pool/bots/`; stage 2 samples village opponents from `checkpoints/pool/village/`. Empty pools fall back to random opponent actions. - **Unified training:** One Hydra entry point (`training=train_unified`, `training.stage=0`) alternates PPO on bots and MaskablePPO on the red manager. Each environment step matches village self-play order (all bots act, then both managers). Blue bots and blue manager are sampled from the same pools; the non-training partner on red is frozen from the last saved checkpoint until the next phase. @@ -66,7 +66,7 @@ The default config composes `training: train_bots_selfplay` (see [`configs/defau **Stage 4 — MAPPO bot self-play (Baseline v1)** -Centralized critic + parameter sharing with round-robin control of allied bots per env step (`MAPPOBotEnv`). Checkpoints under `checkpoints/bots_mappo/`; self-play **snapshots** go to `checkpoints/pool/bots_mappo/mappo_bot_iter*.zip` (extended obs). **Opponents** are sampled only from `checkpoints/pool/bots/*.zip` and must be **181-dim** (stage 1 / unified bot). Uses **TensorBoard** only (`logging.use_tensorboard` in [`configs/training/train_mappo_bots.yaml`](configs/training/train_mappo_bots.yaml); scalars under `logs/mappo_bots/`). +Centralized critic + parameter sharing: **all** learner-team bots move each tick (`MAPPOBotEnv`, `MultiDiscrete` length `game.max_bots_for_role_change`). Checkpoints under `checkpoints/bots_mappo/`; self-play **snapshots** go to `checkpoints/pool/bots_mappo/mappo_bot_iter*.zip` (extended obs). **Opponents** are sampled only from `checkpoints/pool/bots/*.zip` and must be **181-dim** (stage 1 / unified bot). Uses **TensorBoard** only (`logging.use_tensorboard` in [`configs/training/train_mappo_bots.yaml`](configs/training/train_mappo_bots.yaml); scalars under `logs/mappo_bots/`). Episode diagnostics: rolling fractions under `mappo/outcome_frac/*` (win / loss / draw / truncated) and `mappo/terminal_reason_frac/*` (e.g. `townhall_destroyed`, `stagnation`, `max_ticks`), controlled by `training.mappo_metrics_window`. Files named `mappo_bot*.zip` under `pool/bots/` are **ignored** (treat as misplaced MAPPO saves). If there is no usable 181-dim checkpoint, only **SubprocVecEnv worker 0** logs a single WARNING (other workers stay silent). Add at least one stage-1-style bot zip in `pool/bots/` for real self-play. diff --git a/configs/training/train_mappo_bots.yaml b/configs/training/train_mappo_bots.yaml index 4f48426..c506cb5 100644 --- a/configs/training/train_mappo_bots.yaml +++ b/configs/training/train_mappo_bots.yaml @@ -20,8 +20,10 @@ training: pool_max_size: 15 checkpoint_interval: 100000 opponent_sampling: uniform - # MAPPO iter*.zip are stored here (2621-dim); opponents load from pool_dir/bots/ only (181-dim). + # K = game.max_bots_for_role_change (stacked locals + global). MAPPO iter*.zip go to mappo_pool_subdir; pool_dir/bots/ is 181-dim opponents only. mappo_pool_subdir: bots_mappo + # TensorBoard rolling window for mappo/outcome_frac/* and mappo/terminal_reason_frac/* + mappo_metrics_window: 512 pool_dir: checkpoints/pool checkpoint_dir: checkpoints log_dir: logs diff --git a/src/village_ai_war/env/game_env.py b/src/village_ai_war/env/game_env.py index 9b01cd0..c5fe1ce 100644 --- a/src/village_ai_war/env/game_env.py +++ b/src/village_ai_war/env/game_env.py @@ -190,13 +190,15 @@ def _simulation_tick( self, *, manager_action: dict[str, Any] | None, - learner_bot_action: int | None, + learner_bot_action: int | None = None, + learner_bot_actions: dict[int, int] | None = None, ) -> tuple[Any, float, bool, bool, dict[str, Any]]: """Run combat/economy/buildings after bot actions queued via :meth:`queue_bot_action`.""" out = self._run_simulation_phase( self._melee_intents, manager_action=manager_action, learner_bot_action=learner_bot_action, + learner_bot_actions=learner_bot_actions, ) self._melee_intents.clear() return out @@ -454,12 +456,14 @@ def _advance_tick_after_bots( melee_intents: list[tuple[int, int, tuple[int, int]]], *, manager_action: dict[str, Any] | None, - learner_bot_action: int | None, + learner_bot_action: int | None = None, + learner_bot_actions: dict[int, int] | None = None, ) -> tuple[Any, float, bool, bool, dict[str, Any]]: return self._run_simulation_phase( melee_intents, manager_action=manager_action, learner_bot_action=learner_bot_action, + learner_bot_actions=learner_bot_actions, ) def _run_simulation_phase( @@ -467,7 +471,8 @@ def _run_simulation_phase( melee_intents: list[tuple[int, int, tuple[int, int]]], *, manager_action: dict[str, Any] | None, - learner_bot_action: int | None, + learner_bot_action: int | None = None, + learner_bot_actions: dict[int, int] | None = None, ) -> tuple[Any, float, bool, bool, dict[str, Any]]: assert self._state is not None state = self._state @@ -517,7 +522,7 @@ def _run_simulation_phase( else: vil.ticks_without_progress += 1 - won = GameEnv._terminal_update(state, self.config) + won, term_reason = GameEnv._terminal_update(state, self.config) if won is not None: terminated = True if state.tick >= state.max_ticks: @@ -531,7 +536,18 @@ def _run_simulation_phase( state.tick += 1 reward: float - if self.mode == "bot" and learner_bot_action is not None: + if self.mode == "bot" and learner_bot_actions is not None: + mode = state.villages[self.team].global_reward_mode + reward = 0.0 + for bid, act in learner_bot_actions.items(): + bot = next( + (b for v in state.villages for b in v.bots if b.bot_id == bid), + None, + ) + if bot is not None: + bev = self._bot_events_for(bot, merged, int(act), state) + reward += float(BotRewardCalculator.compute(bev, bot, mode, self.config)) + elif self.mode == "bot" and learner_bot_action is not None: bot = next( ( b @@ -571,6 +587,19 @@ def _run_simulation_phase( "bots_alive": bots_alive, "winner": state.winner, } + if truncated: + info["terminal_reason"] = "max_ticks" + info["episode_outcome"] = "truncated" + elif terminated: + if term_reason is not None: + info["terminal_reason"] = term_reason + w = state.winner + if w is None: + info["episode_outcome"] = "draw" + elif w == self.team: + info["episode_outcome"] = "win" + else: + info["episode_outcome"] = "loss" return obs, reward, terminated, truncated, info def _apply_village_decision(self, team: int, dec: Mapping[str, Any]) -> None: @@ -850,28 +879,34 @@ def _bot_events_for( return ev @staticmethod - def _terminal_update(state: GameState, config: Mapping[str, Any]) -> int | None: - """Set ``is_done``/``winner`` if TH destroyed or stalemate.""" + def _terminal_update( + state: GameState, config: Mapping[str, Any] + ) -> tuple[int | None, str | None]: + """Set ``is_done``/``winner`` if TH destroyed or stalemate. + + Returns ``(winner_team_id, terminal_reason)`` when the episode ends from this + check; otherwise ``(None, None)``. ``winner_team_id`` is ``None`` for a draw. + """ for v in state.villages: ths = [b for b in v.buildings if b.building_type == BuildingType.TOWNHALL] if ths and ths[0].hp <= 0: state.is_done = True state.winner = 1 - v.team - return int(state.winner) + return int(state.winner), "townhall_destroyed" alive0 = sum(1 for b in state.villages[0].bots if b.is_alive) alive1 = sum(1 for b in state.villages[1].bots if b.is_alive) if alive0 == 0 and alive1 == 0: state.is_done = True state.winner = None - return None + return None, "mutual_elimination" if alive0 == 0: state.is_done = True state.winner = 1 - return 1 + return 1, "team0_eliminated" if alive1 == 0: state.is_done = True state.winner = 0 - return 0 + return 0, "team1_eliminated" thresh = int(config["rewards"]["village"]["stagnation_threshold"]) if state.villages[0].ticks_without_progress >= thresh and state.villages[ @@ -879,5 +914,5 @@ def _terminal_update(state: GameState, config: Mapping[str, Any]) -> int | None: ].ticks_without_progress >= thresh: state.is_done = True state.winner = None - return None - return None + return None, "stagnation" + return None, None diff --git a/src/village_ai_war/models/mappo_actor.py b/src/village_ai_war/models/mappo_actor.py index 8f7da99..4ebce51 100644 --- a/src/village_ai_war/models/mappo_actor.py +++ b/src/village_ai_war/models/mappo_actor.py @@ -18,7 +18,7 @@ class MAPPOActorExtractor(BaseFeaturesExtractor): - """Role-conditioned trunk on the first ``local_dim`` components of the observation vector.""" + """Role-conditioned trunk on the first ``K * local_dim`` components (K bot slots).""" def __init__( self, @@ -28,13 +28,20 @@ def __init__( role_embed_dim: int = 16, n_roles: int = 4, local_dim: int | None = None, + n_bot_slots: int = 1, ) -> None: obs_dim = int(np.prod(observation_space.shape)) ld = int(local_dim) if local_dim is not None else mappo_local_dim() - if obs_dim < ld: - raise ValueError(f"Expected observation dim >= {ld}, got {obs_dim}") - super().__init__(observation_space, features_dim) + k = int(n_bot_slots) + if k < 1: + raise ValueError("n_bot_slots must be >= 1") + if obs_dim < k * ld: + raise ValueError(f"Expected observation dim >= {k * ld}, got {obs_dim}") + per_bot = int(features_dim) + super().__init__(observation_space, k * per_bot) self._local_dim = ld + self._n_bot_slots = k + self._per_bot_features = per_bot self.backbone = nn.Sequential( nn.Linear(_BACKBONE_DIM, backbone_hidden), @@ -46,12 +53,16 @@ def __init__( ) self.role_embedding = nn.Embedding(n_roles, role_embed_dim) self.head = nn.Sequential( - nn.Linear(backbone_hidden + role_embed_dim, features_dim), + nn.Linear(backbone_hidden + role_embed_dim, per_bot), nn.ReLU(), ) def forward(self, observations: torch.Tensor) -> torch.Tensor: - local = observations[:, : self._local_dim] + k = self._n_bot_slots + ld = self._local_dim + bsz = observations.shape[0] + bk = bsz * k + local = observations[:, : k * ld].reshape(bk, ld) role_oh = local[:, _ROLE_START:_ROLE_END] role_ids = role_oh.argmax(dim=1).clamp(0, self.role_embedding.num_embeddings - 1) rest_before = local[:, :_ROLE_START] @@ -59,4 +70,5 @@ def forward(self, observations: torch.Tensor) -> torch.Tensor: obs_no_role = torch.cat([rest_before, rest_after], dim=1) hidden = self.backbone(obs_no_role) role_vec = self.role_embedding(role_ids) - return self.head(torch.cat([hidden, role_vec], dim=1)) + feat = self.head(torch.cat([hidden, role_vec], dim=1)) + return feat.reshape(bsz, k * self._per_bot_features) diff --git a/src/village_ai_war/models/mappo_layout.py b/src/village_ai_war/models/mappo_layout.py index d22c79c..ee0e730 100644 --- a/src/village_ai_war/models/mappo_layout.py +++ b/src/village_ai_war/models/mappo_layout.py @@ -20,21 +20,40 @@ def mappo_village_total() -> int: return int(VillageObsBuilder.VEC_DIM) * 2 -def mappo_obs_dim(map_size: int) -> int: - return mappo_local_dim() + mappo_map_flat(map_size) + mappo_village_total() +def mappo_obs_dim(map_size: int, n_bot_slots: int = 1) -> int: + return ( + int(n_bot_slots) * mappo_local_dim() + + mappo_map_flat(map_size) + + mappo_village_total() + ) -def pack_mappo_obs( - local_obs: np.ndarray, +def pack_mappo_obs_slots( + locals_k: np.ndarray, map_obs: np.ndarray, village0: np.ndarray, village1: np.ndarray, ) -> np.ndarray: - """Concatenate local bot obs, flattened map (team-0 POV), and both village vectors.""" + """Concatenate K local bot rows (K * 181), flattened map (team-0 POV), both village vectors.""" flat_map = np.asarray(map_obs, dtype=np.float32).reshape(-1) v = np.concatenate( [np.asarray(village0, dtype=np.float32), np.asarray(village1, dtype=np.float32)], axis=0, ) - loc = np.asarray(local_obs, dtype=np.float32).reshape(-1) - return np.concatenate([loc, flat_map, v], axis=0).astype(np.float32, copy=False) + locs = np.asarray(locals_k, dtype=np.float32).reshape(-1) + return np.concatenate([locs, flat_map, v], axis=0).astype(np.float32, copy=False) + + +def pack_mappo_obs( + local_obs: np.ndarray, + map_obs: np.ndarray, + village0: np.ndarray, + village1: np.ndarray, +) -> np.ndarray: + """Single-bot layout: same as ``pack_mappo_obs_slots`` with one local row.""" + return pack_mappo_obs_slots( + np.asarray(local_obs, dtype=np.float32).reshape(1, -1), + map_obs, + village0, + village1, + ) diff --git a/src/village_ai_war/models/mappo_policy.py b/src/village_ai_war/models/mappo_policy.py index 25120b4..073e861 100644 --- a/src/village_ai_war/models/mappo_policy.py +++ b/src/village_ai_war/models/mappo_policy.py @@ -25,21 +25,24 @@ def __init__( lr_schedule: Schedule, map_size: int, critic_hidden_dim: int = 256, + n_bot_slots: int = 1, *args: Any, **kwargs: Any, ) -> None: self._mappo_map_size = int(map_size) + self._n_bot_slots = int(n_bot_slots) self._local_dim = mappo_local_dim() + self._local_prefix = self._n_bot_slots * self._local_dim self._map_flat = mappo_map_flat(self._mappo_map_size) self._village_total = mappo_village_total() tail = self._map_flat + self._village_total - expected = self._local_dim + tail + expected = self._local_prefix + tail if isinstance(observation_space, spaces.Box): od = int(np.prod(observation_space.shape)) if od != expected: raise ValueError( f"MAPPOPolicy expects observation dim {expected} " - f"(local {self._local_dim} + global {tail}), got {od}" + f"(local {self._local_prefix} + global {tail}), got {od}" ) kwargs.setdefault("features_extractor_class", MAPPOActorExtractor) @@ -51,6 +54,7 @@ def __init__( "role_embed_dim": 16, "n_roles": 4, "local_dim": self._local_dim, + "n_bot_slots": self._n_bot_slots, }, ) super().__init__(observation_space, action_space, lr_schedule, *args, **kwargs) @@ -64,7 +68,7 @@ def __init__( self.centralized_critic.to(self.device) def _global_tensors(self, obs: th.Tensor) -> tuple[th.Tensor, th.Tensor]: - rest = obs[:, self._local_dim :] + rest = obs[:, self._local_prefix :] map_flat = rest[:, : self._map_flat] vil = rest[:, self._map_flat :] n = self._mappo_map_size diff --git a/src/village_ai_war/training/mappo_env.py b/src/village_ai_war/training/mappo_env.py index d701367..af2bc64 100644 --- a/src/village_ai_war/training/mappo_env.py +++ b/src/village_ai_war/training/mappo_env.py @@ -1,7 +1,7 @@ """ -MAPPO training env: one allied bot per PPO step (round-robin), all opponent bots, then one sim tick. +MAPPO training env: all allied bots act each tick, all opponent bots, then one sim tick. -Unlike :class:`GameEnv` bot mode, not every allied bot moves on every environment step. +Observation stacks K local bot vectors (``game.max_bots_for_role_change``) plus global map/village tail. """ from __future__ import annotations @@ -18,7 +18,7 @@ from village_ai_war.agents.village_obs_builder import VillageObsBuilder from village_ai_war.env.game_env import GameEnv -from village_ai_war.models.mappo_layout import mappo_obs_dim, pack_mappo_obs +from village_ai_war.models.mappo_layout import mappo_obs_dim, pack_mappo_obs_slots from village_ai_war.state.game_state import GameState @@ -31,7 +31,7 @@ def _as_plain_config(config: Mapping[str, Any] | Any) -> dict[str, Any]: class MAPPOBotEnv(gym.Env): - """MAPPO with global state concatenated to each bot observation for the centralized critic.""" + """MAPPO with K local slots + global state for centralized critic (all allies move per tick).""" def __init__( self, @@ -50,21 +50,22 @@ def __init__( self.opponent_team = 1 - self.team self._local_dim = int(self.inner.observation_space.shape[0]) # BotObsBuilder.OBS_DIM - self._obs_dim = mappo_obs_dim(n) + self._n_bot_slots = int(self._flat_cfg["game"]["max_bots_for_role_change"]) + self._obs_dim = mappo_obs_dim(n, self._n_bot_slots) self.observation_space = spaces.Box( low=0.0, high=1.0, shape=(self._obs_dim,), dtype=np.float32, ) - self.action_space = self.inner.action_space + self.action_space = spaces.MultiDiscrete( + [GameEnv.BOT_ACTIONS] * self._n_bot_slots + ) self.opponent_pool_dir = Path(opponent_pool_dir) self.opponent_sampling = opponent_sampling self._vec_env_index = int(vec_env_index) self._opponent_policy: PPO | None = None - self._current_bot_idx: int = 0 - self._alive_bot_ids: list[int] = [] self._skip_opponent_logged: set[str] = set() self._warned_no_compatible_opponent: bool = False @@ -147,26 +148,27 @@ def _global_state(self, state: GameState) -> dict[str, np.ndarray]: v1 = self.village_obs_builder.build_village_vec(state, team=1) return {"map": mp, "village": np.concatenate([v0, v1], axis=0).astype(np.float32)} - def _pack(self, local: np.ndarray, gs: dict[str, np.ndarray]) -> np.ndarray: - return pack_mappo_obs(local, gs["map"], gs["village"][:20], gs["village"][20:]) + def _locals_matrix(self, state: GameState) -> np.ndarray: + """K x local_dim; alive bots sorted by bot_id fill low rows, then zeros.""" + k = self._n_bot_slots + d = self._local_dim + out = np.zeros((k, d), dtype=np.float32) + village = state.villages[self.team] + alive = sorted((b for b in village.bots if b.is_alive), key=lambda b: int(b.bot_id)) + for i, bot in enumerate(alive[:k]): + out[i] = self.inner._get_single_bot_obs(bot.bot_id, self.team) + return out + + def _pack_slots(self, locals_k: np.ndarray, gs: dict[str, np.ndarray]) -> np.ndarray: + return pack_mappo_obs_slots( + locals_k, gs["map"], gs["village"][:20], gs["village"][20:] + ) def _get_global_from_state(self) -> dict[str, np.ndarray]: st = self.inner.game_state assert st is not None return self._global_state(st) - def _get_current_bot_local_obs(self) -> np.ndarray | None: - st = self.inner.game_state - assert st is not None - village = st.villages[self.team] - alive = [b for b in village.bots if b.is_alive] - if not alive: - return None - self._alive_bot_ids = [b.bot_id for b in alive] - self._current_bot_idx = self._current_bot_idx % len(alive) - bot = alive[self._current_bot_idx] - return self.inner._get_single_bot_obs(bot.bot_id, self.team) - def _step_opponent_bots(self) -> None: st = self.inner.game_state assert st is not None @@ -189,58 +191,67 @@ def reset( options: dict[str, Any] | None = None, ) -> tuple[np.ndarray, dict[str, Any]]: super().reset(seed=seed) - self._current_bot_idx = 0 _, info = self.inner.reset(seed=seed, options=options) if self.np_random.random() < 0.1: self._load_opponent() gs = self._get_global_from_state() + st = self.inner.game_state + assert st is not None info = dict(info) info["global_state"] = gs - local = self._get_current_bot_local_obs() - if local is None: - local = np.zeros((self._local_dim,), dtype=np.float32) - packed = self._pack(local, gs) + mat = self._locals_matrix(st) + packed = self._pack_slots(mat, gs) return packed, info def step(self, action: Any) -> tuple[np.ndarray, SupportsFloat, bool, bool, dict[str, Any]]: st0 = self.inner.game_state assert st0 is not None village = st0.villages[self.team] - alive = [b for b in village.bots if b.is_alive] - learner_action = int(action) + alive = sorted((b for b in village.bots if b.is_alive), key=lambda b: int(b.bot_id)) + + acts = np.asarray(action, dtype=np.int64).reshape(-1) + if acts.shape[0] != self._n_bot_slots: + raise ValueError( + f"Expected {self._n_bot_slots} actions, got shape {acts.shape}" + ) if not alive: gs = self._get_global_from_state() + z = np.zeros((self._n_bot_slots, self._local_dim), dtype=np.float32) info = {**self.inner._info_dict(), "global_state": gs} - z = np.zeros((self._local_dim,), dtype=np.float32) - return self._pack(z, gs), 0.0, True, False, info + return self._pack_slots(z, gs), 0.0, True, False, info self.inner.snapshot_bot_positions_for_tick() self.inner.begin_mappo_tick() - idx = self._current_bot_idx % len(alive) - bot = alive[idx] - self.inner._controlled_bot_id = bot.bot_id - self.inner.queue_bot_action(self.team, bot.bot_id, learner_action) - self._current_bot_idx = (idx + 1) % len(alive) + controlled: list[tuple[int, int]] = [] + for i, bot in enumerate(alive[: self._n_bot_slots]): + a = int(acts[i]) + self.inner.queue_bot_action(self.team, bot.bot_id, a) + controlled.append((bot.bot_id, a)) + if controlled: + self.inner._controlled_bot_id = controlled[0][0] self._step_opponent_bots() + learner_bot_actions = {bid: ac for bid, ac in controlled} _, reward, terminated, truncated, info = self.inner._simulation_tick( manager_action=None, - learner_bot_action=learner_action, + learner_bot_action=None, + learner_bot_actions=learner_bot_actions, ) gs = self._get_global_from_state() info = dict(info) info["global_state"] = gs - next_local = self._get_current_bot_local_obs() - if next_local is None: - next_local = np.zeros((self._local_dim,), dtype=np.float32) + st1 = self.inner.game_state + assert st1 is not None + mat = self._locals_matrix(st1) + if not any(b.is_alive for b in st1.villages[self.team].bots): terminated = True - packed = self._pack(next_local, gs) + packed = self._pack_slots(mat, gs) return packed, reward, terminated, truncated, info def render(self) -> Any: diff --git a/src/village_ai_war/training/mappo_episode_metrics_callback.py b/src/village_ai_war/training/mappo_episode_metrics_callback.py new file mode 100644 index 0000000..9cb1485 --- /dev/null +++ b/src/village_ai_war/training/mappo_episode_metrics_callback.py @@ -0,0 +1,42 @@ +"""TensorBoard metrics for MAPPO episode outcomes (win/loss/draw/trunc + terminal reasons).""" + +from __future__ import annotations + +from collections import Counter, deque + +from stable_baselines3.common.callbacks import BaseCallback + + +class MAPPOEpisodeMetricsCallback(BaseCallback): + """Rolling-window fractions of ``episode_outcome`` and ``terminal_reason`` from env info.""" + + def __init__(self, window: int = 512, verbose: int = 0) -> None: + super().__init__(verbose) + self._window = max(int(window), 1) + self._outcomes: deque[str] = deque(maxlen=self._window) + self._reasons: deque[str] = deque(maxlen=self._window) + + def _on_step(self) -> bool: + infos = self.locals.get("infos") + if not infos: + return True + updated = False + for info in infos: + if not isinstance(info, dict) or "episode_outcome" not in info: + continue + self._outcomes.append(str(info["episode_outcome"])) + tr = info.get("terminal_reason") + if tr is not None: + self._reasons.append(str(tr)) + updated = True + + if updated and self.logger is not None and self._outcomes: + n = len(self._outcomes) + for k, v in Counter(self._outcomes).items(): + self.logger.record(f"mappo/outcome_frac/{k}", float(v) / float(n)) + self.logger.record("mappo/episodes_in_window", float(n)) + rn = len(self._reasons) + if rn > 0: + for k, v in Counter(self._reasons).items(): + self.logger.record(f"mappo/terminal_reason_frac/{k}", float(v) / float(rn)) + return True diff --git a/src/village_ai_war/training/train_mappo_bots.py b/src/village_ai_war/training/train_mappo_bots.py index 6cafd24..610d2fe 100644 --- a/src/village_ai_war/training/train_mappo_bots.py +++ b/src/village_ai_war/training/train_mappo_bots.py @@ -9,10 +9,11 @@ from loguru import logger from omegaconf import OmegaConf from stable_baselines3 import PPO -from stable_baselines3.common.callbacks import CheckpointCallback +from stable_baselines3.common.callbacks import CallbackList, CheckpointCallback from stable_baselines3.common.vec_env import DummyVecEnv, SubprocVecEnv, VecMonitor from village_ai_war.models.mappo_policy import MAPPOPolicy +from village_ai_war.training.mappo_episode_metrics_callback import MAPPOEpisodeMetricsCallback from village_ai_war.training.mappo_env import MAPPOBotEnv from village_ai_war.training.pool_manager import PoolManager @@ -50,6 +51,7 @@ def run_mappo_bots_training(cfg: Any) -> None: pool_manager = PoolManager(mappo_pool_dir, max_size=int(tcfg.get("pool_max_size", 15))) n = int(flat["map"]["size"]) + n_bot_slots = int(flat["game"]["max_bots_for_role_change"]) n_envs = int(tcfg["n_envs"]) total = int(tcfg["total_timesteps"]) @@ -101,10 +103,13 @@ def _init() -> MAPPOBotEnv: policy_kwargs={ "map_size": n, "critic_hidden_dim": critic_h, + "n_bot_slots": n_bot_slots, }, ) save_freq = max(int(tcfg.get("checkpoint_interval", 100_000)) // max(n_envs, 1), 1) + metrics_window = int(tcfg.get("mappo_metrics_window", 512)) + metrics_cb = MAPPOEpisodeMetricsCallback(window=metrics_window) for iteration in range(iterations): logger.info("MAPPO self-play iteration {} / {}", iteration + 1, iterations) @@ -113,9 +118,10 @@ def _init() -> MAPPOBotEnv: save_path=str(checkpoint_dir), name_prefix=f"mappo_bot_iter{iteration}", ) + learn_cb = CallbackList([checkpoint_callback, metrics_cb]) model.learn( total_timesteps=steps_per_iter, - callback=checkpoint_callback, + callback=learn_cb, reset_num_timesteps=(iteration == 0), tb_log_name="mappo_bot_selfplay", ) diff --git a/tests/test_mappo_baseline.py b/tests/test_mappo_baseline.py index 720cfd5..0645f11 100644 --- a/tests/test_mappo_baseline.py +++ b/tests/test_mappo_baseline.py @@ -19,7 +19,12 @@ from village_ai_war.env.game_env import GameEnv from village_ai_war.env.map_generator import generate_initial_state from village_ai_war.models.mappo_critic import MAPPOCentralizedCritic -from village_ai_war.models.mappo_layout import mappo_local_dim, mappo_obs_dim, pack_mappo_obs +from village_ai_war.models.mappo_layout import ( + mappo_local_dim, + mappo_obs_dim, + pack_mappo_obs, + pack_mappo_obs_slots, +) from village_ai_war.models.mappo_policy import MAPPOPolicy from village_ai_war.training.mappo_env import MAPPOBotEnv @@ -168,7 +173,9 @@ def test_queue_and_simulation_tick_matches_step_with_opponent() -> None: def test_mappo_layout_and_critic_shapes() -> None: n = 12 + k = 16 assert mappo_obs_dim(n) == mappo_local_dim() + n * n * 6 + 40 + assert mappo_obs_dim(n, k) == k * mappo_local_dim() + n * n * 6 + 40 import torch crit = MAPPOCentralizedCritic(map_shape=(n, n, 6), village_vec_dim=40, hidden_dim=64) @@ -181,13 +188,16 @@ def test_mappo_layout_and_critic_shapes() -> None: def test_mappo_bot_env_and_policy(tmp_path: Path) -> None: cfg = _tiny() + k = int(cfg["game"]["max_bots_for_role_change"]) pool = tmp_path / "bot_pool" env = MAPPOBotEnv(cfg, team=0, opponent_pool_dir=str(pool)) obs, info = env.reset(seed=0) - assert obs.shape == (mappo_obs_dim(12),) + assert obs.shape == (mappo_obs_dim(12, k),) + assert env.action_space.shape == (k,) assert "global_state" in info assert info["global_state"]["map"].shape == (12, 12, 6) - obs2, _r, _t, _tr, info2 = env.step(0) + noop = np.zeros((k,), dtype=np.int64) + obs2, _r, _t, _tr, info2 = env.step(noop) assert obs2.shape == obs.shape assert "global_state" in info2 env.close() @@ -201,11 +211,11 @@ def test_mappo_bot_env_and_policy(tmp_path: Path) -> None: n_steps=64, batch_size=32, verbose=0, - policy_kwargs={"map_size": 12, "critic_hidden_dim": 64}, + policy_kwargs={"map_size": 12, "critic_hidden_dim": 64, "n_bot_slots": k}, ) obs = venv.reset() act, _ = model.predict(obs, deterministic=True) - assert act.shape == (1,) + assert act.shape == (1, k) venv.close() @@ -217,3 +227,24 @@ def test_pack_mappo_obs_roundtrip_dims() -> None: v1 = np.zeros((20,), dtype=np.float32) p = pack_mappo_obs(loc, mp, v0, v1) assert p.shape == (mappo_obs_dim(n),) + k = 3 + locs = np.zeros((k, mappo_local_dim()), dtype=np.float32) + ps = pack_mappo_obs_slots(locs, mp, v0, v1) + assert ps.shape == (mappo_obs_dim(n, k),) + + +def test_game_env_terminal_info_keys() -> None: + cfg = _tiny() + cfg["game"]["max_ticks"] = 5 + e = GameEnv(cfg, mode="bot", team=0, render_mode=None) + e.reset(seed=0) + seen = False + for _ in range(30): + _o, _r, t, tr, info = e.step(0) + if t or tr: + assert "episode_outcome" in info + assert "terminal_reason" in info + seen = True + break + assert seen + e.close() From 0843aa4ae81d3a9385daef4b52ef6fa8487f3867 Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Tue, 31 Mar 2026 10:56:44 +0300 Subject: [PATCH 07/11] Enhance human vs AI gameplay and MAPPO integration - Added support for human players to compete against trained MAPPO policies in a 2D environment, allowing for interactive gameplay. - Updated the README to include detailed instructions for human vs AI matches and configuration requirements. - Modified the training configuration to increase the number of initial bots for improved gameplay dynamics. - Refactored the game environment to accommodate human actions and ensure proper integration with existing bot policies. - Introduced new helper functions for collecting human actions and rendering overlays during gameplay. - Enhanced the rendering logic to display relevant information during human play, improving user experience. - Added tests to validate the new human vs AI functionality and ensure robustness of the gameplay features. --- README.md | 20 ++ configs/training/train_mappo_bots.yaml | 4 +- scripts/run_game.py | 197 +++++++++++++++++- src/village_ai_war/env/game_env.py | 35 +++- src/village_ai_war/play/__init__.py | 15 ++ src/village_ai_war/play/human_controls.py | 147 +++++++++++++ src/village_ai_war/play/mappo_human_tick.py | 84 ++++++++ src/village_ai_war/play/mappo_obs.py | 52 +++++ .../rendering/pygame_renderer.py | 47 ++++- src/village_ai_war/training/mappo_env.py | 30 ++- tests/test_mappo_baseline.py | 60 ++++++ tests/test_smoke_game_env.py | 24 +++ 12 files changed, 684 insertions(+), 31 deletions(-) create mode 100644 src/village_ai_war/play/__init__.py create mode 100644 src/village_ai_war/play/human_controls.py create mode 100644 src/village_ai_war/play/mappo_human_tick.py create mode 100644 src/village_ai_war/play/mappo_obs.py diff --git a/README.md b/README.md index 1336db7..1ac46a3 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,26 @@ python scripts/run_game.py \ --deterministic --seed 42 ``` +### Human vs AI + +**Against a trained MAPPO policy (micro only, matches `MAPPOBotEnv` training):** you play **BLUE**; **RED** bots are controlled by a `MAPPOPolicy` checkpoint. There is **no** village manager phase in this mode (same as stage-4 bot training). Requires a **2D pygame** window. + +```bash +python scripts/run_game.py \ + --mappo-opponent checkpoints/bots_mappo/mappo_bot_final.zip \ + --seed 0 --max-steps 500 +``` + +The checkpoint must match your config (`map.size`, `game.max_bots_for_role_change`). `--human red` is rejected (MAPPO is trained as team 0 with a fixed observation layout). + +**Against MaskablePPO (village) + 181-dim PPO (bots):** use `--human red` or `--human blue`. Each tick you choose actions for **all** alive bots on your team, then (on manager ticks) a village action via `[` / `]` and Enter. The other side uses loaded checkpoints or random valid actions. Human play is **2D only** (`--human-3d` is ignored). + +```bash +python scripts/run_game.py --human blue \ + --village-checkpoint checkpoints/village/village_final.zip \ + --bot-checkpoint checkpoints/bots/bot_final.zip +``` + When any village, opponent, or bot checkpoint is loaded (or you pass a path that exists), the viewer runs **both** village managers each tick (trained or random per side), then resolves the tick in the same order as self-play training (`run_bots_then_village_decisions` in [`GameEnv`](src/village_ai_war/env/game_env.py)). If **no** checkpoints load, behavior matches the legacy script: one random manager action per step for team 0 only. The pygame **human** window includes numeric **row/column axes**, a **legend** (terrain colors, harvest hints `w`/`s`/`f`, unit roles and team rings, building abbreviations), and a **bottom HUD** (tick, winner, resources, population, global mode per team). `rgb_array` mode is still the raw map only for headless frames. diff --git a/configs/training/train_mappo_bots.yaml b/configs/training/train_mappo_bots.yaml index c506cb5..2c9441a 100644 --- a/configs/training/train_mappo_bots.yaml +++ b/configs/training/train_mappo_bots.yaml @@ -8,7 +8,7 @@ training: selfplay_iterations: 30 n_envs: 8 n_steps: 512 - learning_rate: 3.0e-4 + learning_rate: 3.0e-3 batch_size: 256 n_epochs: 10 gamma: 0.99 @@ -29,7 +29,7 @@ training: log_dir: logs game: - initial_bots: 4 + initial_bots: 8 logging: use_tensorboard: true diff --git a/scripts/run_game.py b/scripts/run_game.py index 305314f..e98cf44 100644 --- a/scripts/run_game.py +++ b/scripts/run_game.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Play a match with optional trained village / bot policies (pygame if available).""" +"""Play a match with optional trained village / bot policies, human vs AI, or vs MAPPO.""" from __future__ import annotations @@ -16,6 +16,12 @@ from village_ai_war.config_load import load_project_config # noqa: E402 from village_ai_war.env.game_env import GameEnv # noqa: E402 +from village_ai_war.play.human_controls import ( # noqa: E402 + collect_blue_bot_actions_for_tick, + collect_team_bot_actions_for_tick, + collect_village_action_for_tick, +) +from village_ai_war.play.mappo_human_tick import play_mappo_human_tick # noqa: E402 from village_ai_war.training.self_play_env import _maskable_village_obs_matches_env # noqa: E402 @@ -28,6 +34,21 @@ def _resolve_ckpt(path_str: str | None) -> Path | None: return p if p.is_file() else None +def _load_mappo_policy(path: Path, _flat: dict) -> object: + import village_ai_war.models.mappo_policy # noqa: F401 — SB3 unpickle + + from stable_baselines3 import PPO + + return PPO.load( + str(path), + device="auto", + custom_objects={ + "lr_schedule": lambda _: 0.0, + "clip_range": lambda _: 0.0, + }, + ) + + def main() -> None: warnings.filterwarnings( "ignore", @@ -35,6 +56,19 @@ def main() -> None: module="pygame.pkgdata", ) parser = argparse.ArgumentParser(description="Run Village AI War with optional RL checkpoints.") + parser.add_argument( + "--mappo-opponent", + default="", + help="Path to MAPPO zip (e.g. checkpoints/bots_mappo/mappo_bot_final.zip). " + "Human plays BLUE vs MAPPO on RED; no village AI (training-faithful micro).", + ) + parser.add_argument( + "--human", + choices=("red", "blue", ""), + default="", + help="Play as this team (village + bots). Leave empty for AI-only demo. " + "With --mappo-opponent only 'blue' is allowed.", + ) parser.add_argument( "--village-checkpoint", default="checkpoints/village/village_final.zip", @@ -55,16 +89,92 @@ def main() -> None: parser.add_argument( "--deterministic", action="store_true", - help="Use deterministic policy.predict for loaded MaskablePPO.", + help="Use deterministic policy.predict for loaded policies.", ) parser.add_argument( "--human-3d", action="store_true", - help="OpenGL 3D board (moderngl + pygame); needs pip install moderngl and a working display.", + help="OpenGL 3D board; not used with --mappo-opponent or --human (2D only).", ) args = parser.parse_args() flat = load_project_config(_ROOT) + mappo_path = _resolve_ckpt(args.mappo_opponent or None) + human_side = args.human or None + + if str(args.mappo_opponent).strip() and mappo_path is None: + logger.error("MAPPO checkpoint not found: {}", args.mappo_opponent) + sys.exit(1) + + if mappo_path is not None: + if args.human == "red": + logger.error("MAPPO opponent only supports playing as BLUE (team 1).") + sys.exit(2) + human_side = "blue" + if args.human_3d: + logger.warning("MAPPO human play uses 2D pygame only; ignoring --human-3d.") + render_mode = "human" + try: + env = GameEnv(flat, mode="bot", team=0, render_mode=render_mode) + except Exception as e: # noqa: BLE001 + logger.warning("Display unavailable ({}); headless MAPPO play", e) + env = GameEnv(flat, mode="bot", team=0, render_mode=None) + + try: + mappo_model = _load_mappo_policy(mappo_path, flat) + except Exception as e: # noqa: BLE001 + logger.error("Failed to load MAPPO from {}: {}", mappo_path, e) + sys.exit(1) + logger.info("Loaded MAPPO policy from {}", mappo_path) + + if env.render_mode is None: + logger.error("Human vs MAPPO needs a display (pygame 2D window).") + sys.exit(1) + + n_slots = int(flat["game"]["max_bots_for_role_change"]) + env.reset(seed=args.seed) + import pygame # noqa: PLC0415 + + def _render(overlay_lines: tuple[str, ...] | None = None) -> None: + if env.render_mode is not None: + env.render(overlay_lines=overlay_lines or ()) + + def _render_cb(overlay_lines: tuple[str, ...] = ()) -> None: + _render(overlay_lines) + + if env.render_mode is not None: + logger.info( + "Human vs MAPPO | BLUE=you | max_steps={} | ESC/close to quit", + args.max_steps, + ) + + for t in range(args.max_steps): + blue_actions = collect_blue_bot_actions_for_tick( + env, + pygame, + render=_render_cb, + ) + _obs, _r, term, trunc, info = play_mappo_human_tick( + env, + mappo_model, + blue_actions, + n_bot_slots=n_slots, + deterministic=args.deterministic, + ) + if env.render_mode is not None: + env.render( + overlay_lines=( + f"tick={info.get('tick', '?')} t={t}", + "Next: choose BLUE bot actions", + ) + ) + if term or trunc: + logger.info("Done at t={} info={}", t, info) + break + env.close() + return + + # --- Legacy AI demo or human vs MaskablePPO + PPO bots --- bot_path = _resolve_ckpt(args.bot_checkpoint) if bot_path is not None: game = dict(flat.get("game", {})) @@ -103,7 +213,10 @@ def main() -> None: except Exception as e: # noqa: BLE001 logger.warning("Could not load bot policy ({}); using random bot moves", e) - render_mode = "human_3d" if args.human_3d else "human" + if human_side and args.human_3d: + logger.warning("Human play uses 2D pygame; ignoring --human-3d.") + render_mode = "human" if human_side or not args.human_3d else "human_3d" + try: env = GameEnv(flat, mode="village", team=0, render_mode=render_mode) except Exception as e: # noqa: BLE001 @@ -136,6 +249,11 @@ def main() -> None: rng = np.random.default_rng(args.seed) obs, _ = env.reset(seed=args.seed) use_trained_tick = red_model is not None or blue_model is not None or bot_policy is not None + noop_v = int(env._village_space.offset_noop) # noqa: SLF001 + + if human_side and env.render_mode is None: + logger.error("Human play needs a display (pygame 2D window).") + sys.exit(1) if env.render_mode == "human_3d": try: @@ -162,6 +280,15 @@ def main() -> None: else: raise + import pygame # noqa: PLC0415 + + def _render_v(overlay_lines: tuple[str, ...] | None = None) -> None: + if env.render_mode is not None: + env.render(overlay_lines=overlay_lines or ()) + + def _render_v_cb(overlay_lines: tuple[str, ...] = ()) -> None: + _render_v(overlay_lines) + if env.render_mode is not None: logger.info( "Viewer render_mode={} | max_steps={} | close the window or Ctrl+C to stop early", @@ -169,8 +296,68 @@ def main() -> None: args.max_steps, ) + human_team: int | None = None + if human_side == "red": + human_team = 0 + elif human_side == "blue": + human_team = 1 + for t in range(args.max_steps): - if use_trained_tick: + st = env.game_state + assert st is not None + interval = int(flat["game"]["manager_interval"]) + is_mgr = st.tick % interval == 0 + + if human_team is not None: + h_bots = collect_team_bot_actions_for_tick( + env, + pygame, + human_team, + render=_render_v_cb, + ) + if is_mgr: + a_human = collect_village_action_for_tick( + env, + human_team, + pygame, + render=_render_v_cb, + ) + else: + a_human = noop_v + if human_team == 0: + a0 = a_human + m1 = env.action_masks(team=1) + if blue_model is not None: + o1 = env.get_village_observation(1) + p1, _ = blue_model.predict( + o1, + action_masks=m1, + deterministic=args.deterministic, + ) + a1 = int(np.asarray(p1).reshape(-1)[0]) + else: + a1 = int(rng.choice(np.flatnonzero(m1))) + else: + a1 = a_human + m0 = env.action_masks(team=0) + if red_model is not None: + o0 = env.get_village_observation(0) + p0, _ = red_model.predict( + o0, + action_masks=m0, + deterministic=args.deterministic, + ) + a0 = int(np.asarray(p0).reshape(-1)[0]) + else: + a0 = int(rng.choice(np.flatnonzero(m0))) + obs, r, term, trunc, info = env.run_bots_then_village_decisions( + bot_policy, + a0, + a1, + human_team=human_team, + human_bot_actions=h_bots, + ) + elif use_trained_tick: m0 = env.action_masks(team=0) m1 = env.action_masks(team=1) obs0 = env.get_village_observation(0) diff --git a/src/village_ai_war/env/game_env.py b/src/village_ai_war/env/game_env.py index c5fe1ce..980f861 100644 --- a/src/village_ai_war/env/game_env.py +++ b/src/village_ai_war/env/game_env.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any, SupportsFloat, cast @@ -277,6 +277,9 @@ def run_bots_then_village_decisions( bot_policy: Any, red_village_action: int, blue_village_action: int, + *, + human_team: int | None = None, + human_bot_actions: Mapping[int, int] | None = None, ) -> tuple[Any, float, bool, bool, dict[str, Any]]: """Run one tick: all bots act, then both managers' village actions are applied. @@ -287,15 +290,37 @@ def run_bots_then_village_decisions( bot_policy: Frozen PPO for bots, or ``None`` for random discrete actions. red_village_action: Flattened village action index for team 0. blue_village_action: Flattened village action index for team 1. + human_team: If set with ``human_bot_actions``, that team's alive bots use + the given actions first; policy fills other bots (excluding those pairs). Raises: - ValueError: If ``mode`` is not ``village`` or ``full``. + ValueError: If ``mode`` is not ``village`` or ``full``, or human actions + omit an alive bot on ``human_team``. """ if self.mode not in ("village", "full"): raise ValueError("run_bots_then_village_decisions requires mode='village' or 'full'") self.snapshot_bot_positions_for_tick() melee_intents: list[tuple[int, int, tuple[int, int]]] = [] - self._step_all_bots_with_policy(bot_policy, melee_intents, exclude=None) + ex: frozenset[tuple[int, int]] | None = None + if human_team is not None and human_bot_actions is not None: + alive_ids = sorted( + int(b.bot_id) for b in self._state.villages[int(human_team)].bots if b.is_alive + ) + missing = [bid for bid in alive_ids if bid not in human_bot_actions] + if missing: + raise ValueError( + f"human_bot_actions must cover all alive bots on team {human_team}; " + f"missing bot_id(s): {missing}" + ) + for bid in alive_ids: + self._apply_bot_action( + int(human_team), + bid, + int(human_bot_actions[bid]), + melee_intents, + ) + ex = frozenset((int(human_team), bid) for bid in alive_ids) + self._step_all_bots_with_policy(bot_policy, melee_intents, exclude=ex) return self.step_village_only( int(red_village_action), int(blue_village_action), melee_intents ) @@ -313,7 +338,7 @@ def action_masks(self, team: int | None = None) -> np.ndarray: m[self._village_space.offset_noop] = True return m - def render(self) -> np.ndarray | None: + def render(self, *, overlay_lines: Sequence[str] | None = None) -> np.ndarray | None: if self.render_mode is None: return None mode = cast(str, self.render_mode) @@ -335,7 +360,7 @@ def render(self) -> np.ndarray | None: if self._renderer is None: self._renderer = PygameRenderer(self.config, self._state) - return self._renderer.render(self._state, mode=mode) + return self._renderer.render(self._state, mode=mode, overlay_lines=overlay_lines) def close(self) -> None: if self._renderer is not None: diff --git a/src/village_ai_war/play/__init__.py b/src/village_ai_war/play/__init__.py new file mode 100644 index 0000000..4a7bd0e --- /dev/null +++ b/src/village_ai_war/play/__init__.py @@ -0,0 +1,15 @@ +"""Interactive play helpers (human vs AI, MAPPO observation packing).""" + +from village_ai_war.play.mappo_human_tick import play_mappo_human_tick +from village_ai_war.play.mappo_obs import ( + build_mappo_global_state, + build_mappo_locals_matrix, + pack_mappo_observation_vector, +) + +__all__ = [ + "build_mappo_global_state", + "build_mappo_locals_matrix", + "pack_mappo_observation_vector", + "play_mappo_human_tick", +] diff --git a/src/village_ai_war/play/human_controls.py b/src/village_ai_war/play/human_controls.py new file mode 100644 index 0000000..f12a8d2 --- /dev/null +++ b/src/village_ai_war/play/human_controls.py @@ -0,0 +1,147 @@ +"""Pygame helpers for human bot / village actions in ``run_game``.""" + +from __future__ import annotations + +from collections.abc import Callable + +import numpy as np + +from village_ai_war.agents.village_action_space import VillageActionSpace, decode_village_action +from village_ai_war.env.game_env import GameEnv +from village_ai_war.state import Role + + +def _action_from_key(pygame: object, key: int) -> int | None: + """Map key to bot action 0..11, or None if unmapped.""" + if key in (pygame.K_w, pygame.K_UP): + return 1 + if key in (pygame.K_d, pygame.K_RIGHT): + return 2 + if key in (pygame.K_s, pygame.K_DOWN): + return 3 + if key in (pygame.K_a, pygame.K_LEFT): + return 4 + if key == pygame.K_i: + return 5 + if key == pygame.K_l: + return 6 + if key == pygame.K_k: + return 7 + if key == pygame.K_j: + return 8 + if key in (pygame.K_SPACE, pygame.K_0, pygame.K_KP0): + return 0 + if key == pygame.K_g: + return 9 + if key == pygame.K_f: + return 10 + if key == pygame.K_r: + return 11 + return None + + +def collect_team_bot_actions_for_tick( + env: GameEnv, + pygame: object, + team: int, + *, + render: Callable[..., None], +) -> dict[int, int]: + """Block until actions for every alive bot on ``team`` (0=red, 1=blue).""" + state = env.game_state + assert state is not None + name = "RED" if team == 0 else "BLUE" + bots = sorted( + (b for b in state.villages[team].bots if b.is_alive), + key=lambda b: int(b.bot_id), + ) + out: dict[int, int] = {} + for bi, bot in enumerate(bots): + tentative: int | None = None + confirmed = False + while not confirmed: + hint = ( + f"{name} bot {bi + 1}/{len(bots)} id={bot.bot_id} {bot.role.name} " + f"@ {bot.position} HP={bot.hp}" + ) + sel = "(press key, then Enter)" if tentative is None else f"action={tentative}" + lines = ( + hint, + sel, + "WASD/arrows move IJKL melee Space noop G/F/R if role fits Enter confirm", + ) + render(overlay_lines=lines) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + raise SystemExit(0) + if event.type != pygame.KEYDOWN: + continue + if event.key == pygame.K_ESCAPE: + raise SystemExit(0) + if event.key in (pygame.K_RETURN, pygame.K_KP_ENTER): + out[int(bot.bot_id)] = 0 if tentative is None else int(tentative) + confirmed = True + break + a = _action_from_key(pygame, event.key) + if a is None: + continue + if a == 9 and bot.role != Role.GATHERER: + continue + if a == 10 and bot.role != Role.FARMER: + continue + if a == 11 and bot.role != Role.BUILDER: + continue + tentative = a + return out + + +def collect_blue_bot_actions_for_tick( + env: GameEnv, + pygame: object, + *, + render: Callable[..., None], +) -> dict[int, int]: + """Convenience: team 1 (vs MAPPO on red).""" + return collect_team_bot_actions_for_tick(env, pygame, 1, render=render) + + +def collect_village_action_for_tick( + env: GameEnv, + human_team: int, + pygame: object, + *, + render: Callable[..., None], +) -> int: + """Pick one valid village action for ``human_team`` ([/] cycle, Enter confirm).""" + space: VillageActionSpace = env._village_space # noqa: SLF001 + m = env.action_masks(team=human_team) + if int(np.sum(m)) == 1 and bool(m[int(space.offset_noop)]): + return int(space.offset_noop) + valid = [int(i) for i in range(m.size) if m[i]] + if not valid: + return int(space.offset_noop) + idx_in_list = 0 + while True: + a = valid[idx_in_list] + dec = decode_village_action(space, a) + lines = ( + f"Village team {human_team} — {idx_in_list + 1}/{len(valid)}", + f"{dec}", + "[ / ] cycle Enter confirm N = noop", + ) + render(overlay_lines=lines) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + raise SystemExit(0) + if event.type != pygame.KEYDOWN: + continue + if event.key == pygame.K_ESCAPE: + raise SystemExit(0) + if event.key in (pygame.K_RETURN, pygame.K_KP_ENTER): + return a + if event.key == pygame.K_n: + return int(space.offset_noop) + if event.key in (pygame.K_LEFTBRACKET, pygame.K_COMMA): + idx_in_list = (idx_in_list - 1) % len(valid) + elif event.key in (pygame.K_RIGHTBRACKET, pygame.K_PERIOD): + idx_in_list = (idx_in_list + 1) % len(valid) diff --git a/src/village_ai_war/play/mappo_human_tick.py b/src/village_ai_war/play/mappo_human_tick.py new file mode 100644 index 0000000..c67185c --- /dev/null +++ b/src/village_ai_war/play/mappo_human_tick.py @@ -0,0 +1,84 @@ +"""One simulation tick: MAPPO (red) vs human bot actions (blue), village-free.""" + +from __future__ import annotations + +from typing import Any, Mapping, SupportsFloat + +import numpy as np + +from village_ai_war.env.game_env import GameEnv +from village_ai_war.play.mappo_obs import ( + build_mappo_global_state, + build_mappo_locals_matrix, + pack_mappo_observation_vector, +) + + +def play_mappo_human_tick( + env: GameEnv, + mappo_model: Any, + human_blue_actions: Mapping[int, int], + *, + n_bot_slots: int, + deterministic: bool = False, +) -> tuple[Any, SupportsFloat, bool, bool, dict[str, Any]]: + """Mirror ``MAPPOBotEnv.step`` with team-0 MAPPO and explicit blue actions. + + Requires ``env.mode == \"bot\"`` and ``env.team == 0`` (MAPPO training layout). + ``human_blue_actions`` must include every alive blue ``bot_id`` -> action in ``0..11``. + """ + if env.mode != "bot": + raise ValueError("play_mappo_human_tick requires GameEnv mode='bot'") + if env.team != 0: + raise ValueError("play_mappo_human_tick requires GameEnv team=0 (MAPPO trained side)") + state = env.game_state + assert state is not None + + blue_alive = sorted( + (b for b in state.villages[1].bots if b.is_alive), + key=lambda b: int(b.bot_id), + ) + for b in blue_alive: + if int(b.bot_id) not in human_blue_actions: + raise ValueError( + f"human_blue_actions missing bot_id={b.bot_id}; need all alive blue bots" + ) + + red_alive = sorted( + (b for b in state.villages[0].bots if b.is_alive), + key=lambda b: int(b.bot_id), + ) + + gs = build_mappo_global_state(state, env._vil_obs) + mat = build_mappo_locals_matrix( + state, + env, + mappo_team=0, + n_bot_slots=n_bot_slots, + ) + packed = pack_mappo_observation_vector(mat, gs) + acts, _ = mappo_model.predict(packed, deterministic=deterministic) + acts = np.asarray(acts, dtype=np.int64).reshape(-1) + if acts.shape[0] != n_bot_slots: + raise ValueError(f"MAPPO expected {n_bot_slots} actions, got {acts.shape[0]}") + + env.snapshot_bot_positions_for_tick() + env.begin_mappo_tick() + + controlled: list[tuple[int, int]] = [] + for i, bot in enumerate(red_alive[:n_bot_slots]): + a = int(acts[i]) + env.queue_bot_action(0, bot.bot_id, a) + controlled.append((bot.bot_id, a)) + if controlled: + env._controlled_bot_id = controlled[0][0] + + for bot in blue_alive: + env.queue_bot_action(1, bot.bot_id, int(human_blue_actions[int(bot.bot_id)])) + + learner_bot_actions = {bid: ac for bid, ac in controlled} + return env._simulation_tick( + manager_action=None, + learner_bot_action=None, + learner_bot_actions=learner_bot_actions, + ) diff --git a/src/village_ai_war/play/mappo_obs.py b/src/village_ai_war/play/mappo_obs.py new file mode 100644 index 0000000..5e34986 --- /dev/null +++ b/src/village_ai_war/play/mappo_obs.py @@ -0,0 +1,52 @@ +"""MAPPO observation layout shared by MAPPOBotEnv and human-vs-MAPPO play.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from village_ai_war.agents.village_obs_builder import VillageObsBuilder +from village_ai_war.models.mappo_layout import pack_mappo_obs_slots +from village_ai_war.state.game_state import GameState + +if TYPE_CHECKING: + from village_ai_war.env.game_env import GameEnv + + +def build_mappo_global_state(state: GameState, vil_obs: VillageObsBuilder) -> dict[str, np.ndarray]: + """Team-0 map POV plus concatenated village vectors (order matches MAPPO critic).""" + mp = vil_obs.build_map(state, team=0) + v0 = vil_obs.build_village_vec(state, team=0) + v1 = vil_obs.build_village_vec(state, team=1) + return {"map": mp, "village": np.concatenate([v0, v1], axis=0).astype(np.float32)} + + +def build_mappo_locals_matrix( + state: GameState, + game_env: GameEnv, + *, + mappo_team: int, + n_bot_slots: int, +) -> np.ndarray: + """K × local_dim; alive bots on ``mappo_team`` sorted by ``bot_id`` fill low rows.""" + k = int(n_bot_slots) + d = int(game_env.observation_space.shape[0]) + out = np.zeros((k, d), dtype=np.float32) + village = state.villages[int(mappo_team)] + alive = sorted((b for b in village.bots if b.is_alive), key=lambda b: int(b.bot_id)) + for i, bot in enumerate(alive[:k]): + out[i] = game_env._get_single_bot_obs(bot.bot_id, int(mappo_team)) + return out + + +def pack_mappo_observation_vector(locals_k: np.ndarray, gs: dict[str, np.ndarray]) -> np.ndarray: + """Flatten packed MAPPO vector (K locals + map + two village vecs).""" + v = gs["village"] + half = VillageObsBuilder.VEC_DIM + return pack_mappo_obs_slots( + locals_k, + gs["map"], + v[:half], + v[half:], + ) diff --git a/src/village_ai_war/rendering/pygame_renderer.py b/src/village_ai_war/rendering/pygame_renderer.py index f52ae60..3f051c7 100644 --- a/src/village_ai_war/rendering/pygame_renderer.py +++ b/src/village_ai_war/rendering/pygame_renderer.py @@ -183,7 +183,13 @@ def _ensure_ui_fonts(self) -> None: def _grid_px(self) -> tuple[int, int]: return self._cell * self._n, self._cell * self._n - def render(self, state: GameState, mode: str) -> np.ndarray | None: + def render( + self, + state: GameState, + mode: str, + *, + overlay_lines: tuple[str, ...] | list[str] | None = None, + ) -> np.ndarray | None: """Blit world; return RGB array when ``mode == \"rgb_array\"`` (map only).""" pygame = self._pygame gw, gh = self._grid_px() @@ -194,7 +200,7 @@ def render(self, state: GameState, mode: str) -> np.ndarray | None: self._draw_map_grid(self._surface, state) if mode == "human": - self._render_human_window(state, gw, gh) + self._render_human_window(state, gw, gh, overlay_lines=overlay_lines or ()) return None rgb = pygame.surfarray.array3d(self._surface) @@ -341,7 +347,14 @@ def team_dark(team: int) -> tuple[int, int, int]: surf.blit(to, (bx + ox, by + oy)) surf.blit(t, (bx, by)) - def _render_human_window(self, state: GameState, gw: int, gh: int) -> None: + def _render_human_window( + self, + state: GameState, + gw: int, + gh: int, + *, + overlay_lines: tuple[str, ...] = (), + ) -> None: pygame = self._pygame self._ensure_ui_fonts() map_block_h = _MARGIN_TOP + gh @@ -376,11 +389,39 @@ def _render_human_window(self, state: GameState, gw: int, gh: int) -> None: self._draw_coordinate_axes(screen, ox, oy, gw, gh) self._draw_legend_panel(screen, legend_x, _TITLE_BAR, _LEGEND_WIDTH, content_h) + if overlay_lines: + self._draw_overlay_lines(screen, legend_x + 8, _TITLE_BAR + 420, overlay_lines) self._draw_bottom_hud(screen, state, 0, _TITLE_BAR + content_h, win_w) pygame.display.flip() pygame.event.pump() pygame.time.delay(int(1000 / max(int(self._config["rendering"]["fps"]), 1))) + def _draw_overlay_lines( + self, + screen: Any, + x: int, + y0: int, + lines: tuple[str, ...], + ) -> None: + pygame = self._pygame + small = self._font_ui_small + yy = y0 + panel_w = _LEGEND_WIDTH - 16 + line_h = 16 + h = min(len(lines) * line_h + 8, 140) + if y0 + h > screen.get_height() - _BOTTOM_HUD_HEIGHT - 8: + yy = max(_TITLE_BAR + 8, screen.get_height() - _BOTTOM_HUD_HEIGHT - h - 8) + rect = pygame.Rect(x, yy, panel_w, h) + s = pygame.Surface((rect.width, rect.height)) + s.set_alpha(230) + s.fill((18, 22, 32)) + screen.blit(s, rect.topleft) + pygame.draw.rect(screen, _ACCENT, rect, 1) + ty = yy + 4 + for line in lines[:8]: + screen.blit(small.render(line[:72], True, (230, 232, 240)), (x + 6, ty)) + ty += line_h + def _draw_coordinate_axes(self, screen: Any, ox: int, oy: int, gw: int, gh: int) -> None: pygame = self._pygame font = self._font_axis diff --git a/src/village_ai_war/training/mappo_env.py b/src/village_ai_war/training/mappo_env.py index af2bc64..60ebdfc 100644 --- a/src/village_ai_war/training/mappo_env.py +++ b/src/village_ai_war/training/mappo_env.py @@ -18,7 +18,12 @@ from village_ai_war.agents.village_obs_builder import VillageObsBuilder from village_ai_war.env.game_env import GameEnv -from village_ai_war.models.mappo_layout import mappo_obs_dim, pack_mappo_obs_slots +from village_ai_war.models.mappo_layout import mappo_obs_dim +from village_ai_war.play.mappo_obs import ( + build_mappo_global_state, + build_mappo_locals_matrix, + pack_mappo_observation_vector, +) from village_ai_war.state.game_state import GameState @@ -143,26 +148,19 @@ def _emit_no_opponent_message(self, msg: str, *args: Any) -> None: def _global_state(self, state: GameState) -> dict[str, np.ndarray]: """Canonical team-0 map POV plus both village vectors (for critic and logging).""" - mp = self.village_obs_builder.build_map(state, team=0) - v0 = self.village_obs_builder.build_village_vec(state, team=0) - v1 = self.village_obs_builder.build_village_vec(state, team=1) - return {"map": mp, "village": np.concatenate([v0, v1], axis=0).astype(np.float32)} + return build_mappo_global_state(state, self.village_obs_builder) def _locals_matrix(self, state: GameState) -> np.ndarray: """K x local_dim; alive bots sorted by bot_id fill low rows, then zeros.""" - k = self._n_bot_slots - d = self._local_dim - out = np.zeros((k, d), dtype=np.float32) - village = state.villages[self.team] - alive = sorted((b for b in village.bots if b.is_alive), key=lambda b: int(b.bot_id)) - for i, bot in enumerate(alive[:k]): - out[i] = self.inner._get_single_bot_obs(bot.bot_id, self.team) - return out + return build_mappo_locals_matrix( + state, + self.inner, + mappo_team=self.team, + n_bot_slots=self._n_bot_slots, + ) def _pack_slots(self, locals_k: np.ndarray, gs: dict[str, np.ndarray]) -> np.ndarray: - return pack_mappo_obs_slots( - locals_k, gs["map"], gs["village"][:20], gs["village"][20:] - ) + return pack_mappo_observation_vector(locals_k, gs) def _get_global_from_state(self) -> dict[str, np.ndarray]: st = self.inner.game_state diff --git a/tests/test_mappo_baseline.py b/tests/test_mappo_baseline.py index 0645f11..0e87591 100644 --- a/tests/test_mappo_baseline.py +++ b/tests/test_mappo_baseline.py @@ -26,6 +26,12 @@ pack_mappo_obs_slots, ) from village_ai_war.models.mappo_policy import MAPPOPolicy +from village_ai_war.play.mappo_obs import ( + build_mappo_global_state, + build_mappo_locals_matrix, + pack_mappo_observation_vector, +) +from village_ai_war.play.mappo_human_tick import play_mappo_human_tick from village_ai_war.training.mappo_env import MAPPOBotEnv @@ -219,6 +225,60 @@ def test_mappo_bot_env_and_policy(tmp_path: Path) -> None: venv.close() +def test_mappo_obs_helpers_match_mapppo_env(tmp_path: Path) -> None: + cfg = _tiny() + k = int(cfg["game"]["max_bots_for_role_change"]) + pool = tmp_path / "pool_mappo_obs" + env = MAPPOBotEnv(cfg, team=0, opponent_pool_dir=str(pool)) + obs, _info = env.reset(seed=42) + st = env.inner.game_state + assert st is not None + gs = build_mappo_global_state(st, env.village_obs_builder) + mat = build_mappo_locals_matrix(st, env.inner, mappo_team=0, n_bot_slots=k) + packed = pack_mappo_observation_vector(mat, gs) + np.testing.assert_allclose(packed, obs, rtol=0, atol=0) + env.close() + + +def test_play_mappo_human_tick_smoke(tmp_path: Path) -> None: + cfg = _tiny() + k = int(cfg["game"]["max_bots_for_role_change"]) + pool = tmp_path / "pool" + pool.mkdir() + venv = DummyVecEnv([lambda: MAPPOBotEnv(cfg, opponent_pool_dir=str(pool))]) + model = PPO( + MAPPOPolicy, + venv, + n_steps=64, + batch_size=32, + verbose=0, + policy_kwargs={"map_size": 12, "critic_hidden_dim": 64, "n_bot_slots": k}, + ) + save_p = tmp_path / "mappo_test" + model.save(str(save_p)) + venv.close() + + loaded = PPO.load( + str(save_p.with_suffix(".zip")), + device="cpu", + custom_objects={"lr_schedule": lambda _: 0.0, "clip_range": lambda _: 0.0}, + ) + ge = GameEnv(cfg, mode="bot", team=0, render_mode=None) + ge.reset(seed=42) + st = ge.game_state + assert st is not None + blue = [b for b in st.villages[1].bots if b.is_alive] + human = {int(b.bot_id): 0 for b in blue} + _o, _r, _t, _tr, _info = play_mappo_human_tick( + ge, + loaded, + human, + n_bot_slots=k, + deterministic=True, + ) + ge.close() + + def test_pack_mappo_obs_roundtrip_dims() -> None: n = 12 loc = np.zeros((mappo_local_dim(),), dtype=np.float32) diff --git a/tests/test_smoke_game_env.py b/tests/test_smoke_game_env.py index 28ee219..27f71f5 100644 --- a/tests/test_smoke_game_env.py +++ b/tests/test_smoke_game_env.py @@ -149,6 +149,30 @@ def test_mutual_extinction_terminates_episode() -> None: assert info.get("winner") is None +def test_run_bots_then_village_human_team_excludes_policy() -> None: + env = GameEnv(_tiny_config(), mode="village", team=0, render_mode=None) + env.reset(seed=7) + st = env.game_state + assert st is not None + blue_alive = [b for b in st.villages[1].bots if b.is_alive] + assert blue_alive + human_act = {int(b.bot_id): 0 for b in blue_alive} + m0 = env.action_masks(team=0) + m1 = env.action_masks(team=1) + a0 = int(np.flatnonzero(m0)[0]) + a1 = int(np.flatnonzero(m1)[0]) + _obs, _r, _t, _tr, _info = env.run_bots_then_village_decisions( + None, + a0, + a1, + human_team=1, + human_bot_actions=human_act, + ) + with pytest.raises(ValueError, match="human_bot_actions"): + env.run_bots_then_village_decisions(None, a0, a1, human_team=1, human_bot_actions={}) + env.close() + + def test_get_village_observation_and_run_bots_then_village() -> None: env = GameEnv(_tiny_config(), mode="village", team=0, render_mode=None) env.reset(seed=3) From e41d0520bbada915f7219546084072d8d9b9bbd0 Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Tue, 31 Mar 2026 11:02:46 +0300 Subject: [PATCH 08/11] Update economy settings and enhance food consumption logic - Adjusted economy configuration to change harvest interval, amount, and food consumption values for improved gameplay balance. - Modified the EconomySystem to handle fractional food consumption correctly, ensuring accurate food deduction based on the number of alive bots. - Added a new test case to validate the correct calculation of food consumption when multiple bots are alive, enhancing test coverage for the economy system. --- configs/economy.yaml | 6 ++--- src/village_ai_war/env/economy_system.py | 5 +++-- tests/test_economy_system.py | 28 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/configs/economy.yaml b/configs/economy.yaml index d0da7d5..457c1b0 100644 --- a/configs/economy.yaml +++ b/configs/economy.yaml @@ -1,8 +1,8 @@ # @package _global_ economy: - harvest_interval: 3 - harvest_amount: 10 - food_consumption: 1 + harvest_interval: 2 + harvest_amount: 14 + food_consumption: 0.5 hunger_damage: 5 bot_cost: wood: 30 diff --git a/src/village_ai_war/env/economy_system.py b/src/village_ai_war/env/economy_system.py index 7e3e465..fe04393 100644 --- a/src/village_ai_war/env/economy_system.py +++ b/src/village_ai_war/env/economy_system.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from typing import Any, Mapping import numpy as np @@ -32,7 +33,7 @@ def step(state: GameState, config: Mapping[str, Any]) -> dict[str, Any]: harvest_interval = int(ecfg["harvest_interval"]) harvest_amount = int(ecfg["harvest_amount"]) - food_per_bot = int(ecfg["food_consumption"]) + food_per_bot = float(ecfg["food_consumption"]) hunger_damage = int(ecfg["hunger_damage"]) spawn_delay = int(ecfg["bot_spawn_delay"]) bot_cost_wood = int(ecfg["bot_cost"]["wood"]) @@ -105,7 +106,7 @@ def add_resource_bot(bot_id: int, amt: int) -> None: add_resource_bot(bot.bot_id, prod) alive = [b for b in village.bots if b.is_alive] - need = food_per_bot * len(alive) + need = max(0, int(math.ceil(food_per_bot * len(alive)))) if village.resources.food >= need: village.resources.food -= need events["food_delta"][team] -= need diff --git a/tests/test_economy_system.py b/tests/test_economy_system.py index 46dffd2..a8fcd77 100644 --- a/tests/test_economy_system.py +++ b/tests/test_economy_system.py @@ -89,6 +89,34 @@ def test_gatherer_collects_wood() -> None: assert ev["resource_collected_by_bot"].get(0, 0) > 0 +def test_fractional_food_consumption_ceil() -> None: + """0.5 per bot × 3 alive → ceil(1.5) = 2 food deducted when affordable.""" + cfg = _minimal_config() + cfg["economy"]["food_consumption"] = 0.5 + g = GameState( + map_size=8, + max_ticks=100, + terrain=[[0] * 8 for _ in range(8)], + resources=[[0] * 8 for _ in range(8)], + resource_amounts=[[0] * 8 for _ in range(8)], + villages=[ + VillageState( + team=0, + resources=ResourceStock(food=10), + bots=[ + BotState(bot_id=0, team=0, role=Role.WARRIOR, position=(0, 0)), + BotState(bot_id=1, team=0, role=Role.WARRIOR, position=(1, 0)), + BotState(bot_id=2, team=0, role=Role.WARRIOR, position=(2, 0)), + ], + ), + VillageState(team=1), + ], + next_bot_id=3, + ) + EconomySystem.step(g, cfg) + assert g.villages[0].resources.food == 8 + + def test_hunger_when_no_food() -> None: cfg = _minimal_config() g = GameState( From 4ca456c04277061ae1a1568e157e041c8bd94841 Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Tue, 31 Mar 2026 14:18:00 +0300 Subject: [PATCH 09/11] Refactor training configurations and enhance linting settings - Updated `pyproject.toml` to ignore specific linting errors for scripts, improving code quality checks. - Modified `README.md` to clarify gameplay instructions and training configurations, focusing on MAPPO integration. - Changed training configuration in `default.yaml` to reflect the new MAPPO training stage. - Removed outdated training configuration files to streamline the training process. - Updated `requirements.txt` by removing unnecessary dependencies, ensuring a cleaner setup. - Enhanced tensorboard plotting scripts to support new training metrics and improve visualization. --- README.md | 196 ++------ configs/default.yaml | 2 +- configs/training/train_bots.yaml | 13 - configs/training/train_bots_selfplay.yaml | 27 -- configs/training/train_joint.yaml | 15 - configs/training/train_mappo_bots.yaml | 5 +- configs/training/train_unified.yaml | 38 -- configs/training/train_village.yaml | 14 - configs/training/train_village_selfplay.yaml | 23 - pyproject.toml | 3 + requirements.txt | 1 - scripts/plot_tensorboard_scalars.py | 2 +- scripts/run_game.py | 344 ++++---------- scripts/run_training.py | 28 +- src/village_ai_war/env/game_env.py | 2 +- .../training/progress_callback.py | 126 ----- src/village_ai_war/training/self_play_env.py | 438 ------------------ .../training/tensorboard_plots.py | 115 +++-- src/village_ai_war/training/train_bots.py | 77 --- .../training/train_bots_selfplay.py | 164 ------- src/village_ai_war/training/train_joint.py | 78 ---- .../training/train_mappo_bots.py | 6 +- src/village_ai_war/training/train_unified.py | 289 ------------ src/village_ai_war/training/train_village.py | 71 --- .../training/train_village_selfplay.py | 169 ------- tests/test_unified_training_smoke.py | 155 ------- 26 files changed, 202 insertions(+), 2199 deletions(-) delete mode 100644 configs/training/train_bots.yaml delete mode 100644 configs/training/train_bots_selfplay.yaml delete mode 100644 configs/training/train_joint.yaml delete mode 100644 configs/training/train_unified.yaml delete mode 100644 configs/training/train_village.yaml delete mode 100644 configs/training/train_village_selfplay.yaml delete mode 100644 src/village_ai_war/training/progress_callback.py delete mode 100644 src/village_ai_war/training/self_play_env.py delete mode 100644 src/village_ai_war/training/train_bots.py delete mode 100644 src/village_ai_war/training/train_bots_selfplay.py delete mode 100644 src/village_ai_war/training/train_joint.py delete mode 100644 src/village_ai_war/training/train_unified.py delete mode 100644 src/village_ai_war/training/train_village.py delete mode 100644 src/village_ai_war/training/train_village_selfplay.py delete mode 100644 tests/test_unified_training_smoke.py diff --git a/README.md b/README.md index 1ac46a3..b473522 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,12 @@ -# Village AI War — 2D Hierarchical RL Environment +# Village AI War -Custom Gymnasium environment for hierarchical multi-agent reinforcement learning. The **baseline is fully RL-driven**: low-level units are controlled by a learned policy (no movement heuristics), and both teams can be trained with **self-play** against pools of past checkpoints. +A **Gymnasium** environment for a small RTS-style village game. You can watch a quick demo, play against a trained **MAPPO** bot team, or train your own policies. -### Research baselines (bots) +Training in this repo is **MAPPO only** (multi-agent PPO with a shared critic). Everything else is the game simulation, rendering, and helpers. -| | Baseline v0 (legacy stage 1) | Baseline v1 (MAPPO, stage 4) | -|---|------------------------------|--------------------------------| -| Algorithm | PPO + `RoleConditionedPolicy` (shared actor–critic on local obs) | PPO + `MAPPOPolicy`: decentralized actor, **centralized critic** on global map + both villages | -| Coordination | Each bot optimizes its own value signal | Shared critic sees full state (CTDE-style training) | -| Observation | `BotObsBuilder` only (181-dim) | **K** stacked local bot vectors (`K = game.max_bots_for_role_change`) + flattened global map (team-0 POV) + both village vectors (see `MAPPOBotEnv`) | +--- -**Baseline v0 (failed for the full game):** villages often **starved before the economy phase** because local critics did not coordinate gatherers/farmers; other issues included reward plumbing and map scale. That stack remains available as **stage 1** for ablations, but **v1 (MAPPO)** is the recommended bot baseline for comparing new architectures. - -## Architecture - -- **Bot agents (low level):** Default legacy path uses one **role-conditioned** PPO policy (`RoleConditionedPolicy`) — shared backbone plus a learned role embedding from the observation one-hot (see `BotObsBuilder`). All roles share weights. **MAPPO path (stage 4):** `MAPPOPolicy` with `MAPPOActorExtractor` over **K local slots** (shared trunk per slot) and `MAPPOCentralizedCritic` on the global tail. One RL step = one game tick with **all allied bots** acting (`MultiDiscrete` of size **K**); opponents still act every tick via the pool policy or random. The self-play pool loads **181-dim** PPO checkpoints for the opponent; the trained MAPPO policy expects the **extended** observation from `MAPPOBotEnv`. **Older MAPPO checkpoints** from the single-bot-per-tick layout are **not** compatible with the current stacked-obs + `MultiDiscrete` policy. -- **Village agent (high level):** Strategic manager — **MaskablePPO** with invalid-action masking (`MultiInputPolicy` on dict observations). -- **Self-play:** Stages 1 and 4 sample bot opponents from `checkpoints/pool/bots/`; stage 2 samples village opponents from `checkpoints/pool/village/`. Empty pools fall back to random opponent actions. -- **Unified training:** One Hydra entry point (`training=train_unified`, `training.stage=0`) alternates PPO on bots and MaskablePPO on the red manager. Each environment step matches village self-play order (all bots act, then both managers). Blue bots and blue manager are sampled from the same pools; the non-training partner on red is frozen from the last saved checkpoint until the next phase. -- **Reward shaping:** Dynamic global reward modes controlled by the village agent (unchanged). - -## Quick Start +## Install and first run ```bash cd VillageAI_War @@ -30,31 +16,13 @@ pip install -e . python scripts/run_game.py ``` -### Watching trained policies +That opens a **random demo**: the AI village manager picks valid actions at random (2D window). Add `--human-3d` for a passive 3D view (needs working OpenGL; on Linux/WSL you may need `libgl1` / Mesa — if 3D fails, the script falls back to 2D). -[`scripts/run_game.py`](scripts/run_game.py) can load the same checkpoints produced by training. If a path is missing or fails to load, that component falls back to random valid actions (same as the old demo). +--- -```bash -# Defaults: checkpoints/village/village_final.zip, checkpoints/bots/bot_final.zip -python scripts/run_game.py +## Play against MAPPO -# Explicit paths and deterministic manager actions -python scripts/run_game.py \ - --village-checkpoint checkpoints/village/village_final.zip \ - --opponent-village-checkpoint checkpoints/pool/village/village_iter5.zip \ - --bot-checkpoint checkpoints/bots/bot_final.zip \ - --deterministic --seed 42 --max-steps 2000 - -# Artifacts from unified training -python scripts/run_game.py \ - --village-checkpoint checkpoints/unified/village_final.zip \ - --bot-checkpoint checkpoints/unified/bot_final.zip \ - --deterministic --seed 42 -``` - -### Human vs AI - -**Against a trained MAPPO policy (micro only, matches `MAPPOBotEnv` training):** you play **BLUE**; **RED** bots are controlled by a `MAPPOPolicy` checkpoint. There is **no** village manager phase in this mode (same as stage-4 bot training). Requires a **2D pygame** window. +You control **blue**; **red** uses your checkpoint. Human play is **2D only** (MAPPO training matches this). ```bash python scripts/run_game.py \ @@ -62,153 +30,55 @@ python scripts/run_game.py \ --seed 0 --max-steps 500 ``` -The checkpoint must match your config (`map.size`, `game.max_bots_for_role_change`). `--human red` is rejected (MAPPO is trained as team 0 with a fixed observation layout). - -**Against MaskablePPO (village) + 181-dim PPO (bots):** use `--human red` or `--human blue`. Each tick you choose actions for **all** alive bots on your team, then (on manager ticks) a village action via `[` / `]` and Enter. The other side uses loaded checkpoints or random valid actions. Human play is **2D only** (`--human-3d` is ignored). - -```bash -python scripts/run_game.py --human blue \ - --village-checkpoint checkpoints/village/village_final.zip \ - --bot-checkpoint checkpoints/bots/bot_final.zip -``` - -When any village, opponent, or bot checkpoint is loaded (or you pass a path that exists), the viewer runs **both** village managers each tick (trained or random per side), then resolves the tick in the same order as self-play training (`run_bots_then_village_decisions` in [`GameEnv`](src/village_ai_war/env/game_env.py)). If **no** checkpoints load, behavior matches the legacy script: one random manager action per step for team 0 only. - -The pygame **human** window includes numeric **row/column axes**, a **legend** (terrain colors, harvest hints `w`/`s`/`f`, unit roles and team rings, building abbreviations), and a **bottom HUD** (tick, winner, resources, population, global mode per team). `rgb_array` mode is still the raw map only for headless frames. - -**3D view** (OpenGL via [moderngl](https://github.com/moderngl/moderngl)): run `python scripts/run_game.py --human-3d` for an extruded terrain board, **building silhouettes** (town hall roof, tower spire, farm silo, etc.), **bots as team disk + role sphere + team ring**, and a **Russian legend panel** on the right (including a **live HUD**: tick, wood/stone/food, alive bots / pop cap, village AI mode per team). **Camera:** left-drag on the map to orbit (yaw/pitch); arrow keys nudge the camera; optional idle spin via `auto_rotate_deg_per_sec` (default `0`). Tuning: `window_width_3d`, `legend_width_3d`, `camera_dist_scale`, `orbit_mouse_sensitivity`, `orbit_key_deg_per_sec`, … in [`configs/default.yaml`](configs/default.yaml). In code, use `GameEnv(..., render_mode="human_3d")` or `render_mode="rgb_array_3d"` for full-window RGB captures (map + legend). - -On **Linux**, Mesa packages expose `libGL.so.1` (not `libGL.so`); this project patches library loading so moderngl finds them. If you still see GL errors, install dri drivers: `sudo apt install -y libgl1-mesa-dri libegl1`. On **WSL2** you need a working GUI stack (WSLg); without it, use 2D or run from native Windows. If 3D fails, `run_game.py` falls back to the 2D pygame window and logs a warning. - -### Training (Hydra) +Use a checkpoint trained with the same **map size** and **`game.max_bots_for_role_change`** as in your config. -The default config composes `training: train_bots_selfplay` (see [`configs/default.yaml`](configs/default.yaml)). Stages are selected with `training.stage` (`0` = unified, `1`–`3` = legacy pipeline, `4` = MAPPO bots). +--- -**Stage 4 — MAPPO bot self-play (Baseline v1)** - -Centralized critic + parameter sharing: **all** learner-team bots move each tick (`MAPPOBotEnv`, `MultiDiscrete` length `game.max_bots_for_role_change`). Checkpoints under `checkpoints/bots_mappo/`; self-play **snapshots** go to `checkpoints/pool/bots_mappo/mappo_bot_iter*.zip` (extended obs). **Opponents** are sampled only from `checkpoints/pool/bots/*.zip` and must be **181-dim** (stage 1 / unified bot). Uses **TensorBoard** only (`logging.use_tensorboard` in [`configs/training/train_mappo_bots.yaml`](configs/training/train_mappo_bots.yaml); scalars under `logs/mappo_bots/`). Episode diagnostics: rolling fractions under `mappo/outcome_frac/*` (win / loss / draw / truncated) and `mappo/terminal_reason_frac/*` (e.g. `townhall_destroyed`, `stagnation`, `max_ticks`), controlled by `training.mappo_metrics_window`. - -Files named `mappo_bot*.zip` under `pool/bots/` are **ignored** (treat as misplaced MAPPO saves). If there is no usable 181-dim checkpoint, only **SubprocVecEnv worker 0** logs a single WARNING (other workers stay silent). Add at least one stage-1-style bot zip in `pool/bots/` for real self-play. +## Train MAPPO ```bash -python scripts/run_training.py training=train_mappo_bots +python scripts/run_training.py ``` -Smoke run: +Quick test (small run): ```bash -python scripts/run_training.py training=train_mappo_bots \ +python scripts/run_training.py \ training.total_timesteps=2000 training.n_envs=1 training.selfplay_iterations=2 ``` -**Stage 1 — bot self-play (role-conditioned PPO, Baseline v0)** - -```bash -python scripts/run_training.py training.stage=1 -``` - -Uses `game.initial_bots: 1` in [`configs/training/train_bots_selfplay.yaml`](configs/training/train_bots_selfplay.yaml) so each team has one controllable unit per episode (reproducible with `SubprocVecEnv`; multi-bot stage 1 would need extra machinery such as checkpoint sync or `DummyVecEnv`-only runs). +Settings live in [`configs/training/train_mappo_bots.yaml`](configs/training/train_mappo_bots.yaml) (merged via [`configs/default.yaml`](configs/default.yaml)). Override anything under `training.*` on the command line. -**Stage 2 — village self-play (MaskablePPO, frozen bot policy)** - -Requires a stage-1 artifact at `checkpoints/bots/bot_final.zip`. +**TensorBoard:** logs go under `logs/mappo_bots/` when enabled. Then: ```bash -python scripts/run_training.py training=train_village_selfplay training.stage=2 -``` - -**Stage 3 — joint fine-tuning** - -Loads `checkpoints/village/village_final.zip` when present; bots use `checkpoints/bots/bot_final.zip` when present (otherwise random bot actions). - -```bash -python scripts/run_training.py training=train_joint training.stage=3 -``` - -**Unified training (recommended)** — `training.stage=0`, single process alternating two learners - -No prerequisite checkpoints. Team 0’s bot policy (PPO) and red manager (MaskablePPO) are updated in turns; after each phase the trainer saves so the other phase loads a frozen partner from disk. Opponents use the same self-play pools as stages 1–2; empty pools still fall back to random valid actions. - -```bash -python scripts/run_training.py training=train_unified +tensorboard --logdir logs/ ``` -You can also set `training.stage=0` with another `training=` group; built-in defaults for `unified.*` still apply, but prefer `training=train_unified` so values in [`configs/training/train_unified.yaml`](configs/training/train_unified.yaml) are loaded. - -Configure macro steps with `unified.bot_steps_per_turn`, `unified.village_steps_per_turn`, `unified.n_cycles`, optional `unified.first_phase` (`bot` or `village`), and `unified.push_to_pool` (append snapshots to `checkpoints/pool/bots` and `.../village`) in [`configs/training/train_unified.yaml`](configs/training/train_unified.yaml). Each phase logs an estimated **SB3 iteration count** (`n_steps × n_envs` env-steps per iteration). `unified.progress_bar: true` enables Stable-Baselines’ tqdm progress bar for the current `learn()` (requires `tqdm` and `rich` in [`requirements.txt`](requirements.txt)). `unified.sb3_verbose` controls SB3’s stdout tables (`0` by default when using the progress bar). `unified.progress_log_interval_sec` throttles **Loguru** lines with progress, ETA, env-steps/s, and wall time of the previous full SB3 iteration (see `run_training.log` under the Hydra run directory). Set `unified.plot_metrics_on_finish: true` to write PNG grids of TensorBoard scalars into the same Hydra output folder when the run finishes (or run [`scripts/plot_tensorboard_scalars.py`](scripts/plot_tensorboard_scalars.py) manually). Outputs live under `checkpoints/unified/` (`bot_final.zip`, `village_final.zip`, plus per-cycle checkpoints and `bot_latest` / `village_latest` stems used between phases). +**Plots:** `python scripts/plot_tensorboard_scalars.py --log-root logs` writes `logs/plots/mappo_bots_scalars.png` by default. -The bot phase uses `DummyVecEnv` only so every sub-env shares the in-process `bot_policy_holder`; do not use `SubprocVecEnv` for that phase. Default `game.initial_bots: 1` matches stage 1; more red bots require a live model in the holder for the extra units. +--- -Stages 1–3 remain a supported alternative pipeline; stage 4 is the MAPPO baseline for multi-bot coordination research. +## Checkpoints (where things go) -**Useful overrides** +| Path | What it is | +|------|------------| +| `checkpoints/bots_mappo/mappo_bot_final.zip` | Your trained MAPPO policy | +| `checkpoints/pool/bots_mappo/*.zip` | Snapshots during training (not used as opponents) | +| `checkpoints/pool/bots/*.zip` | *Optional* opponent bots (181-dim PPO, same obs as `GameEnv` bot mode). Empty pool → random opponents. | -```bash -python scripts/run_training.py training.total_timesteps=2000 training.n_envs=1 -``` +--- -### Metrics (TensorBoard) - -With `logging.use_tensorboard: true` (default) and `tensorboard` installed, stage 1 and 2 write training scalars under `logs/bots/` and `logs/village/`; stage 4 (MAPPO) under `logs/mappo_bots/`; unified training writes under `logs/unified_bots/` and `logs/unified_village/`. Periodic evaluation logs `eval/mean_reward` (and related fields) under `logs/bots_eval/` and `logs/village_eval/` when `training.eval_freq > 0` (default `10000` **environment timesteps** between evals; internally scaled by `n_envs` per Stable-Baselines3). The unified config sets `eval_freq: 0` by default (no separate eval pass yet). Tune with `training.n_eval_episodes`. +## Optional: stress-test the env ```bash -tensorboard --logdir logs/ -``` - -After unified training (or anytime), generate static PNG grids from the latest run in each subfolder: - -```bash -python scripts/plot_tensorboard_scalars.py --log-root logs +python scripts/evaluate.py ``` -Default outputs: `logs/plots/unified_bots_scalars.png` and `logs/plots/unified_village_scalars.png` (or under the Hydra run directory when `unified.plot_metrics_on_finish` is enabled). - -**Best vs last checkpoint:** when evaluation is enabled and at least one eval run produced a best model, `checkpoints/bots/bot_final.zip` and `checkpoints/village/village_final.zip` are copies of the best eval checkpoint (also saved as `bot_best.zip` / `village_best.zip`). The last weights after all self-play iterations are kept as `bot_last.zip` / `village_last.zip`. Set `training.eval_freq=0` to keep the previous behavior (final = last iteration only). Stage 3 joint training does not add a separate eval pass yet; it still saves `checkpoints/joint/joint_final.zip` from the end of the run. Unified training always ends with `bot_final.zip` / `village_final.zip` as the last full save after all cycles (no best-model selection unless you add eval later). +Runs many episodes with random village actions and prints rough stats. -**Evaluation** +--- -```bash -python scripts/evaluate.py -``` +## Code map (if you want to dig in) -## Checkpoints (typical layout) - -| Path | Contents | -|------|-----------| -| `checkpoints/bots/bot_final.zip` | Stage 1 policy (best eval mean reward when `training.eval_freq > 0`, else last iteration) | -| `checkpoints/bots_mappo/mappo_bot_final.zip` | Stage 4 MAPPO policy (last save after all self-play iterations) | -| `checkpoints/pool/bots/*.zip` | 181-dim bot policies (opponents for MAPPO and stage 1) | -| `checkpoints/pool/bots_mappo/*.zip` | MAPPO training snapshots (extended obs; not used as opponents) | -| `checkpoints/village/village_final.zip` | Stage 2 manager (same best-vs-last rule as bots) | -| `checkpoints/pool/village/*.zip` | Historical village policies for self-play | -| `checkpoints/joint/joint_final.zip` | Stage 3 output | -| `checkpoints/unified/bot_final.zip` | Unified loop bot policy (last save after all cycles) | -| `checkpoints/unified/village_final.zip` | Unified loop village policy (last save after all cycles) | -| `checkpoints/unified/bot_latest.zip` / `village_latest.zip` | Latest weights exchanged between alternating phases | -| `checkpoints/unified/bot_cycle*.zip` / `village_cycle*.zip` | Periodic `CheckpointCallback` snapshots during unified runs | - -## Project layout (RL baseline) - -- [`src/village_ai_war/env/game_env.py`](src/village_ai_war/env/game_env.py) — `step`, `step_with_opponent`, `step_village_only`, `queue_bot_action`, `_simulation_tick` (MAPPO tick path), optional `game.bot_rl_checkpoint` / `training.bot_checkpoint` for frozen bot PPO -- [`src/village_ai_war/agents/village_obs_builder.py`](src/village_ai_war/agents/village_obs_builder.py) — `build_map`, `build_village_vec` (global critic / MAPPO) -- [`src/village_ai_war/models/role_conditioned_policy.py`](src/village_ai_war/models/role_conditioned_policy.py) -- [`src/village_ai_war/models/mappo_actor.py`](src/village_ai_war/models/mappo_actor.py), [`mappo_critic.py`](src/village_ai_war/models/mappo_critic.py), [`mappo_policy.py`](src/village_ai_war/models/mappo_policy.py), [`mappo_layout.py`](src/village_ai_war/models/mappo_layout.py) -- [`src/village_ai_war/training/self_play_env.py`](src/village_ai_war/training/self_play_env.py) — `SelfPlayBotEnv`, `SelfPlayVillageEnv`, `UnifiedBotSelfPlayEnv` -- [`src/village_ai_war/training/mappo_env.py`](src/village_ai_war/training/mappo_env.py) — `MAPPOBotEnv`; [`global_state_callback.py`](src/village_ai_war/training/global_state_callback.py) (optional diagnostics from `info`) -- [`src/village_ai_war/training/train_bots_selfplay.py`](src/village_ai_war/training/train_bots_selfplay.py), [`train_mappo_bots.py`](src/village_ai_war/training/train_mappo_bots.py), [`train_village_selfplay.py`](src/village_ai_war/training/train_village_selfplay.py), [`train_joint.py`](src/village_ai_war/training/train_joint.py), [`train_unified.py`](src/village_ai_war/training/train_unified.py), [`tensorboard_plots.py`](src/village_ai_war/training/tensorboard_plots.py), [`plot_tensorboard_scalars.py`](scripts/plot_tensorboard_scalars.py) - -Legacy trainers [`train_bots.py`](src/village_ai_war/training/train_bots.py) and [`train_village.py`](src/village_ai_war/training/train_village.py) are not used by [`scripts/run_training.py`](scripts/run_training.py). - -## Project status - -- [x] Data structures (Pydantic) -- [x] Map generator -- [x] Economy / combat / building systems -- [x] Observation builders and action masker -- [x] GameEnv (Gymnasium), no bot heuristics -- [x] Role-conditioned bot policy + PPO self-play (stage 1, Baseline v0) -- [x] MAPPO bot baseline — centralized critic, concat global obs, stage 4 (`train_mappo_bots`) -- [x] Village MaskablePPO self-play with RL bots (stage 2) -- [x] Joint fine-tuning with RL bots (stage 3) -- [x] Unified training loop (alternating bot PPO + village MaskablePPO) -- [x] Pygame renderer +Core pieces: [`GameEnv`](src/village_ai_war/env/game_env.py), [`MAPPOBotEnv`](src/village_ai_war/training/mappo_env.py), [`train_mappo_bots.py`](src/village_ai_war/training/train_mappo_bots.py), MAPPO models under [`src/village_ai_war/models/mappo_*.py`](src/village_ai_war/models/). diff --git a/configs/default.yaml b/configs/default.yaml index 333d0b9..4b34f55 100644 --- a/configs/default.yaml +++ b/configs/default.yaml @@ -5,7 +5,7 @@ defaults: - buildings - rewards/bot_rewards - rewards/village_rewards - - training: train_bots_selfplay + - training: train_mappo_bots - _self_ game: diff --git a/configs/training/train_bots.yaml b/configs/training/train_bots.yaml deleted file mode 100644 index 402f9d9..0000000 --- a/configs/training/train_bots.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# @package _global_ -training: - stage: 1 - total_timesteps: 100000 - n_envs: 4 - learning_rate: 3.0e-4 - batch_size: 256 - n_epochs: 10 - gamma: 0.99 - checkpoint_interval: 50000 - log_dir: logs/ - checkpoint_dir: checkpoints/ - bot_joint_lr_factor: 0.1 diff --git a/configs/training/train_bots_selfplay.yaml b/configs/training/train_bots_selfplay.yaml deleted file mode 100644 index 2b29a71..0000000 --- a/configs/training/train_bots_selfplay.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# @package _global_ -# Self-play stage 1: one role-conditioned PPO for all bot roles. -# Use: python scripts/run_training.py training=train_bots_selfplay -# Multi-bot stage-1 needs policy sync or DummyVecEnv-only; see initial_bots below. -training: - stage: 1 - total_timesteps: 200000 - selfplay_iterations: 20 - n_envs: 8 - n_steps: 512 - learning_rate: 3.0e-4 - batch_size: 256 - n_epochs: 10 - gamma: 0.99 - gae_lambda: 0.95 - clip_range: 0.2 - ent_coef: 0.01 - pool_max_size: 10 - checkpoint_interval: 50000 - eval_freq: 10000 - n_eval_episodes: 5 - pool_dir: checkpoints/pool - checkpoint_dir: checkpoints - log_dir: logs - -game: - initial_bots: 1 diff --git a/configs/training/train_joint.yaml b/configs/training/train_joint.yaml deleted file mode 100644 index 84f34a7..0000000 --- a/configs/training/train_joint.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# @package _global_ -training: - stage: 3 - total_timesteps: 500000 - n_envs: 4 - learning_rate: 1.0e-4 - batch_size: 256 - n_epochs: 10 - gamma: 0.99 - checkpoint_interval: 50000 - eval_freq: 10000 - n_eval_episodes: 5 - log_dir: logs/ - checkpoint_dir: checkpoints/ - bot_joint_lr_factor: 0.1 diff --git a/configs/training/train_mappo_bots.yaml b/configs/training/train_mappo_bots.yaml index 2c9441a..7584bee 100644 --- a/configs/training/train_mappo_bots.yaml +++ b/configs/training/train_mappo_bots.yaml @@ -1,8 +1,7 @@ # @package _global_ -# MAPPO baseline (stage 4): centralized critic + concat global obs. -# Use: python scripts/run_training.py training=train_mappo_bots +# MAPPO: decentralized actor + centralized critic on global obs. +# Use: python scripts/run_training.py training: - stage: 4 algorithm: mappo total_timesteps: 3000000 selfplay_iterations: 30 diff --git a/configs/training/train_unified.yaml b/configs/training/train_unified.yaml deleted file mode 100644 index 5f1fc1c..0000000 --- a/configs/training/train_unified.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# @package _global_ -# Unified training loop: alternating bot PPO and village MaskablePPO. -# Use: python scripts/run_training.py training=train_unified -training: - stage: 0 - total_timesteps: 400000 - n_envs: 8 - n_steps: 512 - learning_rate: 3.0e-3 - batch_size: 256 - n_epochs: 10 - gamma: 0.95 - gae_lambda: 0.95 - clip_range: 0.1 - ent_coef: 0.01 - pool_max_size: 10 - checkpoint_interval: 50000 - eval_freq: 0 - n_eval_episodes: 5 - pool_dir: checkpoints/pool - checkpoint_dir: checkpoints - log_dir: logs - -unified: - n_cycles: 10 - bot_steps_per_turn: 20000 - village_steps_per_turn: 20000 - first_phase: bot - push_to_pool: true - progress_log_interval_sec: 30.0 - # SB3 tqdm/rich bar for this learn(); needs tqdm + rich in requirements - progress_bar: true - # SB3 stdout tables; default pairs with progress_bar (quieter terminal) - sb3_verbose: 0 - plot_metrics_on_finish: true - -game: - initial_bots: 10 diff --git a/configs/training/train_village.yaml b/configs/training/train_village.yaml deleted file mode 100644 index 4e90908..0000000 --- a/configs/training/train_village.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# @package _global_ -# Override via: python scripts/run_training.py --config-name=training/train_village -training: - stage: 2 - total_timesteps: 1000000 - n_envs: 4 - learning_rate: 3.0e-4 - batch_size: 256 - n_epochs: 10 - gamma: 0.99 - checkpoint_interval: 50000 - log_dir: logs/ - checkpoint_dir: checkpoints/ - bot_joint_lr_factor: 0.1 diff --git a/configs/training/train_village_selfplay.yaml b/configs/training/train_village_selfplay.yaml deleted file mode 100644 index 9d31a6d..0000000 --- a/configs/training/train_village_selfplay.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# @package _global_ -# Self-play stage 2: MaskablePPO manager vs pool; bots from stage-1 checkpoint. -# Use: python scripts/run_training.py training=train_village_selfplay training.stage=2 -training: - stage: 2 - total_timesteps: 1000000 - selfplay_iterations: 10 - n_envs: 4 - n_steps: 256 - learning_rate: 1.0e-4 - batch_size: 128 - n_epochs: 10 - gamma: 0.99 - pool_max_size: 10 - checkpoint_interval: 25000 - eval_freq: 10000 - n_eval_episodes: 5 - pool_dir: checkpoints/pool - checkpoint_dir: checkpoints - log_dir: logs - -game: - bot_rl_checkpoint: checkpoints/bots/bot_final.zip diff --git a/pyproject.toml b/pyproject.toml index d3f1c2e..98fc854 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,3 +29,6 @@ target-version = "py310" [tool.ruff.lint] select = ["E", "F", "I", "UP"] + +[tool.ruff.lint.per-file-ignores] +"scripts/*.py" = ["E402"] diff --git a/requirements.txt b/requirements.txt index 388aa28..d5e6167 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ gymnasium>=0.29 stable-baselines3>=2.0 tensorboard>=2.14 -sb3-contrib>=2.0 torch>=2.0 pygame>=2.5 moderngl>=5.10 diff --git a/scripts/plot_tensorboard_scalars.py b/scripts/plot_tensorboard_scalars.py index 367e897..d3cc6fe 100644 --- a/scripts/plot_tensorboard_scalars.py +++ b/scripts/plot_tensorboard_scalars.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Plot TensorBoard scalar runs (e.g. unified_bots / unified_village) to PNG grids.""" +"""Plot TensorBoard scalar runs (default: MAPPO logs/mappo_bots) to PNG grids.""" from __future__ import annotations diff --git a/scripts/run_game.py b/scripts/run_game.py index e98cf44..9909e67 100644 --- a/scripts/run_game.py +++ b/scripts/run_game.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Play a match with optional trained village / bot policies, human vs AI, or vs MAPPO.""" +"""Play: human vs MAPPO (2D), or passive demo with random village manager steps.""" from __future__ import annotations @@ -11,18 +11,13 @@ _ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_ROOT / "src")) -import numpy as np # noqa: E402 -from loguru import logger # noqa: E402 +import numpy as np +from loguru import logger -from village_ai_war.config_load import load_project_config # noqa: E402 -from village_ai_war.env.game_env import GameEnv # noqa: E402 -from village_ai_war.play.human_controls import ( # noqa: E402 - collect_blue_bot_actions_for_tick, - collect_team_bot_actions_for_tick, - collect_village_action_for_tick, -) -from village_ai_war.play.mappo_human_tick import play_mappo_human_tick # noqa: E402 -from village_ai_war.training.self_play_env import _maskable_village_obs_matches_env # noqa: E402 +from village_ai_war.config_load import load_project_config +from village_ai_war.env.game_env import GameEnv +from village_ai_war.play.human_controls import collect_blue_bot_actions_for_tick +from village_ai_war.play.mappo_human_tick import play_mappo_human_tick def _resolve_ckpt(path_str: str | None) -> Path | None: @@ -35,10 +30,10 @@ def _resolve_ckpt(path_str: str | None) -> Path | None: def _load_mappo_policy(path: Path, _flat: dict) -> object: - import village_ai_war.models.mappo_policy # noqa: F401 — SB3 unpickle - from stable_baselines3 import PPO + import village_ai_war.models.mappo_policy # noqa: F401 — SB3 unpickle + return PPO.load( str(path), device="auto", @@ -49,68 +44,106 @@ def _load_mappo_policy(path: Path, _flat: dict) -> object: ) +def _run_random_village_demo( + flat: dict, + *, + seed: int, + max_steps: int, + human_3d: bool, +) -> None: + render_mode = "human_3d" if human_3d else "human" + try: + env = GameEnv(flat, mode="village", team=0, render_mode=render_mode) + except Exception as e: # noqa: BLE001 + logger.warning( + "Display render unavailable ({}); falling back to no window", + e, + ) + env = GameEnv(flat, mode="village", team=0, render_mode=None) + + if env.render_mode == "human_3d": + try: + env.render() + except (OSError, RuntimeError) as e: + msg = str(e) + if ( + "OpenGL libraries missing" in msg + or "Could not load OpenGL" in msg + or "libGL" in msg + or "libEGL" in msg + or "libgl.so" in msg.lower() + or "libegl.so" in msg.lower() + ): + logger.warning( + "3D view failed ({}). Install OpenGL on Linux/WSL " + "(e.g. sudo apt install -y libgl1 libegl1), or use 2D. " + "Falling back to pygame window.", + e, + ) + env.close() + env = GameEnv(flat, mode="village", team=0, render_mode="human") + else: + raise + + rng = np.random.default_rng(seed) + _obs, _ = env.reset(seed=seed) + + if env.render_mode is not None: + logger.info( + "Random village demo | render_mode={} | max_steps={} | close window or Ctrl+C to stop", + env.render_mode, + max_steps, + ) + + for t in range(max_steps): + m = env.action_masks() + a = int(rng.choice(np.flatnonzero(m))) + _obs, _r, term, trunc, info = env.step(a) + if env.render_mode is not None: + env.render() + if term or trunc: + logger.info("Done at t={} info={}", t, info) + break + env.close() + + def main() -> None: warnings.filterwarnings( "ignore", category=UserWarning, module="pygame.pkgdata", ) - parser = argparse.ArgumentParser(description="Run Village AI War with optional RL checkpoints.") + parser = argparse.ArgumentParser( + description="Village AI War: human vs MAPPO, or random village-manager demo." + ) parser.add_argument( "--mappo-opponent", default="", help="Path to MAPPO zip (e.g. checkpoints/bots_mappo/mappo_bot_final.zip). " "Human plays BLUE vs MAPPO on RED; no village AI (training-faithful micro).", ) - parser.add_argument( - "--human", - choices=("red", "blue", ""), - default="", - help="Play as this team (village + bots). Leave empty for AI-only demo. " - "With --mappo-opponent only 'blue' is allowed.", - ) - parser.add_argument( - "--village-checkpoint", - default="checkpoints/village/village_final.zip", - help="MaskablePPO zip for red manager; if missing, random valid actions.", - ) - parser.add_argument( - "--opponent-village-checkpoint", - default="", - help="MaskablePPO zip for blue manager; if empty or missing, random valid actions.", - ) - parser.add_argument( - "--bot-checkpoint", - default="checkpoints/bots/bot_final.zip", - help="PPO zip for low-level bots; if missing, random bot moves.", - ) parser.add_argument("--seed", type=int, default=0) parser.add_argument("--max-steps", type=int, default=500) parser.add_argument( "--deterministic", action="store_true", - help="Use deterministic policy.predict for loaded policies.", + help="Deterministic policy.predict for MAPPO opponent.", ) parser.add_argument( "--human-3d", action="store_true", - help="OpenGL 3D board; not used with --mappo-opponent or --human (2D only).", + help="OpenGL 3D board for the random demo only (ignored with --mappo-opponent).", ) args = parser.parse_args() flat = load_project_config(_ROOT) mappo_path = _resolve_ckpt(args.mappo_opponent or None) - human_side = args.human or None if str(args.mappo_opponent).strip() and mappo_path is None: logger.error("MAPPO checkpoint not found: {}", args.mappo_opponent) sys.exit(1) if mappo_path is not None: - if args.human == "red": - logger.error("MAPPO opponent only supports playing as BLUE (team 1).") - sys.exit(2) - human_side = "blue" if args.human_3d: logger.warning("MAPPO human play uses 2D pygame only; ignoring --human-3d.") render_mode = "human" @@ -174,223 +207,12 @@ def _render_cb(overlay_lines: tuple[str, ...] = ()) -> None: env.close() return - # --- Legacy AI demo or human vs MaskablePPO + PPO bots --- - bot_path = _resolve_ckpt(args.bot_checkpoint) - if bot_path is not None: - game = dict(flat.get("game", {})) - game["bot_rl_checkpoint"] = str(bot_path) - flat = {**flat, "game": game} - - village_path = _resolve_ckpt(args.village_checkpoint) - opp_path = _resolve_ckpt(args.opponent_village_checkpoint or None) - - red_model = None - blue_model = None - if village_path is not None: - try: - from sb3_contrib import MaskablePPO - - red_model = MaskablePPO.load(str(village_path), device="auto") - logger.info("Loaded red village policy from {}", village_path) - except Exception as e: # noqa: BLE001 - logger.warning("Could not load red village policy ({}); using random red actions", e) - if opp_path is not None: - try: - from sb3_contrib import MaskablePPO - - blue_model = MaskablePPO.load(str(opp_path), device="auto") - logger.info("Loaded blue village policy from {}", opp_path) - except Exception as e: # noqa: BLE001 - logger.warning("Could not load blue village policy ({}); using random blue actions", e) - - bot_policy = None - if bot_path is not None: - try: - from stable_baselines3 import PPO - - bot_policy = PPO.load(str(bot_path), device="auto") - logger.info("Loaded bot policy from {}", bot_path) - except Exception as e: # noqa: BLE001 - logger.warning("Could not load bot policy ({}); using random bot moves", e) - - if human_side and args.human_3d: - logger.warning("Human play uses 2D pygame; ignoring --human-3d.") - render_mode = "human" if human_side or not args.human_3d else "human_3d" - - try: - env = GameEnv(flat, mode="village", team=0, render_mode=render_mode) - except Exception as e: # noqa: BLE001 - logger.warning( - "Display render unavailable ({}); falling back to no window", - e, - ) - env = GameEnv(flat, mode="village", team=0, render_mode=None) - - env_obs_space = env.observation_space - if red_model is not None and not _maskable_village_obs_matches_env(red_model, env_obs_space): - logger.warning( - "Red village checkpoint {} does not match game observation space (e.g. map.size); " - "policy obs {} != env {} — using random red manager actions", - village_path, - red_model.observation_space, - env_obs_space, - ) - red_model = None - if blue_model is not None and not _maskable_village_obs_matches_env(blue_model, env_obs_space): - logger.warning( - "Blue village checkpoint {} does not match game observation space; " - "policy obs {} != env {} — using random blue manager actions", - opp_path, - blue_model.observation_space, - env_obs_space, - ) - blue_model = None - - rng = np.random.default_rng(args.seed) - obs, _ = env.reset(seed=args.seed) - use_trained_tick = red_model is not None or blue_model is not None or bot_policy is not None - noop_v = int(env._village_space.offset_noop) # noqa: SLF001 - - if human_side and env.render_mode is None: - logger.error("Human play needs a display (pygame 2D window).") - sys.exit(1) - - if env.render_mode == "human_3d": - try: - env.render() - except (OSError, RuntimeError) as e: - msg = str(e) - if ( - "OpenGL libraries missing" in msg - or "Could not load OpenGL" in msg - or "libGL" in msg - or "libEGL" in msg - or "libgl.so" in msg.lower() - or "libegl.so" in msg.lower() - ): - logger.warning( - "3D view failed ({}). Install OpenGL on Linux/WSL " - "(e.g. sudo apt install -y libgl1 libegl1), or use 2D. " - "Falling back to pygame window.", - e, - ) - env.close() - env = GameEnv(flat, mode="village", team=0, render_mode="human") - obs, _ = env.reset(seed=args.seed) - else: - raise - - import pygame # noqa: PLC0415 - - def _render_v(overlay_lines: tuple[str, ...] | None = None) -> None: - if env.render_mode is not None: - env.render(overlay_lines=overlay_lines or ()) - - def _render_v_cb(overlay_lines: tuple[str, ...] = ()) -> None: - _render_v(overlay_lines) - - if env.render_mode is not None: - logger.info( - "Viewer render_mode={} | max_steps={} | close the window or Ctrl+C to stop early", - env.render_mode, - args.max_steps, - ) - - human_team: int | None = None - if human_side == "red": - human_team = 0 - elif human_side == "blue": - human_team = 1 - - for t in range(args.max_steps): - st = env.game_state - assert st is not None - interval = int(flat["game"]["manager_interval"]) - is_mgr = st.tick % interval == 0 - - if human_team is not None: - h_bots = collect_team_bot_actions_for_tick( - env, - pygame, - human_team, - render=_render_v_cb, - ) - if is_mgr: - a_human = collect_village_action_for_tick( - env, - human_team, - pygame, - render=_render_v_cb, - ) - else: - a_human = noop_v - if human_team == 0: - a0 = a_human - m1 = env.action_masks(team=1) - if blue_model is not None: - o1 = env.get_village_observation(1) - p1, _ = blue_model.predict( - o1, - action_masks=m1, - deterministic=args.deterministic, - ) - a1 = int(np.asarray(p1).reshape(-1)[0]) - else: - a1 = int(rng.choice(np.flatnonzero(m1))) - else: - a1 = a_human - m0 = env.action_masks(team=0) - if red_model is not None: - o0 = env.get_village_observation(0) - p0, _ = red_model.predict( - o0, - action_masks=m0, - deterministic=args.deterministic, - ) - a0 = int(np.asarray(p0).reshape(-1)[0]) - else: - a0 = int(rng.choice(np.flatnonzero(m0))) - obs, r, term, trunc, info = env.run_bots_then_village_decisions( - bot_policy, - a0, - a1, - human_team=human_team, - human_bot_actions=h_bots, - ) - elif use_trained_tick: - m0 = env.action_masks(team=0) - m1 = env.action_masks(team=1) - obs0 = env.get_village_observation(0) - if red_model is not None: - a0, _ = red_model.predict( - obs0, - action_masks=m0, - deterministic=args.deterministic, - ) - a0 = int(np.asarray(a0).reshape(-1)[0]) - else: - a0 = int(rng.choice(np.flatnonzero(m0))) - if blue_model is not None: - obs1 = env.get_village_observation(1) - a1, _ = blue_model.predict( - obs1, - action_masks=m1, - deterministic=args.deterministic, - ) - a1 = int(np.asarray(a1).reshape(-1)[0]) - else: - a1 = int(rng.choice(np.flatnonzero(m1))) - obs, r, term, trunc, info = env.run_bots_then_village_decisions(bot_policy, a0, a1) - else: - m = env.action_masks() - a = int(rng.choice(np.flatnonzero(m))) - obs, r, term, trunc, info = env.step(a) - if env.render_mode is not None: - env.render() - if term or trunc: - logger.info("Done at t={} info={}", t, info) - break - env.close() + _run_random_village_demo( + flat, + seed=args.seed, + max_steps=args.max_steps, + human_3d=args.human_3d, + ) if __name__ == "__main__": diff --git a/scripts/run_training.py b/scripts/run_training.py index c370332..0a9fc0a 100644 --- a/scripts/run_training.py +++ b/scripts/run_training.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Launch training stages via Hydra (``training.stage``: 0 unified, 1–3 legacy, 4 MAPPO bots).""" +"""Launch MAPPO bot training via Hydra.""" from __future__ import annotations @@ -14,13 +14,9 @@ import hydra from hydra.core.hydra_config import HydraConfig from loguru import logger -from omegaconf import DictConfig, OmegaConf +from omegaconf import DictConfig -from village_ai_war.training.train_bots_selfplay import run_bots_selfplay_training -from village_ai_war.training.train_joint import run_joint_training from village_ai_war.training.train_mappo_bots import run_mappo_bots_training -from village_ai_war.training.train_unified import run_unified_training -from village_ai_war.training.train_village_selfplay import run_village_selfplay_training class _InterceptHandler(logging.Handler): @@ -55,24 +51,10 @@ def _configure_run_log_file() -> None: @hydra.main(version_base=None, config_path=str(_ROOT / "configs"), config_name="default") def main(cfg: DictConfig) -> None: - """Dispatch to stage trainers.""" + """Run MAPPO bot self-play training.""" _configure_run_log_file() - flat = OmegaConf.to_container(cfg, resolve=True) - - stage = int(flat["training"]["stage"]) - logger.info("Starting training stage {}", stage) - if stage == 0: - run_unified_training(cfg) - elif stage == 1: - run_bots_selfplay_training(cfg) - elif stage == 2: - run_village_selfplay_training(cfg) - elif stage == 3: - run_joint_training(cfg) - elif stage == 4: - run_mappo_bots_training(cfg) - else: - raise ValueError(f"Unknown training.stage={stage}") + logger.info("Starting MAPPO bot training") + run_mappo_bots_training(cfg) if __name__ == "__main__": diff --git a/src/village_ai_war/env/game_env.py b/src/village_ai_war/env/game_env.py index 980f861..6878d9c 100644 --- a/src/village_ai_war/env/game_env.py +++ b/src/village_ai_war/env/game_env.py @@ -131,7 +131,7 @@ def reset( self._prev_distances.clear() self._tick_start_positions.clear() - # Pick controlled bot for bot mode (optional role filter for stage-1 training) + # Pick controlled bot for bot mode (optional role filter) if self.mode == "bot": vb = self._state.villages[self.team].bots role_filter: int | None = int(self.bot_role) if self.bot_role is not None else None diff --git a/src/village_ai_war/training/progress_callback.py b/src/village_ai_war/training/progress_callback.py deleted file mode 100644 index 13f372a..0000000 --- a/src/village_ai_war/training/progress_callback.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Loguru progress logging for a single SB3 ``learn()`` phase (unified training).""" - -from __future__ import annotations - -import time -from typing import Any - -from loguru import logger -from stable_baselines3.common.callbacks import BaseCallback - - -class UnifiedPhaseProgressCallback(BaseCallback): - """Log phase progress, ETA, env-steps/s, and SB3 iteration wall time (rollout+train).""" - - def __init__( - self, - phase_name: str, - cycle_one_based: int, - n_cycles: int, - steps_budget: int, - timesteps_start: int, - log_interval_sec: float = 30.0, - verbose: int = 0, - ) -> None: - super().__init__(verbose) - self.phase_name = phase_name - self.cycle_one_based = cycle_one_based - self.n_cycles = n_cycles - self.steps_budget = max(1, int(steps_budget)) - self.timesteps_start = int(timesteps_start) - self.log_interval_sec = float(log_interval_sec) - self._t0 = time.perf_counter() - self._last_log_t = self._t0 - self._rollout_idx = 0 - self._rollout_start_t: float | None = None - self._last_iter_wall_s: float | None = None - - def _on_step(self) -> bool: - return True - - def _on_rollout_start(self) -> None: - now = time.perf_counter() - if self._rollout_start_t is not None: - self._last_iter_wall_s = now - self._rollout_start_t - self._rollout_start_t = now - - def _on_rollout_end(self) -> None: - assert self.model is not None - self._rollout_idx += 1 - now = time.perf_counter() - done = int(self.model.num_timesteps) - self.timesteps_start - if done <= 0: - return - - due_time = (now - self._last_log_t) >= self.log_interval_sec - due_complete = done >= self.steps_budget - first = self._rollout_idx == 1 - if not first and not due_time and not due_complete: - return - - self._last_log_t = now - elapsed = now - self._t0 - frac = min(1.0, done / self.steps_budget) - remaining = max(0, self.steps_budget - done) - eta_sec = (elapsed / done) * remaining if done > 0 else 0.0 - fps = done / elapsed if elapsed > 0 else 0.0 - - iter_part = ( - f" | prev_full_iter ~{self._last_iter_wall_s:.1f}s" - if self._last_iter_wall_s is not None - else "" - ) - if first: - logger.info( - "Unified progress | cycle {}/{} | phase {} | first rollout done | " - "env_steps {}/{} ({:.1%}) | elapsed {:.0f}s | ETA ~{:.0f}s | " - "~{:.1f} env_steps/s{} (first iter often slower — GPU warmup)", - self.cycle_one_based, - self.n_cycles, - self.phase_name, - done, - self.steps_budget, - frac, - elapsed, - eta_sec, - fps, - iter_part, - ) - else: - logger.info( - "Unified progress | cycle {}/{} | phase {} | SB3 iter {} | " - "env_steps {}/{} ({:.1%}) | elapsed {:.0f}s | ETA ~{:.0f}s | " - "~{:.1f} env_steps/s{}", - self.cycle_one_based, - self.n_cycles, - self.phase_name, - self._rollout_idx, - done, - self.steps_budget, - frac, - elapsed, - eta_sec, - fps, - iter_part, - ) - - -def make_progress_callback( - model: Any, - *, - phase_name: str, - cycle_zero_based: int, - n_cycles: int, - steps_budget: int, - log_interval_sec: float, -) -> UnifiedPhaseProgressCallback: - """Snapshot ``model.num_timesteps`` as start of this ``learn()`` call.""" - start = int(model.num_timesteps) - return UnifiedPhaseProgressCallback( - phase_name=phase_name, - cycle_one_based=cycle_zero_based + 1, - n_cycles=n_cycles, - steps_budget=steps_budget, - timesteps_start=start, - log_interval_sec=log_interval_sec, - ) diff --git a/src/village_ai_war/training/self_play_env.py b/src/village_ai_war/training/self_play_env.py deleted file mode 100644 index 3cbcd7e..0000000 --- a/src/village_ai_war/training/self_play_env.py +++ /dev/null @@ -1,438 +0,0 @@ -"""Gym wrappers for self-play training (bots and village manager).""" - -from __future__ import annotations - -from collections.abc import Mapping, MutableMapping -from pathlib import Path -from typing import Any, SupportsFloat - -import gymnasium as gym -import numpy as np -from gymnasium import spaces -from loguru import logger -from sb3_contrib import MaskablePPO -from stable_baselines3 import PPO - -from village_ai_war.agents.bot_obs_builder import BotObsBuilder -from village_ai_war.env.game_env import GameEnv -from village_ai_war.rewards.bot_reward import BotRewardCalculator -from village_ai_war.state import GlobalRewardMode - - -def _maskable_village_obs_matches_env(policy: MaskablePPO, env_obs_space: gym.Space) -> bool: - """True if ``policy`` was trained on the same observation space as ``env_obs_space``.""" - try: - return policy.observation_space == env_obs_space - except Exception: # noqa: BLE001 - return False - - -def _obs_map_tensor_shape(obs_space: gym.Space) -> tuple[int, ...] | None: - """Shape of the ``map`` tensor in a village Dict obs space, or ``None``.""" - if isinstance(obs_space, spaces.Dict) and "map" in obs_space.spaces: - m = obs_space.spaces["map"] - if isinstance(m, spaces.Box): - return tuple(int(x) for x in m.shape) - return None - - -# One WARNING per (policy map shape, env map shape): many envs/zips share the same mismatch. -_village_obs_mismatch_warned_shapes: set[ - tuple[tuple[int, ...] | None, tuple[int, ...] | None] -] = set() - - -def _warn_village_obs_mismatch_once( - path: Path, - *, - role: str, - policy_space: gym.Space, - env_space: gym.Space, -) -> None: - pshape = _obs_map_tensor_shape(policy_space) - eshape = _obs_map_tensor_shape(env_space) - sig = (pshape, eshape) - if sig in _village_obs_mismatch_warned_shapes: - return - _village_obs_mismatch_warned_shapes.add(sig) - logger.warning( - "Village checkpoints do not match env obs (first hit: {} side, example {}): " - "policy {} != env {}; further incompatible zips are skipped without extra logs. " - "Using random village manager actions until compatible checkpoints exist.", - role, - path, - policy_space, - env_space, - ) - - -def _as_plain_config(config: Mapping[str, Any] | Any) -> dict[str, Any]: - from omegaconf import OmegaConf - - if OmegaConf.is_config(config): - return OmegaConf.to_container(config, resolve=True) # type: ignore[return-value] - return dict(config) - - -class SelfPlayBotEnv(gym.Env): - """Bot-mode env: team 0 learns; team 1 actions come from a checkpoint pool.""" - - def __init__( - self, - config: Mapping[str, Any] | Any, - opponent_pool_dir: str = "checkpoints/pool/bots", - opponent_sampling: str = "uniform", - ) -> None: - super().__init__() - self._flat_cfg = _as_plain_config(config) - self.inner = GameEnv(self._flat_cfg, mode="bot", team=0, render_mode=None) - self.observation_space = self.inner.observation_space - self.action_space = self.inner.action_space - self.opponent_pool_dir = Path(opponent_pool_dir) - self.opponent_sampling = opponent_sampling - self._opponent_policy: PPO | None = None - self._load_opponent() - - def _load_opponent(self) -> None: - self.opponent_pool_dir.mkdir(parents=True, exist_ok=True) - checkpoints = sorted(self.opponent_pool_dir.glob("*.zip")) - if not checkpoints: - self._opponent_policy = None - return - if self.opponent_sampling == "latest": - ckpt = checkpoints[-1] - elif self.opponent_sampling == "random": - ckpt = checkpoints[int(np.random.randint(len(checkpoints)))] - else: - ckpt = checkpoints[int(np.random.randint(len(checkpoints)))] - try: - self._opponent_policy = PPO.load(str(ckpt)) - except Exception as e: # noqa: BLE001 - logger.warning("Failed to load opponent bot policy from {}: {}", ckpt, e) - self._opponent_policy = None - - def reset( - self, - *, - seed: int | None = None, - options: dict[str, Any] | None = None, - ) -> tuple[np.ndarray, dict[str, Any]]: - super().reset(seed=seed) - if self.np_random.random() < 0.1: - self._load_opponent() - obs, info = self.inner.reset(seed=seed, options=options) - return obs, info - - def step(self, action: Any) -> tuple[np.ndarray, SupportsFloat, bool, bool, dict[str, Any]]: - blue_obs = self.inner._get_bot_obs(1) - if self._opponent_policy is not None and blue_obs is not None: - blue_act, _ = self._opponent_policy.predict(blue_obs, deterministic=False) - blue_action = int(np.asarray(blue_act).reshape(-1)[0]) - else: - blue_action = int(self.inner.action_space.sample()) - return self.inner.step_with_opponent(int(action), blue_action) - - def render(self) -> Any: - return self.inner.render() - - def close(self) -> None: - self.inner.close() - - -class SelfPlayVillageEnv(gym.Env): - """Village-mode env: RL bots frozen checkpoint + self-play opponent manager.""" - - def __init__( - self, - config: Mapping[str, Any] | Any, - bot_checkpoint_dir: str = "checkpoints/bots", - opponent_pool_dir: str = "checkpoints/pool/village", - opponent_sampling: str = "uniform", - ) -> None: - super().__init__() - self._flat_cfg = _as_plain_config(config) - self.inner = GameEnv(self._flat_cfg, mode="village", team=0, render_mode=None) - self.observation_space = self.inner.observation_space - self.action_space = self.inner.action_space - self.bot_checkpoint_dir = Path(bot_checkpoint_dir) - self.opponent_pool_dir = Path(opponent_pool_dir) - self.opponent_sampling = opponent_sampling - self._bot_policy: PPO | None = None - self._opponent_policy: MaskablePPO | None = None - self._load_bot_policy() - self._load_opponent() - - def _load_bot_policy(self) -> None: - for name in ("bot_final.zip", "bot_final"): - ckpt = self.bot_checkpoint_dir / name - if ckpt.is_file(): - try: - self._bot_policy = PPO.load(str(ckpt)) - return - except Exception as e: # noqa: BLE001 - logger.warning("Failed to load bot policy from {}: {}", ckpt, e) - self._bot_policy = None - - def _load_opponent(self) -> None: - self.opponent_pool_dir.mkdir(parents=True, exist_ok=True) - checkpoints = sorted(self.opponent_pool_dir.glob("*.zip")) - if not checkpoints: - self._opponent_policy = None - return - if self.opponent_sampling == "latest": - ckpt = checkpoints[-1] - else: - ckpt = checkpoints[int(np.random.randint(len(checkpoints)))] - try: - self._opponent_policy = MaskablePPO.load(str(ckpt)) - except Exception as e: # noqa: BLE001 - logger.warning("Failed to load opponent village policy from {}: {}", ckpt, e) - self._opponent_policy = None - - def action_masks(self) -> np.ndarray: - return self.inner.action_masks(team=0) - - def reset( - self, - *, - seed: int | None = None, - options: dict[str, Any] | None = None, - ) -> tuple[dict[str, np.ndarray], dict[str, Any]]: - super().reset(seed=seed) - if self.np_random.random() < 0.1: - self._load_opponent() - obs, info = self.inner.reset(seed=seed, options=options) - return obs, info - - def step(self, action: Any) -> tuple[Any, SupportsFloat, bool, bool, dict[str, Any]]: - melee_intents: list[tuple[int, int, tuple[int, int]]] = [] - self.inner._step_all_bots_with_policy(self._bot_policy, melee_intents, exclude=None) - blue_obs = self.inner._get_village_obs(1) - if self._opponent_policy is not None: - blue_masks = self.inner.action_masks(team=1) - blue_act, _ = self._opponent_policy.predict( - blue_obs, action_masks=blue_masks, deterministic=False - ) - blue_action = int(np.asarray(blue_act).reshape(-1)[0]) - else: - blue_action = int(self.inner.action_space.sample()) - return self.inner.step_village_only(int(action), blue_action, melee_intents) - - def render(self) -> Any: - return self.inner.render() - - def close(self) -> None: - self.inner.close() - - -class UnifiedBotSelfPlayEnv(gym.Env): - """Full-game env for unified training: one bot on team 0 learns, everything else frozen. - - Each tick mirrors ``SelfPlayVillageEnv`` (bots move, then both village managers act), - but the learner controls a single bot on team 0 and receives bot-level reward. - - Frozen policies loaded from paths/pools: - * Other team-0 bots: ``bot_policy_holder["model"]`` (mutable dict shared with trainer). - * Team-1 bots: opponent pool ``checkpoints/pool/bots``. - * Red village manager: checkpoint at ``village_checkpoint_path`` (or random if missing). - * Blue village manager: opponent pool ``checkpoints/pool/village`` (or random). - """ - - def __init__( - self, - config: Mapping[str, Any] | Any, - bot_policy_holder: MutableMapping[str, Any] | None = None, - village_checkpoint_path: str = "checkpoints/unified/village_latest", - opponent_bot_pool_dir: str = "checkpoints/pool/bots", - opponent_village_pool_dir: str = "checkpoints/pool/village", - opponent_sampling: str = "uniform", - ) -> None: - super().__init__() - self._flat_cfg = _as_plain_config(config) - self.inner = GameEnv(self._flat_cfg, mode="village", team=0, render_mode=None) - - self.action_space = spaces.Discrete(GameEnv.BOT_ACTIONS) - self.observation_space = spaces.Box( - low=0.0, high=1.0, shape=(BotObsBuilder.OBS_DIM,), dtype=np.float32, - ) - - self._bot_policy_holder = bot_policy_holder - self._village_ckpt_path = Path(village_checkpoint_path) - self._opponent_bot_pool_dir = Path(opponent_bot_pool_dir) - self._opponent_village_pool_dir = Path(opponent_village_pool_dir) - self._opponent_sampling = opponent_sampling - - self._opponent_bot_policy: PPO | None = None - self._red_village_policy: MaskablePPO | None = None - self._blue_village_policy: MaskablePPO | None = None - self._controlled_bot_id: int = 0 - - self._load_opponent_bot() - self._load_red_village() - self._load_blue_village() - - def _load_opponent_bot(self) -> None: - self._opponent_bot_pool_dir.mkdir(parents=True, exist_ok=True) - checkpoints = sorted(self._opponent_bot_pool_dir.glob("*.zip")) - if not checkpoints: - self._opponent_bot_policy = None - return - ckpt = checkpoints[-1] if self._opponent_sampling == "latest" else checkpoints[ - int(np.random.randint(len(checkpoints))) - ] - try: - self._opponent_bot_policy = PPO.load(str(ckpt)) - except Exception as e: # noqa: BLE001 - logger.warning("Failed to load opponent bot policy from {}: {}", ckpt, e) - self._opponent_bot_policy = None - - def _load_red_village(self) -> None: - env_space = self.inner.observation_space - for suffix in ("", ".zip"): - p = self._village_ckpt_path.parent / (self._village_ckpt_path.name + suffix) - if p.is_file(): - try: - loaded = MaskablePPO.load(str(p)) - if not _maskable_village_obs_matches_env(loaded, env_space): - _warn_village_obs_mismatch_once( - p, - role="red", - policy_space=loaded.observation_space, - env_space=env_space, - ) - continue - self._red_village_policy = loaded - return - except Exception as e: # noqa: BLE001 - logger.warning("Failed to load red village policy from {}: {}", p, e) - self._red_village_policy = None - - def _load_blue_village(self) -> None: - self._opponent_village_pool_dir.mkdir(parents=True, exist_ok=True) - checkpoints = sorted(self._opponent_village_pool_dir.glob("*.zip")) - if not checkpoints: - self._blue_village_policy = None - return - ckpt = checkpoints[-1] if self._opponent_sampling == "latest" else checkpoints[ - int(np.random.randint(len(checkpoints))) - ] - env_space = self.inner.observation_space - try: - loaded = MaskablePPO.load(str(ckpt)) - if not _maskable_village_obs_matches_env(loaded, env_space): - _warn_village_obs_mismatch_once( - ckpt, - role="blue", - policy_space=loaded.observation_space, - env_space=env_space, - ) - self._blue_village_policy = None - return - self._blue_village_policy = loaded - except Exception as e: # noqa: BLE001 - logger.warning("Failed to load blue village policy from {}: {}", ckpt, e) - self._blue_village_policy = None - - def _pick_controlled_bot(self) -> None: - assert self.inner._state is not None - alive = [b for b in self.inner._state.villages[0].bots if b.is_alive] - self._controlled_bot_id = alive[0].bot_id if alive else 0 - - def reset( - self, - *, - seed: int | None = None, - options: dict[str, Any] | None = None, - ) -> tuple[np.ndarray, dict[str, Any]]: - super().reset(seed=seed) - if self.np_random.random() < 0.1: - self._load_opponent_bot() - self._load_blue_village() - obs, info = self.inner.reset(seed=seed, options=options) - self._pick_controlled_bot() - bot_obs = self.inner._get_single_bot_obs(self._controlled_bot_id) - return bot_obs, info - - def step(self, action: Any) -> tuple[np.ndarray, SupportsFloat, bool, bool, dict[str, Any]]: - assert self.inner._state is not None and self.inner._rng is not None - self.inner.snapshot_bot_positions_for_tick() - melee_intents: list[tuple[int, int, tuple[int, int]]] = [] - - # --- bot phase --- - learner_action = int(action) - self.inner._apply_bot_action(0, self._controlled_bot_id, learner_action, melee_intents) - - friendly_policy = ( - self._bot_policy_holder.get("model") if self._bot_policy_holder else None - ) - for bot in self.inner._state.villages[0].bots: - if not bot.is_alive or bot.bot_id == self._controlled_bot_id: - continue - obs_b = self.inner._get_single_bot_obs(bot.bot_id) - if friendly_policy is not None: - a, _ = friendly_policy.predict(obs_b, deterministic=False) - act_int = int(np.asarray(a).reshape(-1)[0]) - else: - act_int = int(self.inner._rng.integers(0, GameEnv.BOT_ACTIONS)) - self.inner._apply_bot_action(0, bot.bot_id, act_int, melee_intents) - - for bot in self.inner._state.villages[1].bots: - if not bot.is_alive: - continue - obs_b = self.inner._get_single_bot_obs(bot.bot_id) - if self._opponent_bot_policy is not None: - a, _ = self._opponent_bot_policy.predict(obs_b, deterministic=False) - act_int = int(np.asarray(a).reshape(-1)[0]) - else: - act_int = int(self.inner._rng.integers(0, GameEnv.BOT_ACTIONS)) - self.inner._apply_bot_action(1, bot.bot_id, act_int, melee_intents) - - # --- village manager phase --- - red_masks = self.inner.action_masks(team=0) - if self._red_village_policy is not None: - red_obs = self.inner._get_village_obs(0) - red_act, _ = self._red_village_policy.predict( - red_obs, action_masks=red_masks, deterministic=False, - ) - red_village_action = int(np.asarray(red_act).reshape(-1)[0]) - else: - valid = np.flatnonzero(red_masks) - red_village_action = int(self.inner._rng.choice(valid)) if len(valid) else 0 - - blue_masks = self.inner.action_masks(team=1) - if self._blue_village_policy is not None: - blue_obs = self.inner._get_village_obs(1) - blue_act, _ = self._blue_village_policy.predict( - blue_obs, action_masks=blue_masks, deterministic=False, - ) - blue_village_action = int(np.asarray(blue_act).reshape(-1)[0]) - else: - valid = np.flatnonzero(blue_masks) - blue_village_action = int(self.inner._rng.choice(valid)) if len(valid) else 0 - - _, _, terminated, truncated, info = self.inner.step_village_only( - red_village_action, blue_village_action, melee_intents, - ) - - # --- bot-level obs & reward --- - bot_obs = self.inner._get_single_bot_obs(self._controlled_bot_id) - bot_state = next( - (b for b in self.inner._state.villages[0].bots if b.bot_id == self._controlled_bot_id), - None, - ) - if bot_state is not None: - mode = self.inner._state.villages[0].global_reward_mode - bev = self.inner._bot_events_for( - bot_state, self.inner._last_tick_merged, learner_action, self.inner._state - ) - reward = float(BotRewardCalculator.compute(bev, bot_state, mode, self.inner.config)) - else: - reward = 0.0 - - return bot_obs, reward, terminated, truncated, info - - def render(self) -> Any: - return self.inner.render() - - def close(self) -> None: - self.inner.close() diff --git a/src/village_ai_war/training/tensorboard_plots.py b/src/village_ai_war/training/tensorboard_plots.py index 4cd8a35..fefca15 100644 --- a/src/village_ai_war/training/tensorboard_plots.py +++ b/src/village_ai_war/training/tensorboard_plots.py @@ -1,9 +1,10 @@ -"""Plot TensorBoard scalar runs as PNG grids (unified training and similar).""" +"""Plot TensorBoard scalar runs as PNG grids (e.g. MAPPO training).""" from __future__ import annotations import math from pathlib import Path + from loguru import logger from tensorboard.backend.event_processing.event_accumulator import EventAccumulator @@ -80,62 +81,86 @@ def plot_scalar_groups( return written -def plot_unified_tensorboard_runs( +def plot_tensorboard_subdir( + log_root: Path, + subdir: str, + output_path: Path | None = None, +) -> list[Path]: + """Plot the latest SB3 run under ``log_root/subdir`` into a PNG grid.""" + if output_path is None: + output_path = log_root / "plots" / f"{subdir}_scalars.png" + run_dir = latest_run_dir(log_root, subdir) + if run_dir is None: + logger.warning("No TensorBoard run directory under {}", log_root / subdir) + return [] + series = load_scalar_series(run_dir) + if not series: + return [] + written = plot_scalar_groups(series, f"TensorBoard scalars ({subdir})", output_path) + logger.info("Wrote metric plots to {}", output_path.parent) + return written + + +def plot_training_tensorboard_runs( log_root: Path, *, - bots_subdir: str = "unified_bots", - village_subdir: str = "unified_village", - output_bots: Path | None = None, - output_village: Path | None = None, + primary_subdir: str = "mappo_bots", + primary_output: Path | None = None, + secondary_subdir: str | None = None, + secondary_output: Path | None = None, ) -> list[Path]: - """Load latest SB3 runs under ``log_root`` and write PNG grids.""" + """Plot one or two TensorBoard run folders (e.g. MAPPO plus an optional legacy folder).""" written: list[Path] = [] - if output_bots is None: - output_bots = log_root / "plots" / "unified_bots_scalars.png" - if output_village is None: - output_village = log_root / "plots" / "unified_village_scalars.png" - - bot_dir = latest_run_dir(log_root, bots_subdir) - if bot_dir is not None: - s = load_scalar_series(bot_dir) - if s: - written.extend( - plot_scalar_groups(s, f"TensorBoard scalars ({bots_subdir})", output_bots) + written.extend(plot_tensorboard_subdir(log_root, primary_subdir, primary_output)) + if secondary_subdir: + written.extend( + plot_tensorboard_subdir( + log_root, + secondary_subdir, + secondary_output, ) - logger.info("Wrote bot phase metric plots to {}", output_bots.parent) - else: - logger.warning("No TensorBoard run directory under {}", log_root / bots_subdir) - - vil_dir = latest_run_dir(log_root, village_subdir) - if vil_dir is not None: - s = load_scalar_series(vil_dir) - if s: - written.extend( - plot_scalar_groups(s, f"TensorBoard scalars ({village_subdir})", output_village) - ) - logger.info("Wrote village phase metric plots to {}", output_village.parent) - else: - logger.warning("No TensorBoard run directory under {}", log_root / village_subdir) - + ) return written def main_cli(argv: list[str] | None = None) -> None: import argparse - p = argparse.ArgumentParser(description="Plot TensorBoard scalars from unified training runs.") - p.add_argument("--log-root", type=Path, default=Path("logs"), help="Root containing unified_bots / unified_village") - p.add_argument("--bots-subdir", default="unified_bots") - p.add_argument("--village-subdir", default="unified_village") - p.add_argument("--output-bots", type=Path, default=None) - p.add_argument("--output-village", type=Path, default=None) + p = argparse.ArgumentParser( + description="Plot TensorBoard scalars (default: MAPPO logs/mappo_bots/).", + ) + p.add_argument( + "--log-root", + type=Path, + default=Path("logs"), + help="Root directory that contains the TensorBoard subfolder (e.g. mappo_bots)", + ) + p.add_argument( + "--subdir", + default="mappo_bots", + help="Subfolder under log-root with SB3 run dirs", + ) + p.add_argument( + "--output", + type=Path, + default=None, + help="Output PNG path (default: log-root/plots/_scalars.png)", + ) + p.add_argument( + "--second-subdir", + default="", + help="Optional second subfolder to plot (e.g. old unified_bots)", + ) + p.add_argument("--second-output", type=Path, default=None, help="Output path for second plot") args = p.parse_args(argv) - plot_unified_tensorboard_runs( - args.log_root.resolve(), - bots_subdir=args.bots_subdir, - village_subdir=args.village_subdir, - output_bots=args.output_bots, - output_village=args.output_village, + root = args.log_root.resolve() + sec = args.second_subdir.strip() or None + plot_training_tensorboard_runs( + root, + primary_subdir=args.subdir, + primary_output=args.output, + secondary_subdir=sec, + secondary_output=args.second_output, ) diff --git a/src/village_ai_war/training/train_bots.py b/src/village_ai_war/training/train_bots.py deleted file mode 100644 index 1055d84..0000000 --- a/src/village_ai_war/training/train_bots.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Stage 1: train one PPO policy per bot role.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any, Mapping - -from loguru import logger -from stable_baselines3 import PPO -from stable_baselines3.common.callbacks import CheckpointCallback -from stable_baselines3.common.vec_env import DummyVecEnv, SubprocVecEnv - -from village_ai_war.env.game_env import GameEnv -from village_ai_war.state import Role - - -def _cfg_to_dict(cfg: Any) -> dict[str, Any]: - from omegaconf import OmegaConf - - if OmegaConf.is_config(cfg): - return OmegaConf.to_container(cfg, resolve=True) # type: ignore[return-value] - return dict(cfg) - - -def _make_env_fn( - flat_cfg: Mapping[str, Any], - team: int, - role: Role, -) -> Any: - def _init() -> GameEnv: - return GameEnv( - dict(flat_cfg), - mode="bot", - team=team, - render_mode=None, - bot_role=role, - ) - - return _init - - -def run_bot_training(cfg: Any, team: int = 0) -> None: - """Train four PPO policies (warrior, gatherer, farmer, builder).""" - flat = _cfg_to_dict(cfg) - tcfg = flat["training"] - n_envs = int(tcfg["n_envs"]) - total = int(tcfg["total_timesteps"]) - ckpt_dir = Path(tcfg["checkpoint_dir"]) / "bots" - ckpt_dir.mkdir(parents=True, exist_ok=True) - - use_subproc = n_envs > 1 - for role in Role: - logger.info("Training bot policy for role {}", role.name) - fns = [_make_env_fn(flat, team, role) for _ in range(n_envs)] - venv: DummyVecEnv | SubprocVecEnv = ( - SubprocVecEnv(fns) if use_subproc else DummyVecEnv(fns) - ) - model = PPO( - "MlpPolicy", - venv, - verbose=1, - learning_rate=float(tcfg["learning_rate"]), - batch_size=int(tcfg["batch_size"]), - n_epochs=int(tcfg["n_epochs"]), - gamma=float(tcfg["gamma"]), - tensorboard_log=str(Path(tcfg["log_dir"]) / "tb_bots"), - ) - cb = CheckpointCallback( - save_freq=max(total // 10, 1000), - save_path=str(ckpt_dir), - name_prefix=f"ppo_{role.name.lower()}", - ) - model.learn(total_timesteps=total, callback=cb) - out = ckpt_dir / f"{role.name.lower()}_final" - model.save(str(out)) - venv.close() - logger.info("Saved {}", out) diff --git a/src/village_ai_war/training/train_bots_selfplay.py b/src/village_ai_war/training/train_bots_selfplay.py deleted file mode 100644 index 3e7a1d0..0000000 --- a/src/village_ai_war/training/train_bots_selfplay.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Stage 1: train a single role-conditioned bot policy via self-play.""" - -from __future__ import annotations - -import importlib.util -import shutil -from pathlib import Path -from typing import Any - -from loguru import logger -from omegaconf import OmegaConf -from stable_baselines3 import PPO -from stable_baselines3.common.callbacks import CheckpointCallback, EvalCallback -from stable_baselines3.common.vec_env import DummyVecEnv, SubprocVecEnv, VecMonitor - -from village_ai_war.models.role_conditioned_policy import RoleConditionedPolicy -from village_ai_war.training.pool_manager import PoolManager -from village_ai_war.training.self_play_env import SelfPlayBotEnv - - -def _flat_cfg(cfg: Any) -> dict[str, Any]: - if OmegaConf.is_config(cfg): - return OmegaConf.to_container(cfg, resolve=True) # type: ignore[return-value] - return dict(cfg) - - -def _tensorboard_log_dir(flat: dict[str, Any], tcfg: dict[str, Any], subdir: str) -> str | None: - if not bool(flat.get("logging", {}).get("use_tensorboard", True)): - return None - if importlib.util.find_spec("tensorboard") is None: - logger.warning( - "logging.use_tensorboard is true but tensorboard is not installed; " - "training without tensorboard_log" - ) - return None - return str(Path(tcfg["log_dir"]) / subdir) - - -def run_bots_selfplay_training(cfg: Any) -> None: - """PPO self-play for bots with a growing opponent pool.""" - flat = _flat_cfg(cfg) - tcfg = flat["training"] - pool_dir = Path(tcfg["pool_dir"]) / "bots" - pool_dir.mkdir(parents=True, exist_ok=True) - checkpoint_dir = Path(tcfg["checkpoint_dir"]) / "bots" - checkpoint_dir.mkdir(parents=True, exist_ok=True) - pool_manager = PoolManager(pool_dir, max_size=int(tcfg.get("pool_max_size", 10))) - - n_envs = int(tcfg["n_envs"]) - total = int(tcfg["total_timesteps"]) - iterations = int(tcfg.get("selfplay_iterations", 1)) - steps_per_iter = max(total // iterations, n_envs) - - def make_env(_rank: int) -> Any: - def _init() -> SelfPlayBotEnv: - return SelfPlayBotEnv( - flat, - opponent_pool_dir=str(pool_dir), - opponent_sampling="uniform", - ) - - return _init - - use_subproc = n_envs > 1 - vec_env: DummyVecEnv | SubprocVecEnv = ( - SubprocVecEnv([make_env(i) for i in range(n_envs)]) - if use_subproc - else DummyVecEnv([make_env(0)]) - ) - vec_env = VecMonitor(vec_env) - - gae_lambda = float(tcfg.get("gae_lambda", 0.95)) - clip_range = float(tcfg.get("clip_range", 0.2)) - ent_coef = float(tcfg.get("ent_coef", 0.0)) - - tb_log = _tensorboard_log_dir(flat, tcfg, "bots") - - model = PPO( - RoleConditionedPolicy, - vec_env, - verbose=1, - learning_rate=float(tcfg["learning_rate"]), - n_steps=int(tcfg.get("n_steps", 2048)), - batch_size=int(tcfg["batch_size"]), - n_epochs=int(tcfg["n_epochs"]), - gamma=float(tcfg["gamma"]), - gae_lambda=gae_lambda, - clip_range=clip_range, - ent_coef=ent_coef, - tensorboard_log=tb_log, - ) - - save_freq = max(int(tcfg.get("checkpoint_interval", 50_000)) // n_envs, 1) - - user_eval_freq = int(tcfg.get("eval_freq", 10_000)) - eval_cb_freq = max(user_eval_freq // max(n_envs, 1), 1) if user_eval_freq > 0 else 0 - eval_sampling = str(tcfg.get("eval_opponent_sampling", "latest")) - - eval_callback: EvalCallback | None = None - eval_venv: DummyVecEnv | None = None - if eval_cb_freq > 0: - - def make_eval_env() -> SelfPlayBotEnv: - return SelfPlayBotEnv( - flat, - opponent_pool_dir=str(pool_dir), - opponent_sampling=eval_sampling, - ) - - eval_venv = DummyVecEnv([make_eval_env]) - eval_venv = VecMonitor(eval_venv) - eval_log = Path(tcfg["log_dir"]) / "bots_eval" - best_path = checkpoint_dir / "best_bot" - eval_callback = EvalCallback( - eval_venv, - best_model_save_path=str(best_path), - log_path=str(eval_log), - eval_freq=eval_cb_freq, - n_eval_episodes=int(tcfg.get("n_eval_episodes", 5)), - deterministic=True, - verbose=1, - ) - - for iteration in range(iterations): - logger.info("Bot self-play iteration {} / {}", iteration + 1, iterations) - checkpoint_callback = CheckpointCallback( - save_freq=save_freq, - save_path=str(checkpoint_dir), - name_prefix=f"bot_iter{iteration}", - ) - cbs: list[Any] = [checkpoint_callback] - if eval_callback is not None: - cbs.append(eval_callback) - model.learn( - total_timesteps=steps_per_iter, - callback=cbs, - reset_num_timesteps=(iteration == 0), - tb_log_name="bot_selfplay", - ) - stem = pool_dir / f"bot_iter{iteration}" - model.save(str(stem)) - pool_manager.add(Path(str(stem) + ".zip")) - - model.save(str(checkpoint_dir / "bot_last")) - best_zip = checkpoint_dir / "best_bot" / "best_model.zip" - if user_eval_freq > 0 and best_zip.is_file(): - shutil.copy2(best_zip, checkpoint_dir / "bot_best.zip") - shutil.copy2(best_zip, checkpoint_dir / "bot_final.zip") - logger.info( - "Exported best eval policy to {} and {} (last weights: {}.zip)", - checkpoint_dir / "bot_final.zip", - checkpoint_dir / "bot_best.zip", - checkpoint_dir / "bot_last", - ) - else: - model.save(str(checkpoint_dir / "bot_final")) - logger.info( - "Exported last-iteration policy to {}.zip (eval disabled or no best checkpoint yet)", - checkpoint_dir / "bot_final", - ) - - if eval_venv is not None: - eval_venv.close() - vec_env.close() diff --git a/src/village_ai_war/training/train_joint.py b/src/village_ai_war/training/train_joint.py deleted file mode 100644 index faf51fa..0000000 --- a/src/village_ai_war/training/train_joint.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Stage 3: joint fine-tuning (village MaskablePPO with reduced learning rate). - - Low-level units are controlled by the frozen stage-1 bot policy when - ``checkpoints/bots/bot_final.zip`` exists; otherwise actions are random. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import numpy as np -from loguru import logger -from sb3_contrib import MaskablePPO -from stable_baselines3.common.callbacks import CheckpointCallback -from stable_baselines3.common.monitor import Monitor -from stable_baselines3.common.vec_env import DummyVecEnv - -from village_ai_war.env.game_env import GameEnv - - -class _MaskableMonitor(Monitor): - def action_masks(self) -> np.ndarray: - return self.env.action_masks() - - -def _cfg_to_dict(cfg: Any) -> dict[str, Any]: - from omegaconf import OmegaConf - - if OmegaConf.is_config(cfg): - return OmegaConf.to_container(cfg, resolve=True) # type: ignore[return-value] - return dict(cfg) - - -def run_joint_training(cfg: Any, team: int = 0) -> None: - """Fine-tune village MaskablePPO in ``full`` mode (RL bots, scripted opponent manager).""" - flat = _cfg_to_dict(cfg) - game = dict(flat.get("game", {})) - bot_ckpt = Path(flat["training"]["checkpoint_dir"]) / "bots" / "bot_final.zip" - game["bot_rl_checkpoint"] = str(bot_ckpt) - flat = {**flat, "game": game} - tcfg = flat["training"] - total = int(tcfg["total_timesteps"]) - base_lr = float(tcfg["learning_rate"]) - lr = base_lr * float(tcfg.get("bot_joint_lr_factor", 0.1)) - ckpt_dir = Path(tcfg["checkpoint_dir"]) / "joint" - ckpt_dir.mkdir(parents=True, exist_ok=True) - - def make_env() -> _MaskableMonitor: - env = GameEnv(dict(flat), mode="full", team=team, render_mode=None) - return _MaskableMonitor(env) - - venv = DummyVecEnv([make_env]) - prev = ckpt_dir.parent / "village" / "village_final.zip" - if prev.exists(): - model = MaskablePPO.load(str(prev), env=venv) - model.learning_rate = lr - logger.info("Loaded village checkpoint for joint fine-tune") - else: - logger.warning("No village checkpoint at {}; training from scratch", prev) - model = MaskablePPO( - "MultiInputPolicy", - venv, - verbose=1, - learning_rate=lr, - batch_size=int(tcfg["batch_size"]), - n_epochs=int(tcfg["n_epochs"]), - gamma=float(tcfg["gamma"]), - ) - cb = CheckpointCallback( - save_freq=max(total // 5, 500), - save_path=str(ckpt_dir), - name_prefix="joint_maskable_ppo", - ) - model.learn(total_timesteps=total, callback=cb) - model.save(str(ckpt_dir / "joint_final")) - venv.close() - logger.info("Joint training saved to {}", ckpt_dir / "joint_final") diff --git a/src/village_ai_war/training/train_mappo_bots.py b/src/village_ai_war/training/train_mappo_bots.py index 610d2fe..9a39415 100644 --- a/src/village_ai_war/training/train_mappo_bots.py +++ b/src/village_ai_war/training/train_mappo_bots.py @@ -1,4 +1,4 @@ -"""Stage 4 (MAPPO): decentralized actor + centralized critic, bot self-play pool.""" +"""MAPPO bot training: decentralized actor + centralized critic, bot self-play pool.""" from __future__ import annotations @@ -13,8 +13,8 @@ from stable_baselines3.common.vec_env import DummyVecEnv, SubprocVecEnv, VecMonitor from village_ai_war.models.mappo_policy import MAPPOPolicy -from village_ai_war.training.mappo_episode_metrics_callback import MAPPOEpisodeMetricsCallback from village_ai_war.training.mappo_env import MAPPOBotEnv +from village_ai_war.training.mappo_episode_metrics_callback import MAPPOEpisodeMetricsCallback from village_ai_war.training.pool_manager import PoolManager @@ -41,7 +41,7 @@ def run_mappo_bots_training(cfg: Any) -> None: flat = _flat_cfg(cfg) tcfg = flat["training"] pool_root = Path(tcfg["pool_dir"]) - # Opponents must be 181-dim (stage 1 / unified bot); MAPPO zips go elsewhere. + # Opponents must be 181-dim bot PPO zips in pool/bots/; MAPPO zips go elsewhere. opponent_pool_dir = pool_root / "bots" opponent_pool_dir.mkdir(parents=True, exist_ok=True) mappo_pool_dir = pool_root / str(tcfg.get("mappo_pool_subdir", "bots_mappo")) diff --git a/src/village_ai_war/training/train_unified.py b/src/village_ai_war/training/train_unified.py deleted file mode 100644 index 89851ff..0000000 --- a/src/village_ai_war/training/train_unified.py +++ /dev/null @@ -1,289 +0,0 @@ -"""Unified training loop: alternating PPO (bots) and MaskablePPO (village) in full-game episodes.""" - -from __future__ import annotations - -import importlib.util -import math -from pathlib import Path -from typing import Any - -import numpy as np -from loguru import logger -from omegaconf import OmegaConf -from sb3_contrib import MaskablePPO -from stable_baselines3 import PPO -from stable_baselines3.common.callbacks import CheckpointCallback -from stable_baselines3.common.monitor import Monitor -from stable_baselines3.common.vec_env import DummyVecEnv, VecMonitor - -from village_ai_war.models.role_conditioned_policy import RoleConditionedPolicy -from village_ai_war.training.pool_manager import PoolManager -from village_ai_war.training.progress_callback import make_progress_callback -from village_ai_war.training.self_play_env import SelfPlayVillageEnv, UnifiedBotSelfPlayEnv - - -class _MaskableMonitor(Monitor): - """Monitor that exposes ``action_masks`` for MaskablePPO.""" - - def action_masks(self) -> np.ndarray: - return self.env.action_masks() - - -def _flat_cfg(cfg: Any) -> dict[str, Any]: - if OmegaConf.is_config(cfg): - return OmegaConf.to_container(cfg, resolve=True) # type: ignore[return-value] - return dict(cfg) - - -def _tensorboard_log_dir(flat: dict[str, Any], tcfg: dict[str, Any], subdir: str) -> str | None: - if not bool(flat.get("logging", {}).get("use_tensorboard", True)): - return None - if importlib.util.find_spec("tensorboard") is None: - logger.warning( - "logging.use_tensorboard is true but tensorboard is not installed; " - "training without tensorboard_log" - ) - return None - return str(Path(tcfg["log_dir"]) / subdir) - - -def run_unified_training(cfg: Any) -> None: - """Alternating bot PPO / village MaskablePPO in full-game episodes.""" - flat = _flat_cfg(cfg) - tcfg = flat["training"] - ucfg = flat.get("unified", {}) - - n_cycles = int(ucfg.get("n_cycles", 10)) - bot_steps = int(ucfg.get("bot_steps_per_turn", 20_000)) - village_steps = int(ucfg.get("village_steps_per_turn", 20_000)) - push_to_pool = bool(ucfg.get("push_to_pool", True)) - first_phase = str(ucfg.get("first_phase", "bot")) - progress_log_interval_sec = float(ucfg.get("progress_log_interval_sec", 30.0)) - plot_metrics_on_finish = bool(ucfg.get("plot_metrics_on_finish", False)) - use_progress_bar = bool(ucfg.get("progress_bar", True)) - _sb3_v = ucfg.get("sb3_verbose") - if _sb3_v is not None: - sb3_verbose = int(_sb3_v) - else: - sb3_verbose = 0 if use_progress_bar else 1 - - n_envs = int(tcfg["n_envs"]) - n_steps_ppo = int(tcfg.get("n_steps", 2048)) - checkpoint_dir = Path(tcfg["checkpoint_dir"]) / "unified" - checkpoint_dir.mkdir(parents=True, exist_ok=True) - bot_ckpt_path = checkpoint_dir / "bot_latest" - village_ckpt_path = checkpoint_dir / "village_latest" - - pool_dir = Path(tcfg.get("pool_dir", "checkpoints/pool")) - bot_pool_dir = pool_dir / "bots" - village_pool_dir = pool_dir / "village" - - bot_pool = PoolManager(bot_pool_dir, max_size=int(tcfg.get("pool_max_size", 10))) - village_pool = PoolManager(village_pool_dir, max_size=int(tcfg.get("pool_max_size", 10))) - - tb_log_bot = _tensorboard_log_dir(flat, tcfg, "unified_bots") - tb_log_vil = _tensorboard_log_dir(flat, tcfg, "unified_village") - log_root = Path(tcfg["log_dir"]).resolve() - - per_cycle_steps = bot_steps + village_steps - total_planned = n_cycles * per_cycle_steps - steps_per_sb3_iter = n_steps_ppo * n_envs - logger.info( - "Unified training plan | cycles={} | phases per cycle: bot {} env_steps, village {} | " - "n_envs={} | ~total env_steps={} | checkpoint_dir={} | tensorboard: bots={} village={} | " - "SB3 n_steps={} → ~{} env-steps per iteration | progress_bar={} | sb3_verbose={}", - n_cycles, - bot_steps, - village_steps, - n_envs, - total_planned, - checkpoint_dir, - tb_log_bot or "(tensorboard off)", - tb_log_vil or "(tensorboard off)", - n_steps_ppo, - steps_per_sb3_iter, - use_progress_bar, - sb3_verbose, - ) - - bot_policy_holder: dict[str, Any] = {"model": None} - - # --- bot vec-env (DummyVecEnv only — holder is in-process) --- - def _make_bot_env() -> Any: - def _init() -> UnifiedBotSelfPlayEnv: - return UnifiedBotSelfPlayEnv( - flat, - bot_policy_holder=bot_policy_holder, - village_checkpoint_path=str(village_ckpt_path), - opponent_bot_pool_dir=str(bot_pool_dir), - opponent_village_pool_dir=str(village_pool_dir), - ) - return _init - - bot_venv = DummyVecEnv([_make_bot_env() for _ in range(n_envs)]) - bot_venv = VecMonitor(bot_venv) - - gae_lambda = float(tcfg.get("gae_lambda", 0.95)) - clip_range = float(tcfg.get("clip_range", 0.2)) - ent_coef = float(tcfg.get("ent_coef", 0.0)) - - model_bot = PPO( - RoleConditionedPolicy, - bot_venv, - verbose=sb3_verbose, - learning_rate=float(tcfg["learning_rate"]), - n_steps=n_steps_ppo, - batch_size=int(tcfg["batch_size"]), - n_epochs=int(tcfg["n_epochs"]), - gamma=float(tcfg["gamma"]), - gae_lambda=gae_lambda, - clip_range=clip_range, - ent_coef=ent_coef, - tensorboard_log=tb_log_bot, - ) - bot_policy_holder["model"] = model_bot - - # --- village vec-env (SubprocVecEnv OK — bot loaded from disk) --- - def _make_vil_env(_rank: int) -> Any: - def _init() -> _MaskableMonitor: - env = SelfPlayVillageEnv( - flat, - bot_checkpoint_dir=str(checkpoint_dir), - opponent_pool_dir=str(village_pool_dir), - opponent_sampling="uniform", - ) - return _MaskableMonitor(env) - return _init - - use_subproc = n_envs > 1 - vil_venv: DummyVecEnv = ( - DummyVecEnv([_make_vil_env(i) for i in range(n_envs)]) - if not use_subproc - else DummyVecEnv([_make_vil_env(i) for i in range(n_envs)]) - ) - - model_vil = MaskablePPO( - "MultiInputPolicy", - vil_venv, - verbose=sb3_verbose, - learning_rate=float(tcfg["learning_rate"]), - n_steps=n_steps_ppo, - batch_size=int(tcfg["batch_size"]), - n_epochs=int(tcfg["n_epochs"]), - gamma=float(tcfg["gamma"]), - tensorboard_log=tb_log_vil, - ) - - save_freq_bot = max(bot_steps // 4, 1) - save_freq_vil = max(village_steps // 4, 1) - - phases = ["bot", "village"] if first_phase == "bot" else ["village", "bot"] - - for cycle in range(n_cycles): - logger.info("Unified cycle {} / {}", cycle + 1, n_cycles) - - for phase in phases: - if phase == "bot": - logger.info(" Bot phase: {} env_steps", bot_steps) - est_bot_iters = max(1, math.ceil(bot_steps / steps_per_sb3_iter)) - logger.info( - " Per-iteration: ~{} env_steps (n_steps={} × n_envs={}) | " - "~{} SB3 iterations this phase | checkpoint every {} env_steps", - steps_per_sb3_iter, - n_steps_ppo, - n_envs, - est_bot_iters, - save_freq_bot, - ) - bot_policy_holder["model"] = model_bot - cb = CheckpointCallback( - save_freq=save_freq_bot, - save_path=str(checkpoint_dir), - name_prefix=f"bot_cycle{cycle}", - ) - prog = make_progress_callback( - model_bot, - phase_name="bot", - cycle_zero_based=cycle, - n_cycles=n_cycles, - steps_budget=bot_steps, - log_interval_sec=progress_log_interval_sec, - ) - model_bot.learn( - total_timesteps=bot_steps, - callback=[cb, prog], - reset_num_timesteps=(cycle == 0 and phase == phases[0]), - tb_log_name="unified_bot", - progress_bar=use_progress_bar, - ) - model_bot.save(str(bot_ckpt_path)) - # SelfPlayVillageEnv expects bot_final.zip inside bot_checkpoint_dir - model_bot.save(str(checkpoint_dir / "bot_final")) - if push_to_pool: - stem = bot_pool_dir / f"unified_bot_c{cycle}" - model_bot.save(str(stem)) - bot_pool.add(Path(str(stem) + ".zip")) - - else: - logger.info(" Village phase: {} env_steps", village_steps) - est_vil_iters = max(1, math.ceil(village_steps / steps_per_sb3_iter)) - logger.info( - " Per-iteration: ~{} env-steps (n_steps={} × n_envs={}) | " - "~{} SB3 iterations this phase | checkpoint every {} env_steps", - steps_per_sb3_iter, - n_steps_ppo, - n_envs, - est_vil_iters, - save_freq_vil, - ) - cb = CheckpointCallback( - save_freq=save_freq_vil, - save_path=str(checkpoint_dir), - name_prefix=f"village_cycle{cycle}", - ) - prog = make_progress_callback( - model_vil, - phase_name="village", - cycle_zero_based=cycle, - n_cycles=n_cycles, - steps_budget=village_steps, - log_interval_sec=progress_log_interval_sec, - ) - model_vil.learn( - total_timesteps=village_steps, - callback=[cb, prog], - reset_num_timesteps=(cycle == 0 and phase == phases[0]), - tb_log_name="unified_village", - progress_bar=use_progress_bar, - ) - model_vil.save(str(village_ckpt_path)) - model_vil.save(str(checkpoint_dir / "village_final")) - if push_to_pool: - stem = village_pool_dir / f"unified_village_c{cycle}" - model_vil.save(str(stem)) - village_pool.add(Path(str(stem) + ".zip")) - - model_bot.save(str(checkpoint_dir / "bot_final")) - model_vil.save(str(checkpoint_dir / "village_final")) - bot_venv.close() - vil_venv.close() - logger.info("Unified training complete — checkpoints in {}", checkpoint_dir) - - if plot_metrics_on_finish: - try: - from village_ai_war.training.tensorboard_plots import plot_unified_tensorboard_runs - - plot_out_dir = log_root / "plots" - try: - from hydra.core.hydra_config import HydraConfig - - plot_out_dir = Path(HydraConfig.get().runtime.output_dir) - except Exception: # noqa: BLE001 - pass - plot_unified_tensorboard_runs( - log_root, - output_bots=plot_out_dir / "unified_bots_scalars.png", - output_village=plot_out_dir / "unified_village_scalars.png", - ) - except Exception as e: # noqa: BLE001 - logger.warning("plot_metrics_on_finish failed: {}", e) diff --git a/src/village_ai_war/training/train_village.py b/src/village_ai_war/training/train_village.py deleted file mode 100644 index 5abb6a4..0000000 --- a/src/village_ai_war/training/train_village.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Stage 2: train village manager with MaskablePPO.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import numpy as np -from loguru import logger -from sb3_contrib import MaskablePPO -from stable_baselines3.common.callbacks import CheckpointCallback -from stable_baselines3.common.monitor import Monitor -from stable_baselines3.common.vec_env import DummyVecEnv, SubprocVecEnv - -from village_ai_war.env.game_env import GameEnv - - -class _MaskableMonitor(Monitor): - """``Monitor`` that forwards ``action_masks`` for MaskablePPO.""" - - def action_masks(self) -> np.ndarray: - return self.env.action_masks() - - -def _cfg_to_dict(cfg: Any) -> dict[str, Any]: - from omegaconf import OmegaConf - - if OmegaConf.is_config(cfg): - return OmegaConf.to_container(cfg, resolve=True) # type: ignore[return-value] - return dict(cfg) - - -def run_village_training(cfg: Any, team: int = 0) -> None: - """Train MaskablePPO on high-level village MDP (bots use heuristics).""" - flat = _cfg_to_dict(cfg) - tcfg = flat["training"] - n_envs = int(tcfg["n_envs"]) - total = int(tcfg["total_timesteps"]) - ckpt_dir = Path(tcfg["checkpoint_dir"]) / "village" - ckpt_dir.mkdir(parents=True, exist_ok=True) - - def make_one(_rank: int) -> Any: - def _init() -> _MaskableMonitor: - env = GameEnv(dict(flat), mode="village", team=team, render_mode=None) - return _MaskableMonitor(env) - - return _init - - if n_envs > 1: - venv = SubprocVecEnv([make_one(i) for i in range(n_envs)]) - else: - venv = DummyVecEnv([make_one(0)]) - model = MaskablePPO( - "MlpPolicy", - venv, - verbose=1, - learning_rate=float(tcfg["learning_rate"]), - batch_size=int(tcfg["batch_size"]), - n_epochs=int(tcfg["n_epochs"]), - gamma=float(tcfg["gamma"]), - tensorboard_log=str(Path(tcfg["log_dir"]) / "tb_village"), - ) - ckpt_cb = CheckpointCallback( - save_freq=max(total // 10, 1000), - save_path=str(ckpt_dir), - name_prefix="maskable_ppo_village", - ) - model.learn(total_timesteps=total, callback=ckpt_cb) - model.save(str(ckpt_dir / "village_final")) - venv.close() - logger.info("Saved village policy to {}", ckpt_dir / "village_final") diff --git a/src/village_ai_war/training/train_village_selfplay.py b/src/village_ai_war/training/train_village_selfplay.py deleted file mode 100644 index ade22e6..0000000 --- a/src/village_ai_war/training/train_village_selfplay.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Stage 2: train village manager (MaskablePPO) with frozen RL bots and self-play.""" - -from __future__ import annotations - -import importlib.util -import shutil -from pathlib import Path -from typing import Any - -import numpy as np -from loguru import logger -from omegaconf import OmegaConf -from sb3_contrib import MaskablePPO -from sb3_contrib.common.maskable.callbacks import MaskableEvalCallback -from stable_baselines3.common.callbacks import CheckpointCallback -from stable_baselines3.common.monitor import Monitor -from stable_baselines3.common.vec_env import DummyVecEnv, SubprocVecEnv - -from village_ai_war.training.pool_manager import PoolManager -from village_ai_war.training.self_play_env import SelfPlayVillageEnv - - -class _MaskableMonitor(Monitor): - """Monitor that exposes ``action_masks`` for MaskablePPO.""" - - def action_masks(self) -> np.ndarray: - return self.env.action_masks() - - -def _flat_cfg(cfg: Any) -> dict[str, Any]: - if OmegaConf.is_config(cfg): - return OmegaConf.to_container(cfg, resolve=True) # type: ignore[return-value] - return dict(cfg) - - -def _tensorboard_log_dir(flat: dict[str, Any], tcfg: dict[str, Any], subdir: str) -> str | None: - if not bool(flat.get("logging", {}).get("use_tensorboard", True)): - return None - if importlib.util.find_spec("tensorboard") is None: - logger.warning( - "logging.use_tensorboard is true but tensorboard is not installed; " - "training without tensorboard_log" - ) - return None - return str(Path(tcfg["log_dir"]) / subdir) - - -def run_village_selfplay_training(cfg: Any) -> None: - """MaskablePPO self-play for the village manager.""" - flat = _flat_cfg(cfg) - tcfg = flat["training"] - pool_dir = Path(tcfg["pool_dir"]) / "village" - pool_dir.mkdir(parents=True, exist_ok=True) - checkpoint_dir = Path(tcfg["checkpoint_dir"]) / "village" - checkpoint_dir.mkdir(parents=True, exist_ok=True) - bot_ckpt_dir = str(Path(tcfg["checkpoint_dir"]) / "bots") - pool_manager = PoolManager(pool_dir, max_size=int(tcfg.get("pool_max_size", 10))) - - n_envs = int(tcfg["n_envs"]) - total = int(tcfg["total_timesteps"]) - iterations = int(tcfg.get("selfplay_iterations", 1)) - steps_per_iter = max(total // iterations, n_envs) - - def make_env(_rank: int) -> Any: - def _init() -> _MaskableMonitor: - env = SelfPlayVillageEnv( - flat, - bot_checkpoint_dir=bot_ckpt_dir, - opponent_pool_dir=str(pool_dir), - opponent_sampling="uniform", - ) - return _MaskableMonitor(env) - - return _init - - use_subproc = n_envs > 1 - vec_env: DummyVecEnv | SubprocVecEnv = ( - SubprocVecEnv([make_env(i) for i in range(n_envs)]) - if use_subproc - else DummyVecEnv([make_env(0)]) - ) - - tb_log = _tensorboard_log_dir(flat, tcfg, "village") - - model = MaskablePPO( - "MultiInputPolicy", - vec_env, - verbose=1, - learning_rate=float(tcfg["learning_rate"]), - n_steps=int(tcfg.get("n_steps", 2048)), - batch_size=int(tcfg["batch_size"]), - n_epochs=int(tcfg["n_epochs"]), - gamma=float(tcfg["gamma"]), - tensorboard_log=tb_log, - ) - - save_freq = max(int(tcfg.get("checkpoint_interval", 25_000)) // n_envs, 1) - - user_eval_freq = int(tcfg.get("eval_freq", 10_000)) - eval_cb_freq = max(user_eval_freq // max(n_envs, 1), 1) if user_eval_freq > 0 else 0 - eval_sampling = str(tcfg.get("eval_opponent_sampling", "latest")) - - eval_callback: MaskableEvalCallback | None = None - eval_venv: DummyVecEnv | None = None - if eval_cb_freq > 0: - - def make_eval_env() -> _MaskableMonitor: - env = SelfPlayVillageEnv( - flat, - bot_checkpoint_dir=bot_ckpt_dir, - opponent_pool_dir=str(pool_dir), - opponent_sampling=eval_sampling, - ) - return _MaskableMonitor(env) - - eval_venv = DummyVecEnv([make_eval_env]) - eval_log = Path(tcfg["log_dir"]) / "village_eval" - best_path = checkpoint_dir / "best_village" - eval_callback = MaskableEvalCallback( - eval_venv, - best_model_save_path=str(best_path), - log_path=str(eval_log), - eval_freq=eval_cb_freq, - n_eval_episodes=int(tcfg.get("n_eval_episodes", 5)), - deterministic=True, - verbose=1, - ) - - for iteration in range(iterations): - logger.info("Village self-play iteration {} / {}", iteration + 1, iterations) - checkpoint_callback = CheckpointCallback( - save_freq=save_freq, - save_path=str(checkpoint_dir), - name_prefix=f"village_iter{iteration}", - ) - cbs: list[Any] = [checkpoint_callback] - if eval_callback is not None: - cbs.append(eval_callback) - model.learn( - total_timesteps=steps_per_iter, - callback=cbs, - reset_num_timesteps=(iteration == 0), - tb_log_name="village_selfplay", - ) - stem = pool_dir / f"village_iter{iteration}" - model.save(str(stem)) - pool_manager.add(Path(str(stem) + ".zip")) - - model.save(str(checkpoint_dir / "village_last")) - best_zip = checkpoint_dir / "best_village" / "best_model.zip" - if user_eval_freq > 0 and best_zip.is_file(): - shutil.copy2(best_zip, checkpoint_dir / "village_best.zip") - shutil.copy2(best_zip, checkpoint_dir / "village_final.zip") - logger.info( - "Exported best eval policy to {} and {} (last weights: {}.zip)", - checkpoint_dir / "village_final.zip", - checkpoint_dir / "village_best.zip", - checkpoint_dir / "village_last", - ) - else: - model.save(str(checkpoint_dir / "village_final")) - logger.info( - "Exported last-iteration policy to {}.zip (eval disabled or no best checkpoint yet)", - checkpoint_dir / "village_final", - ) - - if eval_venv is not None: - eval_venv.close() - vec_env.close() diff --git a/tests/test_unified_training_smoke.py b/tests/test_unified_training_smoke.py deleted file mode 100644 index 33d5653..0000000 --- a/tests/test_unified_training_smoke.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Smoke tests for the unified training env and village self-play with empty pools.""" - -from typing import Any - -import numpy as np -import pytest - -pytest.importorskip("gymnasium") - -from village_ai_war.training.self_play_env import SelfPlayVillageEnv, UnifiedBotSelfPlayEnv - - -def _tiny_config() -> dict[str, Any]: - return { - "map": { - "size": 12, - "seed": 0, - "resource_density": 0.1, - "mountain_density": 0.02, - "resource_capacity": {"forest": 100, "stone": 50, "field": 999}, - }, - "economy": { - "harvest_interval": 3, - "harvest_amount": 5, - "food_consumption": 1, - "hunger_damage": 5, - "bot_cost": {"wood": 50, "food": 100}, - "bot_spawn_delay": 2, - "farm_food_bonus": 0.5, - }, - "combat": { - "stats": { - "warrior": {"hp": 100, "damage": 10, "attack_range": 1}, - "gatherer": {"hp": 80, "damage": 8, "attack_range": 1}, - "farmer": {"hp": 70, "damage": 5, "attack_range": 1}, - "builder": {"hp": 80, "damage": 8, "attack_range": 1}, - }, - "tower_damage": 15, - "tower_range": 3, - }, - "buildings": { - "townhall": {"hp": 500, "cost": {}}, - "barracks": {"hp": 100, "cost": {"wood": 100}}, - "storage": {"hp": 100, "cost": {"wood": 50}}, - "farm": {"hp": 100, "cost": {"wood": 80}}, - "tower": {"hp": 100, "cost": {"stone": 100}}, - "wall": {"hp": 100, "cost": {"stone": 30}}, - "citadel": {"hp": 100, "cost": {"stone": 200, "wood": 150}}, - "citadel_pop_bonus": 5, - }, - "game": { - "max_ticks": 50, - "manager_interval": 5, - "initial_resources": {"wood": 200, "stone": 100, "food": 500}, - "initial_bots": 1, - "initial_buildings": ["barracks", "storage"], - "blueprint_adjacent_to_townhall": True, - "max_bots_for_role_change": 16, - }, - "rewards": { - "bot": { - "alpha": 0.7, - "warrior": { - "damage_dealt": 0.1, - "kill": 5.0, - "damage_taken": -0.05, - "death": -10.0, - "noop": -0.01, - }, - "gatherer": {"resource_collected": 0.5, "damage_taken": -0.05, "death": -10.0, "noop": -0.01}, - "farmer": {"food_produced": 0.5, "damage_taken": -0.05, "death": -10.0, "noop": -0.01}, - "builder": { - "block_placed": 2.0, - "repair_pct": 0.1, - "damage_taken": -0.05, - "death": -10.0, - "noop": -0.01, - }, - "global_modes": {"defend_coeff": -0.05, "attack_coeff": 0.05, "gather_coeff": 0.1}, - }, - "village": { - "economy_coeff": 0.01, - "kill_reward": 5.0, - "loss_penalty": -3.0, - "building_reward": 10.0, - "stagnation_penalty": -0.05, - "stagnation_threshold": 50, - "win": 1000.0, - "loss": -1000.0, - }, - }, - "rendering": {"cell_size": 16, "fps": 60}, - } - - -def test_unified_bot_env_reset_step(tmp_path: Any) -> None: - """UnifiedBotSelfPlayEnv should reset and step without errors using empty pools.""" - cfg = _tiny_config() - env = UnifiedBotSelfPlayEnv( - cfg, - bot_policy_holder=None, - village_checkpoint_path=str(tmp_path / "no_village"), - opponent_bot_pool_dir=str(tmp_path / "pool_bots"), - opponent_village_pool_dir=str(tmp_path / "pool_village"), - ) - obs, info = env.reset(seed=42) - assert obs.shape == (181,), f"Expected bot obs shape (181,), got {obs.shape}" - for _ in range(10): - action = int(np.random.randint(0, env.action_space.n)) - obs, reward, terminated, truncated, info = env.step(action) - assert obs.shape == (181,) - assert isinstance(reward, float) - if terminated or truncated: - obs, info = env.reset(seed=43) - env.close() - - -def test_unified_bot_env_multi_bots(tmp_path: Any) -> None: - """With initial_bots > 1, friendly bots fall back to random actions when holder is empty.""" - cfg = _tiny_config() - cfg["game"]["initial_bots"] = 4 - env = UnifiedBotSelfPlayEnv( - cfg, - bot_policy_holder={"model": None}, - village_checkpoint_path=str(tmp_path / "no_village"), - opponent_bot_pool_dir=str(tmp_path / "pool_bots"), - opponent_village_pool_dir=str(tmp_path / "pool_village"), - ) - obs, _ = env.reset(seed=7) - assert obs.shape == (181,) - for _ in range(5): - obs, r, term, trunc, _ = env.step(0) - if term or trunc: - obs, _ = env.reset(seed=8) - env.close() - - -def test_selfplay_village_env_empty_pools(tmp_path: Any) -> None: - """SelfPlayVillageEnv should work with no bot checkpoint and empty opponent pool.""" - cfg = _tiny_config() - env = SelfPlayVillageEnv( - cfg, - bot_checkpoint_dir=str(tmp_path / "no_bots"), - opponent_pool_dir=str(tmp_path / "pool_village"), - ) - obs, _ = env.reset(seed=10) - assert "map" in obs and "village" in obs - masks = env.action_masks() - assert masks.any() - for _ in range(5): - action = int(np.flatnonzero(env.action_masks())[0]) - obs, r, term, trunc, _ = env.step(action) - if term or trunc: - obs, _ = env.reset(seed=11) - env.close() From ddec13a6bc41282b09e119eb08e6e5faef476e49 Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Tue, 31 Mar 2026 14:33:54 +0300 Subject: [PATCH 10/11] Refactor MAPPO training scripts and enhance configuration management - Introduced a new script for hyperparameter tuning of MAPPO using Optuna, allowing for more efficient training configurations. - Updated `README.md` to remove outdated checkpoint information and clarify the training process for MAPPO. - Modified `train_mappo_bots.py` to return metrics from training, improving performance tracking. - Enhanced the `load_project_config` function to accept overrides, providing more flexibility in configuration management. - Cleaned up the `MAPPOBotEnv` class by removing unused opponent loading logic, streamlining the environment setup. - Updated various observation and training configurations to reflect changes in the MAPPO implementation. - Added tests to validate the new hyperparameter tuning functionality and ensure robustness of the training process. --- README.md | 15 -- configs/training/train_mappo_bots.yaml | 3 +- requirements.txt | 1 + scripts/optuna_tune_mappo.py | 144 ++++++++++++++++++ src/village_ai_war/agents/bot_obs_builder.py | 10 +- src/village_ai_war/config_load.py | 8 +- src/village_ai_war/env/game_env.py | 53 +------ src/village_ai_war/models/mappo_layout.py | 2 +- src/village_ai_war/training/mappo_env.py | 91 +---------- .../mappo_episode_metrics_callback.py | 7 + .../training/train_mappo_bots.py | 23 ++- tests/test_mappo_baseline.py | 14 +- tests/test_smoke_game_env.py | 8 +- 13 files changed, 198 insertions(+), 181 deletions(-) create mode 100644 scripts/optuna_tune_mappo.py diff --git a/README.md b/README.md index b473522..87584f9 100644 --- a/README.md +++ b/README.md @@ -59,26 +59,11 @@ tensorboard --logdir logs/ --- -## Checkpoints (where things go) - -| Path | What it is | -|------|------------| -| `checkpoints/bots_mappo/mappo_bot_final.zip` | Your trained MAPPO policy | -| `checkpoints/pool/bots_mappo/*.zip` | Snapshots during training (not used as opponents) | -| `checkpoints/pool/bots/*.zip` | *Optional* opponent bots (181-dim PPO, same obs as `GameEnv` bot mode). Empty pool → random opponents. | - ---- - -## Optional: stress-test the env - ```bash python scripts/evaluate.py ``` Runs many episodes with random village actions and prints rough stats. ---- -## Code map (if you want to dig in) -Core pieces: [`GameEnv`](src/village_ai_war/env/game_env.py), [`MAPPOBotEnv`](src/village_ai_war/training/mappo_env.py), [`train_mappo_bots.py`](src/village_ai_war/training/train_mappo_bots.py), MAPPO models under [`src/village_ai_war/models/mappo_*.py`](src/village_ai_war/models/). diff --git a/configs/training/train_mappo_bots.yaml b/configs/training/train_mappo_bots.yaml index 7584bee..03aa8ef 100644 --- a/configs/training/train_mappo_bots.yaml +++ b/configs/training/train_mappo_bots.yaml @@ -18,8 +18,7 @@ training: critic_hidden_dim: 256 pool_max_size: 15 checkpoint_interval: 100000 - opponent_sampling: uniform - # K = game.max_bots_for_role_change (stacked locals + global). MAPPO iter*.zip go to mappo_pool_subdir; pool_dir/bots/ is 181-dim opponents only. + # K = game.max_bots_for_role_change (stacked locals + global). MAPPO iter*.zip go to mappo_pool_subdir. mappo_pool_subdir: bots_mappo # TensorBoard rolling window for mappo/outcome_frac/* and mappo/terminal_reason_frac/* mappo_metrics_window: 512 diff --git a/requirements.txt b/requirements.txt index d5e6167..a09a1d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,5 +12,6 @@ numpy>=1.24 matplotlib>=3.8 tqdm>=4.66 rich>=13.0 +optuna>=3.0 pytest>=7.4 ruff>=0.1 diff --git a/scripts/optuna_tune_mappo.py b/scripts/optuna_tune_mappo.py new file mode 100644 index 0000000..9e2553c --- /dev/null +++ b/scripts/optuna_tune_mappo.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""MAPPO hyperparameter search with Optuna (Hydra compose, no @hydra.main).""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] +if str(_ROOT / "src") not in sys.path: + sys.path.insert(0, str(_ROOT / "src")) + +import optuna +from loguru import logger +from optuna import TrialPruned + +from village_ai_war.config_load import load_project_config +from village_ai_war.training.train_mappo_bots import run_mappo_bots_training + + +def _study_path_slug(study_name: str) -> str: + s = re.sub(r"[^a-zA-Z0-9_-]+", "_", study_name).strip("_") + return s or "study" + + +def _valid_batch_sizes(buffer: int) -> list[int]: + opts = [32, 64, 128, 256, 512, 1024] + valid = [b for b in opts if b <= buffer and buffer % b == 0] + if valid: + return valid + for b in range(8, buffer + 1): + if buffer % b == 0: + return [b] + if buffer >= 1: + return [1] + return [] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Optuna hyperparameter search for MAPPO.") + parser.add_argument("--n-trials", type=int, default=20) + parser.add_argument("--n-jobs", type=int, default=1, help="Parallel trials (default 1 for RL).") + parser.add_argument("--study-name", type=str, default="mappo_hpo") + parser.add_argument( + "--storage", + type=str, + default="", + help="Optuna RDB URL, e.g. sqlite:///optuna_mappo.db (empty = in-memory).", + ) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument("--config-name", type=str, default="default") + parser.add_argument("--n-envs", type=int, default=1) + parser.add_argument("--total-timesteps", type=int, default=50_000) + parser.add_argument("--selfplay-iterations", type=int, default=2) + parser.add_argument( + "--disable-tensorboard", + action="store_true", + help="Set logging.use_tensorboard=false for faster trials.", + ) + parser.add_argument( + "--override", + action="append", + default=[], + metavar="KEY=VALUE", + help="Extra Hydra-style overrides (repeatable). Applied after trial suggestions.", + ) + args = parser.parse_args() + + slug = _study_path_slug(args.study_name) + user_overrides: list[str] = list(args.override) + + def objective(trial: optuna.Trial) -> float: + n_envs = int(args.n_envs) + n_steps = trial.suggest_categorical("n_steps", [128, 256, 512]) + buffer = n_envs * n_steps + valid_bs = _valid_batch_sizes(buffer) + if not valid_bs: + raise TrialPruned(f"no valid batch_size for buffer={buffer}") + + lr = trial.suggest_float("learning_rate", 1e-5, 1e-2, log=True) + batch_size = trial.suggest_categorical("batch_size", valid_bs) + n_epochs = trial.suggest_int("n_epochs", 4, 15) + ent_coef = trial.suggest_float("ent_coef", 1e-4, 0.1, log=True) + gamma = trial.suggest_float("gamma", 0.95, 0.999) + gae_lambda = trial.suggest_float("gae_lambda", 0.9, 0.99) + clip_range = trial.suggest_float("clip_range", 0.05, 0.3) + vf_coef = trial.suggest_float("vf_coef", 0.1, 1.0) + critic_hidden_dim = trial.suggest_categorical("critic_hidden_dim", [128, 256, 512]) + + trial_dir = f"{slug}/trial_{trial.number}" + overrides: list[str] = [ + f"training.total_timesteps={args.total_timesteps}", + f"training.selfplay_iterations={args.selfplay_iterations}", + f"training.n_envs={n_envs}", + f"training.n_steps={n_steps}", + f"training.batch_size={batch_size}", + f"training.n_epochs={n_epochs}", + f"training.learning_rate={lr}", + f"training.ent_coef={ent_coef}", + f"training.gamma={gamma}", + f"training.gae_lambda={gae_lambda}", + f"training.clip_range={clip_range}", + f"training.vf_coef={vf_coef}", + f"training.critic_hidden_dim={critic_hidden_dim}", + f"training.checkpoint_dir=checkpoints/optuna/{trial_dir}", + f"training.pool_dir=checkpoints/optuna/{trial_dir}/pool", + f"training.log_dir=logs/optuna/{trial_dir}", + ] + if args.disable_tensorboard: + overrides.append("logging.use_tensorboard=false") + overrides.extend(user_overrides) + + flat = load_project_config(_ROOT, config_name=args.config_name, overrides=overrides) + metrics = run_mappo_bots_training(flat, return_metrics=True) + assert metrics is not None + win_frac = float(metrics["win_frac"]) + logger.info( + "trial {} finished: win_frac={} outcomes={}", + trial.number, + win_frac, + metrics.get("outcome_fractions"), + ) + return win_frac + + storage = args.storage.strip() or None + sampler = optuna.samplers.TPESampler(seed=args.seed) + study = optuna.create_study( + study_name=args.study_name, + storage=storage, + direction="maximize", + load_if_exists=True, + sampler=sampler, + ) + study.optimize(objective, n_trials=args.n_trials, n_jobs=args.n_jobs, show_progress_bar=True) + + best = study.best_trial + logger.info("Best trial: {} value={}", best.number, best.value) + logger.info("Best params: {}", best.params) + + +if __name__ == "__main__": + main() diff --git a/src/village_ai_war/agents/bot_obs_builder.py b/src/village_ai_war/agents/bot_obs_builder.py index d2fd739..2c4e00f 100644 --- a/src/village_ai_war/agents/bot_obs_builder.py +++ b/src/village_ai_war/agents/bot_obs_builder.py @@ -1,4 +1,4 @@ -"""Build normalized low-level bot observations (fixed length 181).""" +"""Build normalized per-bot observation vectors (MAPPO local slots, ``GameEnv`` bot mode).""" from __future__ import annotations @@ -12,7 +12,7 @@ class BotObsBuilder: """Observation layout for a single controllable bot. - Layout (``181`` floats in ``[0, 1]``): + Vector length is ``OBS_DIM`` (all values in ``[0, 1]``): - ``0:49`` — 7×7 local terrain patch (centered on bot), values are ``TerrainType / max_terrain``. @@ -25,7 +25,7 @@ class BotObsBuilder: - ``110:114`` — ally alive count / ``pop_cap``, enemy alive / ``pop_cap``, ally TH HP fraction, enemy TH HP fraction (4 scalars). - ``114:116`` — role-specific hints (dist / map_size, secondary norm). - - ``116:181`` — reserved (zeros). + - ``116:OBS_DIM`` — reserved (zeros). Args: map_size: Side length of the square map. @@ -33,8 +33,10 @@ class BotObsBuilder: config: Optional merged game config (for ``map.resource_capacity``). """ - OBS_DIM = 181 PATCH = 7 + _CORE_END = 116 + _RESERVED_TAIL = 65 + OBS_DIM = _CORE_END + _RESERVED_TAIL def __init__( self, diff --git a/src/village_ai_war/config_load.py b/src/village_ai_war/config_load.py index b204a41..b066177 100644 --- a/src/village_ai_war/config_load.py +++ b/src/village_ai_war/config_load.py @@ -8,11 +8,15 @@ from omegaconf import DictConfig, OmegaConf -def load_project_config(project_root: Path, config_name: str = "default") -> dict[str, Any]: +def load_project_config( + project_root: Path, + config_name: str = "default", + overrides: list[str] | None = None, +) -> dict[str, Any]: """Compose ``config_name`` from ``project_root/configs`` and return a plain dict.""" from hydra import compose, initialize_config_dir cfg_dir = str((project_root / "configs").resolve()) with initialize_config_dir(config_dir=cfg_dir, version_base=None): - cfg: DictConfig = compose(config_name=config_name) + cfg: DictConfig = compose(config_name=config_name, overrides=overrides or []) return OmegaConf.to_container(cfg, resolve=True) # type: ignore[return-value] diff --git a/src/village_ai_war/env/game_env.py b/src/village_ai_war/env/game_env.py index 6878d9c..9c51316 100644 --- a/src/village_ai_war/env/game_env.py +++ b/src/village_ai_war/env/game_env.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from pathlib import Path from typing import Any, SupportsFloat, cast import gymnasium as gym @@ -68,8 +67,6 @@ def __init__( self._opponent_controlled_bot_id: int = 0 self._renderer: Any = None self._last_tick_merged: dict[str, Any] = {} - self._loaded_bot_policy: Any = None - self._bot_policy_load_attempted: bool = False self._tick_start_positions: dict[int, tuple[int, int]] = {} self._prev_distances: dict[int, float] = {} self._tick_food_by_bot: dict[int, int] = {} @@ -166,7 +163,6 @@ def step(self, action: Any) -> tuple[Any, SupportsFloat, bool, bool, dict[str, A if self.mode == "full" and state.tick % interval == 0: self._apply_village_decision(1 - self.team, {"kind": "noop"}) - self._ensure_bot_policy_loaded() melee_intents: list[tuple[int, int, tuple[int, int]]] = [] learner_bot_action: int | None = None @@ -176,9 +172,9 @@ def step(self, action: Any) -> tuple[Any, SupportsFloat, bool, bool, dict[str, A self.team, self._controlled_bot_id, learner_bot_action, melee_intents ) ex = frozenset({(self.team, self._controlled_bot_id)}) - self._step_all_bots_with_policy(self._loaded_bot_policy, melee_intents, exclude=ex) + self._step_all_bots_with_policy(None, melee_intents, exclude=ex) else: - self._step_all_bots_with_policy(self._loaded_bot_policy, melee_intents, exclude=None) + self._step_all_bots_with_policy(None, melee_intents, exclude=None) return self._advance_tick_after_bots( melee_intents, @@ -222,8 +218,7 @@ def step_with_opponent( (1, self._opponent_controlled_bot_id), } ) - self._ensure_bot_policy_loaded() - self._step_all_bots_with_policy(self._loaded_bot_policy, melee_intents, exclude=ex) + self._step_all_bots_with_policy(None, melee_intents, exclude=ex) return self._advance_tick_after_bots( melee_intents, manager_action=None, @@ -284,10 +279,10 @@ def run_bots_then_village_decisions( """Run one tick: all bots act, then both managers' village actions are applied. Matches the order used in self-play village training. Use ``bot_policy=None`` - (or pass a loaded PPO) for low-level control; ``None`` yields random bot moves. + for random bot moves, or pass a policy with ``predict(obs)`` for scripted bots. Args: - bot_policy: Frozen PPO for bots, or ``None`` for random discrete actions. + bot_policy: Optional policy for non-human bots, or ``None`` for random actions. red_village_action: Flattened village action index for team 0. blue_village_action: Flattened village action index for team 1. human_team: If set with ``human_bot_actions``, that team's alive bots use @@ -381,44 +376,6 @@ def _info_dict(self) -> dict[str, Any]: "mode": self.mode, } - def _bot_checkpoint_path(self) -> Path | None: - from omegaconf import OmegaConf - - cfg: dict[str, Any] = ( - OmegaConf.to_container(self.config, resolve=True) # type: ignore[assignment] - if OmegaConf.is_config(self.config) - else dict(self.config) - ) - game = cfg.get("game") - if isinstance(game, dict): - p = game.get("bot_rl_checkpoint") - if p: - path = Path(str(p)) - return path if path.suffix else path.with_suffix(".zip") - training = cfg.get("training") - if isinstance(training, dict) and training.get("bot_checkpoint"): - path = Path(str(training["bot_checkpoint"])) - return path if path.suffix else path.with_suffix(".zip") - return None - - def _ensure_bot_policy_loaded(self) -> None: - if self._bot_policy_load_attempted: - return - self._bot_policy_load_attempted = True - path = self._bot_checkpoint_path() - if path is None: - return - if not path.is_file(): - logger.warning("Bot RL checkpoint not found at {}, using random bot actions", path) - return - try: - from stable_baselines3 import PPO - - self._loaded_bot_policy = PPO.load(str(path)) - logger.info("Loaded bot policy from {}", path) - except Exception as e: # noqa: BLE001 - logger.warning("Failed to load bot policy from {}: {}", path, e) - def _get_single_bot_obs(self, bot_id: int, team: int | None = None) -> np.ndarray: assert self._state is not None if team is not None: diff --git a/src/village_ai_war/models/mappo_layout.py b/src/village_ai_war/models/mappo_layout.py index ee0e730..d2af123 100644 --- a/src/village_ai_war/models/mappo_layout.py +++ b/src/village_ai_war/models/mappo_layout.py @@ -34,7 +34,7 @@ def pack_mappo_obs_slots( village0: np.ndarray, village1: np.ndarray, ) -> np.ndarray: - """Concatenate K local bot rows (K * 181), flattened map (team-0 POV), both village vectors.""" + """Concatenate K local bot rows (K × ``mappo_local_dim()``), map flat, both village vectors.""" flat_map = np.asarray(map_obs, dtype=np.float32).reshape(-1) v = np.concatenate( [np.asarray(village0, dtype=np.float32), np.asarray(village1, dtype=np.float32)], diff --git a/src/village_ai_war/training/mappo_env.py b/src/village_ai_war/training/mappo_env.py index 60ebdfc..8efdbc1 100644 --- a/src/village_ai_war/training/mappo_env.py +++ b/src/village_ai_war/training/mappo_env.py @@ -7,14 +7,11 @@ from __future__ import annotations from collections.abc import Mapping -from pathlib import Path from typing import Any, SupportsFloat import gymnasium as gym import numpy as np from gymnasium import spaces -from loguru import logger -from stable_baselines3 import PPO from village_ai_war.agents.village_obs_builder import VillageObsBuilder from village_ai_war.env.game_env import GameEnv @@ -42,8 +39,6 @@ def __init__( self, config: Mapping[str, Any] | Any, team: int = 0, - opponent_pool_dir: str = "checkpoints/pool/bots", - opponent_sampling: str = "uniform", *, vec_env_index: int = 0, ) -> None: @@ -54,7 +49,7 @@ def __init__( self.team = int(team) self.opponent_team = 1 - self.team - self._local_dim = int(self.inner.observation_space.shape[0]) # BotObsBuilder.OBS_DIM + self._local_dim = int(self.inner.observation_space.shape[0]) self._n_bot_slots = int(self._flat_cfg["game"]["max_bots_for_role_change"]) self._obs_dim = mappo_obs_dim(n, self._n_bot_slots) self.observation_space = spaces.Box( @@ -67,84 +62,9 @@ def __init__( [GameEnv.BOT_ACTIONS] * self._n_bot_slots ) - self.opponent_pool_dir = Path(opponent_pool_dir) - self.opponent_sampling = opponent_sampling self._vec_env_index = int(vec_env_index) - self._opponent_policy: PPO | None = None - self._skip_opponent_logged: set[str] = set() - self._warned_no_compatible_opponent: bool = False self.village_obs_builder = VillageObsBuilder(n) - self._load_opponent() - - def _opponent_obs_compatible(self, model: PPO) -> bool: - """Opponent policy must match ``inner`` bot Box (181-dim), not MAPPO extended obs.""" - pe = model.observation_space - ee = self.inner.observation_space - if not isinstance(pe, spaces.Box) or not isinstance(ee, spaces.Box): - return False - return tuple(int(x) for x in pe.shape) == tuple(int(x) for x in ee.shape) - - def _load_opponent(self) -> None: - self.opponent_pool_dir.mkdir(parents=True, exist_ok=True) - all_zips = sorted(self.opponent_pool_dir.glob("*.zip")) - # MAPPO zips live in pool/bots_mappo/; skip mappo_bot* here to avoid loading them. - checkpoints = [p for p in all_zips if not p.name.startswith("mappo_bot")] - if not all_zips: - self._opponent_policy = None - return - if not checkpoints: - self._opponent_policy = None - self._emit_no_opponent_message( - "opponent pool has only mappo_bot_*.zip (skipped); add 181-dim zips or use " - "pool/bots_mappo/ for MAPPO — random opponent moves", - ) - return - - rng = np.random.default_rng() - if self.opponent_sampling == "latest": - order = list(reversed(checkpoints)) - else: - perm = rng.permutation(len(checkpoints)) - order = [checkpoints[int(i)] for i in perm] - - self._opponent_policy = None - for ckpt in order: - try: - model = PPO.load(str(ckpt)) - except Exception as e: # noqa: BLE001 - logger.debug("Skip opponent {}: {}", ckpt.name, e) - continue - if not self._opponent_obs_compatible(model): - key = str(ckpt.resolve()) - if key not in self._skip_opponent_logged: - self._skip_opponent_logged.add(key) - logger.debug( - "Skip opponent {}: policy obs {} != inner bot obs {}", - ckpt.name, - getattr(model.observation_space, "shape", None), - self.inner.observation_space.shape, - ) - continue - self._opponent_policy = model - self._warned_no_compatible_opponent = False - logger.debug("Loaded opponent: {}", ckpt.name) - return - - self._emit_no_opponent_message( - "no compatible opponent in {} (need Box shape {}); using random opponent moves", - self.opponent_pool_dir, - self.inner.observation_space.shape, - ) - - def _emit_no_opponent_message(self, msg: str, *args: Any) -> None: - """SubprocVecEnv: each process has its own memory — log only from worker 0, once (no DEBUG spam).""" - if self._vec_env_index != 0: - return - if self._warned_no_compatible_opponent: - return - self._warned_no_compatible_opponent = True - logger.warning(msg, *args) def _global_state(self, state: GameState) -> dict[str, np.ndarray]: """Canonical team-0 map POV plus both village vectors (for critic and logging).""" @@ -174,12 +94,7 @@ def _step_opponent_bots(self) -> None: for bot in village.bots: if not bot.is_alive: continue - obs_local = self.inner._get_single_bot_obs(bot.bot_id, self.opponent_team) - if self._opponent_policy is not None: - act, _ = self._opponent_policy.predict(obs_local, deterministic=False) - act_int = int(np.asarray(act).reshape(-1)[0]) - else: - act_int = int(self.inner.action_space.sample()) + act_int = int(self.inner.action_space.sample()) self.inner.queue_bot_action(self.opponent_team, bot.bot_id, act_int) def reset( @@ -190,8 +105,6 @@ def reset( ) -> tuple[np.ndarray, dict[str, Any]]: super().reset(seed=seed) _, info = self.inner.reset(seed=seed, options=options) - if self.np_random.random() < 0.1: - self._load_opponent() gs = self._get_global_from_state() st = self.inner.game_state assert st is not None diff --git a/src/village_ai_war/training/mappo_episode_metrics_callback.py b/src/village_ai_war/training/mappo_episode_metrics_callback.py index 9cb1485..7185866 100644 --- a/src/village_ai_war/training/mappo_episode_metrics_callback.py +++ b/src/village_ai_war/training/mappo_episode_metrics_callback.py @@ -40,3 +40,10 @@ def _on_step(self) -> bool: for k, v in Counter(self._reasons).items(): self.logger.record(f"mappo/terminal_reason_frac/{k}", float(v) / float(rn)) return True + + def outcome_fractions(self) -> dict[str, float]: + """Normalized outcome counts in the current rolling window (empty if no episodes yet).""" + if not self._outcomes: + return {} + n = len(self._outcomes) + return {str(k): float(v) / float(n) for k, v in Counter(self._outcomes).items()} diff --git a/src/village_ai_war/training/train_mappo_bots.py b/src/village_ai_war/training/train_mappo_bots.py index 9a39415..e8f9ba9 100644 --- a/src/village_ai_war/training/train_mappo_bots.py +++ b/src/village_ai_war/training/train_mappo_bots.py @@ -36,14 +36,17 @@ def _tensorboard_log_dir(flat: dict[str, Any], tcfg: dict[str, Any], subdir: str return str(Path(tcfg["log_dir"]) / subdir) -def run_mappo_bots_training(cfg: Any) -> None: - """MAPPO (PPO + centralized critic) with growing opponent checkpoint pool.""" +def run_mappo_bots_training( + cfg: Any, *, return_metrics: bool = False +) -> dict[str, Any] | None: + """MAPPO (PPO + centralized critic) with a rolling pool of past MAPPO snapshots. + + If ``return_metrics`` is True, returns ``win_frac`` and ``outcome_fractions`` from the + episode metrics callback; otherwise returns ``None``. + """ flat = _flat_cfg(cfg) tcfg = flat["training"] pool_root = Path(tcfg["pool_dir"]) - # Opponents must be 181-dim bot PPO zips in pool/bots/; MAPPO zips go elsewhere. - opponent_pool_dir = pool_root / "bots" - opponent_pool_dir.mkdir(parents=True, exist_ok=True) mappo_pool_dir = pool_root / str(tcfg.get("mappo_pool_subdir", "bots_mappo")) mappo_pool_dir.mkdir(parents=True, exist_ok=True) checkpoint_dir = Path(tcfg["checkpoint_dir"]) / "bots_mappo" @@ -63,8 +66,6 @@ def _init() -> MAPPOBotEnv: return MAPPOBotEnv( flat, team=0, - opponent_pool_dir=str(opponent_pool_dir), - opponent_sampling=str(tcfg.get("opponent_sampling", "uniform")), vec_env_index=_rank, ) @@ -133,3 +134,11 @@ def _init() -> MAPPOBotEnv: logger.info("Saved MAPPO policy to {}.zip", checkpoint_dir / "mappo_bot_final") vec_env.close() + + if return_metrics: + fracs = metrics_cb.outcome_fractions() + return { + "win_frac": float(fracs.get("win", 0.0)), + "outcome_fractions": fracs, + } + return None diff --git a/tests/test_mappo_baseline.py b/tests/test_mappo_baseline.py index 0e87591..58281e0 100644 --- a/tests/test_mappo_baseline.py +++ b/tests/test_mappo_baseline.py @@ -195,8 +195,7 @@ def test_mappo_layout_and_critic_shapes() -> None: def test_mappo_bot_env_and_policy(tmp_path: Path) -> None: cfg = _tiny() k = int(cfg["game"]["max_bots_for_role_change"]) - pool = tmp_path / "bot_pool" - env = MAPPOBotEnv(cfg, team=0, opponent_pool_dir=str(pool)) + env = MAPPOBotEnv(cfg, team=0) obs, info = env.reset(seed=0) assert obs.shape == (mappo_obs_dim(12, k),) assert env.action_space.shape == (k,) @@ -208,9 +207,7 @@ def test_mappo_bot_env_and_policy(tmp_path: Path) -> None: assert "global_state" in info2 env.close() - pool2 = tmp_path / "bot_pool2" - pool2.mkdir() - venv = DummyVecEnv([lambda: MAPPOBotEnv(_tiny(), opponent_pool_dir=str(pool2))]) + venv = DummyVecEnv([lambda: MAPPOBotEnv(_tiny())]) model = PPO( MAPPOPolicy, venv, @@ -228,8 +225,7 @@ def test_mappo_bot_env_and_policy(tmp_path: Path) -> None: def test_mappo_obs_helpers_match_mapppo_env(tmp_path: Path) -> None: cfg = _tiny() k = int(cfg["game"]["max_bots_for_role_change"]) - pool = tmp_path / "pool_mappo_obs" - env = MAPPOBotEnv(cfg, team=0, opponent_pool_dir=str(pool)) + env = MAPPOBotEnv(cfg, team=0) obs, _info = env.reset(seed=42) st = env.inner.game_state assert st is not None @@ -243,9 +239,7 @@ def test_mappo_obs_helpers_match_mapppo_env(tmp_path: Path) -> None: def test_play_mappo_human_tick_smoke(tmp_path: Path) -> None: cfg = _tiny() k = int(cfg["game"]["max_bots_for_role_change"]) - pool = tmp_path / "pool" - pool.mkdir() - venv = DummyVecEnv([lambda: MAPPOBotEnv(cfg, opponent_pool_dir=str(pool))]) + venv = DummyVecEnv([lambda: MAPPOBotEnv(cfg)]) model = PPO( MAPPOPolicy, venv, diff --git a/tests/test_smoke_game_env.py b/tests/test_smoke_game_env.py index 27f71f5..067ff2f 100644 --- a/tests/test_smoke_game_env.py +++ b/tests/test_smoke_game_env.py @@ -7,6 +7,7 @@ pytest.importorskip("gymnasium") +from village_ai_war.agents.bot_obs_builder import BotObsBuilder from village_ai_war.env.game_env import GameEnv @@ -107,9 +108,10 @@ def test_village_reset_step() -> None: def test_bot_reset_step() -> None: env = GameEnv(_tiny_config(), mode="bot", team=0, render_mode=None) obs, _ = env.reset(seed=2) - assert obs.shape == (181,) + d = BotObsBuilder.OBS_DIM + assert obs.shape == (d,) obs2, r, term, trunc, _ = env.step(0) - assert len(obs2) == 181 + assert len(obs2) == d def test_step_with_opponent_and_action_masks_team() -> None: @@ -118,7 +120,7 @@ def test_step_with_opponent_and_action_masks_team() -> None: env = GameEnv(cfg, mode="bot", team=0, render_mode=None) env.reset(seed=0) obs, r, term, trunc, info = env.step_with_opponent(0, 0) - assert obs.shape == (181,) + assert obs.shape == (BotObsBuilder.OBS_DIM,) assert "kills_this_tick" in info assert "winner" in info From 8a52daf8a42f0b93a2e992e3fdb3697337cb4ff8 Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Tue, 31 Mar 2026 14:44:26 +0300 Subject: [PATCH 11/11] Enhance project configuration and CI/CD setup - Updated `.gitignore` to include `dist/` and `build/` directories for cleaner builds. - Modified `pyproject.toml` to ignore specific linting errors, improving code quality checks. - Introduced CI workflows in `.github/workflows/ci.yml` for automated linting, building, and testing. - Added a release workflow in `.github/workflows/release.yml` to automate package building and asset publishing on version tags. - Refactored imports in several source files to use `collections.abc.Mapping`, improving compatibility with Python 3.10 and above. --- .github/workflows/ci.yml | 86 +++++++++++++++++++ .github/workflows/release.yml | 34 ++++++++ .gitignore | 2 + pyproject.toml | 1 + src/village_ai_war/agents/action_masker.py | 3 +- src/village_ai_war/agents/bot_obs_builder.py | 5 +- .../agents/village_action_space.py | 2 - src/village_ai_war/env/building_system.py | 3 +- src/village_ai_war/env/combat_system.py | 3 +- src/village_ai_war/env/economy_system.py | 7 +- src/village_ai_war/env/game_env.py | 1 - src/village_ai_war/env/map_generator.py | 3 +- src/village_ai_war/play/mappo_human_tick.py | 3 +- .../rendering/moderngl_3d_renderer.py | 23 +++-- src/village_ai_war/rewards/bot_reward.py | 3 +- src/village_ai_war/rewards/village_reward.py | 3 +- src/village_ai_war/state/game_state.py | 4 +- src/village_ai_war/state/village_state.py | 5 +- tests/test_building_construction.py | 9 +- tests/test_combat_system.py | 10 ++- tests/test_mappo_baseline.py | 2 +- 21 files changed, 179 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9471ba9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + push: + branches: [main, master, dev] + pull_request: + branches: [main, master, dev] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install linters + run: pip install "ruff>=0.1" + + - name: Ruff check + run: ruff check src tests scripts + + build: + name: Package build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install build backend + run: pip install build + + - name: Build sdist and wheel + run: python -m build + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/* + + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: requirements.txt + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgl1 libglib2.0-0 libsdl2-2.0-0 + + - name: Install dependencies + run: | + python -m pip install -U pip + pip install -r requirements.txt + pip install -e . + + - name: Pytest + env: + # Headless / no display for pygame in CI + SDL_VIDEODRIVER: dummy + run: pytest -q --tb=short diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2c2d42f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build-and-attach: + name: Build and attach wheels + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install build + run: pip install build + + - name: Build sdist and wheel + run: python -m build + + - name: Publish GitHub release assets + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 26a25f1..823584e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ wandb/ outputs/ .hydra/ *.egg-info +dist/ +build/ .cursor/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 98fc854..7eb4459 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ target-version = "py310" [tool.ruff.lint] select = ["E", "F", "I", "UP"] +ignore = ["E501"] [tool.ruff.lint.per-file-ignores] "scripts/*.py" = ["E402"] diff --git a/src/village_ai_war/agents/action_masker.py b/src/village_ai_war/agents/action_masker.py index 22f86c5..254809c 100644 --- a/src/village_ai_war/agents/action_masker.py +++ b/src/village_ai_war/agents/action_masker.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any import numpy as np diff --git a/src/village_ai_war/agents/bot_obs_builder.py b/src/village_ai_war/agents/bot_obs_builder.py index 2c4e00f..4a2dd13 100644 --- a/src/village_ai_war/agents/bot_obs_builder.py +++ b/src/village_ai_war/agents/bot_obs_builder.py @@ -2,11 +2,12 @@ from __future__ import annotations -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any import numpy as np -from village_ai_war.state import GameState, GlobalRewardMode, ResourceLayer, Role, TerrainType +from village_ai_war.state import GameState, ResourceLayer, Role, TerrainType class BotObsBuilder: diff --git a/src/village_ai_war/agents/village_action_space.py b/src/village_ai_war/agents/village_action_space.py index 5a3ecb9..281a9b7 100644 --- a/src/village_ai_war/agents/village_action_space.py +++ b/src/village_ai_war/agents/village_action_space.py @@ -4,8 +4,6 @@ from dataclasses import dataclass -import numpy as np - from village_ai_war.state import BuildingType, GlobalRewardMode, Role diff --git a/src/village_ai_war/env/building_system.py b/src/village_ai_war/env/building_system.py index dce1827..1b329ba 100644 --- a/src/village_ai_war/env/building_system.py +++ b/src/village_ai_war/env/building_system.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any import numpy as np diff --git a/src/village_ai_war/env/combat_system.py b/src/village_ai_war/env/combat_system.py index df50df1..5f1e0b4 100644 --- a/src/village_ai_war/env/combat_system.py +++ b/src/village_ai_war/env/combat_system.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any import numpy as np diff --git a/src/village_ai_war/env/economy_system.py b/src/village_ai_war/env/economy_system.py index fe04393..d537b5e 100644 --- a/src/village_ai_war/env/economy_system.py +++ b/src/village_ai_war/env/economy_system.py @@ -3,7 +3,8 @@ from __future__ import annotations import math -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any import numpy as np @@ -35,9 +36,6 @@ def step(state: GameState, config: Mapping[str, Any]) -> dict[str, Any]: harvest_amount = int(ecfg["harvest_amount"]) food_per_bot = float(ecfg["food_consumption"]) hunger_damage = int(ecfg["hunger_damage"]) - spawn_delay = int(ecfg["bot_spawn_delay"]) - bot_cost_wood = int(ecfg["bot_cost"]["wood"]) - bot_cost_food = int(ecfg["bot_cost"]["food"]) farm_bonus = float(ecfg.get("farm_food_bonus", 0.5)) resource_by_bot: dict[int, int] = {} @@ -111,7 +109,6 @@ def add_resource_bot(bot_id: int, amt: int) -> None: village.resources.food -= need events["food_delta"][team] -= need else: - short = need - village.resources.food village.resources.food = 0 events["food_delta"][team] -= need for _b in alive: diff --git a/src/village_ai_war/env/game_env.py b/src/village_ai_war/env/game_env.py index 9c51316..b5c3c95 100644 --- a/src/village_ai_war/env/game_env.py +++ b/src/village_ai_war/env/game_env.py @@ -8,7 +8,6 @@ import gymnasium as gym import numpy as np from gymnasium import spaces -from loguru import logger from village_ai_war.agents.action_masker import ActionMasker from village_ai_war.agents.bot_obs_builder import BotObsBuilder diff --git a/src/village_ai_war/env/map_generator.py b/src/village_ai_war/env/map_generator.py index b878cd3..fd98af2 100644 --- a/src/village_ai_war/env/map_generator.py +++ b/src/village_ai_war/env/map_generator.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any import numpy as np diff --git a/src/village_ai_war/play/mappo_human_tick.py b/src/village_ai_war/play/mappo_human_tick.py index c67185c..84043bb 100644 --- a/src/village_ai_war/play/mappo_human_tick.py +++ b/src/village_ai_war/play/mappo_human_tick.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Mapping, SupportsFloat +from collections.abc import Mapping +from typing import Any, SupportsFloat import numpy as np diff --git a/src/village_ai_war/rendering/moderngl_3d_renderer.py b/src/village_ai_war/rendering/moderngl_3d_renderer.py index 4003d33..a1dc9b9 100644 --- a/src/village_ai_war/rendering/moderngl_3d_renderer.py +++ b/src/village_ai_war/rendering/moderngl_3d_renderer.py @@ -7,19 +7,26 @@ import numpy as np -from village_ai_war.rendering.building_models_3d import add_building_variant as _add_building_variant -from village_ai_war.rendering.world_scenery_3d import ( - add_resource_prop as _add_resource_prop, - add_terrain_cell as _add_terrain_cell, - terrain_height as _terrain_height, +from village_ai_war.rendering.building_models_3d import ( + add_building_variant as _add_building_variant, ) from village_ai_war.rendering.mesh_primitives import ( add_cuboid as _add_cuboid, +) +from village_ai_war.rendering.mesh_primitives import ( add_cylinder_y as _add_cylinder_y, - add_pyramid as _add_pyramid, - add_quad as _add_quad, +) +from village_ai_war.rendering.mesh_primitives import ( add_sphere as _add_sphere, - add_tri as _add_tri, +) +from village_ai_war.rendering.world_scenery_3d import ( + add_resource_prop as _add_resource_prop, +) +from village_ai_war.rendering.world_scenery_3d import ( + add_terrain_cell as _add_terrain_cell, +) +from village_ai_war.rendering.world_scenery_3d import ( + terrain_height as _terrain_height, ) from village_ai_war.state import ( GameState, diff --git a/src/village_ai_war/rewards/bot_reward.py b/src/village_ai_war/rewards/bot_reward.py index 2e95b85..443b4d6 100644 --- a/src/village_ai_war/rewards/bot_reward.py +++ b/src/village_ai_war/rewards/bot_reward.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from village_ai_war.rewards.global_reward import mode_coefficient from village_ai_war.state import BotState, GlobalRewardMode diff --git a/src/village_ai_war/rewards/village_reward.py b/src/village_ai_war/rewards/village_reward.py index 6f22562..9fafc2f 100644 --- a/src/village_ai_war/rewards/village_reward.py +++ b/src/village_ai_war/rewards/village_reward.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from village_ai_war.state import VillageState diff --git a/src/village_ai_war/state/game_state.py b/src/village_ai_war/state/game_state.py index bf79dfa..dede041 100644 --- a/src/village_ai_war/state/game_state.py +++ b/src/village_ai_war/state/game_state.py @@ -1,6 +1,6 @@ """Full world state: map, villages, blueprints, termination.""" -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, Field @@ -28,7 +28,7 @@ class GameState(BaseModel): blueprints: list[dict[str, Any]] = Field(default_factory=list) villages: list[VillageState] = Field(default_factory=list) is_done: bool = False - winner: Optional[int] = Field( + winner: int | None = Field( default=None, description="0=red, 1=blue, None=ongoing or draw.", ) diff --git a/src/village_ai_war/state/village_state.py b/src/village_ai_war/state/village_state.py index df2bc47..cc84582 100644 --- a/src/village_ai_war/state/village_state.py +++ b/src/village_ai_war/state/village_state.py @@ -1,7 +1,6 @@ """Village-level aggregates: resources, buildings, manager state.""" from enum import IntEnum -from typing import Optional from pydantic import BaseModel, Field @@ -66,13 +65,13 @@ class VillageState(BaseModel): resources: ResourceStock = Field(default_factory=ResourceStock) pop_cap: int = 10 global_reward_mode: GlobalRewardMode = GlobalRewardMode.NEUTRAL - rally_point: Optional[tuple[int, int]] = None + rally_point: tuple[int, int] | None = None bots: list[BotState] = Field(default_factory=list) buildings: list[BuildingState] = Field(default_factory=list) total_kills: int = 0 total_losses: int = 0 ticks_without_progress: int = 0 spawn_queue_ticks_remaining: int = 0 - pending_recruit_role: Optional[int] = None + pending_recruit_role: int | None = None model_config = {"frozen": False} diff --git a/tests/test_building_construction.py b/tests/test_building_construction.py index b060730..fb54b81 100644 --- a/tests/test_building_construction.py +++ b/tests/test_building_construction.py @@ -5,7 +5,14 @@ from typing import Any from village_ai_war.env.building_system import BuildingSystem -from village_ai_war.state import BotState, BuildingType, GameState, ResourceStock, Role, VillageState +from village_ai_war.state import ( + BotState, + BuildingType, + GameState, + ResourceStock, + Role, + VillageState, +) def _bcfg() -> dict[str, Any]: diff --git a/tests/test_combat_system.py b/tests/test_combat_system.py index fa63f32..b15d5e1 100644 --- a/tests/test_combat_system.py +++ b/tests/test_combat_system.py @@ -3,7 +3,15 @@ from typing import Any from village_ai_war.env.combat_system import CombatSystem -from village_ai_war.state import BotState, BuildingState, BuildingType, GameState, ResourceStock, Role, VillageState +from village_ai_war.state import ( + BotState, + BuildingState, + BuildingType, + GameState, + ResourceStock, + Role, + VillageState, +) def _ccfg() -> dict[str, Any]: diff --git a/tests/test_mappo_baseline.py b/tests/test_mappo_baseline.py index 58281e0..e19082b 100644 --- a/tests/test_mappo_baseline.py +++ b/tests/test_mappo_baseline.py @@ -26,12 +26,12 @@ pack_mappo_obs_slots, ) from village_ai_war.models.mappo_policy import MAPPOPolicy +from village_ai_war.play.mappo_human_tick import play_mappo_human_tick from village_ai_war.play.mappo_obs import ( build_mappo_global_state, build_mappo_locals_matrix, pack_mappo_observation_vector, ) -from village_ai_war.play.mappo_human_tick import play_mappo_human_tick from village_ai_war.training.mappo_env import MAPPOBotEnv