using System; using System.IO; namespace IslaApocalypse.Core { /// /// ⭐ 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. → . /// /// ═══ 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 . 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. /// 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; /// /// 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. /// public static void Configure(string userDataDir) { if (string.IsNullOrWhiteSpace(userDataDir)) throw new ArgumentException("A user data directory is required.", nameof(userDataDir)); _userDataDir = userDataDir; } /// The resolved user data directory, or the working directory if none was configured. public static string UserDataDir => _userDataDir ?? Directory.GetCurrentDirectory(); /// Whether has been called. Tooling should assert this before a run. 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 ), 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 : with no slug set, // 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; /// /// Set the chat namespace batches are written under. Called once at startup by every batch /// tool, with its authoring chat as the fallback: ConfigureChat(EnvStr(ChatVar, "chat2")). /// /// /// 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. /// 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; } /// The chat namespace. ⚠ Throws if has not been called. public static string ChatSlug => _chatSlug ?? throw new InvalidOperationException( "CHAT SLUG NOT SET. Batches are namespaced by chat (batches//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."); /// Whether has been called. public static bool IsChatConfigured => _chatSlug != null; /// The generation config file. Override: ISLA_CONFIG_PATH. public static string ConfigPath => Override(ConfigPathVar) ?? Path.Combine(UserDataDir, "config.json"); /// Where blueprints are read from and written to. Override: ISLA_BLUEPRINT_PATH. public static string BlueprintPath => Override(BlueprintPathVar) ?? Path.Combine(UserDataDir, "blueprints"); /// Where a generation run writes its output. Override: ISLA_OUTPUT_DIR. public static string OutputDir => Override(OutputDirVar) ?? Path.Combine(UserDataDir, "output"); /// /// The batches root. → `Design - Tooling - Iteration and Batching.md`: /// batches/NN_<name>/<seed>_<variant>/, 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 , which inserts the 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 ShapingOracle.LoadAnchor now throws on a missing anchor. /// /// ⚠ PROTECTED FROM DELETION. → . /// public static string BatchesRoot => Path.Combine(OutputDir, "batches"); /// /// 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. /// public static string BatchScratch(string batchDir) => Path.Combine(batchDir, "scratch"); /// /// ⭐ A BATCH ROOT: batches/<chat>/<task>_<descriptor>/. /// /// ═══ ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER. ═══ /// /// 04_review means "the batch task 04 authored". It does NOT mean "the fifth batch". /// A task that produces six batches produces six 04_* folders, not 04_ through /// 09_. /// /// 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 00_smoke05_trophy_10240 /// 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 , because the task-number prefix restarts at 00 in /// every chat — see the note on . 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. /// public static string BatchRoot(int taskNumber, string descriptor) => BatchRoot(taskNumber, "", descriptor); /// /// ⭐ The same, for a LETTERED SUB-TASK: batches/<chat>/<task><suffix>_<descriptor>/, /// e.g. 02b_composition (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 ("02b_composition") 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. /// 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}"); } /// /// A variant directory inside a batch: /// batches/<task>_<descriptor>/<seed>_<variant>/. /// public static string BatchDir(int taskNumber, string descriptor, long seed, string variant) => Path.Combine(BatchRoot(taskNumber, descriptor), $"{seed}_{variant}"); /// /// Resolve a HISTORICAL batch by its namespaced name, e.g. "chat1/02_pass1_port" — the /// form every `ISLA_*_SOURCE` anchor default takes since rivers/01. Kept beside /// so a READ and a WRITE are visibly two different operations. /// 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; } /// Every resolved path, for a run report's header. Print this; it is cheap and it has caught things. 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 : "")}/NN_slug/"; private static string Marker(string variable) => Override(variable) != null ? $" [{variable}]" : ""; } }