Add JAX PPO opponent to classic GUI

This commit is contained in:
2026-07-12 04:14:02 +09:00
parent 19560029b5
commit 3b86f81bf6
6 changed files with 310 additions and 71 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ dependencies = [
[project.optional-dependencies]
gui = [
"pygame>=2.6.1",
"pygame-ce>=2.5.3",
"pygame-gui>=0.6.14",
]
wandb = [
@@ -0,0 +1,179 @@
"""Adapter from the classic two-phase GUI to a JAX PPO checkpoint."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import jax.numpy as jnp
import numpy as np
from lost_cities_jax.human_play import (
evaluate_agent_policy,
infer_config_path,
load_agent,
)
from lost_cities_jax.ppo import load_config
from lost_cities_jax.types import (
LOC_DECK,
LOC_DISCARD,
LOC_P0_BOARD,
LOC_P0_HAND,
MAX_PILE_SIZE,
N_CARDS,
N_COLORS,
NO_CARD,
State,
)
from .game import Card, GameState
from .snapshots import Snapshot, snapshot_from_state
def _jax_card_id(card: Card, handshake_copy: int = 0) -> int:
slot = handshake_copy if card.is_handshake else card.numeric_value(2) + 1
return card.color * 12 + slot
def snapshot_to_jax_state(snapshot: Snapshot) -> tuple[State, list[int]]:
"""Convert a classic card-phase snapshot into the equivalent JAX state.
The returned list maps each JAX sorted hand slot back to the classic hand
slot. Handshake copies are interchangeable to both rules engines and the
policy observation, so they are assigned stable per-color identities here.
"""
if (
snapshot.config.n_colors != N_COLORS
or snapshot.config.n_ranks != 9
or snapshot.config.n_handshakes != 3
or snapshot.config.hand_size != 8
or snapshot.config.min_rank != 2
):
raise ValueError("JAX PPO checkpoints require the classic 5-color, 60-card rules")
if snapshot.phase != "card":
raise ValueError("JAX PPO policy conversion must start during the card phase")
handshake_next = [0] * N_COLORS
def allocate(card: Card) -> int:
if not card.is_handshake:
return _jax_card_id(card)
copy = handshake_next[card.color]
if copy >= 3:
raise ValueError(f"too many handshake copies for color {card.color}")
handshake_next[card.color] += 1
return _jax_card_id(card, copy)
card_loc = np.full(N_CARDS, LOC_DECK, dtype=np.int8)
zone_ids: list[int] = []
hand_pairs: list[tuple[int, int]] = []
deck_ids = [allocate(card) for card in snapshot.deck]
zone_ids.extend(deck_ids)
for player, hand in enumerate(snapshot.hands):
for classic_slot, card in enumerate(hand):
card_id = allocate(card)
card_loc[card_id] = LOC_P0_HAND + player
zone_ids.append(card_id)
if player == snapshot.current_player:
hand_pairs.append((card_id, classic_slot))
col_top = np.zeros((2, N_COLORS), dtype=np.int8)
col_hs = np.zeros((2, N_COLORS), dtype=np.int8)
col_len = np.zeros((2, N_COLORS), dtype=np.int8)
for player, expeditions in enumerate(snapshot.expeditions):
for color, expedition in enumerate(expeditions):
for card in expedition:
card_id = allocate(card)
card_loc[card_id] = LOC_P0_BOARD + player
zone_ids.append(card_id)
col_len[player, color] += 1
if card.is_handshake:
col_hs[player, color] += 1
else:
col_top[player, color] = card.numeric_value(2)
pile = np.full((N_COLORS, MAX_PILE_SIZE), NO_CARD, dtype=np.int8)
pile_len = np.zeros(N_COLORS, dtype=np.int8)
for color, discard in enumerate(snapshot.discards):
for index, card in enumerate(discard):
card_id = allocate(card)
card_loc[card_id] = LOC_DISCARD
pile[color, index] = card_id
pile_len[color] += 1
zone_ids.append(card_id)
if len(zone_ids) != N_CARDS or len(set(zone_ids)) != N_CARDS:
raise ValueError("classic snapshot does not contain one complete 60-card deck")
# Classic draws from the end of its deck list. JAX draws forward from
# draw_ptr, so its remaining suffix is the reversed classic deck.
remaining = list(reversed(deck_ids))
deck_id_set = set(deck_ids)
used = [card_id for card_id in range(N_CARDS) if card_id not in deck_id_set]
deck_order = np.asarray([*used, *remaining], dtype=np.int8)
draw_ptr = len(used)
hand_slot_map = [slot for _, slot in sorted(hand_pairs)]
state = State(
deck_order=jnp.asarray(deck_order),
draw_ptr=jnp.asarray(draw_ptr, dtype=jnp.int32),
card_loc=jnp.asarray(card_loc),
hand_public=jnp.zeros((N_CARDS,), dtype=jnp.bool_),
col_top=jnp.asarray(col_top),
col_hs=jnp.asarray(col_hs),
col_len=jnp.asarray(col_len),
pile=jnp.asarray(pile),
pile_len=jnp.asarray(pile_len),
to_move=jnp.asarray(snapshot.current_player, dtype=jnp.int8),
just_discarded=jnp.asarray(NO_CARD, dtype=jnp.int8),
step_count=jnp.asarray(snapshot.turn_count, dtype=jnp.int32),
done=jnp.asarray(snapshot.terminal),
)
return state, hand_slot_map
class JaxPPOPolicy:
"""Expose an atomic-action JAX policy through the classic policy protocol."""
def __init__(self, checkpoint: str | Path, *, config: str | Path | None = None):
# The classic GUI enables root DEBUG logging for its own interaction
# trace. Keep JAX/Orbax internals from flooding the launcher terminal.
for logger_name in ("jax", "absl", "orbax"):
logging.getLogger(logger_name).setLevel(logging.WARNING)
cfg_path = infer_config_path(checkpoint) if config is None else Path(config)
self.cfg = load_config(cfg_path)
self.params, self.model = load_agent(self.cfg, checkpoint)
self.checkpoint = Path(checkpoint)
self.pending_draw: int | None = None
self.last_evaluation: Any | None = None
def act(self, obs_or_state: Any) -> int:
if not isinstance(obs_or_state, GameState):
raise TypeError("JAX PPO GUI policy requires a GameState")
if obs_or_state.phase == "draw":
if self.pending_draw is None:
raise RuntimeError("JAX PPO draw requested without a pending atomic action")
draw = self.pending_draw
self.pending_draw = None
return draw
snapshot = snapshot_from_state(obs_or_state)
jax_state, hand_slot_map = snapshot_to_jax_state(snapshot)
result = evaluate_agent_policy(
self.cfg,
self.params,
self.model,
jax_state,
snapshot.current_player,
)
self.last_evaluation = result
jax_hand_slot = result.action // 12
remainder = result.action % 12
place = remainder // 6
self.pending_draw = remainder % 6
return 2 * hand_slot_map[jax_hand_slot] + place
__all__ = ["JaxPPOPolicy", "snapshot_to_jax_state"]
@@ -158,6 +158,8 @@ def build_argparser() -> argparse.ArgumentParser:
parser.add_argument("--width", type=int, default=1536)
parser.add_argument("--height", type=int, default=964)
parser.add_argument("--screenshot-on-start", action="store_true")
parser.add_argument("--jax-checkpoint", help="Play PVC against a JAX PPO checkpoint")
parser.add_argument("--jax-config", help="Optional JAX PPO config (normally inferred)")
return parser
@@ -171,6 +173,8 @@ class LostCitiesGuiApp:
width: int = 1536,
height: int = 964,
screenshot_on_start: bool = False,
jax_checkpoint: str | None = None,
jax_config: str | None = None,
):
import pygame
import pygame_gui
@@ -198,6 +202,8 @@ class LostCitiesGuiApp:
self.computer_player = 1
self.next_computer_action_at_ms = 0
self.config = classic_config()
self.jax_checkpoint = jax_checkpoint
self.jax_config = jax_config
self.computer_bot, self.computer_bot_label = self._build_computer_bot()
self.state = GameState.new_game(self.config, seed=self.seed)
self.history: list[GameState] = []
@@ -240,6 +246,11 @@ class LostCitiesGuiApp:
return self.computer_bot_label
def _build_computer_bot(self) -> tuple[LostCitiesPolicy, str]:
if self.jax_checkpoint:
from .jax_ppo_policy import JaxPPOPolicy
policy = JaxPPOPolicy(self.jax_checkpoint, config=self.jax_config)
return policy, f"jax-ppo:{Path(self.jax_checkpoint).name}"
return build_bot(self.bot_name, seed=self._bot_seed()), self.bot_name
def snapshot(self) -> Snapshot:
@@ -614,28 +625,38 @@ class LostCitiesGuiApp:
for element in self.ui_elements:
element.kill()
self.ui_elements = []
self.mode_dropdown = pygame_gui.elements.UIDropDownMenu(
options_list=["pvp", "pvc"],
starting_option=self.mode,
relative_rect=pygame.Rect(92, 17, 108, 46),
manager=self.manager,
)
self.bot_dropdown = pygame_gui.elements.UIDropDownMenu(
options_list=available_bot_names(),
starting_option=self.bot_name,
relative_rect=pygame.Rect(288, 17, 138, 46),
manager=self.manager,
)
if self.mode != "pvc":
self.bot_dropdown.disable()
game_label = pygame_gui.elements.UILabel(
relative_rect=pygame.Rect(448, 17, 190, 46),
text="CLASSIC / PYTHON",
manager=self.manager,
)
self.mode_dropdown = None
self.bot_dropdown = None
if self.jax_checkpoint:
game_label = pygame_gui.elements.UILabel(
relative_rect=pygame.Rect(30, 17, 390, 46),
text="COOLRL LOST CITIES / FINAL JAX PPO",
manager=self.manager,
)
new_game_x = 448
else:
self.mode_dropdown = pygame_gui.elements.UIDropDownMenu(
options_list=["pvp", "pvc"],
starting_option=self.mode,
relative_rect=pygame.Rect(92, 17, 108, 46),
manager=self.manager,
)
self.bot_dropdown = pygame_gui.elements.UIDropDownMenu(
options_list=available_bot_names(),
starting_option=self.bot_name,
relative_rect=pygame.Rect(288, 17, 138, 46),
manager=self.manager,
)
if self.mode != "pvc":
self.bot_dropdown.disable()
game_label = pygame_gui.elements.UILabel(
relative_rect=pygame.Rect(448, 17, 190, 46),
text="CLASSIC / PYTHON",
manager=self.manager,
)
new_game_x = 660
self.new_game_button = pygame_gui.elements.UIButton(
relative_rect=pygame.Rect(660, 17, 148, 46),
relative_rect=pygame.Rect(new_game_x, 17, 148, 46),
text="NEW GAME",
manager=self.manager,
)
@@ -654,14 +675,16 @@ class LostCitiesGuiApp:
if not self.snapshot().terminal:
self.export_button.disable()
self.ui_elements.extend(
[
element
for element in (
self.mode_dropdown,
self.bot_dropdown,
game_label,
self.new_game_button,
self.undo_button,
self.export_button,
]
)
if element is not None
)
def draw(self) -> None:
@@ -689,9 +712,10 @@ class LostCitiesGuiApp:
pygame.draw.line(self.screen, LINE, (0, 0), (width, 0), 1)
pygame.draw.line(self.screen, LINE, (0, 90), (width, 90), 1)
pygame.draw.line(self.screen, LINE, (1080, 0), (1080, 90), 1)
self._draw_text("MODE", (31, 31), MUTED, 18)
self._draw_text("BOT", (220, 31), MUTED, 18)
self._draw_text("GAME", (448, 31), MUTED, 18)
if not self.jax_checkpoint:
self._draw_text("MODE", (31, 31), MUTED, 18)
self._draw_text("BOT", (220, 31), MUTED, 18)
self._draw_text("GAME", (448, 31), MUTED, 18)
if snapshot.terminal:
diff = snapshot.score_diff(0)
if diff > 0:
@@ -1283,6 +1307,8 @@ def main(argv: list[str] | None = None) -> None:
width=args.width,
height=args.height,
screenshot_on_start=args.screenshot_on_start,
jax_checkpoint=args.jax_checkpoint,
jax_config=args.jax_config,
)
app.run()
@@ -0,0 +1,60 @@
from __future__ import annotations
import numpy as np
from coolrl_lost_cities.games.classic.game import GameState, classic_config
from coolrl_lost_cities.games.classic.jax_ppo_policy import (
JaxPPOPolicy,
snapshot_to_jax_state,
)
from coolrl_lost_cities.games.classic.snapshots import snapshot_from_state
from lost_cities_jax.engine import board_score, current_hand_sorted, legal_action_mask
from lost_cities_jax.human_play import PolicyEval
def test_snapshot_to_jax_state_preserves_initial_public_state() -> None:
state = GameState.new_game(classic_config(), seed=7)
snapshot = snapshot_from_state(state)
converted, hand_slot_map = snapshot_to_jax_state(snapshot)
assert int(converted.to_move) == snapshot.current_player
assert int(converted.draw_ptr) == 16
assert hand_slot_map == sorted(
hand_slot_map,
key=lambda slot: (
snapshot.hands[0][slot].color,
snapshot.hands[0][slot].rank,
),
)
assert np.asarray(current_hand_sorted(converted)).shape == (8,)
assert np.asarray(legal_action_mask(converted)).any()
assert np.asarray(board_score(converted)).tolist() == [0.0, 0.0]
def test_jax_policy_splits_atomic_action_across_classic_phases(monkeypatch) -> None:
state = GameState.new_game(classic_config(), seed=11)
converted, hand_slot_map = snapshot_to_jax_state(snapshot_from_state(state))
legal = np.flatnonzero(np.asarray(legal_action_mask(converted), dtype=bool))
atomic_action = int(legal[0])
expected_slot = hand_slot_map[atomic_action // 12]
expected_place = (atomic_action % 12) // 6
expected_draw = atomic_action % 6
monkeypatch.setattr(
"coolrl_lost_cities.games.classic.jax_ppo_policy.evaluate_agent_policy",
lambda *_args: PolicyEval(action=atomic_action, top3=[], value=0.0),
)
policy = object.__new__(JaxPPOPolicy)
policy.cfg = object()
policy.params = object()
policy.model = object()
policy.pending_draw = None
policy.last_evaluation = None
card_action = policy.act(state)
assert card_action == 2 * expected_slot + expected_place
state.apply_action(card_action)
assert state.phase == "draw"
assert policy.act(state) == expected_draw
assert policy.pending_draw is None
+3
View File
@@ -18,6 +18,8 @@ def test_gui_argparser_accepts_classic_options() -> None:
"1024",
"--height",
"768",
"--jax-checkpoint",
"/tmp/final_candidate",
]
)
@@ -26,6 +28,7 @@ def test_gui_argparser_accepts_classic_options() -> None:
assert args.seed == 7
assert args.width == 1024
assert args.height == 768
assert args.jax_checkpoint == "/tmp/final_candidate"
def test_gui_argparser_rejects_removed_backend_option() -> None:
Generated
+15 -44
View File
@@ -280,7 +280,7 @@ dependencies = [
[package.optional-dependencies]
gui = [
{ name = "pygame" },
{ name = "pygame-ce" },
{ name = "pygame-gui" },
]
wandb = [
@@ -305,7 +305,7 @@ requires-dist = [
{ name = "optax", specifier = ">=0.2.8" },
{ name = "orbax-checkpoint", specifier = ">=0.12.1" },
{ name = "pydantic", specifier = ">=2.7" },
{ name = "pygame", marker = "extra == 'gui'", specifier = ">=2.6.1" },
{ name = "pygame-ce", marker = "extra == 'gui'", specifier = ">=2.5.3" },
{ name = "pygame-gui", marker = "extra == 'gui'", specifier = ">=0.6.14" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "torch", specifier = ">=2.3.0" },
@@ -359,37 +359,37 @@ wheels = [
[package.optional-dependencies]
cublas = [
{ name = "nvidia-cublas", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "nvidia-cublas" },
]
cudart = [
{ name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "nvidia-cuda-runtime" },
]
cufft = [
{ name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "nvidia-cufft" },
]
cufile = [
{ name = "nvidia-cufile", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cufile" },
]
cupti = [
{ name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "nvidia-cuda-cupti" },
]
curand = [
{ name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "nvidia-curand" },
]
cusolver = [
{ name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "nvidia-cusolver" },
]
cusparse = [
{ name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "nvidia-cusparse" },
]
nvjitlink = [
{ name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "nvidia-nvjitlink" },
]
nvrtc = [
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "nvidia-cuda-nvrtc" },
]
nvtx = [
{ name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "nvidia-nvtx" },
]
[[package]]
@@ -1658,35 +1658,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
]
[[package]]
name = "pygame"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/cc/08bba60f00541f62aaa252ce0cfbd60aebd04616c0b9574f755b583e45ae/pygame-2.6.1.tar.gz", hash = "sha256:56fb02ead529cee00d415c3e007f75e0780c655909aaa8e8bf616ee09c9feb1f", size = 14808125, upload-time = "2024-09-29T13:41:34.698Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/ca/8f367cb9fe734c4f6f6400e045593beea2635cd736158f9fabf58ee14e3c/pygame-2.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:20349195326a5e82a16e351ed93465a7845a7e2a9af55b7bc1b2110ea3e344e1", size = 13113753, upload-time = "2024-09-29T14:26:13.751Z" },
{ url = "https://files.pythonhosted.org/packages/83/47/6edf2f890139616b3219be9cfcc8f0cb8f42eb15efd59597927e390538cb/pygame-2.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f3935459109da4bb0b3901da9904f0a3e52028a3332a355d298b1673a334cf21", size = 12378146, upload-time = "2024-09-29T14:26:22.456Z" },
{ url = "https://files.pythonhosted.org/packages/00/9e/0d8aa8cf93db2d2ee38ebaf1c7b61d0df36ded27eb726221719c150c673d/pygame-2.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c31dbdb5d0217f32764797d21c2752e258e5fb7e895326538d82b5f75a0cd856", size = 13611760, upload-time = "2024-09-29T11:10:47.317Z" },
{ url = "https://files.pythonhosted.org/packages/d7/9e/d06adaa5cc65876bcd7a24f59f67e07f7e4194e6298130024ed3fb22c456/pygame-2.6.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:173badf82fa198e6888017bea40f511cb28e69ecdd5a72b214e81e4dcd66c3b1", size = 14298054, upload-time = "2024-09-29T11:39:53.891Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a1/9ae2852ebd3a7cc7d9ae7ff7919ab983e4a5c1b7a14e840732f23b2b48f6/pygame-2.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce8cc108b92de9b149b344ad2e25eedbe773af0dc41dfb24d1f07f679b558c60", size = 13977107, upload-time = "2024-09-29T11:39:56.831Z" },
{ url = "https://files.pythonhosted.org/packages/31/df/6788fd2e9a864d0496a77670e44a7c012184b7a5382866ab0e60c55c0f28/pygame-2.6.1-cp311-cp311-win32.whl", hash = "sha256:811e7b925146d8149d79193652cbb83e0eca0aae66476b1cb310f0f4226b8b5c", size = 10250863, upload-time = "2024-09-29T11:44:48.199Z" },
{ url = "https://files.pythonhosted.org/packages/d2/55/ca3eb851aeef4f6f2e98a360c201f0d00bd1ba2eb98e2c7850d80aabc526/pygame-2.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:91476902426facd4bb0dad4dc3b2573bc82c95c71b135e0daaea072ed528d299", size = 10622016, upload-time = "2024-09-29T12:17:01.545Z" },
{ url = "https://files.pythonhosted.org/packages/92/16/2c602c332f45ff9526d61f6bd764db5096ff9035433e2172e2d2cadae8db/pygame-2.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4ee7f2771f588c966fa2fa8b829be26698c9b4836f82ede5e4edc1a68594942e", size = 13118279, upload-time = "2024-09-29T14:26:30.427Z" },
{ url = "https://files.pythonhosted.org/packages/cd/53/77ccbc384b251c6e34bfd2e734c638233922449a7844e3c7a11ef91cee39/pygame-2.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c8040ea2ab18c6b255af706ec01355c8a6b08dc48d77fd4ee783f8fc46a843bf", size = 12384524, upload-time = "2024-09-29T14:26:49.996Z" },
{ url = "https://files.pythonhosted.org/packages/06/be/3ed337583f010696c3b3435e89a74fb29d0c74d0931e8f33c0a4246307a9/pygame-2.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47a6938de93fa610accd4969e638c2aebcb29b2fca518a84c3a39d91ab47116", size = 13587123, upload-time = "2024-09-29T11:10:50.072Z" },
{ url = "https://files.pythonhosted.org/packages/fd/ca/b015586a450db59313535662991b34d24c1f0c0dc149cc5f496573900f4e/pygame-2.6.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33006f784e1c7d7e466fcb61d5489da59cc5f7eb098712f792a225df1d4e229d", size = 14275532, upload-time = "2024-09-29T11:39:59.356Z" },
{ url = "https://files.pythonhosted.org/packages/b9/f2/d31e6ad42d657af07be2ffd779190353f759a07b51232b9e1d724f2cda46/pygame-2.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1206125f14cae22c44565c9d333607f1d9f59487b1f1432945dfc809aeaa3e88", size = 13952653, upload-time = "2024-09-29T11:40:01.781Z" },
{ url = "https://files.pythonhosted.org/packages/f3/42/8ea2a6979e6fa971702fece1747e862e2256d4a8558fe0da6364dd946c53/pygame-2.6.1-cp312-cp312-win32.whl", hash = "sha256:84fc4054e25262140d09d39e094f6880d730199710829902f0d8ceae0213379e", size = 10252421, upload-time = "2024-09-29T11:14:26.877Z" },
{ url = "https://files.pythonhosted.org/packages/5f/90/7d766d54bb95939725e9a9361f9c06b0cfbe3fe100aa35400f0a461a278a/pygame-2.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9e7396be0d9633831c3f8d5d82dd63ba373ad65599628294b7a4f8a5a01a65", size = 10624591, upload-time = "2024-09-29T11:52:54.489Z" },
{ url = "https://files.pythonhosted.org/packages/e1/91/718acf3e2a9d08a6ddcc96bd02a6f63c99ee7ba14afeaff2a51c987df0b9/pygame-2.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6039f3a55d800db80e8010f387557b528d34d534435e0871326804df2a62f2", size = 13090765, upload-time = "2024-09-29T14:27:02.377Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c6/9cb315de851a7682d9c7568a41ea042ee98d668cb8deadc1dafcab6116f0/pygame-2.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a3a1288e2e9b1e5834e425bedd5ba01a3cd4902b5c2bff8ed4a740ccfe98171", size = 12381704, upload-time = "2024-09-29T14:27:10.228Z" },
{ url = "https://files.pythonhosted.org/packages/9f/8f/617a1196e31ae3b46be6949fbaa95b8c93ce15e0544266198c2266cc1b4d/pygame-2.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b", size = 13581091, upload-time = "2024-09-29T11:30:27.653Z" },
{ url = "https://files.pythonhosted.org/packages/3b/87/2851a564e40a2dad353f1c6e143465d445dab18a95281f9ea458b94f3608/pygame-2.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b", size = 14273844, upload-time = "2024-09-29T11:40:04.138Z" },
{ url = "https://files.pythonhosted.org/packages/85/b5/aa23aa2e70bcba42c989c02e7228273c30f3b44b9b264abb93eaeff43ad7/pygame-2.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c", size = 13951197, upload-time = "2024-09-29T11:40:06.785Z" },
{ url = "https://files.pythonhosted.org/packages/a6/06/29e939b34d3f1354738c7d201c51c250ad7abefefaf6f8332d962ff67c4b/pygame-2.6.1-cp313-cp313-win32.whl", hash = "sha256:3acd8c009317190c2bfd81db681ecef47d5eb108c2151d09596d9c7ea9df5c0e", size = 10249309, upload-time = "2024-09-29T11:10:23.329Z" },
{ url = "https://files.pythonhosted.org/packages/7e/11/17f7f319ca91824b86557e9303e3b7a71991ef17fd45286bf47d7f0a38e6/pygame-2.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:813af4fba5d0b2cb8e58f5d95f7910295c34067dcc290d34f1be59c48bd1ea6a", size = 10620084, upload-time = "2024-09-29T11:48:51.587Z" },
]
[[package]]
name = "pygame-ce"
version = "2.5.7"
@@ -1923,7 +1894,7 @@ resolution-markers = [
"python_full_version < '3.12'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.12'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
@@ -1998,7 +1969,7 @@ resolution-markers = [
"python_full_version == '3.12.*'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.12'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
wheels = [