spawnpoint-studio 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spawnpoint/__init__.py +3 -0
- spawnpoint/_data/examples/coin_hopper.json +228 -0
- spawnpoint/_data/examples/neon_arena.json +251 -0
- spawnpoint/_data/examples/pixel_arena.json +242 -0
- spawnpoint/_data/examples/pixel_hopper.json +221 -0
- spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
- spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
- spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
- spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
- spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
- spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
- spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
- spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
- spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
- spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
- spawnpoint/analyze.py +387 -0
- spawnpoint/build.py +146 -0
- spawnpoint/cli.py +290 -0
- spawnpoint/cockpit/__init__.py +0 -0
- spawnpoint/cockpit/server.py +473 -0
- spawnpoint/cockpit/static/app.css +111 -0
- spawnpoint/cockpit/static/app.js +327 -0
- spawnpoint/cockpit/static/index.html +79 -0
- spawnpoint/diagnostics.py +91 -0
- spawnpoint/expr.py +219 -0
- spawnpoint/gif.py +58 -0
- spawnpoint/godot.py +107 -0
- spawnpoint/graph.py +175 -0
- spawnpoint/level.py +157 -0
- spawnpoint/llm_server.py +77 -0
- spawnpoint/manifest.py +222 -0
- spawnpoint/playtest/__init__.py +104 -0
- spawnpoint/playtest/report.py +148 -0
- spawnpoint/playtest/runner.py +51 -0
- spawnpoint/playtest/tuner.py +151 -0
- spawnpoint/project.py +94 -0
- spawnpoint/py.typed +0 -0
- spawnpoint/rules.py +310 -0
- spawnpoint/sfx.py +352 -0
- spawnpoint/sprites/__init__.py +0 -0
- spawnpoint/sprites/ai.py +222 -0
- spawnpoint/sprites/contour.py +147 -0
- spawnpoint/sprites/generate.py +507 -0
- spawnpoint/sprites/importer.py +212 -0
- spawnpoint/sprites/resolve.py +139 -0
- spawnpoint/sprites/spriteset.py +159 -0
- spawnpoint/sprites/tiles.py +102 -0
- spawnpoint/studio/__init__.py +0 -0
- spawnpoint/studio/assemble.py +528 -0
- spawnpoint/studio/brief.py +197 -0
- spawnpoint/studio/creative.py +124 -0
- spawnpoint/studio/dataset.py +136 -0
- spawnpoint/studio/edits.py +206 -0
- spawnpoint/studio/levelgen.py +352 -0
- spawnpoint/studio/llm.py +145 -0
- spawnpoint/studio/orchestrator.py +341 -0
- spawnpoint/studio/prompts.py +122 -0
- spawnpoint/studio/web.py +123 -0
- spawnpoint/studio/webcheck.py +210 -0
- spawnpoint/studio_cli.py +232 -0
- spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
- spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
- spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
- spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
- spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
- tests/conftest.py +49 -0
- tests/test_ai_sprites.py +122 -0
- tests/test_analyze.py +116 -0
- tests/test_cockpit.py +148 -0
- tests/test_e2e.py +33 -0
- tests/test_expr.py +59 -0
- tests/test_gif.py +19 -0
- tests/test_godot_library.py +17 -0
- tests/test_graph.py +44 -0
- tests/test_level.py +64 -0
- tests/test_live_llm.py +38 -0
- tests/test_manifest.py +54 -0
- tests/test_playtest.py +129 -0
- tests/test_rules.py +85 -0
- tests/test_sfx.py +30 -0
- tests/test_sprites.py +261 -0
- tests/test_studio.py +335 -0
- tests/test_web.py +41 -0
spawnpoint/level.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""ASCII level parsing, checks, and a platformer reachability estimate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections import deque
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from .diagnostics import Diagnostics, did_you_mean, join_path
|
|
10
|
+
from .graph import Level
|
|
11
|
+
|
|
12
|
+
TILE_KINDS = ("solid", "oneway")
|
|
13
|
+
EMPTY = {".", " "}
|
|
14
|
+
MAX_W, MAX_H = 512, 256
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Placement:
|
|
19
|
+
entity: str
|
|
20
|
+
x: int
|
|
21
|
+
y: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LevelData:
|
|
26
|
+
width: int
|
|
27
|
+
height: int
|
|
28
|
+
tiles: dict[tuple[int, int], str] = field(default_factory=dict) # (x, y) -> solid | oneway
|
|
29
|
+
placements: list[Placement] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
def is_solid(self, x: int, y: int) -> bool:
|
|
32
|
+
return self.tiles.get((x, y)) == "solid"
|
|
33
|
+
|
|
34
|
+
def is_floor(self, x: int, y: int) -> bool:
|
|
35
|
+
return (x, y) in self.tiles
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_level(level: Level, entity_names: set[str], diags: Diagnostics) -> LevelData | None:
|
|
39
|
+
path = "level"
|
|
40
|
+
ok = True
|
|
41
|
+
legend: dict[str, str] = {}
|
|
42
|
+
for ch, value in level.legend.items():
|
|
43
|
+
p = join_path(path, "legend", ch)
|
|
44
|
+
if len(ch) != 1:
|
|
45
|
+
diags.error("E_LEGEND_KEY", p, f"legend keys must be single characters, got '{ch}'")
|
|
46
|
+
ok = False
|
|
47
|
+
continue
|
|
48
|
+
if ch in EMPTY:
|
|
49
|
+
diags.error("E_LEGEND_KEY", p, f"'{ch}' is reserved for empty space")
|
|
50
|
+
ok = False
|
|
51
|
+
continue
|
|
52
|
+
if value not in TILE_KINDS and value not in entity_names:
|
|
53
|
+
diags.error("E_LEGEND_VALUE", p, f"'{value}' is neither a tile kind nor an entity",
|
|
54
|
+
did_you_mean(value, [*TILE_KINDS, *entity_names]))
|
|
55
|
+
ok = False
|
|
56
|
+
continue
|
|
57
|
+
legend[ch] = value
|
|
58
|
+
|
|
59
|
+
rows = level.map
|
|
60
|
+
if not rows or not any(r.strip(". ") for r in rows):
|
|
61
|
+
diags.error("E_MAP_EMPTY", join_path(path, "map"), "the map is empty")
|
|
62
|
+
return None
|
|
63
|
+
width = max(len(r) for r in rows)
|
|
64
|
+
height = len(rows)
|
|
65
|
+
if width > MAX_W or height > MAX_H:
|
|
66
|
+
diags.error("E_MAP_SIZE", join_path(path, "map"), f"map is {width}x{height}; maximum is {MAX_W}x{MAX_H}")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
data = LevelData(width, height)
|
|
70
|
+
unknown: dict[str, list[tuple[int, int]]] = {}
|
|
71
|
+
for y, row in enumerate(rows):
|
|
72
|
+
for x, ch in enumerate(row):
|
|
73
|
+
if ch in EMPTY:
|
|
74
|
+
continue
|
|
75
|
+
if ch not in legend:
|
|
76
|
+
unknown.setdefault(ch, []).append((x, y))
|
|
77
|
+
continue
|
|
78
|
+
value = legend[ch]
|
|
79
|
+
if value in TILE_KINDS:
|
|
80
|
+
data.tiles[(x, y)] = value
|
|
81
|
+
else:
|
|
82
|
+
data.placements.append(Placement(value, x, y))
|
|
83
|
+
for ch, cells in unknown.items():
|
|
84
|
+
if ch in level.legend:
|
|
85
|
+
continue # already reported as a bad legend entry
|
|
86
|
+
where = ", ".join(f"({x},{y})" for x, y in cells[:4]) + (" ..." if len(cells) > 4 else "")
|
|
87
|
+
diags.error("E_MAP_CHAR", join_path(path, "map"), f"character '{ch}' at {where} is not in the legend",
|
|
88
|
+
f'Add "{ch}": "<tile kind or entity>" to level.legend, or use "." for empty')
|
|
89
|
+
ok = False
|
|
90
|
+
return data if ok else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class JumpModel:
|
|
95
|
+
"""How far the player can travel, in tiles (rough estimate for warnings)."""
|
|
96
|
+
|
|
97
|
+
up: int
|
|
98
|
+
across: int
|
|
99
|
+
height_tiles: int
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def jump_model(jump_velocity: float, gravity: float, fall_mult: float, max_speed: float,
|
|
103
|
+
tile: int, player_h: float, air_jumps: int = 0) -> JumpModel:
|
|
104
|
+
if gravity <= 0:
|
|
105
|
+
return JumpModel(99, 99, 1)
|
|
106
|
+
jumps = 1 + max(0, air_jumps)
|
|
107
|
+
h = jump_velocity**2 / (2 * gravity) * jumps
|
|
108
|
+
t_up = jump_velocity / gravity * jumps
|
|
109
|
+
t_down = math.sqrt(2 * h / (gravity * fall_mult))
|
|
110
|
+
return JumpModel(int(h // tile), max(1, int(max_speed * (t_up + t_down) // tile)),
|
|
111
|
+
max(1, math.ceil(player_h / tile)))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def unreachable(data: LevelData, start: tuple[int, int], targets: list[Placement], jm: JumpModel) -> list[Placement]:
|
|
115
|
+
"""Targets the player probably cannot reach (platformer heuristic: jumps, walks and falls)."""
|
|
116
|
+
|
|
117
|
+
def free(x: int, y: int) -> bool:
|
|
118
|
+
return 0 <= x < data.width and y < data.height and not data.is_solid(x, y)
|
|
119
|
+
|
|
120
|
+
def standable(x: int, y: int) -> bool:
|
|
121
|
+
if not (0 <= x < data.width and 0 <= y < data.height - 1):
|
|
122
|
+
return False
|
|
123
|
+
if not data.is_floor(x, y + 1):
|
|
124
|
+
return False
|
|
125
|
+
return all(free(x, y - k) for k in range(jm.height_tiles))
|
|
126
|
+
|
|
127
|
+
# Drop the player onto the floor below its start cell.
|
|
128
|
+
sx, sy = start
|
|
129
|
+
while sy < data.height - 1 and not standable(sx, sy):
|
|
130
|
+
sy += 1
|
|
131
|
+
if not standable(sx, sy):
|
|
132
|
+
return [] # Reported separately as W_NO_GROUND.
|
|
133
|
+
stands = [(x, y) for x in range(data.width) for y in range(data.height) if standable(x, y)]
|
|
134
|
+
if len(stands) > 4000:
|
|
135
|
+
return [] # Too big for this quadratic heuristic; skip the warning.
|
|
136
|
+
seen = {(sx, sy)}
|
|
137
|
+
queue = deque([(sx, sy)])
|
|
138
|
+
while queue:
|
|
139
|
+
x, y = queue.popleft()
|
|
140
|
+
for nx, ny in stands:
|
|
141
|
+
if (nx, ny) in seen:
|
|
142
|
+
continue
|
|
143
|
+
dy = ny - y # positive = lower
|
|
144
|
+
reach = jm.across + max(0, dy)
|
|
145
|
+
if abs(nx - x) <= reach and dy >= -jm.up:
|
|
146
|
+
seen.add((nx, ny))
|
|
147
|
+
queue.append((nx, ny))
|
|
148
|
+
missing = []
|
|
149
|
+
for t in targets:
|
|
150
|
+
ok = any(abs(t.x - x) <= jm.across and y - jm.up - jm.height_tiles < t.y <= y + 1 for x, y in seen)
|
|
151
|
+
if not ok:
|
|
152
|
+
missing.append(t)
|
|
153
|
+
return missing
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def has_ground_below(data: LevelData, x: int, y: int) -> bool:
|
|
157
|
+
return any(data.is_floor(x, yy) for yy in range(y + 1, data.height))
|
spawnpoint/llm_server.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""`spawnpoint llm up|down|status`: run SpawnPoint-Coder-7B locally in the vLLM Docker image."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
NAME = os.environ.get("SPAWNPOINT_LLM_CONTAINER", "spawnpoint-llm")
|
|
14
|
+
IMAGE = os.environ.get("VLLM_IMAGE", "vllm/vllm-openai:v0.10.2") # CUDA 12.8: NVIDIA driver >= 570
|
|
15
|
+
MODEL = os.environ.get("SPAWNPOINT_MODEL", "smoky1496/SpawnPoint-Coder-7B-AWQ")
|
|
16
|
+
SERVED = "SpawnPoint-Coder-7B"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _docker(*args: str, check: bool = True, capture: bool = True) -> subprocess.CompletedProcess:
|
|
20
|
+
if shutil.which("docker") is None:
|
|
21
|
+
raise RuntimeError("Docker is required (with the NVIDIA container toolkit) to run the local model")
|
|
22
|
+
return subprocess.run(["docker", *args], check=check, capture_output=capture, text=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _exists() -> bool:
|
|
26
|
+
return bool(_docker("ps", "-a", "--filter", f"name=^{NAME}$", "--format", "{{.Names}}").stdout.strip())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _running() -> bool:
|
|
30
|
+
out = _docker("inspect", "-f", "{{.State.Running}}", NAME, check=False)
|
|
31
|
+
return out.returncode == 0 and out.stdout.strip() == "true"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ready(port: int = 8000) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
urllib.request.urlopen(f"http://localhost:{port}/v1/models", timeout=2)
|
|
37
|
+
return True
|
|
38
|
+
except Exception:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def up(port: int = 8000, max_len: int = 16384, say=print) -> int:
|
|
43
|
+
if _exists():
|
|
44
|
+
_docker("start", NAME)
|
|
45
|
+
else:
|
|
46
|
+
hf = Path.home() / ".cache" / "huggingface"
|
|
47
|
+
hf.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
user = getpass.getuser()
|
|
49
|
+
# Run as the current user so downloaded weights are not root-owned; that UID does not exist in
|
|
50
|
+
# the image, so USER/LOGNAME are set for torch's getpass.getuser().
|
|
51
|
+
_docker("run", "-d", "--name", NAME, "--gpus", "all", "-p", f"{port}:8000", "--ipc=host",
|
|
52
|
+
"-u", f"{os.getuid()}:{os.getgid()}", "-e", f"USER={user}", "-e", f"LOGNAME={user}",
|
|
53
|
+
"-e", "HOME=/tmp", "-e", "HF_HOME=/hf", "-v", f"{hf}:/hf", IMAGE, "--model", MODEL,
|
|
54
|
+
"--served-model-name", SERVED, "--max-model-len", str(max_len), "--gpu-memory-utilization", "0.85")
|
|
55
|
+
say(f"Starting {SERVED} on :{port} (first start downloads ~5.6 GB) ...")
|
|
56
|
+
while not ready(port):
|
|
57
|
+
if not _running():
|
|
58
|
+
say(_docker("logs", "--tail", "30", NAME, check=False).stdout)
|
|
59
|
+
return 1
|
|
60
|
+
time.sleep(2)
|
|
61
|
+
say(f"Ready: http://localhost:{port}/v1 — stop with `spawnpoint llm down`.")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def down(say=print) -> int:
|
|
66
|
+
if _exists():
|
|
67
|
+
_docker("stop", NAME)
|
|
68
|
+
say("Stopped.")
|
|
69
|
+
else:
|
|
70
|
+
say("Not running.")
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def status(port: int = 8000, say=print) -> int:
|
|
75
|
+
say(f"container {NAME}: {'running' if _exists() and _running() else 'stopped'}; "
|
|
76
|
+
f"API on :{port}: {'ready' if ready(port) else 'not responding'}")
|
|
77
|
+
return 0
|
spawnpoint/manifest.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Component manifest: what the Godot library offers, generated from the scripts themselves.
|
|
2
|
+
|
|
3
|
+
`generate()` runs Godot headless to introspect every component script (exports, ranges, signals,
|
|
4
|
+
methods, metadata constants) and merges the `##` doc comments parsed from source. `load()` reads
|
|
5
|
+
the resulting manifest.json. Nobody edits the manifest by hand.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import tempfile
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import godot
|
|
19
|
+
|
|
20
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
21
|
+
PACKAGE_DATA = Path(__file__).resolve().parent / "_data" # filled when building a wheel (see setup.py)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _library_project() -> Path:
|
|
25
|
+
"""The Godot project holding the addon: $SPAWNPOINT_LIBRARY, the repository (development), or
|
|
26
|
+
the copy bundled in the installed package."""
|
|
27
|
+
env = os.environ.get("SPAWNPOINT_LIBRARY")
|
|
28
|
+
if env:
|
|
29
|
+
return Path(env)
|
|
30
|
+
repo = REPO_ROOT / "godot"
|
|
31
|
+
if (repo / "addons" / "spawn_point" / "manifest.json").exists():
|
|
32
|
+
return repo
|
|
33
|
+
return PACKAGE_DATA / "godot"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
LIBRARY_PROJECT = _library_project()
|
|
37
|
+
ADDON_DIR = LIBRARY_PROJECT / "addons" / "spawn_point"
|
|
38
|
+
MANIFEST_PATH = ADDON_DIR / "manifest.json"
|
|
39
|
+
EXAMPLES_DIR = REPO_ROOT / "examples" if (REPO_ROOT / "examples").is_dir() else PACKAGE_DATA / "examples"
|
|
40
|
+
|
|
41
|
+
BODY_CLASSES = {"character": "CharacterBody2D", "static": "StaticBody2D", "area": "Area2D"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Param:
|
|
46
|
+
name: str
|
|
47
|
+
type: str # Godot type name: bool, int, float, String, Vector2, Color
|
|
48
|
+
default: Any
|
|
49
|
+
doc: str = ""
|
|
50
|
+
min: float | None = None
|
|
51
|
+
max: float | None = None
|
|
52
|
+
choices: tuple[str, ...] | None = None
|
|
53
|
+
|
|
54
|
+
def describe(self) -> str:
|
|
55
|
+
s = f"{self.name}: {self.type} = {json.dumps(self.default)}"
|
|
56
|
+
if self.min is not None:
|
|
57
|
+
s += f" [{self.min:g}..{self.max:g}]"
|
|
58
|
+
if self.choices:
|
|
59
|
+
s += " one of " + "|".join(self.choices)
|
|
60
|
+
return s
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Arg:
|
|
65
|
+
name: str
|
|
66
|
+
type: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Signal:
|
|
71
|
+
name: str
|
|
72
|
+
args: tuple[Arg, ...]
|
|
73
|
+
doc: str = ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Method:
|
|
78
|
+
name: str
|
|
79
|
+
args: tuple[Arg, ...]
|
|
80
|
+
default_count: int
|
|
81
|
+
doc: str = ""
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def min_args(self) -> int:
|
|
85
|
+
return len(self.args) - self.default_count
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Component:
|
|
90
|
+
name: str
|
|
91
|
+
class_name: str
|
|
92
|
+
script: str
|
|
93
|
+
base: str
|
|
94
|
+
hosts: list[str]
|
|
95
|
+
requires: list[str]
|
|
96
|
+
category: str
|
|
97
|
+
entity_params: list[str]
|
|
98
|
+
doc: str
|
|
99
|
+
params: dict[str, Param] = field(default_factory=dict)
|
|
100
|
+
state: dict[str, str] = field(default_factory=dict)
|
|
101
|
+
signals: dict[str, Signal] = field(default_factory=dict)
|
|
102
|
+
methods: dict[str, Method] = field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def is_area(self) -> bool:
|
|
106
|
+
return self.base == "Area2D"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Manifest:
|
|
111
|
+
godot_version: str
|
|
112
|
+
components: dict[str, Component]
|
|
113
|
+
|
|
114
|
+
def get(self, name: str) -> Component | None:
|
|
115
|
+
return self.components.get(name)
|
|
116
|
+
|
|
117
|
+
def __contains__(self, name: str) -> bool:
|
|
118
|
+
return name in self.components
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------------------------
|
|
122
|
+
# Loading
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load(path: Path | None = None) -> Manifest:
|
|
126
|
+
data = json.loads(Path(path or MANIFEST_PATH).read_text())
|
|
127
|
+
return from_dict(data)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def from_dict(data: dict) -> Manifest:
|
|
131
|
+
comps: dict[str, Component] = {}
|
|
132
|
+
for c in data["components"]:
|
|
133
|
+
comp = Component(
|
|
134
|
+
name=c["name"],
|
|
135
|
+
class_name=c["class"],
|
|
136
|
+
script=c["script"],
|
|
137
|
+
base=c["base"],
|
|
138
|
+
hosts=list(c["hosts"]),
|
|
139
|
+
requires=list(c["requires"]),
|
|
140
|
+
category=c["category"],
|
|
141
|
+
entity_params=list(c.get("entity_params", [])),
|
|
142
|
+
doc=c.get("doc", ""),
|
|
143
|
+
state={s["name"]: s["type"] for s in c.get("state", [])},
|
|
144
|
+
)
|
|
145
|
+
for p in c["params"]:
|
|
146
|
+
comp.params[p["name"]] = _param(p)
|
|
147
|
+
for s in c["signals"]:
|
|
148
|
+
comp.signals[s["name"]] = Signal(s["name"], tuple(Arg(a["name"], a["type"]) for a in s["args"]), s.get("doc", ""))
|
|
149
|
+
for m in c["methods"]:
|
|
150
|
+
comp.methods[m["name"]] = Method(
|
|
151
|
+
m["name"], tuple(Arg(a["name"], a["type"]) for a in m["args"]), m["default_count"], m.get("doc", "")
|
|
152
|
+
)
|
|
153
|
+
comps[comp.name] = comp
|
|
154
|
+
return Manifest(data.get("godot", ""), comps)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _param(p: dict) -> Param:
|
|
158
|
+
lo = hi = None
|
|
159
|
+
choices = None
|
|
160
|
+
if p.get("hint") == "range":
|
|
161
|
+
parts = [x.strip() for x in p["hint_string"].split(",")]
|
|
162
|
+
lo, hi = float(parts[0]), float(parts[1])
|
|
163
|
+
elif p.get("hint") == "enum":
|
|
164
|
+
choices = tuple(x.split(":")[0] for x in p["hint_string"].split(","))
|
|
165
|
+
return Param(p["name"], p["type"], p.get("default"), p.get("doc", ""), lo, hi, choices)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------------------------
|
|
169
|
+
# Generation
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def generate(project: Path = LIBRARY_PROJECT, out: Path | None = None) -> dict:
|
|
173
|
+
"""Introspect the library in Godot and write manifest.json. Returns the manifest dict."""
|
|
174
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
175
|
+
raw_path = Path(tmp) / "raw.json"
|
|
176
|
+
godot.run_scene(project, "res://addons/spawn_point/tools/dump_manifest.tscn", [str(raw_path)], import_first=True)
|
|
177
|
+
raw = json.loads(raw_path.read_text())
|
|
178
|
+
for comp in raw["components"]:
|
|
179
|
+
docs = parse_docs((project / comp["script"].removeprefix("res://")).read_text())
|
|
180
|
+
comp["doc"] = docs.get("@class", "")
|
|
181
|
+
for group in ("params", "signals", "methods"):
|
|
182
|
+
for item in comp[group]:
|
|
183
|
+
item["doc"] = docs.get(item["name"], "")
|
|
184
|
+
data = {"godot": raw["godot"], "components": raw["components"]}
|
|
185
|
+
target = out or (project / "addons" / "spawn_point" / "manifest.json")
|
|
186
|
+
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
187
|
+
return data
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
_DECL = re.compile(r"^(?:@export\w*(?:\([^)]*\))?\s+)?(?:var|signal|func|static func)\s+(\w+)")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_docs(source: str) -> dict[str, str]:
|
|
194
|
+
"""Map declared names to their `##` doc comments. The class doc is stored under '@class'."""
|
|
195
|
+
docs: dict[str, str] = {}
|
|
196
|
+
pending: list[str] = []
|
|
197
|
+
class_doc: list[str] = []
|
|
198
|
+
seen_decl = False
|
|
199
|
+
for raw in source.splitlines():
|
|
200
|
+
line = raw.strip()
|
|
201
|
+
if line.startswith("##"):
|
|
202
|
+
pending.append(line[2:].strip())
|
|
203
|
+
continue
|
|
204
|
+
if not line:
|
|
205
|
+
if pending and not seen_decl and not class_doc:
|
|
206
|
+
class_doc, pending = pending, []
|
|
207
|
+
continue
|
|
208
|
+
if line.startswith(("class_name", "extends")):
|
|
209
|
+
if pending:
|
|
210
|
+
class_doc += pending
|
|
211
|
+
pending = []
|
|
212
|
+
continue
|
|
213
|
+
m = _DECL.match(line)
|
|
214
|
+
if m:
|
|
215
|
+
seen_decl = True
|
|
216
|
+
if pending:
|
|
217
|
+
docs[m.group(1)] = " ".join(p for p in pending if p).strip()
|
|
218
|
+
elif not seen_decl and pending and line.startswith("const") and not class_doc:
|
|
219
|
+
class_doc, pending = pending, []
|
|
220
|
+
pending = []
|
|
221
|
+
docs["@class"] = " ".join(p if p else "\n" for p in class_doc).replace(" \n ", "\n").strip()
|
|
222
|
+
return docs
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Sub-project 5: bots play the game, telemetry is aggregated, parameters are tuned within bounds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from .. import manifest as manifest_mod
|
|
12
|
+
from ..build import build
|
|
13
|
+
from ..manifest import Manifest
|
|
14
|
+
from ..studio.creative import apply_patch
|
|
15
|
+
from . import report as report_mod, runner, tuner
|
|
16
|
+
from .report import Report
|
|
17
|
+
from .tuner import Change
|
|
18
|
+
|
|
19
|
+
OUT_DIR = "playtest"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class PlaytestResult:
|
|
24
|
+
baseline: Report
|
|
25
|
+
final: Report
|
|
26
|
+
changes: list[Change] = field(default_factory=list)
|
|
27
|
+
rounds: list[dict] = field(default_factory=list)
|
|
28
|
+
files: dict[str, Path] = field(default_factory=dict)
|
|
29
|
+
seconds: float = 0.0
|
|
30
|
+
difficulty: int = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def goal_entities(graph: dict) -> set[str]:
|
|
34
|
+
return {n for n, e in graph["entities"].items()
|
|
35
|
+
if any(c in e.get("components", {}) for c in ("Collectible", "TriggerZone"))}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def merge_tuning(old: list[dict], changes: list[Change]) -> list[dict]:
|
|
39
|
+
merged = {(t["entity"], t["component"], t["param"]): t for t in old}
|
|
40
|
+
for c in changes:
|
|
41
|
+
merged[(c.entity, c.component, c.param)] = c.to_patch()
|
|
42
|
+
return list(merged.values())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def playtest(project: Path, *, runs: int = 3, profiles: tuple[str, ...] = runner.PROFILES, tune: bool = False,
|
|
46
|
+
rounds: int = 2, seed: int = 1, manifest: Manifest | None = None, original: dict | None = None,
|
|
47
|
+
difficulty: int = 2, brief: dict | None = None,
|
|
48
|
+
on_progress: Callable[[str], None] | None = None) -> PlaytestResult:
|
|
49
|
+
"""Plays `project` with bots; with `tune`, proposes bounded changes and keeps them only if the
|
|
50
|
+
score improves. `original` is the untuned graph used for the bounds (default: current graph)."""
|
|
51
|
+
t0 = time.perf_counter()
|
|
52
|
+
manifest = manifest or manifest_mod.load()
|
|
53
|
+
say = on_progress or (lambda _m: None)
|
|
54
|
+
graph = json.loads((project / "spawnpoint.json").read_text())
|
|
55
|
+
original = original or graph
|
|
56
|
+
goals = goal_entities(graph)
|
|
57
|
+
limit = runner.time_limit(graph, brief)
|
|
58
|
+
|
|
59
|
+
say(f"playing {len(profiles)} profiles x {runs} runs (≤ {limit:.0f}s each)")
|
|
60
|
+
baseline = report_mod.aggregate(runner.run(project, profiles=profiles, runs=runs, seed=seed, limit_s=limit), goals)
|
|
61
|
+
best_graph, best = graph, baseline
|
|
62
|
+
result = PlaytestResult(baseline, baseline, difficulty=difficulty)
|
|
63
|
+
built_graph = graph
|
|
64
|
+
if tune:
|
|
65
|
+
for i in range(rounds):
|
|
66
|
+
changes = tuner.propose(best, best_graph, original, manifest, difficulty)
|
|
67
|
+
if not changes:
|
|
68
|
+
break
|
|
69
|
+
say(f"round {i + 1}: trying {len(changes)} change(s)")
|
|
70
|
+
candidate = apply_patch(best_graph, {"set_params": [c.to_patch() for c in changes], "add_rules": []})
|
|
71
|
+
built = build(candidate, project, manifest=manifest, smoke=True)
|
|
72
|
+
built_graph = candidate
|
|
73
|
+
if not built.ok:
|
|
74
|
+
result.rounds.append({"round": i + 1, "changes": [str(c) for c in changes], "accepted": False,
|
|
75
|
+
"error": [str(d) for d in built.diagnostics.errors]})
|
|
76
|
+
break
|
|
77
|
+
rep = report_mod.aggregate(runner.run(project, profiles=profiles, runs=runs, seed=seed, limit_s=limit,
|
|
78
|
+
sync=False), goals)
|
|
79
|
+
before, after = tuner.score(best, difficulty), tuner.score(rep, difficulty)
|
|
80
|
+
accepted = after < before
|
|
81
|
+
result.rounds.append({"round": i + 1, "changes": [str(c) for c in changes], "score_before": before,
|
|
82
|
+
"score_after": after, "accepted": accepted})
|
|
83
|
+
say(f"round {i + 1}: score {before} -> {after} ({'kept' if accepted else 'reverted'})")
|
|
84
|
+
if not accepted:
|
|
85
|
+
break
|
|
86
|
+
best_graph, best = candidate, rep
|
|
87
|
+
result.changes.extend(changes)
|
|
88
|
+
if built_graph is not best_graph: # the last candidate was rejected: rebuild the best one
|
|
89
|
+
build(best_graph, project, manifest=manifest, smoke=False)
|
|
90
|
+
result.final = best
|
|
91
|
+
out = project / OUT_DIR
|
|
92
|
+
out.mkdir(exist_ok=True)
|
|
93
|
+
payload = {"difficulty": difficulty, "targets": tuner.targets(difficulty),
|
|
94
|
+
"score": {"baseline": tuner.score(baseline, difficulty), "final": tuner.score(best, difficulty)},
|
|
95
|
+
"baseline": baseline.to_dict(), "final": best.to_dict(), "rounds": result.rounds,
|
|
96
|
+
"changes": [c.to_patch() | {"reason": c.reason} for c in result.changes]}
|
|
97
|
+
(out / "report.json").write_text(json.dumps(payload, indent=1, ensure_ascii=False))
|
|
98
|
+
(out / "report.md").write_text(report_mod.markdown(best, best_graph.get("name", project.name),
|
|
99
|
+
[str(c) for c in result.changes]))
|
|
100
|
+
plan = json.loads((project / "build_plan.json").read_text())
|
|
101
|
+
report_mod.heatmap(plan, best.runs, out / "heatmap.png")
|
|
102
|
+
result.files = {"json": out / "report.json", "md": out / "report.md", "heatmap": out / "heatmap.png"}
|
|
103
|
+
result.seconds = time.perf_counter() - t0
|
|
104
|
+
return result
|