Skip to content

Commit 36d56ec

Browse files
gajopclaude
andcommitted
Persist just-run write dir; repopulate project on load; map-export e2e
- just run (launch_manual) now uses a durable write dir (XDG data, or SBC_WRITE_DIR) so projects and imports/exports survive a restart; tests pass no write_dir and keep their throwaway temp dirs. - LoadProjectCommand repopulates ProjectManager's name/path. A reload/open resets the model to default, which left the loaded project looking unsaved: Ctrl+S reopened Save As and Export failed with "missing project path". Now Save and Export work on a project after any reload. - map_export e2e: save a project, sculpt and paint the whole map, Ctrl+S, then Export -> Spring archive, asserting the sculpt/paint/export commands and that the compiled .sdz lands on disk. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2f9090a commit 36d56ec

4 files changed

Lines changed: 133 additions & 5 deletions

File tree

native/src/sbc/project/commands/load_project_command.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::sbc::command_system::command::Command;
66
use crate::sbc::command_system::context::Context;
77
use crate::sbc::command_system::registry::register_command;
88
use crate::sbc::project::io_registries::load;
9+
use crate::sbc::project::ProjectManager;
910

1011
#[derive(Deserialize, Debug)]
1112
pub struct LoadProjectCommand {
@@ -14,7 +15,18 @@ pub struct LoadProjectCommand {
1415

1516
impl Command for LoadProjectCommand {
1617
fn execute(&mut self, ctx: &mut Context) {
17-
load::load_project(ctx, &PathBuf::from(&self.path));
18+
let path = PathBuf::from(&self.path);
19+
load::load_project(ctx, &path);
20+
// A reload or open resets ProjectManager to default, so restore the
21+
// project's identity here. Without it Save (Ctrl+S) treats the loaded
22+
// project as unsaved and reopens Save As, and Export has no project path.
23+
let name = path
24+
.file_stem()
25+
.and_then(|stem| stem.to_str())
26+
.unwrap_or_default()
27+
.to_string();
28+
ctx.model::<ProjectManager>()
29+
.set_name_path(&name, &self.path);
1830
}
1931

2032
fn undoable(&self) -> bool {

tools/e2e/scenarios/geometry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,8 @@
249249
"file_ok": (347, 602),
250250
# A name-input dialog (Save As) has its footer one row below the plain one.
251251
"file_ok_name": (347, 641),
252+
# Export adds a type dropdown as well, dropping the footer another row.
253+
"file_ok_export": (347, 680),
252254
"asset_core_cell": (104, 360),
253255
"asset_ok": (343, 603),
254256
"asset_ok_gallery": (347, 602),

tools/e2e/scenarios/map.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
from typing import TYPE_CHECKING
66

7+
import time
8+
from pathlib import Path
9+
710
from scenarios.geometry import (
811
dropdown_option,
912
DIALOG,
@@ -14,6 +17,7 @@
1417
PARK_PANEL,
1518
TAB_X,
1619
TAB_Y,
20+
TOOLBAR,
1721
dialog_point,
1822
editor_point,
1923
panel_left,
@@ -461,3 +465,86 @@ def settings_panel(run_state: E2ERun) -> None:
461465
run_state.screenshot("specular-off")
462466
run_state.click(*panel_point(left, MAP["settings_map_size"]), delay=0.9)
463467
run_state.screenshot_root("specular-on-root")
468+
469+
470+
def _sweep(run_state: E2ERun, left: int, width: int, height: int) -> None:
471+
"""One held stroke across the map, so the whole surface is covered rather
472+
than a patch. The map fills the screen at the default zoom, so no zooming is
473+
needed; the sweep stays inside the map (its edges are sky)."""
474+
y = height // 2
475+
run_state.press(width // 3, y)
476+
run_state.move(left - 200, y, delay=0.4)
477+
run_state.release(left - 200, y)
478+
479+
480+
def _wait_for_archive(run_state: E2ERun, stem: str, timeout_s: float = 20.0) -> Path | None:
481+
"""Poll the write dir for the compiled `.sdz`. The archive is built off the
482+
draw thread, so it lands a little after the export command is logged."""
483+
assert run_state.write_dir is not None
484+
deadline = time.monotonic() + timeout_s
485+
while time.monotonic() < deadline:
486+
matches = [p for p in run_state.write_dir.rglob(f"{stem}*.sdz")]
487+
if matches:
488+
return matches[0]
489+
time.sleep(0.3)
490+
return None
491+
492+
493+
@scenario()
494+
def map_export(run_state: E2ERun) -> None:
495+
"""The whole map pipeline: save a project, sculpt and paint the map, then
496+
compile it to a Spring archive.
497+
498+
Save As establishes a project (Export needs its path); a Terrain/Add sweep
499+
reshapes the ground across the whole map, a texture sweep paints it, Ctrl+S
500+
saves, and Export -> Spring archive runs the bundled compiler. Asserts the
501+
sculpt/paint/export commands and that the compiled `.sdz` lands on disk.
502+
Deliberately fast -- the point is the pipeline runs end to end.
503+
"""
504+
run_state.focus()
505+
left = panel_left(run_state)
506+
width, height = window_size(run_state)
507+
508+
# A project first: Export compiles the *saved* project, so it needs a path.
509+
# Save As creates it and reloads into it (the one unavoidable wait).
510+
run_state.click(*panel_point(left, TOOLBAR["save_as"]), delay=0.6)
511+
run_state.click(*dialog_point(run_state, DIALOG["file_name"]), delay=0.15)
512+
run_state.type_text("ExportMap")
513+
run_state.key("Return", delay=0.15)
514+
# The one unavoidable wait: Save As restarts the engine into the new project.
515+
run_state.click(*dialog_point(run_state, DIALOG["file_ok_name"]), delay=8.0)
516+
517+
# Terrain: pick a pattern, arm Add, a fat brush, one sweep across the map.
518+
run_state.click(left + TAB_X["map"], TAB_Y, delay=0.15)
519+
run_state.click(*editor_point(left, "map", "terrain"), delay=0.3)
520+
run_state.click(*panel_point(left, MAP["terrain_pattern"]), delay=0.15)
521+
run_state.click(*panel_point(left, MAP_ACTIONS["terrain_add"]), delay=0.15)
522+
click_field(run_state, left, MAP["terrain_size"], "1200")
523+
click_field(run_state, left, MAP["terrain_height"], "80")
524+
_sweep(run_state, left, width, height)
525+
run_state.assert_command_at_least("TerrainShapeModifyCommand", 1)
526+
527+
# Texture: choose a material, pick a brush shape, paint across the map.
528+
run_state.click(*editor_point(left, "map", "texture"), delay=0.3)
529+
run_state.click(*panel_point(left, MAP["saved_brush_add"]), delay=0.4)
530+
run_state.click(*dialog_point(run_state, DIALOG["asset_core_cell"]), delay=0.3)
531+
run_state.click(*panel_point(left, MAP["saved_brush_rect"]), delay=0.15)
532+
run_state.click(*panel_point(left, MAP_ACTIONS["texture_paint"]), delay=0.15)
533+
_sweep(run_state, left, width, height)
534+
run_state.assert_any_command("TerrainChangeTextureCommand", paintMode="paint")
535+
536+
# Save the edits, then Export -> Spring archive (the default type).
537+
run_state.key("ctrl+s", delay=0.5)
538+
run_state.assert_command_at_least("SaveCommand", 1)
539+
run_state.click(*panel_point(left, TOOLBAR["export"]), delay=0.3)
540+
run_state.click(*dialog_point(run_state, DIALOG["file_name"]), delay=0.15)
541+
run_state.type_text("ExportMap")
542+
run_state.key("Return", delay=0.15)
543+
run_state.click(*dialog_point(run_state, DIALOG["file_ok_export"]), delay=0.3)
544+
run_state.assert_command("ExportSpringArchiveCommand")
545+
546+
archive = _wait_for_archive(run_state, "ExportMap")
547+
if archive is None:
548+
raise AssertionError("export did not produce a .sdz archive")
549+
if archive.stat().st_size < 1024:
550+
raise AssertionError(f"compiled archive is suspiciously small: {archive}")

tools/smoke/run_sbc.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@ def launch_manual(config: Path | None = None) -> int:
6161
The interactive twin of `boot()` — no timeout, no test spec. This is the whole
6262
body of the old tools/dev/launch.sh; that script now just calls it.
6363
"""
64-
write_dir, env, cmd = prepare(prefix="sbc-manual-", port_flags_config=config)
64+
write_dir, env, cmd = prepare(
65+
prefix="sbc-manual-",
66+
port_flags_config=config,
67+
write_dir=_manual_write_dir(),
68+
)
6569
# Manual sessions intentionally use a fresh isolated engine write-dir, but
6670
# command history is user state rather than run output. Keep it outside the
6771
# temporary directory so restarting `just run` restores it. Honour an
@@ -113,6 +117,7 @@ def prepare(
113117
tags: list[str] | None = None,
114118
prefix: str = "sbc-",
115119
port_flags_config: Path | None = None,
120+
write_dir: Path | None = None,
116121
) -> tuple[Path, dict[str, str], list[str]]:
117122
"""Set up an isolated write dir and return (write_dir, env, spring command).
118123
@@ -137,9 +142,18 @@ def prepare(
137142
f"run `just build` (cargo build --release) in {sbc_root} first"
138143
)
139144

140-
write_dir = Path(tempfile.mkdtemp(prefix=prefix))
141-
(write_dir / "games").mkdir()
145+
# A persistent write dir (interactive `just run`) keeps projects, imports and
146+
# exports across restarts; tests pass none and get a throwaway temp dir. Only
147+
# the game copy is refreshed each run -- everything the editor writes under
148+
# springboard/ is left untouched.
149+
if write_dir is None:
150+
write_dir = Path(tempfile.mkdtemp(prefix=prefix))
151+
else:
152+
write_dir.mkdir(parents=True, exist_ok=True)
153+
(write_dir / "games").mkdir(exist_ok=True)
142154
game_dir = write_dir / "games" / "SpringBoard Core.sdd"
155+
if game_dir.exists():
156+
shutil.rmtree(game_dir)
143157
shutil.copytree(
144158
sbc_root,
145159
game_dir,
@@ -166,7 +180,9 @@ def prepare(
166180
Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "sbc-fontcache"
167181
)
168182
fontcache.mkdir(parents=True, exist_ok=True)
169-
(write_dir / "fontcache").symlink_to(fontcache)
183+
fontcache_link = write_dir / "fontcache"
184+
fontcache_link.unlink(missing_ok=True)
185+
fontcache_link.symlink_to(fontcache)
170186

171187
# Shared dev config (tools/dev/). Its script.txt sets up two teams in two
172188
# ally-teams so team/alliance tests have something to act on (set_ally,
@@ -210,6 +226,17 @@ def _manual_history_path() -> Path:
210226
return state_home / "springboard" / "chonsole-history"
211227

212228

229+
def _manual_write_dir() -> Path:
230+
"""Durable write dir for interactive `just run`, so projects and imports/
231+
exports survive a restart. `SBC_WRITE_DIR` overrides it; tests never set it
232+
and keep their throwaway temp dirs."""
233+
override = os.environ.get("SBC_WRITE_DIR")
234+
if override:
235+
return Path(override).expanduser()
236+
data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
237+
return data_home / "springboard" / "write-dir"
238+
239+
213240
def _read_port_flags_config(path: Path) -> tuple[dict[str, str], dict[str, str]]:
214241
if not path.is_file():
215242
raise RuntimeError(f"run config does not exist: {path}")

0 commit comments

Comments
 (0)