AetherDb for ship authors: rooted at a game folder, found in ModTools; ship-ID rule in create
FindRoot keys on GameData/Aetheria.cc. ShipAuthoringStore.RequireShipId is the one ID rule (Validate and create), refusing Windows device names and a trailing dot, naming no value. The add-on's one preference is Game folder; _aetheria_repo, the repo preference and the dotnet-run invocation are deleted. Smokes take SMOKE_GAME_FOLDER. Not yet built or tested at this commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7f64e0d733
commit
63d16e81f3
11 changed files with 183 additions and 52 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -66,6 +66,7 @@ GameData/*.lock
|
|||
/Aetheria.Shared/obj/
|
||||
/tools/**/bin/
|
||||
/tools/**/obj/
|
||||
/ModTools/
|
||||
/tests/**/bin/
|
||||
/tests/**/obj/
|
||||
|
||||
|
|
|
|||
|
|
@ -182,14 +182,26 @@ public static class ShipAuthoringStore
|
|||
throw new InvalidOperationException($"{label}: a hull names one body, but this one names both a Unity prefab and a visual record.");
|
||||
}
|
||||
|
||||
// The one ship-ID rule, shared by Validate and ship-authoring create. The ID becomes a folder name on every platform, so
|
||||
// Windows device names (alone or before a dot) and a trailing dot are refused too. The messages name the rule, never the value.
|
||||
public static void RequireShipId(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) throw new InvalidOperationException("Ship ID is required.");
|
||||
if (!(id[0] >= 'a' && id[0] <= 'z' || id[0] >= '0' && id[0] <= '9') || id.Any(c =>
|
||||
!(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-')))
|
||||
throw new InvalidOperationException("Ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens.");
|
||||
if (id[id.Length - 1] == '.') throw new InvalidOperationException("Ship ID must not end with a dot.");
|
||||
var stem = id.Split('.')[0];
|
||||
if (stem is "con" or "prn" or "aux" or "nul"
|
||||
|| stem.Length == 4 && (stem.StartsWith("com") || stem.StartsWith("lpt")) && stem[3] >= '1' && stem[3] <= '9')
|
||||
throw new InvalidOperationException("Ship ID must not be a Windows device name.");
|
||||
}
|
||||
|
||||
// The one semantic check every path shares: the hull and its visual, judged together.
|
||||
public static void Validate(HullData hull, ShipAuthoring ship)
|
||||
{
|
||||
if (ship == null) throw new InvalidOperationException("Ship authoring record is null.");
|
||||
if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required.");
|
||||
if (!(ship.Id[0] >= 'a' && ship.Id[0] <= 'z' || ship.Id[0] >= '0' && ship.Id[0] <= '9') || ship.Id.Any(c =>
|
||||
!(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-')))
|
||||
throw new InvalidOperationException($"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens.");
|
||||
RequireShipId(ship.Id);
|
||||
if (hull == null) throw new InvalidOperationException($"{ship.Id}: hull data is required.");
|
||||
if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required.");
|
||||
if (hull.Shape?.Cells == null || !hull.Shape.Cells.Cast<bool>().Any(occupied => occupied))
|
||||
|
|
|
|||
|
|
@ -61,12 +61,30 @@ weapon mount object. Thrusters and radiators are mesh objects; the others may
|
|||
be empties. Package exports the collection, Grease Pencil excluded, to
|
||||
`ship.glb` beside the `.cc`, writes the anchors, captures the lines when the
|
||||
collection holds exactly one Grease Pencil object, and shows AetherDb's
|
||||
verdict in the panel. It runs `dotnet run --project <repo>/tools/AetherDb`;
|
||||
the repo is the add-on preference **Aetheria repo**, or by default the nearest
|
||||
folder above the `.cc` holding `tools/AetherDb`.
|
||||
verdict in the panel. It runs the AetherDb in the game folder's `ModTools`
|
||||
(see Tools below).
|
||||
`ship-authoring inspect <ship.cc>` reads drafts; `validate` requires complete
|
||||
hull, model, and anchor authoring, and judges `ship.glb` once it exists.
|
||||
|
||||
## Tools
|
||||
|
||||
Install Blender and the `aetheria_ships` add-on. In the add-on's preferences set
|
||||
**Game folder** to your Aetheria install, the folder holding `GameData` (its
|
||||
`GameData/Aetheria.cc` and `GameData/Mods`). Left empty, the add-on uses the
|
||||
nearest folder above the bound `.cc` whose `GameData` holds `Aetheria.cc`.
|
||||
AetherDb ships with the game in `ModTools`; the add-on runs it with the game
|
||||
folder as its working directory, and needs neither Unity, the Aetheria repository
|
||||
nor dotnet. New Ship creates the package under `<game folder>/GameData/Mods/<id>`.
|
||||
|
||||
From a repository checkout, publish AetherDb into the ignored `<repo>/ModTools` and
|
||||
author from the repo as the game folder:
|
||||
|
||||
dotnet publish tools/AetherDb -c Release -r <rid> --self-contained -p:PublishSingleFile=true -o ModTools
|
||||
|
||||
with `<rid>` one of `win-x64`, `linux-x64`, `osx-arm64`. Linux needs `libssl3`
|
||||
and `libicu`; on macOS clear the quarantine flag (`xattr -d com.apple.quarantine
|
||||
ModTools/AetherDb`).
|
||||
|
||||
## Objective
|
||||
|
||||
A mod author edits one ship `.cc` file, including the hull schematic and every
|
||||
|
|
|
|||
41
tests/Aetheria.Shared.Tests/AetherDbRootTests.cs
Normal file
41
tests/Aetheria.Shared.Tests/AetherDbRootTests.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
[CollectionDefinition("process working directory", DisableParallelization = true)]
|
||||
public sealed class ProcessWorkingDirectoryCollection { }
|
||||
|
||||
// FindRoot reads the process working directory, so these run alone.
|
||||
[Collection("process working directory")]
|
||||
public sealed class AetherDbRootTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _game = new TempDirectory();
|
||||
private readonly string _previous = Environment.CurrentDirectory;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.CurrentDirectory = _previous;
|
||||
_game.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheRootIsTheNearestFolderWhoseGameDataHoldsAetheriaCc()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_game.Path, "GameData", "Mods", "probe.a"));
|
||||
File.WriteAllBytes(Path.Combine(_game.Path, "GameData", "Aetheria.cc"), Array.Empty<byte>());
|
||||
Environment.CurrentDirectory = _game.Path;
|
||||
var game = Environment.CurrentDirectory;
|
||||
Environment.CurrentDirectory = Path.Combine(game, "GameData", "Mods", "probe.a");
|
||||
Assert.Equal(game, AetherDb.FindRoot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AFolderHoldingOnlyTheRepositoryMarkerIsNotARoot()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_game.Path, "Aetheria.Shared"));
|
||||
File.WriteAllText(Path.Combine(_game.Path, "Aetheria.Shared", "Aetheria.Shared.csproj"), "");
|
||||
Environment.CurrentDirectory = _game.Path;
|
||||
var error = Assert.Throws<DirectoryNotFoundException>(AetherDb.FindRoot);
|
||||
Assert.Contains("GameData holds Aetheria.cc", error.Message);
|
||||
}
|
||||
}
|
||||
|
|
@ -419,6 +419,17 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
Assert.Contains("records and model", output);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("con"), InlineData("nul.x"), InlineData("abc.")]
|
||||
public void CreateRefusesABadIdBeforeTouchingTheDisk(string id)
|
||||
{
|
||||
var target = Path.Combine(_directory.Path, "GameData", "Mods", id, "ship.cc");
|
||||
var (code, output, error) = Run("create", target, id, "Probe", "--like", "Djinni");
|
||||
Assert.Equal(1, code);
|
||||
Assert.Contains("Ship ID", error);
|
||||
Assert.False(Directory.Exists(Path.Combine(_directory.Path, "GameData")));
|
||||
}
|
||||
|
||||
private static (int Code, string Output, string Error) Run(params string[] args)
|
||||
{
|
||||
var (output, error) = (new StringWriter(), new StringWriter());
|
||||
|
|
|
|||
|
|
@ -59,7 +59,25 @@ public sealed class ShipValidationTests
|
|||
{
|
||||
var ship = ShipAuthoringTests.Fixture();
|
||||
ship.WithId(id);
|
||||
Assert.Contains("ship ID must use", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
|
||||
Assert.Contains("Ship ID must use", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("com0"), InlineData("console"), InlineData("lpt"), InlineData("auxiliary"), InlineData("a.con"), InlineData("com10")]
|
||||
public void IdsThatOnlyResembleDeviceNamesAreAdmitted(string id)
|
||||
{
|
||||
var ship = ShipAuthoringTests.Fixture();
|
||||
ship.WithId(id);
|
||||
ship.Validate();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("con"), InlineData("abc.")]
|
||||
public void IdRefusalsNameNoValue(string id)
|
||||
{
|
||||
var ship = ShipAuthoringTests.Fixture();
|
||||
ship.WithId(id);
|
||||
Assert.DoesNotContain(id, Assert.Throws<InvalidOperationException>(ship.Validate).Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -157,6 +175,11 @@ public sealed class ShipValidationTests
|
|||
|
||||
Add("id-null", s => s.Visual.Id = null, "Ship ID is required");
|
||||
Add("id-blank", s => s.Visual.Id = " ", "Ship ID is required");
|
||||
Add("id-device-con", s => s.Visual.Id = "con", "Windows device name");
|
||||
Add("id-device-with-extension", s => s.Visual.Id = "nul.x", "Windows device name");
|
||||
Add("id-device-com1", s => s.Visual.Id = "com1", "Windows device name");
|
||||
Add("id-device-lpt9", s => s.Visual.Id = "lpt9", "Windows device name");
|
||||
Add("id-trailing-dot", s => s.Visual.Id = "abc.", "must not end with a dot");
|
||||
Add("hull-null", s => s.Hull = null, "hull data is required");
|
||||
Add("hull-name-null", s => s.Hull.Name = null, "hull name is required");
|
||||
Add("hull-name-blank", s => s.Hull.Name = " ", "hull name is required");
|
||||
|
|
|
|||
|
|
@ -19,15 +19,15 @@ public sealed class AetherDb
|
|||
catalogWritable: catalogWritable);
|
||||
}
|
||||
|
||||
// With no root given, walks up from the working directory to the repository
|
||||
// With no root given, walks up from the working directory to the game folder: the first folder whose GameData holds Aetheria.cc
|
||||
public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null) =>
|
||||
new AetherDb(root ?? FindRoot(), catalogWritable, withRun);
|
||||
|
||||
public static string FindRoot()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(Environment.CurrentDirectory); dir != null; dir = dir.Parent)
|
||||
if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj")))
|
||||
if (File.Exists(Path.Combine(dir.FullName, "GameData", "Aetheria.cc")))
|
||||
return dir.FullName;
|
||||
throw new DirectoryNotFoundException("Run from inside the Aetheria repository.");
|
||||
throw new DirectoryNotFoundException("Run from a folder whose GameData holds Aetheria.cc.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ public static class ShipAuthoringCommands
|
|||
}
|
||||
if (args[0] == "create" && (args.Length == 4 || args.Length == 6 && args[4] == "--like"))
|
||||
{
|
||||
ShipAuthoringStore.RequireShipId(args[2]);
|
||||
var path = Path.GetFullPath(args[1]);
|
||||
if (File.Exists(path)) throw new InvalidOperationException($"Refusing to replace {path}");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ bl_info = {
|
|||
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
|
@ -26,7 +27,7 @@ MODEL_ASSET = "ship.glb"
|
|||
SOURCE = "Source" # child collection: the Tripo mesh, input to Rasterise, never exported
|
||||
GENERATED = "Generated" # child collection: derived objects (the Grid), never exported
|
||||
SHIP_ROOT = "Ship Root"
|
||||
AETHERDB_TIMEOUT = 600 # seconds; the first run builds AetherDb
|
||||
AETHERDB_TIMEOUT = 120 # seconds
|
||||
|
||||
|
||||
HARDPOINT_TYPES = tuple((str(i), name, name) for i, name in enumerate(HARDPOINT_TYPE_NAMES))
|
||||
|
|
@ -302,38 +303,49 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator):
|
|||
class AETHERIA_AP_preferences(bpy.types.AddonPreferences):
|
||||
bl_idname = __package__
|
||||
|
||||
aetheria_repo: bpy.props.StringProperty(
|
||||
name="Aetheria repo", subtype="DIR_PATH",
|
||||
description="Aetheria checkout whose tools/AetherDb validates packages. "
|
||||
"Empty: the nearest folder above the bound .cc that holds tools/AetherDb")
|
||||
game_folder: bpy.props.StringProperty(
|
||||
name="Game folder", subtype="DIR_PATH",
|
||||
description="The folder whose GameData holds Aetheria.cc and Mods (the install folder); its ModTools holds "
|
||||
"AetherDb. Empty: the nearest folder above the bound .cc whose GameData holds Aetheria.cc")
|
||||
|
||||
def draw(self, context):
|
||||
self.layout.prop(self, "aetheria_repo")
|
||||
self.layout.prop(self, "game_folder")
|
||||
|
||||
|
||||
def _aetheria_repo(context, ship_cc):
|
||||
GAME_FOLDER_REFUSAL = ("Set the add-on's Game folder preference to the folder whose GameData holds Aetheria.cc "
|
||||
"and whose ModTools holds AetherDb")
|
||||
|
||||
|
||||
def _game_folder(context, ship_cc=None):
|
||||
"""The folder AetherDb runs against: the Game folder preference, else the nearest folder above the bound .cc whose
|
||||
GameData holds Aetheria.cc. Refusals name the preference and never a path."""
|
||||
addon = context.preferences.addons.get(__package__)
|
||||
configured = addon.preferences.aetheria_repo if addon and addon.preferences else ""
|
||||
configured = addon.preferences.game_folder if addon and addon.preferences else ""
|
||||
if configured:
|
||||
repo = Path(bpy.path.abspath(configured))
|
||||
if not (repo / "tools" / "AetherDb").is_dir():
|
||||
raise ValueError(f"The Aetheria repo preference {repo} has no tools/AetherDb")
|
||||
return repo
|
||||
for folder in Path(ship_cc).resolve().parents:
|
||||
if (folder / "tools" / "AetherDb").is_dir():
|
||||
return folder
|
||||
raise ValueError("No Aetheria repo above the ship .cc; set it in the add-on's preferences")
|
||||
folder = Path(bpy.path.abspath(configured))
|
||||
if not (folder / "GameData" / "Aetheria.cc").is_file():
|
||||
raise ValueError(GAME_FOLDER_REFUSAL)
|
||||
return folder
|
||||
if ship_cc:
|
||||
for folder in Path(ship_cc).resolve().parents:
|
||||
if (folder / "GameData" / "Aetheria.cc").is_file():
|
||||
return folder
|
||||
raise ValueError(GAME_FOLDER_REFUSAL)
|
||||
|
||||
|
||||
def _aetherdb(context, ship_cc, *args):
|
||||
"""Runs one AetherDb ship-authoring command; returns its exit code and its output, stdout then stderr. The compiler
|
||||
warnings 'dotnet run' prints when it rebuilds AetherDb are left out; errors are kept."""
|
||||
repo = _aetheria_repo(context, ship_cc)
|
||||
result = subprocess.run(
|
||||
["dotnet", "run", "--project", str(repo / "tools" / "AetherDb"), "--", "ship-authoring", *args],
|
||||
cwd=repo, capture_output=True, text=True, timeout=AETHERDB_TIMEOUT)
|
||||
lines = (result.stdout + "\n" + result.stderr).splitlines()
|
||||
return result.returncode, "\n".join(line for line in lines if line.strip() and ": warning " not in line)
|
||||
def _aetherdb(context, *args, ship_cc=None):
|
||||
"""Runs one AetherDb ship-authoring command from the game folder's ModTools; returns its exit code and its output,
|
||||
stdout then stderr. ship_cc is the bound .cc the game folder is found from when the preference is empty."""
|
||||
folder = _game_folder(context, ship_cc)
|
||||
tool = folder / "ModTools" / ("AetherDb.exe" if sys.platform == "win32" else "AetherDb")
|
||||
if not tool.is_file():
|
||||
raise ValueError(GAME_FOLDER_REFUSAL)
|
||||
result = subprocess.run([str(tool), "ship-authoring", *args], cwd=folder, capture_output=True, text=True,
|
||||
timeout=AETHERDB_TIMEOUT)
|
||||
lines = (result.stdout + "
|
||||
" + result.stderr).splitlines()
|
||||
return result.returncode, "
|
||||
".join(line for line in lines if line.strip())
|
||||
|
||||
|
||||
def _layer_collection(layer, collection):
|
||||
|
|
@ -573,9 +585,8 @@ def _new_ship(context, ship_id, name, reference, length):
|
|||
longest = max(extent_x, extent_y)
|
||||
if longest <= 0:
|
||||
raise ValueError("The hull meshes have no horizontal extent")
|
||||
repo = _aetheria_repo(context, bpy.data.filepath or ".")
|
||||
path = str(repo / "GameData" / "Mods" / ship_id / "ship.cc")
|
||||
code, message = _aetherdb(context, path, "create", path, ship_id, name, "--like", reference)
|
||||
path = str(_game_folder(context, bpy.data.filepath or None) / "GameData" / "Mods" / ship_id / "ship.cc")
|
||||
code, message = _aetherdb(context, "create", path, ship_id, name, "--like", reference, ship_cc=path)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"create failed: {message}")
|
||||
collection = _bind_collection(context, path)
|
||||
|
|
@ -647,7 +658,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator):
|
|||
replace_lines(path, cultlib, capture_grease_pencil(
|
||||
pencils[0], context.evaluated_depsgraph_get(), scene.frame_current,
|
||||
evaluated=scene.aetheria_capture_evaluated_lines))
|
||||
code, message = _aetherdb(context, path, "validate", path)
|
||||
code, message = _aetherdb(context, "validate", path, ship_cc=path)
|
||||
except (OSError, ValueError, RuntimeError, ImportError, KeyError, subprocess.SubprocessError) as exc:
|
||||
scene.aetheria_package_report = f"Package failed: {exc}"
|
||||
self.report({"ERROR"}, scene.aetheria_package_report)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_frame.py
|
||||
|
||||
Environment as smoke_package.py's header says: CULTLIB_PACKAGES, plus CULTLIB_ROOT and CULTMATH_ROOT at the pinned
|
||||
revisions for the 'dotnet run --project tools/AetherDb' calls, and msgpack importable. New Ship writes the ship under
|
||||
<repo>/GameData/Mods/smoke.frame, which this smoke removes when it ends.
|
||||
Environment as smoke_package.py's header says: CULTLIB_PACKAGES and SMOKE_GAME_FOLDER (the published AetherDb under its
|
||||
ModTools, no dotnet), and msgpack importable. New Ship writes the ship under <game folder>/GameData/Mods/smoke.frame,
|
||||
which this smoke removes when it ends.
|
||||
|
||||
Pass 1 sends a UV sphere (1 x 0.6 x 0.4, long axis X, off the origin) through New Ship, length 10, like Djinni.
|
||||
Pass 2 adds a render mesh (a copy of the sphere) and a nub at the +Y end, rasterises, flips the nose, and requires the
|
||||
|
|
@ -27,7 +27,8 @@ import bpy
|
|||
REPO = Path(__file__).resolve().parents[3]
|
||||
PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory")
|
||||
SHIP_ID = "smoke.frame"
|
||||
MODS = REPO / "GameData" / "Mods"
|
||||
GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb"))
|
||||
MODS = GAME / "GameData" / "Mods"
|
||||
|
||||
|
||||
def new_object(collection, name, mesh, location=(0, 0, 0), parent=None):
|
||||
|
|
@ -95,7 +96,7 @@ def main():
|
|||
addon_utils.enable("aetheria_ships", default_set=True, handle_error=None)
|
||||
import aetheria_ships
|
||||
aetheria_ships._brokkr_cultlib = lambda context: PACKAGES
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.aetheria_repo = str(REPO)
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
|
||||
require(not (MODS / SHIP_ID).exists(), f"{MODS / SHIP_ID} exists; remove it first")
|
||||
|
||||
scene = bpy.context.scene
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@
|
|||
|
||||
blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_package.py
|
||||
|
||||
Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py), and whatever AetherDb's build needs
|
||||
(CULTLIB_ROOT and CULTMATH_ROOT at the pinned revisions), since the add-on runs 'dotnet run --project tools/AetherDb'.
|
||||
Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py) and SMOKE_GAME_FOLDER, a folder holding
|
||||
GameData/Aetheria.cc and the published AetherDb under ModTools (AetherDb.exe on Windows), which the smoke sets as the
|
||||
add-on's Game folder. No dotnet is needed: the add-on runs the published tool and nothing else.
|
||||
SMOKE_MODS names the mods directory to write into (default: a new temp directory); the package is left there so the
|
||||
Unity play smoke can load it, and its path is printed as SMOKE_PACKAGE=<ship.cc>.
|
||||
|
||||
Pass 1 packages a primitive ship built from role-tagged objects and requires the C# validator to accept it. Pass 2 swaps
|
||||
the thruster disc for an empty of the same id and requires the validator to refuse it, naming the anchor. Pass 3 puts
|
||||
the disc back and packages again, so the package left behind is the good one.
|
||||
the disc back and packages again, so the package left behind is the good one. Pass 4 points Game folder at a folder
|
||||
that holds Aetheria.cc but no ModTools/AetherDb and requires Package to report the fixed preference text and no path.
|
||||
|
||||
Before packaging, the bind cases: a bind that fails while computing the stored path leaves no aetheria.* property; an
|
||||
unsaved .blend stores the absolute path, which still resolves after Save As to two other folders; a saved .blend on the
|
||||
|
|
@ -27,6 +29,7 @@ import addon_utils
|
|||
import bpy
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb"))
|
||||
PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory")
|
||||
SHIP_ID = "smoke.gale"
|
||||
|
||||
|
|
@ -130,13 +133,13 @@ def main():
|
|||
import aetheria_ships
|
||||
# Brokkr is the Blender host's CultLib path provider and is not installed in a factory-startup Blender.
|
||||
aetheria_ships._brokkr_cultlib = lambda context: PACKAGES
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.aetheria_repo = str(REPO)
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
|
||||
|
||||
mods = Path(os.environ.get("SMOKE_MODS") or tempfile.mkdtemp(prefix="aetheria-smoke-mods-"))
|
||||
path = str(mods / SHIP_ID / "ship.cc")
|
||||
if Path(path).exists():
|
||||
raise SystemExit(f"{path} already exists; point SMOKE_MODS at an empty directory")
|
||||
code, message = aetheria_ships._aetherdb(bpy.context, path, "create", path, SHIP_ID, "Smoke Gale", "--like", "Djinni")
|
||||
code, message = aetheria_ships._aetherdb(bpy.context, "create", path, SHIP_ID, "Smoke Gale", "--like", "Djinni")
|
||||
print(f"CREATE {code}: {message}")
|
||||
if code != 0:
|
||||
raise SystemExit("create --like Djinni failed")
|
||||
|
|
@ -184,6 +187,15 @@ def main():
|
|||
result, report = package(aetheria_ships)
|
||||
if result != {"FINISHED"}:
|
||||
raise SystemExit("Pass 3: the restored ship was refused")
|
||||
|
||||
bare = Path(tempfile.mkdtemp(prefix="aetheria-smoke-bare-"))
|
||||
(bare / "GameData").mkdir()
|
||||
(bare / "GameData" / "Aetheria.cc").write_bytes(b"")
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(bare)
|
||||
result, report = package(aetheria_ships)
|
||||
if result != {"CANCELLED"} or report != "Package failed: " + aetheria_ships.GAME_FOLDER_REFUSAL or "Game folder" not in report or str(bare) in report or str(GAME) in report:
|
||||
raise SystemExit("Pass 4: a game folder without ModTools/AetherDb was not refused with the fixed text")
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
|
||||
print(f"SMOKE_PACKAGE={path}")
|
||||
print("SMOKE_PACKAGE_OK")
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue