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.
Files changed (118) hide show
  1. spawnpoint/__init__.py +3 -0
  2. spawnpoint/_data/examples/coin_hopper.json +228 -0
  3. spawnpoint/_data/examples/neon_arena.json +251 -0
  4. spawnpoint/_data/examples/pixel_arena.json +242 -0
  5. spawnpoint/_data/examples/pixel_hopper.json +221 -0
  6. spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
  7. spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
  8. spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
  9. spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
  10. spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
  11. spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
  12. spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
  13. spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
  14. spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
  15. spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
  16. spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
  17. spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
  18. spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
  19. spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
  20. spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
  21. spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
  22. spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
  23. spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
  24. spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
  25. spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
  26. spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
  27. spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
  28. spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
  29. spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
  30. spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
  31. spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
  32. spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
  33. spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
  34. spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
  35. spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
  36. spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
  37. spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
  38. spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
  39. spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
  40. spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
  41. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
  42. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
  43. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
  44. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
  45. spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
  46. spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
  47. spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
  48. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
  49. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
  50. spawnpoint/analyze.py +387 -0
  51. spawnpoint/build.py +146 -0
  52. spawnpoint/cli.py +290 -0
  53. spawnpoint/cockpit/__init__.py +0 -0
  54. spawnpoint/cockpit/server.py +473 -0
  55. spawnpoint/cockpit/static/app.css +111 -0
  56. spawnpoint/cockpit/static/app.js +327 -0
  57. spawnpoint/cockpit/static/index.html +79 -0
  58. spawnpoint/diagnostics.py +91 -0
  59. spawnpoint/expr.py +219 -0
  60. spawnpoint/gif.py +58 -0
  61. spawnpoint/godot.py +107 -0
  62. spawnpoint/graph.py +175 -0
  63. spawnpoint/level.py +157 -0
  64. spawnpoint/llm_server.py +77 -0
  65. spawnpoint/manifest.py +222 -0
  66. spawnpoint/playtest/__init__.py +104 -0
  67. spawnpoint/playtest/report.py +148 -0
  68. spawnpoint/playtest/runner.py +51 -0
  69. spawnpoint/playtest/tuner.py +151 -0
  70. spawnpoint/project.py +94 -0
  71. spawnpoint/py.typed +0 -0
  72. spawnpoint/rules.py +310 -0
  73. spawnpoint/sfx.py +352 -0
  74. spawnpoint/sprites/__init__.py +0 -0
  75. spawnpoint/sprites/ai.py +222 -0
  76. spawnpoint/sprites/contour.py +147 -0
  77. spawnpoint/sprites/generate.py +507 -0
  78. spawnpoint/sprites/importer.py +212 -0
  79. spawnpoint/sprites/resolve.py +139 -0
  80. spawnpoint/sprites/spriteset.py +159 -0
  81. spawnpoint/sprites/tiles.py +102 -0
  82. spawnpoint/studio/__init__.py +0 -0
  83. spawnpoint/studio/assemble.py +528 -0
  84. spawnpoint/studio/brief.py +197 -0
  85. spawnpoint/studio/creative.py +124 -0
  86. spawnpoint/studio/dataset.py +136 -0
  87. spawnpoint/studio/edits.py +206 -0
  88. spawnpoint/studio/levelgen.py +352 -0
  89. spawnpoint/studio/llm.py +145 -0
  90. spawnpoint/studio/orchestrator.py +341 -0
  91. spawnpoint/studio/prompts.py +122 -0
  92. spawnpoint/studio/web.py +123 -0
  93. spawnpoint/studio/webcheck.py +210 -0
  94. spawnpoint/studio_cli.py +232 -0
  95. spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
  96. spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
  97. spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
  98. spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
  99. spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
  100. spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
  101. tests/conftest.py +49 -0
  102. tests/test_ai_sprites.py +122 -0
  103. tests/test_analyze.py +116 -0
  104. tests/test_cockpit.py +148 -0
  105. tests/test_e2e.py +33 -0
  106. tests/test_expr.py +59 -0
  107. tests/test_gif.py +19 -0
  108. tests/test_godot_library.py +17 -0
  109. tests/test_graph.py +44 -0
  110. tests/test_level.py +64 -0
  111. tests/test_live_llm.py +38 -0
  112. tests/test_manifest.py +54 -0
  113. tests/test_playtest.py +129 -0
  114. tests/test_rules.py +85 -0
  115. tests/test_sfx.py +30 -0
  116. tests/test_sprites.py +261 -0
  117. tests/test_studio.py +335 -0
  118. 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))
@@ -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