A re-selection over rivers/02's candidates, not a new analysis: DrainageAnalysis is reused untouched and the 8-seed distribution sweep is not re-run. - RiverPromotionTool: ISLA_PROMOTE_MODE=composition builds both compositions at one fixed total — PURE (top N by drainage, terminus irrelevant) and QUOTA (the K largest sea-reaching forced in + the N-K largest endorheic). Deterministic; shortfall is handled and flagged rather than back-filled. BindCourses now takes an explicit candidate set, because a forced sea river can sit far below rank N (measured: #29). - DrainageRenderer.RiverComposition: the composition plate, at ONE ABSOLUTE width->drainage constant (1/180 px per sqrt(drainage px)) shared across both plates and all seeds — per-plate normalisation cannot answer "is this river thin?" — plus per-river drainage/rank labels with collision-avoided placement. - ToolingPaths.BatchRoot: additive overload for a lettered sub-task, so 02b writes to 02b_composition instead of claiming task 03's number. Taste gate: no N, no K, no default set anywhere. Nothing carved, no routing, and Giant.ProvisionalRoute is still never drawn.
258 lines
14 KiB
C#
258 lines
14 KiB
C#
using System;
|
|
using System.IO;
|
|
|
|
namespace IslaApocalypse.Core
|
|
{
|
|
/// <summary>
|
|
/// ⭐ EVERY PATH THE TOOLING READS OR WRITES, RESOLVED IN ONE PLACE, OVERRIDABLE BY ENVIRONMENT.
|
|
/// → `Design - Tooling - Iteration and Batching.md` § "Tooling must be safe BY CODE, not by care".
|
|
///
|
|
/// ═══ WHY THIS TYPE EXISTS ═══
|
|
///
|
|
/// So a batch run CANNOT read over or write to the developer's live config, staged blueprints,
|
|
/// or output directory. Not "should not" — cannot, because there is no other way to obtain a
|
|
/// path, and each one has an environment override that a batch script sets before it starts.
|
|
///
|
|
/// > ⭐ THE POINT IS THAT IT IS ENFORCED BY CODE, NOT BY CARE.
|
|
/// > A rule that depends on an executor remembering it will eventually meet an executor who
|
|
/// > does not.
|
|
///
|
|
/// ⚠ This is not hypothetical. These rules were born from a real incident: an executor admitted
|
|
/// it had been deleting the developer's staged test blueprint. It was owned and fixed IN CODE.
|
|
/// That is why these are rules and not guidance. → <see cref="FileSafety"/>.
|
|
///
|
|
/// ═══ THE OVERRIDES ═══
|
|
///
|
|
/// ISLA_CONFIG_PATH the generation config file default: user://config.json
|
|
/// ISLA_BLUEPRINT_PATH the blueprint read/written default: user://blueprints
|
|
/// ISLA_OUTPUT_DIR generation output (maps, batches) default: user://output
|
|
/// ISLA_CHAT the batch chat namespace default: the tool's authoring chat
|
|
///
|
|
/// ⚠ user:// RESOLUTION. Defaults sit under the project's own user data directory, which this
|
|
/// project pins away from the old prototype's — see project.godot's user:// isolation block.
|
|
/// Core is engine-free by design, so the caller supplies the resolved user directory (from
|
|
/// Godot's OS.GetUserDataDir()) via <see cref="Configure"/>. Until it does, the defaults resolve
|
|
/// under the process working directory, which is wrong for a real run and loud enough to notice.
|
|
///
|
|
/// No generator exists this phase. The rails are laid before it so it inherits them.
|
|
/// </summary>
|
|
public static class ToolingPaths
|
|
{
|
|
public const string ConfigPathVar = "ISLA_CONFIG_PATH";
|
|
public const string BlueprintPathVar = "ISLA_BLUEPRINT_PATH";
|
|
public const string OutputDirVar = "ISLA_OUTPUT_DIR";
|
|
|
|
private static string _userDataDir;
|
|
|
|
/// <summary>
|
|
/// Hand Core the engine-resolved user data directory. Called once at startup by whichever
|
|
/// layer owns the engine (Tools or Server); Core never asks Godot for it itself.
|
|
/// </summary>
|
|
public static void Configure(string userDataDir)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(userDataDir))
|
|
throw new ArgumentException("A user data directory is required.", nameof(userDataDir));
|
|
_userDataDir = userDataDir;
|
|
}
|
|
|
|
/// <summary>The resolved user data directory, or the working directory if none was configured.</summary>
|
|
public static string UserDataDir => _userDataDir ?? Directory.GetCurrentDirectory();
|
|
|
|
/// <summary>Whether <see cref="Configure"/> has been called. Tooling should assert this before a run.</summary>
|
|
public static bool IsConfigured => _userDataDir != null;
|
|
|
|
// ═══ ⭐⭐ THE CHAT NAMESPACE (rivers/01) ═══════════════════════════════════════════════════
|
|
//
|
|
// ═══ WHY BATCHES ARE NAMESPACED BY CHAT ═══
|
|
//
|
|
// The batch prefix is the AUTHORING TASK NUMBER (see <see cref="BatchRoot"/>), and task
|
|
// numbers restart at 00 in every new build chat. So a flat batches/ directory COLLIDES the
|
|
// moment a second chat exists: chat 1's `02_pass1_port` and chat 2's `02_curve_continuous`
|
|
// are both "batch 02", and nothing in either name says which chat made it. Measured on the
|
|
// real pile at rivers/01: 25 batches, FOUR colliding prefixes (02, 03, 04, 06), 13 folders
|
|
// belonging to chat 1 and 12 to chat 2 — separable only by SLUG, never by number.
|
|
//
|
|
// > ### ⚠ The slug is WHO IS RUNNING, not who authored.
|
|
// > A tool carries its authoring chat as its default so that re-running it reproduces its own
|
|
// > batch in place. A different chat re-running it for its own purposes sets ISLA_CHAT and
|
|
// > writes under its own namespace — which is also what stops an acceptance run from
|
|
// > OVERWRITING THE VERY ANCHOR IT IS CHECKING AGAINST.
|
|
//
|
|
// ⚠ REQUIRED, exactly like <see cref="Configure"/>: with no slug set, <see cref="BatchRoot"/>
|
|
// throws rather than quietly writing to the un-namespaced root and re-creating the collision
|
|
// this exists to end.
|
|
|
|
public const string ChatVar = "ISLA_CHAT";
|
|
|
|
private static string _chatSlug;
|
|
|
|
/// <summary>
|
|
/// Set the chat namespace batches are written under. Called once at startup by every batch
|
|
/// tool, with its authoring chat as the fallback: <c>ConfigureChat(EnvStr(ChatVar, "chat2"))</c>.
|
|
/// </summary>
|
|
/// <param name="slug">
|
|
/// A short domain slug — `chat1`, `chat2`, `rivers`. ⚠ It becomes a single path SEGMENT, so
|
|
/// separators are refused rather than silently creating a nested tree nobody asked for.
|
|
/// </param>
|
|
public static void ConfigureChat(string slug)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(slug))
|
|
throw new ArgumentException("A chat slug is required — batches are namespaced by chat.", nameof(slug));
|
|
string t = slug.Trim();
|
|
if (t.IndexOf('/') >= 0 || t.IndexOf('\\') >= 0 || t.IndexOf(Path.DirectorySeparatorChar) >= 0
|
|
|| t == "." || t == "..")
|
|
throw new ArgumentException(
|
|
$"Chat slug '{t}' is not a single path segment. The slug is ONE folder under batches/ — " +
|
|
"pass \"rivers\", not \"a/b\" or \"..\".", nameof(slug));
|
|
_chatSlug = t;
|
|
}
|
|
|
|
/// <summary>The chat namespace. ⚠ Throws if <see cref="ConfigureChat"/> has not been called.</summary>
|
|
public static string ChatSlug => _chatSlug ?? throw new InvalidOperationException(
|
|
"CHAT SLUG NOT SET. Batches are namespaced by chat (batches/<chat>/NN_slug/); a tool must call " +
|
|
"ToolingPaths.ConfigureChat(...) before composing a batch path. Writing to the un-namespaced root " +
|
|
$"is what collided task numbers across chats in the first place. (Override with {ChatVar}.) — rivers/01.");
|
|
|
|
/// <summary>Whether <see cref="ConfigureChat"/> has been called.</summary>
|
|
public static bool IsChatConfigured => _chatSlug != null;
|
|
|
|
/// <summary>The generation config file. Override: ISLA_CONFIG_PATH.</summary>
|
|
public static string ConfigPath =>
|
|
Override(ConfigPathVar) ?? Path.Combine(UserDataDir, "config.json");
|
|
|
|
/// <summary>Where blueprints are read from and written to. Override: ISLA_BLUEPRINT_PATH.</summary>
|
|
public static string BlueprintPath =>
|
|
Override(BlueprintPathVar) ?? Path.Combine(UserDataDir, "blueprints");
|
|
|
|
/// <summary>Where a generation run writes its output. Override: ISLA_OUTPUT_DIR.</summary>
|
|
public static string OutputDir =>
|
|
Override(OutputDirVar) ?? Path.Combine(UserDataDir, "output");
|
|
|
|
/// <summary>
|
|
/// The batches root. → `Design - Tooling - Iteration and Batching.md`:
|
|
/// <c>batches/NN_<name>/<seed>_<variant>/</c>, each batch carrying an
|
|
/// INDEX.md and a persistent scratch/. A/B comparisons are browsed by a human, and a flat
|
|
/// directory of same-named PNGs is not browsable.
|
|
///
|
|
/// ⚠⚠ THIS IS THE ROOT, NOT A BATCH, AND THE DISTINCTION IS LOAD-BEARING. Batch WRITES go
|
|
/// through <see cref="BatchRoot"/>, which inserts the <see cref="ChatSlug"/> segment. Anchor
|
|
/// READS compose against THIS, so an anchor's source string must carry its own explicit
|
|
/// `chatN/` prefix (e.g. `"chat1/02_pass1_port"`). Changing only `BatchRoot` would namespace
|
|
/// every write and silently orphan every historical read — the exact trap rivers/01 had to
|
|
/// walk through, and why <c>ShapingOracle.LoadAnchor</c> now throws on a missing anchor.
|
|
///
|
|
/// ⚠ PROTECTED FROM DELETION. → <see cref="FileSafety"/>.
|
|
/// </summary>
|
|
public static string BatchesRoot => Path.Combine(OutputDir, "batches");
|
|
|
|
/// <summary>
|
|
/// The scratch subfolder of a batch. INTERMEDIATES PERSIST HERE AND ARE NEVER CLEANED —
|
|
/// the whole point is that a run's intermediates survive it, so a surprising result can be
|
|
/// investigated instead of regenerated.
|
|
/// </summary>
|
|
public static string BatchScratch(string batchDir) => Path.Combine(batchDir, "scratch");
|
|
|
|
/// <summary>
|
|
/// ⭐ A BATCH ROOT: <c>batches/<chat>/<task>_<descriptor>/</c>.
|
|
///
|
|
/// ═══ ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER. ═══
|
|
///
|
|
/// <c>04_review</c> means "the batch task 04 authored". It does NOT mean "the fifth batch".
|
|
/// A task that produces six batches produces six <c>04_*</c> folders, not <c>04_</c> through
|
|
/// <c>09_</c>.
|
|
///
|
|
/// This is enforced here, in code, because it already drifted once: tasks 02 and 03 used the
|
|
/// prefix as a global running counter and produced <c>00_smoke</c> … <c>05_trophy_10240</c>
|
|
/// across two tasks, so nothing in the folder name said which task made what. Passing the
|
|
/// task number as an explicit argument — rather than letting a caller compose a free-form
|
|
/// string — is what makes the convention unbreakable rather than remembered.
|
|
/// → `Design - Tooling - Iteration and Batching.md`.
|
|
///
|
|
/// The descriptor must NOT carry its own numeric prefix; that is the mistake this method
|
|
/// exists to prevent, so it is refused rather than silently accepted.
|
|
///
|
|
/// ═══ ⭐ THE <chat> SEGMENT (rivers/01) ═══
|
|
///
|
|
/// Prepended from <see cref="ChatSlug"/>, because the task-number prefix restarts at 00 in
|
|
/// every chat — see the note on <see cref="ConfigureChat"/>. It is a SEPARATE segment and is
|
|
/// never folded into the descriptor: the prefix guard below fires on a descriptor starting
|
|
/// with digits, so passing `"chat2/12_drainage"` as a descriptor would be a different kind
|
|
/// of wrong.
|
|
/// </summary>
|
|
public static string BatchRoot(int taskNumber, string descriptor) => BatchRoot(taskNumber, "", descriptor);
|
|
|
|
/// <summary>
|
|
/// ⭐ The same, for a LETTERED SUB-TASK: <c>batches/<chat>/<task><suffix>_<descriptor>/</c>,
|
|
/// e.g. <c>02b_composition</c> (rivers/02b).
|
|
///
|
|
/// ═══ WHY A SUFFIX RATHER THAN A NEW TASK NUMBER ═══
|
|
///
|
|
/// The prefix is the AUTHORING TASK's identity, and a task numbered "02b" — a follow-up that
|
|
/// re-renders 02's material under one changed choice — has exactly that identity. Giving it a
|
|
/// fresh number (03) would claim it is the next task in the sequence and collide with the one
|
|
/// that actually is; folding the letter into the descriptor (<c>"02b_composition"</c>) would
|
|
/// smuggle a prefix past the guard below, which is the drift that guard exists to stop.
|
|
///
|
|
/// ⚠ Letters only, and lowercase — a suffix that could be read as part of a number would
|
|
/// reintroduce the ambiguity. Refused rather than sanitised.
|
|
/// </summary>
|
|
public static string BatchRoot(int taskNumber, string suffix, string descriptor)
|
|
{
|
|
string sfx = (suffix ?? "").Trim();
|
|
foreach (char c in sfx)
|
|
if (c < 'a' || c > 'z')
|
|
throw new ArgumentException(
|
|
$"Task suffix '{sfx}' must be lowercase letters only (e.g. \"b\" for task 02b). A suffix that " +
|
|
"could be read as part of the task number is exactly the ambiguity the prefix rule removes.", nameof(suffix));
|
|
if (taskNumber < 0)
|
|
throw new ArgumentOutOfRangeException(nameof(taskNumber), taskNumber,
|
|
"A batch is named for the task that authored it; there is no negative task.");
|
|
if (string.IsNullOrWhiteSpace(descriptor))
|
|
throw new ArgumentException("A batch needs a descriptor — '04_' alone is not browsable.", nameof(descriptor));
|
|
|
|
string d = descriptor.Trim();
|
|
|
|
// Refuse "04_review", "4_review", "05_foo" — the caller is re-adding a prefix, which is
|
|
// exactly how the counter drifted. The task number is this method's job, not theirs.
|
|
int us = d.IndexOf('_');
|
|
if (us > 0 && int.TryParse(d.Substring(0, us), out _))
|
|
throw new ArgumentException(
|
|
$"Descriptor '{d}' starts with its own numeric prefix. Pass the task number as " +
|
|
$"taskNumber and the descriptor WITHOUT one (e.g. \"review\", not \"04_review\") — " +
|
|
"the prefix is composed here so it cannot drift.", nameof(descriptor));
|
|
|
|
return Path.Combine(BatchesRoot, ChatSlug, $"{taskNumber:D2}{sfx}_{d}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A variant directory inside a batch:
|
|
/// <c>batches/<task>_<descriptor>/<seed>_<variant>/</c>.
|
|
/// </summary>
|
|
public static string BatchDir(int taskNumber, string descriptor, long seed, string variant)
|
|
=> Path.Combine(BatchRoot(taskNumber, descriptor), $"{seed}_{variant}");
|
|
|
|
/// <summary>
|
|
/// Resolve a HISTORICAL batch by its namespaced name, e.g. <c>"chat1/02_pass1_port"</c> — the
|
|
/// form every `ISLA_*_SOURCE` anchor default takes since rivers/01. Kept beside
|
|
/// <see cref="BatchRoot"/> so a READ and a WRITE are visibly two different operations.
|
|
/// </summary>
|
|
public static string BatchSource(string namespacedName) => Path.Combine(BatchesRoot, namespacedName);
|
|
|
|
private static string Override(string variable)
|
|
{
|
|
string v = Environment.GetEnvironmentVariable(variable);
|
|
return string.IsNullOrWhiteSpace(v) ? null : v;
|
|
}
|
|
|
|
/// <summary>Every resolved path, for a run report's header. Print this; it is cheap and it has caught things.</summary>
|
|
public static string Describe() =>
|
|
$"user data : {UserDataDir}{(IsConfigured ? "" : " ⚠ NOT CONFIGURED — falling back to CWD")}\n" +
|
|
$"config : {ConfigPath}{Marker(ConfigPathVar)}\n" +
|
|
$"blueprints: {BlueprintPath}{Marker(BlueprintPathVar)}\n" +
|
|
$"output : {OutputDir}{Marker(OutputDirVar)}\n" +
|
|
$"batches : {BatchesRoot}\n" +
|
|
$"chat : {(IsChatConfigured ? ChatSlug : "⚠ NOT SET")} → writes land under batches/{(IsChatConfigured ? ChatSlug : "<chat>")}/NN_slug/";
|
|
|
|
private static string Marker(string variable) => Override(variable) != null ? $" [{variable}]" : "";
|
|
}
|
|
}
|