commit 9107b0822bf519f708db01ff28e9bbc3def1fe1d Author: beezm Date: Wed Aug 19 20:21:12 2026 -0400 Phase 0: clean project skeleton for the v2 rewrite Stands up the CODE_REPO the rewrite is written into (D-049, D-058). Godot 4.7.2 / .NET 8 / Godot.NET.Sdk 4.7.2. Nothing is generated, meshed, or ported — this is the shell and the two data contracts. Four layers, each with its boundary stated in a directory README: Core/ math + data only, and engine-free — depends on nothing above it Server/ authoritative logic (empty this phase) Client/ rendering (empty this phase) Tools/ the offline generator — may NOT reference Client (Phase 1 fills it) Contracts (compiling stubs, no algorithms): - Column model (D-053): a 2D grid of columns, each a stack of (material, thickness) runs, any material at any depth. Air is the TOP RUN — there is no air-vs-solid height branch, and no surface-height accessor exists to reintroduce one. Water is not a band; it stays an overlay. - Material schemas (D-054): terrain and building as two append-only registries, bridged by a recipe seam that is deliberately EMPTY. Identity is a registry key, never a serialized ordinal. Mesh style is per-ORIGIN and is not a material field. Rails, so Phase 1 inherits them rather than rediscovering them: - GenerationScale: MapSize is a parameter, everything derives from it, nothing uses a raw pixel number. Tightening over the reference — decorrelation offsets are declared in map widths, not pixels (chat1/00 §4.2). - ToolingPaths: config/blueprint/output paths all env-overridable, resolved in one place, so a batch cannot touch the developer's live files. - FileSafety: the permanent no-deletion rules as throws rather than sentences. user:// isolation: project name and use_custom_user_dir both pin the runtime dir to ~/.local/share/islaApocalypse-v2/, away from the old prototype's preserved seeds and batches. Tools/Scenes/UserDirProbe.tscn confirms it rather than assuming it. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b53526d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.svg text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77d910d --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# ---- Godot 4.x ------------------------------------------------------------ +# The import/script cache. Regenerated on import; never committed. +.godot/ +# Exported builds. +/build/ +/export/ +*.translation + +# ---- .NET / C# ------------------------------------------------------------ +bin/ +obj/ +.mono/ +*.user +*.userprefs +*.suo +*.swp +[Dd]ebug/ +[Rr]elease/ +msbuild.binlog + +# ---- OS / editor ---------------------------------------------------------- +.DS_Store +Thumbs.db +.idea/ +.vs/ +.vscode/ + +# ---- ⚠ NEVER COMMIT GENERATED WORLD DATA --------------------------------- +# Blueprints, batches and snapshots are OUTPUT — large, regenerable, and they live under the +# project's user:// directory, not here. If any of these ever appear inside the repo, something +# resolved a path wrong; fix the path, do not commit the file. +# → Core/Scripts/ToolingPaths.cs, Tools/README.md +*.blueprint +batches/*/ +!batches/README.md + +# Godot's C# project editor rewrites the .csproj on import and leaves a backup beside it. +# An artifact of the editor, not a source file. (Not deleted — file-safety rule: no deletions.) +*.csproj.old diff --git a/Client/README.md b/Client/README.md new file mode 100644 index 0000000..38b8641 --- /dev/null +++ b/Client/README.md @@ -0,0 +1,22 @@ +# Client — rendering and presentation + +**Holds:** rendering, meshing, UI, input, everything the player sees. + +**Boundary:** may use `Core/`. Must not depend on `Server/` internals. + +> ### ⚠ Nothing in `Tools/` may reference this folder. The wall runs one way. +> → `Tools/README.md`, `Design - Tooling - Offline Generation.md`. + +## Empty this phase — deliberately + +Phase 0 has no renderer. The mesher (SN/DC smooth terrain + greedy-cube builds with seam-blend, +→ D-052, `Design - Rendering - Mesher.md`) is a much later phase, and building it before the data is +right is explicitly the trap the rewrite exists to undo (D-056: *"the data is perfected before the +mesher"*). + +## One thing already decided, so it is not re-litigated later + +**Mesh style is per-ORIGIN, not per-material.** Natural runs mesh smooth, player-placed runs mesh as +cubes, and the same material can be both. The flag the mesher reads is `RunOrigin` on the run — there +is no mesh-style field on any material row, and adding one would be a design regression. +→ `Core/Scripts/Column.cs`, D-054. diff --git a/Client/Scripts/.gitkeep b/Client/Scripts/.gitkeep new file mode 100644 index 0000000..0d207bc --- /dev/null +++ b/Client/Scripts/.gitkeep @@ -0,0 +1 @@ +# Placeholder so the empty layer folder is tracked. Delete when the first script lands. diff --git a/Core/README.md b/Core/README.md new file mode 100644 index 0000000..65159b9 --- /dev/null +++ b/Core/README.md @@ -0,0 +1,55 @@ +# Core — math and data only + +**Holds:** the column model, the material schemas, the scaling discipline, tooling path +resolution and the file-safety rails. Constants and contracts. + +**Boundary: Core depends on nothing above it.** Not on `Server/`, not on `Client/`, not on +`Tools/`. The dependency arrow points one way into this folder and never out of it. + +> ### ⚠ Additional boundary, tighter than the layer rule: **Core is engine-free.** +> +> No `using Godot;` anywhere in this folder. Core is plain C# — no `Node`, no `GD.Print`, no +> `ProjectSettings`, no engine types in any signature. +> +> **Two reasons, both load-bearing.** (1) D-049 commits to *"C# for fast iteration, with C++-ready +> seams… port path-by-path when settled and hot"* — the hot paths that get ported first are exactly +> the ones in here, and a type that names `Godot.Vector2` cannot cross that seam. (2) It keeps the +> column model testable and reviewable without standing up an engine. +> +> Where Core genuinely needs something the engine knows — the resolved `user://` directory — the +> engine layer **hands it in** (`ToolingPaths.Configure`), rather than Core reaching for it. +> If you find yourself wanting an engine type here, the type belongs one layer up. + +## What is in here + +| File | What it is | +|---|---| +| `Scripts/Column.cs` | ★★ **The load-bearing contract** (D-053). A column as run-length bands; `MaterialRun`; `RunOrigin`. Read its header before touching anything that stores world data. | +| `Scripts/ColumnGrid.cs` | The 2D grid of columns. Shape only — chunking and streaming are later phases. | +| `Scripts/MaterialKey.cs` | Material **identity** — a registry key, never an ordinal. | +| `Scripts/TerrainMaterial.cs` | A row in the terrain schema (D-054). | +| `Scripts/BuildingMaterial.cs` | A row in the building schema (D-054). | +| `Scripts/MaterialRegistry.cs` | Both registries, append-only, seed rows only. | +| `Scripts/RecipeRegistry.cs` | The transformation seam — **deliberately empty**. | +| `Scripts/GenerationScale.cs` | ⭐ The scaling discipline. `MapSize`, `ScaleFactor`, normalized offsets. | +| `Scripts/ToolingPaths.cs` | Every tooling path, env-overridable, resolved in one place. | +| `Scripts/FileSafety.cs` | The permanent file-safety rules, as throws rather than sentences. | + +## The three rules this layer exists to make unbreakable + +1. **Air is the top run.** There is no "air vs solid" height branch, anywhere, ever. + → `Scripts/Column.cs`. +2. **Properties come from the registry, never from storage.** A run stores an identity. + → `Scripts/MaterialRegistry.cs`. +3. **Nothing uses a raw pixel number.** Every distance is a fraction of `MapSize`. + → `Scripts/GenerationScale.cs`. + +## Not here, and not by accident + +- **No water.** Water is an overlay over the columns (levels-not-cells), never a band. +- **No biomes.** Biomes are a later *classification* of finished shape, not an input to it (D-049). +- **No algorithms.** Phase 0 is shape. Stratigraphy, feature passes, meshing and run-splitting on + dig are later phases. + +→ `Design - Data - Column Model.md`, `Design - Data - Material Schema.md`, +`Design - Tooling - Scaling Discipline.md` diff --git a/Core/Scripts/BuildingMaterial.cs b/Core/Scripts/BuildingMaterial.cs new file mode 100644 index 0000000..710a7de --- /dev/null +++ b/Core/Scripts/BuildingMaterial.cs @@ -0,0 +1,64 @@ +namespace IslaApocalypse.Core +{ + /// + /// A row in the BUILDING material schema — what players construct with. + /// → `Design - Data - Material Schema.md` §2, D-054. + /// + /// ⚠ DELIBERATELY A SEPARATE SCHEMA FROM , NOT A TAG ON ONE TABLE. + /// Natural stone and a placed stone block are not the same thing tagged differently: one is a + /// resource you harvest, the other is something you craft and build with. One tagged table was + /// considered and REJECTED — it blurs the resource→product relationship that IS the survival + /// loop, and invites the two concerns to bleed into each other the way biomes bled into terrain. + /// The bridge between the two schemas is , not a shared row type. + /// + /// ⚠ NO MESH-STYLE FIELD HERE EITHER — same rule, same reason. See . + /// + /// APPEND-ONLY. Adding a material is one new row. + /// + public sealed class BuildingMaterial + { + /// Identity. A registry key, not an ordinal. + public MaterialKey Key { get; } + + /// Human-facing name. Presentation only. + public string DisplayName { get; } + + /// + /// How far this material carries structural support from an anchor, before decay. + /// → `Design - Systems - Structural Integrity.md` (D-055: per-material reach). + /// + public float SupportReach { get; } + + /// How fast support falls off across that reach. 0 = no decay, 1 = immediate. + public float SupportDecay { get; } + + /// Weight the material imposes on what carries it. + public float Weight { get; } + + /// Damage the material absorbs before failing. + public float Durability { get; } + + /// + /// Runtime-only dense index. ⚠⚠ NEVER SERIALIZED — see . + /// + public int RuntimeIndex { get; internal set; } = -1; + + public BuildingMaterial( + MaterialKey key, + string displayName, + float supportReach, + float supportDecay, + float weight, + float durability) + { + Key = key; + DisplayName = displayName; + SupportReach = supportReach; + SupportDecay = supportDecay; + Weight = weight; + Durability = durability; + } + + public override string ToString() => $"BuildingMaterial({Key})"; + } +} diff --git a/Core/Scripts/BuildingMaterial.cs.uid b/Core/Scripts/BuildingMaterial.cs.uid new file mode 100644 index 0000000..19f322e --- /dev/null +++ b/Core/Scripts/BuildingMaterial.cs.uid @@ -0,0 +1 @@ +uid://dxqo466b6ns0l diff --git a/Core/Scripts/Column.cs b/Core/Scripts/Column.cs new file mode 100644 index 0000000..57dee02 --- /dev/null +++ b/Core/Scripts/Column.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; + +namespace IslaApocalypse.Core +{ + /// + /// Where a run came FROM. Not a material property — a fact about provenance. + /// + /// ⭐ THIS, AND ONLY THIS, DECIDES MESH STYLE: natural runs mesh smooth (SN/DC), player-placed + /// runs mesh as cubes, and the seam-blend handles the join. The same material can be both — + /// which is exactly why mesh style is NOT a field on the material row. + /// → D-054 ("material does not override mesh style"), `Design - Rendering - Mesher.md`, D-052. + /// + /// No mesher exists this phase. The flag exists so the mesher has something true to read. + /// + public enum RunOrigin : byte + { + /// Produced by world generation — stratigraphy, or a 3D feature pass. + Natural = 0, + + /// Placed by a player. + PlayerPlaced = 1, + } + + /// + /// One run-length band in a column: a material, a thickness, and where it came from. + /// → `Design - Data - Column Model.md`, D-053. + /// + /// ⚠ A RUN STORES A MATERIAL IDENTITY AND NOTHING ELSE ABOUT THE MATERIAL. + /// Hardness, yield, support strength — every property — comes from + /// by lookup on . Nothing is stored + /// per-run. That is properties-from-type, and it is what makes materials, support and + /// mod-ability clean. → `Design - Data - Material Schema.md`. + /// + /// is not an exception to that rule: it is not a property OF the material, + /// it is a property of THIS RUN. + /// + public readonly struct MaterialRun + { + /// The material's registry key. → . + public readonly MaterialKey Material; + + /// Thickness in voxels, bottom to top. Strictly positive. + public readonly int Thickness; + + /// Provenance. Decides mesh style; see . + public readonly RunOrigin Origin; + + public MaterialRun(MaterialKey material, int thickness, RunOrigin origin = RunOrigin.Natural) + { + if (material.IsNone) + throw new ArgumentException("A run must name a material. Air is a material (core.air), not an absence.", nameof(material)); + if (thickness <= 0) + throw new ArgumentOutOfRangeException(nameof(thickness), thickness, "A run's thickness must be strictly positive. A zero-thickness run is not a thing; omit it."); + + Material = material; + Thickness = thickness; + Origin = origin; + } + + public override string ToString() => $"{Material} x{Thickness} ({Origin})"; + } + + /// + /// ★★ THE LOAD-BEARING CONTRACT — one horizontal cell of the world, as a vertical stack of + /// run-length bands, bottom to top. → `Design - Data - Column Model.md`, D-053. + /// + /// + /// bedrock 0–50 + /// limestone 50–60 + /// air 60–70 ← a cave: an air run mid-column + /// limestone 70–84 + /// iron 84–86 ← an ore vein: a material run inside limestone + /// limestone 86–90 + /// clay 90–98 + /// topsoil 98–100 + /// air 100+ ← everything above the surface is simply the top air run + /// + /// + /// ═══ TWO GENERALIZATIONS THIS SHAPE BUYS ═══ + /// + /// 1. ANY MATERIAL AT ANY DEPTH — not "one material per depth zone". A run can be any material + /// at any position, which is what lets 3D feature passes write into columns: a cave is an + /// AIR run mid-stack, an ore vein is an ore run inside rock. No cave or ore code exists this + /// phase; the SHAPE that makes them trivial does. + /// + /// 2. SURFACE MATERIAL IS CLIMATE-DRIVEN, not a fixed top layer. The topmost solid run's + /// material is f(elevation, temperature, precipitation) from the 2D maps, so snow caps and + /// bare peaks fall out for free. ⭐ Snow is a surface MATERIAL, not a biome — biomes are a + /// separate classification layer. No stratigraphy exists this phase. + /// + /// ═══ ⚠⚠ THE BEHAVIOURAL RULE — THE ONE THIS TYPE EXISTS TO ENFORCE ═══ + /// + /// NEVER BRANCH ON "AIR VS SOLID" FROM HEIGHT AS A SPECIAL CASE. + /// + /// The prototype's mistake began exactly there — panicking about "is this air or solid, based + /// on the heightmap". In the band model there is NO height-triggered branching: everything + /// above the surface is simply the top AIR run. Air is not a special height rule; it is just + /// the topmost band, a registry row like any other (). + /// + /// This type deliberately exposes NO surface-height, ground-level, or is-solid-at accessor. + /// Every one of those is the old mistake wearing a helper's clothes. If you find yourself + /// wanting one, you are about to reintroduce the bug this model was built to make impossible. + /// + /// ═══ ⚠ WHAT IS NOT HERE, AND MUST NOT BE ═══ + /// + /// • WATER IS NOT A BAND. Water is a separate OVERLAY over the columns — the levels-not-cells + /// model (sea, lakes, rivers). It is never stored as a run. Nothing about water belongs in + /// this type. → `Design - Water - Runtime Water.md`, `Design - Water - Sea Level Model.md`. + /// + /// • NO PER-RUN PROPERTIES. Runs store identity; the registry answers everything else. + /// + /// • NO ALGORITHMS. Phase 0 is the shape only. Run splitting on dig, merging, stability + /// storage, chunking for streaming, and the mesher are all later phases. The minimal API + /// below — construct, append, iterate — is the whole surface on purpose. + /// + /// ⚠ A LIVE INVARIANT constrains how the shaping/hydrology/water passes use this model (D-046): + /// classify-height and render-height must stay consistent. Its home is + /// `Design - Pipeline - Generation Order.md` § LIVE INVARIANT — read it there before writing + /// anything that reads or writes columns from both height views. + /// + public sealed class Column + { + private readonly List _runs; + + public Column(int expectedRuns = 8) + { + _runs = new List(expectedRuns); + } + + /// The runs, bottom to top. Read-only: mutation goes through . + public IReadOnlyList Runs => _runs; + + public int RunCount => _runs.Count; + + /// + /// Append a run ON TOP of the stack. Runs are built bottom-up, in order. + /// + /// No merging of like-material neighbours, no splitting, no insertion mid-stack. Those are + /// later-phase operations with their own correctness questions; this phase builds the shape. + /// + public void Append(MaterialRun run) + { + _runs.Add(run); + } + + /// Append a run by its parts. Convenience over . + public void Append(MaterialKey material, int thickness, RunOrigin origin = RunOrigin.Natural) + => Append(new MaterialRun(material, thickness, origin)); + + /// + /// Total stacked thickness of every run, including air. + /// + /// ⚠ THIS IS NOT A SURFACE HEIGHT AND MUST NOT BE USED AS ONE. It is the top of the column + /// including its air run. There is deliberately no "height of the solid part" here — see + /// the behavioural rule in this type's summary. + /// + public int TotalThickness + { + get + { + int t = 0; + for (int i = 0; i < _runs.Count; i++) t += _runs[i].Thickness; + return t; + } + } + + public override string ToString() => $"Column({_runs.Count} runs, {TotalThickness} thick)"; + } +} diff --git a/Core/Scripts/Column.cs.uid b/Core/Scripts/Column.cs.uid new file mode 100644 index 0000000..172d66d --- /dev/null +++ b/Core/Scripts/Column.cs.uid @@ -0,0 +1 @@ +uid://bui0ydptx6ndk diff --git a/Core/Scripts/ColumnGrid.cs b/Core/Scripts/ColumnGrid.cs new file mode 100644 index 0000000..d8786b7 --- /dev/null +++ b/Core/Scripts/ColumnGrid.cs @@ -0,0 +1,54 @@ +using System; + +namespace IslaApocalypse.Core +{ + /// + /// The world as a 2D GRID OF COLUMNS — one per horizontal cell. + /// → `Design - Data - Column Model.md`, D-053. + /// + /// The grid is square and its side is a GENERATION PARAMETER, never a constant. It is carried + /// as a so that every derived distance in the project is forced + /// through the one scaling rule. → `Design - Tooling - Scaling Discipline.md`. + /// + /// ⚠ SHAPE ONLY, PHASE 0. Chunking for streaming, region files, serialization and the mesher + /// interface are all later phases. A flat array is the honest Phase 0 representation: it says + /// "grid of columns" and commits to nothing about how it will be paged. + /// + /// ⚠ NOTHING ABOUT WATER LIVES HERE. Water is an overlay over the grid, not a layer in it. + /// + public sealed class ColumnGrid + { + private readonly Column[] _columns; + + /// The scale this world was generated at. Side length, scale factor, derived distances. + public GenerationScale Scale { get; } + + /// Grid side in columns. 1 column = 1 metre (D-002, 1:1 scale). + public int Side => Scale.MapSize; + + public ColumnGrid(GenerationScale scale) + { + Scale = scale; + _columns = new Column[(long)scale.MapSize * scale.MapSize <= int.MaxValue + ? scale.MapSize * scale.MapSize + : throw new ArgumentOutOfRangeException(nameof(scale), scale.MapSize, + "MapSize squared exceeds a single flat array. Chunked storage is a later phase — see the class comment.")]; + } + + /// The column at (x, y). Null until one is placed — Phase 0 allocates no columns. + public Column this[int x, int y] + { + get => _columns[Index(x, y)]; + set => _columns[Index(x, y)] = value; + } + + private int Index(int x, int y) + { + if ((uint)x >= (uint)Side) throw new ArgumentOutOfRangeException(nameof(x), x, $"x outside [0,{Side})."); + if ((uint)y >= (uint)Side) throw new ArgumentOutOfRangeException(nameof(y), y, $"y outside [0,{Side})."); + return y * Side + x; + } + + public override string ToString() => $"ColumnGrid({Side}x{Side})"; + } +} diff --git a/Core/Scripts/ColumnGrid.cs.uid b/Core/Scripts/ColumnGrid.cs.uid new file mode 100644 index 0000000..cb4953a --- /dev/null +++ b/Core/Scripts/ColumnGrid.cs.uid @@ -0,0 +1 @@ +uid://dgcj88njh3wvl diff --git a/Core/Scripts/FileSafety.cs b/Core/Scripts/FileSafety.cs new file mode 100644 index 0000000..a8cd4c5 --- /dev/null +++ b/Core/Scripts/FileSafety.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; + +namespace IslaApocalypse.Core +{ + /// + /// ⚠⚠ THE PERMANENT FILE-SAFETY RULES, ENFORCED IN CODE. + /// → `Design - Tooling - Iteration and Batching.md` § FILE-SAFETY rules — permanent. + /// + /// ═══ THE RULES ═══ + /// + /// 1. NO DELETION in the runtime root or anywhere under batches/. + /// 2. INTERMEDIATES PERSIST in a scratch/ subfolder that is NEVER cleaned. + /// 3. THE DEVELOPER'S PLACED FILES ARE NEVER TOUCHED. + /// 4. ANY DELETION IS NAMED EXPLICITLY IN THE RUN REPORT. + /// + /// ═══ WHY THIS IS CODE AND NOT A README LINE ═══ + /// + /// These 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. A rule that depends on an + /// executor remembering it will eventually meet an executor who does not — so the rule is a + /// throw, not a sentence. + /// + /// ═══ HOW TO USE IT ═══ + /// + /// There is no Delete() convenience here on purpose. Any code that removes a file calls + /// first, with a reason, and the reason goes in the run report. + /// Deleting without asking is the thing being prevented; making it one line easier to ask is + /// the whole mechanism. + /// + /// Nothing deletes anything this phase. + /// + public static class FileSafety + { + /// + /// Refuse the deletion unless it is provably outside every protected root, and record why. + /// Throws when the path is protected. + /// + /// The file or directory a caller intends to remove. + /// + /// Why. Required, non-blank — it is what the run report has to print. "cleanup" is not a + /// reason; "regenerable legacy blueprint, 22 GB, nothing reads it" is. + /// + public static void AssertDeletable(string path, string reason) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("A path is required.", nameof(path)); + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException( + "A deletion needs a stated reason — it must be named explicitly in the run report. " + + "If you cannot name one, that is the answer.", nameof(reason)); + + string full = Path.GetFullPath(path); + + foreach (string protectedRoot in ProtectedRoots()) + { + if (IsWithin(full, protectedRoot)) + throw new UnauthorizedAccessException( + $"REFUSED: '{full}' is inside a protected root ('{protectedRoot}'). " + + "No deletion is permitted in the runtime root or under batches/ — intermediates persist. " + + $"(Stated reason was: {reason})"); + } + } + + /// + /// The roots nothing may delete from. The batches root and the resolved output/user roots — + /// i.e. everything a generation run and a human's browsing of it depend on. + /// + public static string[] ProtectedRoots() => new[] + { + Path.GetFullPath(ToolingPaths.BatchesRoot), + Path.GetFullPath(ToolingPaths.OutputDir), + Path.GetFullPath(ToolingPaths.BlueprintPath), + Path.GetFullPath(ToolingPaths.UserDataDir), + }; + + private static bool IsWithin(string candidate, string root) + { + string c = candidate.TrimEnd(Path.DirectorySeparatorChar); + string r = root.TrimEnd(Path.DirectorySeparatorChar); + return string.Equals(c, r, StringComparison.Ordinal) + || c.StartsWith(r + Path.DirectorySeparatorChar, StringComparison.Ordinal); + } + } +} diff --git a/Core/Scripts/FileSafety.cs.uid b/Core/Scripts/FileSafety.cs.uid new file mode 100644 index 0000000..ef69274 --- /dev/null +++ b/Core/Scripts/FileSafety.cs.uid @@ -0,0 +1 @@ +uid://jdpjwyumfo76 diff --git a/Core/Scripts/GenerationScale.cs b/Core/Scripts/GenerationScale.cs new file mode 100644 index 0000000..b2ce7a6 --- /dev/null +++ b/Core/Scripts/GenerationScale.cs @@ -0,0 +1,119 @@ +using System; + +namespace IslaApocalypse.Core +{ + /// + /// ⭐ THE SCALING DISCIPLINE, IN CODE. → `Design - Tooling - Scaling Discipline.md`, D-002. + /// + /// ═══ THE RULE ═══ + /// + /// NOTHING IN THE GENERATOR USES A RAW PIXEL NUMBER. + /// Every distance, radius, threshold and noise frequency is a FRACTION OF MapSize, + /// or derived from ScaleFactor. + /// + /// ═══ WHY IT IS A RULE AND NOT A PREFERENCE ═══ + /// + /// These failures are SILENT. A hardcoded pixel count does not throw — the generator still + /// runs and still produces a map, and the map is just subtly wrong in ways that are hard to + /// attribute. The prototype learned this the expensive way: moving to 1:1 scale made the world + /// four times wider in pixels while the noise settings stayed put, and the terrain became + /// "TV static" — the same features packed into a quarter of the space. The same transition + /// broke a long tail of checks like "look 10 pixels away to see if there's water", which + /// quietly started measuring a quarter of the distance they used to. + /// + /// In the developer's own words: "These kind of numbers I'm getting tired of changing because + /// of map size. We need to make sure these scale!" + /// + /// ═══ ⚠ ONE BASELINE, DELIBERATELY ═══ + /// + /// The prototype ran TWO reference scales side by side: /1024 for noise and map drawing, + /// /4096 for town counts and road repulsion. Neither was wrong, but a reader — or a resize — + /// had to know which applied where, and the design doc's standing instruction is to "pick + /// deliberately and say which". This repo picks: ONE baseline, 1024, for everything. If a + /// second is ever genuinely needed it gets its own named type and its own reason, not a + /// bare divisor at a call site. + /// + /// ═══ ⚠⚠ THE TIGHTENING OVER THE REFERENCE — NORMALIZED ADDITIVE OFFSETS ═══ + /// + /// The reference decorrelated noise layers with offsets in RAW PIXELS — GetNoise2D(x + 1000, …), + /// GetNoise2D(x * 1.5f + 5000, …). Because frequency already carries a 1/MapSize normalization, + /// a pixel-space offset does NOT hold still across map sizes: 1000 px is 1.0 normalized units + /// at 4K but 0.5 at 8K, so a resize silently samples a DIFFERENT SLICE of the noise field. The + /// feature scale is preserved; the REALIZATION is not. The same seed at two map sizes gives two + /// different worlds, for no reason anyone wrote down. (Found by the chat1/00 reference + /// inventory, §4.2 — where it also corrected the vault's standing diagnosis that the detail + /// noises were unscaled. They are scaled; it is the OFFSETS that are not.) + /// + /// So in this repo: DECORRELATION OFFSETS ARE DECLARED IN MAP WIDTHS, via + /// . Never write a bare pixel constant into a noise coordinate. + /// + /// No noise is built this phase. The convention is established before the generator exists so + /// that nothing is ever added unscaled — which is the only time this rule is cheap to hold. + /// + public readonly struct GenerationScale + { + /// + /// The reference map width the scaling baseline is anchored to. The world's noise looked + /// right at 1024 columns; every larger map stretches its features back out to match. + /// + public const int BaselineMapSize = 1024; + + /// + /// The map's side in columns. 1 column = 1 metre (D-002, 1:1 scale). + /// + /// ⚠ A GENERATION PARAMETER. Never a constant, never baked in. The rewrite's default target + /// is 10k x 10k, configurable 8k–12k (D-056) — but that is a DEFAULT chosen by config, and + /// no code below this line may assume it. + /// + public readonly int MapSize; + + public GenerationScale(int mapSize) + { + if (mapSize < BaselineMapSize) + throw new ArgumentOutOfRangeException(nameof(mapSize), mapSize, + $"MapSize must be at least the {BaselineMapSize} baseline."); + MapSize = mapSize; + } + + /// + /// MapSize / 1024. Divide a baseline-tuned noise frequency by this to hold feature size + /// constant in METRES as the map grows. A mountain range is the same physical size at any + /// map profile — which is what makes the map-size setting safe to change at all. + /// + public float ScaleFactor => MapSize / (float)BaselineMapSize; + + /// + /// A distance, from a fraction of the map. Use this instead of writing a pixel count. + /// Fraction(0.75f) is "three quarters of the way across", at every map size. + /// + public float Fraction(float fractionOfMap) => fractionOfMap * MapSize; + + /// + /// The inverse: what fraction of the map a distance in columns represents. For turning a + /// measured pixel distance back into a scale-free constant before you commit it to code. + /// + public float AsFraction(float distanceInColumns) => distanceInColumns / MapSize; + + /// + /// A noise frequency, from one tuned at the 1024 baseline. + /// NoiseFrequency(0.004f) reproduces the reference's base terrain frequency at any + /// map size. → the reference's 0.004f / scaleFactor, chat1/00 §2.1. + /// + public float NoiseFrequency(float baselineFrequency) => baselineFrequency / ScaleFactor; + + /// + /// ⭐ A decorrelation offset for a noise coordinate, declared in MAP WIDTHS. + /// + /// OffsetInMapWidths(0.25f) shifts the sample by a quarter of the map at EVERY map + /// size — so two noise layers stay exactly as decorrelated at 12K as they were at 8K, and + /// the same seed produces the same world shape at any size. + /// + /// ⚠ This is the one place a decorrelation offset may come from. A bare pixel constant in a + /// noise coordinate is the bug described in this type's summary; there is no correct value + /// for one. + /// + public float OffsetInMapWidths(float mapWidths) => mapWidths * MapSize; + + public override string ToString() => $"GenerationScale(MapSize={MapSize}, ScaleFactor={ScaleFactor:F3})"; + } +} diff --git a/Core/Scripts/GenerationScale.cs.uid b/Core/Scripts/GenerationScale.cs.uid new file mode 100644 index 0000000..e679e70 --- /dev/null +++ b/Core/Scripts/GenerationScale.cs.uid @@ -0,0 +1 @@ +uid://bnpvgf20ybmhd diff --git a/Core/Scripts/MaterialKey.cs b/Core/Scripts/MaterialKey.cs new file mode 100644 index 0000000..90bcce2 --- /dev/null +++ b/Core/Scripts/MaterialKey.cs @@ -0,0 +1,47 @@ +using System; + +namespace IslaApocalypse.Core +{ + /// + /// A material's IDENTITY. → `Design - Data - Material Schema.md`, D-054. + /// + /// ⚠⚠ IDENTITY IS A REGISTRY KEY, NEVER A POSITIONAL ORDINAL. + /// + /// This is deliberately a stable string key and not an enum. The prototype's blueprint format + /// keyed on enum ordinals, which made the enum's DECLARATION ORDER a wire format: inserting a + /// value in the middle silently reinterpreted every world already generated + /// (→ `Design - Tooling - Offline Generation.md`: "anything that changes its format or its enum + /// ordinals breaks every world already generated"). The registry is also the mod surface + /// (→ D-011, D-043) — a mod adds rows, and rows cannot be given ordinals by a third party. + /// + /// So: adding a material is one new row, appended anywhere, in any order, by anyone, forever. + /// + /// No serialization is built this phase. This type exists so that when it is, the identity it + /// writes is already the key rather than a position. + /// + public readonly struct MaterialKey : IEquatable + { + /// The stable identity. Lowercase, dot-namespaced: "core.limestone", "mod.basalt". + public readonly string Value; + + public MaterialKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("A material key may not be null or blank.", nameof(value)); + Value = value; + } + + /// The unset key. Distinguishable from any real row; never registered. + public static readonly MaterialKey None = default; + + public bool IsNone => Value == null; + + public bool Equals(MaterialKey other) => string.Equals(Value, other.Value, StringComparison.Ordinal); + public override bool Equals(object obj) => obj is MaterialKey k && Equals(k); + public override int GetHashCode() => Value == null ? 0 : StringComparer.Ordinal.GetHashCode(Value); + public override string ToString() => Value ?? ""; + + public static bool operator ==(MaterialKey a, MaterialKey b) => a.Equals(b); + public static bool operator !=(MaterialKey a, MaterialKey b) => !a.Equals(b); + } +} diff --git a/Core/Scripts/MaterialKey.cs.uid b/Core/Scripts/MaterialKey.cs.uid new file mode 100644 index 0000000..1fb73d2 --- /dev/null +++ b/Core/Scripts/MaterialKey.cs.uid @@ -0,0 +1 @@ +uid://dw6ee00eqjwp1 diff --git a/Core/Scripts/MaterialRegistry.cs b/Core/Scripts/MaterialRegistry.cs new file mode 100644 index 0000000..ab6c78c --- /dev/null +++ b/Core/Scripts/MaterialRegistry.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; + +namespace IslaApocalypse.Core +{ + /// + /// The two material schemas, as APPEND-ONLY registries. → `Design - Data - Material Schema.md`, D-054. + /// + /// ⭐ NO MATERIAL LOGIC IS HARDCODED ANYWHERE — everything reads from here. + /// This is the specific discipline that makes deferring the full material list SAFE, unlike + /// biomes, which were hardcoded and coupled. Understand the shape now; fill the rows forever. + /// + /// ⚠ THE SEED ROWS BELOW ARE NOT THE MATERIAL LIST. They are the few that are certain + /// (limestone is in — karst substrate). Do NOT enumerate the full list now; design materials + /// incrementally as building proceeds. Adding one is one new row, appended, nothing else. + /// + /// ⚠ REGISTRATION ORDER IS NOT MEANINGFUL. It sets RuntimeIndex, which is never + /// serialized. Reordering these lines must not change a single persisted byte, now or ever. + /// + /// Phase 0: rows are seeded in code. Loading them from a data file is a later phase — the shape + /// here (identity + properties, no logic) is what makes that a loader change and nothing more. + /// + public static class MaterialRegistry + { + // ---- terrain -------------------------------------------------------- + private static readonly Dictionary _terrain = new(); + private static readonly List _terrainByIndex = new(); + + // ---- building ------------------------------------------------------- + private static readonly Dictionary _building = new(); + private static readonly List _buildingByIndex = new(); + + /// + /// AIR. The one key the column model leans on structurally: everything above the surface is + /// the top AIR run, and a cave is an air run mid-column. It is a material row like any + /// other — there is no "is air" branch anywhere. → `Design - Data - Column Model.md`. + /// + public static readonly MaterialKey Air = new("core.air"); + + static MaterialRegistry() + { + // ===== TERRAIN — seed rows, NOT final ===== + // key display hardness yield per-voxel support + RegisterTerrain("core.air", "Air", 0.0f, MaterialKey.None, 0, 0.0f); + RegisterTerrain("core.sand", "Sand", 0.2f, "core.sand", 1, 0.1f); + RegisterTerrain("core.dirt", "Dirt", 0.3f, "core.dirt", 1, 0.2f); + RegisterTerrain("core.clay", "Clay", 0.4f, "core.clay", 1, 0.3f); + RegisterTerrain("core.limestone", "Limestone", 0.7f, "core.limestone", 1, 0.7f); // karst substrate + RegisterTerrain("core.stone", "Stone", 0.9f, "core.stone", 1, 0.9f); + RegisterTerrain("core.bedrock", "Bedrock", 1.0f, MaterialKey.None, 0, 1.0f); // unmineable floor + + // ===== BUILDING — seed rows, NOT final ===== + // key display reach decay weight durability + RegisterBuilding("build.thatch", "Thatch/Palm", 1.5f, 0.60f, 0.2f, 0.2f); + RegisterBuilding("build.wood", "Wood Plank", 4.0f, 0.30f, 0.5f, 0.5f); + RegisterBuilding("build.stone", "Worked Stone", 6.0f, 0.20f, 1.0f, 0.8f); + RegisterBuilding("build.concrete", "Concrete", 9.0f, 0.12f, 1.1f, 0.9f); + RegisterBuilding("build.steel", "Steel", 14.0f, 0.06f, 0.9f, 1.0f); + } + + // ---- registration (append-only; no removal, by design) -------------- + + private static void RegisterTerrain(string key, string name, float hardness, MaterialKey yieldKey, int yieldPer, float support) + => RegisterTerrain(new TerrainMaterial(new MaterialKey(key), name, hardness, yieldKey, yieldPer, support)); + + private static void RegisterTerrain(string key, string name, float hardness, string yieldKey, int yieldPer, float support) + => RegisterTerrain(key, name, hardness, new MaterialKey(yieldKey), yieldPer, support); + + private static void RegisterBuilding(string key, string name, float reach, float decay, float weight, float durability) + => RegisterBuilding(new BuildingMaterial(new MaterialKey(key), name, reach, decay, weight, durability)); + + /// Append a terrain material. Public so mods and data loaders add rows the same way seeds do. + public static void RegisterTerrain(TerrainMaterial material) + { + if (material == null) throw new ArgumentNullException(nameof(material)); + if (_terrain.ContainsKey(material.Key)) + throw new InvalidOperationException($"Terrain material '{material.Key}' is already registered. The registry is append-only; keys are unique."); + material.RuntimeIndex = _terrainByIndex.Count; + _terrainByIndex.Add(material); + _terrain.Add(material.Key, material); + } + + /// Append a building material. + public static void RegisterBuilding(BuildingMaterial material) + { + if (material == null) throw new ArgumentNullException(nameof(material)); + if (_building.ContainsKey(material.Key)) + throw new InvalidOperationException($"Building material '{material.Key}' is already registered. The registry is append-only; keys are unique."); + material.RuntimeIndex = _buildingByIndex.Count; + _buildingByIndex.Add(material); + _building.Add(material.Key, material); + } + + // ---- lookup --------------------------------------------------------- + + public static TerrainMaterial Terrain(MaterialKey key) + => _terrain.TryGetValue(key, out var m) + ? m + : throw new KeyNotFoundException($"No terrain material registered for key '{key}'."); + + public static BuildingMaterial Building(MaterialKey key) + => _building.TryGetValue(key, out var m) + ? m + : throw new KeyNotFoundException($"No building material registered for key '{key}'."); + + public static bool TryTerrain(MaterialKey key, out TerrainMaterial material) => _terrain.TryGetValue(key, out material); + public static bool TryBuilding(MaterialKey key, out BuildingMaterial material) => _building.TryGetValue(key, out material); + + public static bool IsRegisteredTerrain(MaterialKey key) => _terrain.ContainsKey(key); + + public static IReadOnlyList AllTerrain => _terrainByIndex; + public static IReadOnlyList AllBuilding => _buildingByIndex; + + public static int TerrainCount => _terrainByIndex.Count; + public static int BuildingCount => _buildingByIndex.Count; + } +} diff --git a/Core/Scripts/MaterialRegistry.cs.uid b/Core/Scripts/MaterialRegistry.cs.uid new file mode 100644 index 0000000..fdad6ab --- /dev/null +++ b/Core/Scripts/MaterialRegistry.cs.uid @@ -0,0 +1 @@ +uid://dtkpl88w2n3q5 diff --git a/Core/Scripts/RecipeRegistry.cs b/Core/Scripts/RecipeRegistry.cs new file mode 100644 index 0000000..b47a400 --- /dev/null +++ b/Core/Scripts/RecipeRegistry.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; + +namespace IslaApocalypse.Core +{ + /// + /// ⭐ THE TRANSFORMATION / RECIPE SEAM — the bridge between the two material schemas. + /// → `Design - Data - Material Schema.md` §3, D-054. + /// + /// Terrain materials are RESOURCES that refine and combine into BUILDING materials — + /// e.g. sand + clay + rock → concrete. That relationship is the survival loop, and keeping the + /// two schemas distinct with a transformation between them is what stops it collapsing into + /// "the same material tagged differently". + /// + /// ⚠⚠ THIS REGISTRY IS DELIBERATELY EMPTY, AND THAT IS THE POINT. + /// + /// The recipes themselves are DEFERRED. Designing the resource tree now would be a + /// biomes-premature-style over-commitment — the exact mistake the rewrite exists to undo. + /// But the SEAM EXISTS NOW: the architecture has the hole, so filling it later is new ROWS and + /// never a rewrite. + /// + /// Do not add recipes here without a design decision that says the tree is ready. + /// + public static class RecipeRegistry + { + private static readonly List _recipes = new(); + + // ===== NO SEED ROWS. See the class comment — the emptiness is the design. ===== + + /// Append a recipe. Append-only, same as the material registries. + public static void Register(Recipe recipe) + { + if (recipe == null) throw new ArgumentNullException(nameof(recipe)); + _recipes.Add(recipe); + } + + public static IReadOnlyList All => _recipes; + public static int Count => _recipes.Count; + } + + /// + /// One transformation: some quantity of terrain-derived inputs produces a building material. + /// + /// ⚠ SHAPE ONLY. The exact recipe FORMAT is an open note in the design doc — inputs may grow + /// tools, stations, time or byproducts. This is the minimum that makes the seam real enough to + /// compile against, not a committed format. + /// + public sealed class Recipe + { + /// Input material keys and their amounts. Terrain-derived resources. + public IReadOnlyList Inputs { get; } + + /// The building material produced. + public MaterialKey Output { get; } + + /// How much of one application yields. + public int OutputAmount { get; } + + public Recipe(IReadOnlyList inputs, MaterialKey output, int outputAmount) + { + Inputs = inputs ?? throw new ArgumentNullException(nameof(inputs)); + Output = output; + OutputAmount = outputAmount; + } + } + + /// One input line of a . + public readonly struct RecipeInput + { + public readonly MaterialKey Material; + public readonly int Amount; + + public RecipeInput(MaterialKey material, int amount) + { + Material = material; + Amount = amount; + } + } +} diff --git a/Core/Scripts/RecipeRegistry.cs.uid b/Core/Scripts/RecipeRegistry.cs.uid new file mode 100644 index 0000000..b7fcfec --- /dev/null +++ b/Core/Scripts/RecipeRegistry.cs.uid @@ -0,0 +1 @@ +uid://ghic8rgrpnre diff --git a/Core/Scripts/TerrainMaterial.cs b/Core/Scripts/TerrainMaterial.cs new file mode 100644 index 0000000..4ccd045 --- /dev/null +++ b/Core/Scripts/TerrainMaterial.cs @@ -0,0 +1,76 @@ +namespace IslaApocalypse.Core +{ + /// + /// A row in the TERRAIN material schema — what the natural world is made of. + /// → `Design - Data - Material Schema.md` §1, D-054. + /// + /// PROPERTIES-FROM-TYPE: a column run stores only a . Every property + /// lives here and is reached by registry lookup. Nothing is ever stored per-run. + /// → `Design - Data - Column Model.md`. + /// + /// ⚠ THERE IS NO MESH-STYLE FIELD ON THIS ROW, AND THERE MUST NEVER BE ONE. + /// Mesh style is per-ORIGIN, not per-material: natural terrain meshes smooth, player-placed + /// blocks mesh as cubes, and the same material can be both. The origin flag lives on the run + /// (), which is where the mesher reads it. + /// → D-054 open notes ("material does not override mesh style"), `Design - Rendering - Mesher.md`. + /// + /// APPEND-ONLY. Adding a material is one new row in — never a + /// code change anywhere else. No material logic is hardcoded anywhere. + /// + public sealed class TerrainMaterial + { + /// Identity. A registry key, not an ordinal. → . + public MaterialKey Key { get; } + + /// Human-facing name. Presentation only — never an identity, never a lookup key. + public string DisplayName { get; } + + /// Mining difficulty. Units deliberately unfixed this phase; relative for now. + public float Hardness { get; } + + /// + /// What a mined voxel gives. SEAM: the resource/item schema is deferred, so this currently + /// names a material key. When resources become their own schema this becomes a resource key + /// — one field's type, not a redesign. + /// + public MaterialKey YieldKey { get; } + + /// How much one mined voxel gives. + public int YieldPerVoxel { get; } + + /// + /// How much structural support this material carries as NATURAL terrain. + /// Consumed by `Design - Systems - Structural Integrity.md` (stability scalar, D-055). + /// Natural terrain is a LAZY anchor there — this is the value it anchors with. + /// + public float NaturalSupportStrength { get; } + + /// + /// Runtime-only dense index, assigned by the registry in registration order. + /// + /// ⚠⚠ NEVER SERIALIZE THIS. NEVER SEND IT OVER A WIRE. NEVER STORE IT IN A BLUEPRINT. + /// It exists solely so hot loops can index dense arrays instead of hashing strings. It + /// changes whenever registration order changes — which a mod adding a row will do. The + /// identity that persists is , always. + /// + public int RuntimeIndex { get; internal set; } = -1; + + public TerrainMaterial( + MaterialKey key, + string displayName, + float hardness, + MaterialKey yieldKey, + int yieldPerVoxel, + float naturalSupportStrength) + { + Key = key; + DisplayName = displayName; + Hardness = hardness; + YieldKey = yieldKey; + YieldPerVoxel = yieldPerVoxel; + NaturalSupportStrength = naturalSupportStrength; + } + + public override string ToString() => $"TerrainMaterial({Key})"; + } +} diff --git a/Core/Scripts/TerrainMaterial.cs.uid b/Core/Scripts/TerrainMaterial.cs.uid new file mode 100644 index 0000000..74c3f4a --- /dev/null +++ b/Core/Scripts/TerrainMaterial.cs.uid @@ -0,0 +1 @@ +uid://b13bbprjpu3jl diff --git a/Core/Scripts/ToolingPaths.cs b/Core/Scripts/ToolingPaths.cs new file mode 100644 index 0000000..1247543 --- /dev/null +++ b/Core/Scripts/ToolingPaths.cs @@ -0,0 +1,114 @@ +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 + /// + /// ⚠ 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 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. + /// + /// ⚠ 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 directory: batches/NN_<name>/<seed>_<variant>/. + /// + public static string BatchDir(int batchNumber, string batchName, long seed, string variant) + => Path.Combine(BatchesRoot, $"{batchNumber:D2}_{batchName}", $"{seed}_{variant}"); + + 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}"; + + private static string Marker(string variable) => Override(variable) != null ? $" [{variable}]" : ""; + } +} diff --git a/Core/Scripts/ToolingPaths.cs.uid b/Core/Scripts/ToolingPaths.cs.uid new file mode 100644 index 0000000..2c768e1 --- /dev/null +++ b/Core/Scripts/ToolingPaths.cs.uid @@ -0,0 +1 @@ +uid://dbgrxcbahn163 diff --git a/README.md b/README.md new file mode 100644 index 0000000..584fc6c --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Isla Apocalypse — v2 + +The rewrite (**D-049**). A post-apocalyptic survival voxel game: one island, generated once offline, +loaded by everyone. + +**Status: Phase 0 — foundation.** This repo is a *skeleton*. It builds, it resolves its own runtime +directory, and it carries the two data contracts and the tooling rails. **It generates nothing.** + +--- + +## ⚠ Two repos. Never one. + +| Repo | Role | +|---|---| +| **`~/celerNexus/islaApocalypse-v2/`** *(this)* | The rewrite. **Every line of new code is written here.** | +| **`~/celerNexus/islaApocalypse/`** | ⛔ **READ-ONLY.** The pre-rewrite prototype, archived at `ab78883`, tag `pre-rewrite-reference`. **Ported FROM; never written to.** | +| **`~/celerNexus/islaApocalypse-vault-v0.1/`** | The design vault — **the source of truth for WHAT and WHY.** The vault leads. | + +**The old codebase is a spent quarry** (D-050): mined, never tracked, never synced, never canon'd. +Writing rewrite code into it would corrupt the very thing being ported from. + +## ⚠ Runtime data isolation — do not weaken + +This project pins its `user://` directory **away from the old prototype's**, which holds the +preserved reference seeds, blueprints and batches: + +``` +user:// → ~/.local/share/islaApocalypse-v2/ ← this project + ~/.local/share/godot/app_userdata/islaApocalypse/ ← ⛔ the OLD prototype. Never touch. +``` + +Pinned two ways in `project.godot` (distinct project name **and** `use_custom_user_dir`). After any +change to that block, re-run the probe: + +```bash +Godot_v4.7.2-stable_mono_linux.x86_64 --headless \ + --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/UserDirProbe.tscn +``` + +## Layout + +``` +Core/ math + data only, ENGINE-FREE — depends on nothing above it +Server/ authoritative logic — may use Core (empty: Phase 0) +Client/ rendering — may use Core (empty: Phase 0) +Tools/ the offline generator — ⚠ may NOT use Client (Phase 1 fills it) +``` + +Each layer has a `README.md` stating its role and its boundary. Read the one for the layer you are +about to write in — they carry the reasons, not just the rules. + +## The contracts this phase establishes + +- **★★ The column model** (D-053) — the world as a 2D grid of columns, each a stack of + `(material, thickness)` runs. **Air is the top run; there is no air-vs-solid height branch.** + → `Core/Scripts/Column.cs` +- **The material schemas** (D-054) — terrain and building, two schemas bridged by a transformation + seam that is **deliberately empty.** Identity is a registry key, never an ordinal. Mesh style is + per-origin, never a material property. → `Core/Scripts/MaterialRegistry.cs` +- **The scaling discipline** — nothing uses a raw pixel number. + → `Core/Scripts/GenerationScale.cs` +- **Tooling safety** — every path env-overridable, deletion refused by code. + → `Core/Scripts/ToolingPaths.cs`, `Core/Scripts/FileSafety.cs` + +## Build + +```bash +dotnet build +Godot_v4.7.2-stable_mono_linux.x86_64 --headless --import --path . +``` + +**Godot 4.7.2** (mono) · **.NET 8** (`net8.0`) · `Godot.NET.Sdk/4.7.2` + +> The reference repo is Godot 4.7.1. This repo deliberately targets **4.7.2** — clean rewrite, +> current tooling. 4.7.1 is the *port source*, not a constraint on new code. + +## What this phase is NOT + +No generation, no mesher, no stratigraphy, no biomes, no water, no algorithms of any kind. The +pipeline is **2D-maps-first** (D-056): stages 1–5 are flat maps, gotten completely right before a +single 3D vertex exists. Building the mesher before the data is right is the specific trap this +rewrite exists to undo. diff --git a/Server/README.md b/Server/README.md new file mode 100644 index 0000000..4a11092 --- /dev/null +++ b/Server/README.md @@ -0,0 +1,20 @@ +# Server — authoritative logic + +**Holds:** the authoritative simulation. What the world *does*, decided in one place. + +**Boundary:** may use `Core/`. Must not depend on `Client/`. + +## Empty this phase — deliberately + +Phase 0 stands up the skeleton; nothing simulates yet. The folder exists so the first authoritative +system has an obvious home and does not get written into `Core/` or a scene. + +## When it fills + +The world is generated **once, offline**, and the runtime **loads** it — the server never +regenerates terrain on start (→ D-028, `Design - Tooling - Offline Generation.md`). Every +participant reads the *same* immutable blueprint rather than re-deriving it, because two machines +that independently compute terrain will eventually disagree about where a mountain is, and in a +voxel game that disagreement is a player falling through the world. + +So: **Server loads blueprints. Server does not generate.** Generation is `Tools/`. diff --git a/Server/Scripts/.gitkeep b/Server/Scripts/.gitkeep new file mode 100644 index 0000000..0d207bc --- /dev/null +++ b/Server/Scripts/.gitkeep @@ -0,0 +1 @@ +# Placeholder so the empty layer folder is tracked. Delete when the first script lands. diff --git a/Tools/README.md b/Tools/README.md new file mode 100644 index 0000000..aa47116 --- /dev/null +++ b/Tools/README.md @@ -0,0 +1,124 @@ +# Tools — the offline generator + +**Holds:** the world generator and its diagnostics. Everything that produces a blueprint, and +nothing that consumes one at play time. + +**Boundary — the tools wall:** + +> ### ⚠⚠ TOOLS MAY NOT REFERENCE `Client/`. THE DEPENDENCY RUNS ONE WAY, OR THE WALL IS NOT A WALL. +> +> No UI, no player controllers, no shaders, no rendering code. `Tools/` may use `Core/`. +> +> **Two reasons, both from `Design - Tooling - Offline Generation.md`:** a lighter shipped client, +> and — less obviously — **not handing players the world-generation logic.** Shipping the generator +> lets it be reverse-engineered into an unfair map preview, with the whole island's layout, every +> settlement and every route known before anyone sets foot on the beach. +> +> **Tools emit data; they never write to a live save.** A generator that could touch live server +> state is a corruption risk for no benefit. + +## Empty this phase — Phase 1 fills it + +The only thing here now is `Scenes/UserDirProbe.tscn` + `Scripts/UserDirProbe.cs`, a print-and-quit +diagnostic that confirms this project's `user://` directory is its own and not the old prototype's. +It generates nothing. + +--- + +## ⭐ The conventions Phase 1's generator inherits + +These were **earned, not assumed** — each one came out of the prototype's terrain arc. +→ `Design - Tooling - Iteration and Batching.md`. + +### 1. `xvfb-run` for unattended generation — NOT `--headless` + +> ### ⚠ `--headless` HANGS on a real generation run. +> +> The snapshot capture **awaits render frames, and there is no frame loop without a display.** +> **Measured, not assumed.** A run left on `--headless` does not fail loudly; it sits there. + +```bash +xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 --path ~/celerNexus/islaApocalypse-v2 +``` + +A windowed run on the desktop also invites being closed mid-run by whoever is using the machine. + +**The one exception is a job that awaits no frames** — like `UserDirProbe`, which is why that one +documents `--headless` explicitly: + +```bash +Godot_v4.7.2-stable_mono_linux.x86_64 --headless \ + --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/UserDirProbe.tscn +``` + +**If a job awaits a frame, it needs `xvfb-run`. When unsure, use `xvfb-run`** — it is correct in +both cases and costs nothing. + +### 2. Environment overrides — safe BY CODE, not by care + +Every path a tool reads or writes resolves through `Core/Scripts/ToolingPaths.cs`, and each has an +environment override, so a batch **cannot** read over or write to the developer's live files: + +| Variable | Overrides | Default | +|---|---|---| +| `ISLA_CONFIG_PATH` | the generation config file | `user://config.json` | +| `ISLA_BLUEPRINT_PATH` | blueprints read/written | `user://blueprints` | +| `ISLA_OUTPUT_DIR` | generation output (batches live under it) | `user://output` | + +> ⭐ **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. + +There is no second way to obtain these paths. Do not add one. + +### 3. ⚠ File safety — permanent rules + +Enforced by `Core/Scripts/FileSafety.cs`, which throws rather than advises. + +1. **No deletion in the runtime root or anywhere under `batches/`.** +2. **Intermediates persist** in a `scratch/` subfolder that is **never cleaned.** +3. **The developer's placed files are never touched.** +4. **Any deletion is named explicitly in the run report.** + +> ⚠ **These came 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. + +### 4. Batch layout + +``` +batches/NN_/_/ +batches/NN_/INDEX.md +batches/NN_/scratch/ ← persistent; never cleaned +``` + +**A/B comparisons are browsed by a human, and a flat directory of same-named PNGs is not +browsable.** The `INDEX.md` is what makes a batch readable a week later. + +⚠ **Batches are OUTPUT.** They live under `ISLA_OUTPUT_DIR` (i.e. under `user://`), **not in this +repo.** The `batches/` folder here carries the convention, not the data. No batches are run this +phase. + +### 5. Iteration levers, for when the generator exists + +- **Skip the road pass for all terrain and water iteration.** In the prototype this cut a run from + ~26 min to ~80 s — a **19× cut**, and the difference between iterating on terrain and not + iterating on terrain. Blueprints from such a run carry present-but-empty road sections, which is + **legitimate, not corrupt.** +- **⭐ Build the oracle before the taste-iteration, not after.** Because the classify path was + pinned to *uncurved* height, every shaping iteration had a hard automatic correctness check + (biome and water maps md5-identical). Five rounds of taste-iteration were safe **because + correctness was not being judged by eye.** *Where a future phase has a subjective gate, ask first + what the automatic invariant is.* +- **Config-gate every shaping change**, with the legacy behaviour surviving on the other side. It + keeps changes comparable as an A/B pair, and it means a rejected change costs a flipped default + rather than a reverted commit. +- **The lever is variants per batch, not speed per pass.** Two Phase B tasks ran ~2 hours each, + which looked like the pipeline getting slower. It was not — a single pass stayed at ~2 minutes. + The cost was A/B/ablation multiplication. **When a batch is genuinely wide, say so up front.** + +### 6. Scaling discipline + +**Nothing uses a raw pixel number.** Every distance, radius, threshold and noise frequency comes +from `Core/Scripts/GenerationScale.cs`. Read its header before adding any constant — including the +tightening on **normalized additive noise offsets**, which is a forward-guard for the Phase 1 noise +port. → `Design - Tooling - Scaling Discipline.md`. diff --git a/Tools/Scenes/UserDirProbe.tscn b/Tools/Scenes/UserDirProbe.tscn new file mode 100644 index 0000000..6a77d3d --- /dev/null +++ b/Tools/Scenes/UserDirProbe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bqp1x2v3isla0"] + +[ext_resource type="Script" path="res://Tools/Scripts/UserDirProbe.cs" id="1_probe"] + +[node name="UserDirProbe" type="Node"] +script = ExtResource("1_probe") diff --git a/Tools/Scripts/UserDirProbe.cs b/Tools/Scripts/UserDirProbe.cs new file mode 100644 index 0000000..9a81162 --- /dev/null +++ b/Tools/Scripts/UserDirProbe.cs @@ -0,0 +1,68 @@ +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// A print-and-quit diagnostic: resolves and reports this project's user:// directory, then + /// exits. It generates nothing and writes nothing. + /// + /// ═══ WHY IT EXISTS ═══ + /// + /// The old prototype is named "islaApocalypse" and owns + /// ~/.local/share/godot/app_userdata/islaApocalypse/, which holds the PRESERVED reference + /// seeds, blueprints and batches. A v2 project sharing that name would read and clobber them + /// at runtime. project.godot pins the isolation two ways (distinct name + custom user dir); + /// this probe CONFIRMS the pin actually resolves, rather than trusting that it does. + /// + /// Run it after any change to project.godot's [application] block: + /// + /// Godot_v4.7.2-stable_mono_linux.x86_64 --headless \ + /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/UserDirProbe.tscn + /// + /// --headless is fine HERE — it hangs only on the snapshot capture's render-frame awaits, and + /// this awaits nothing. → Tools/README.md, `Design - Tooling - Iteration and Batching.md`. + /// + public partial class UserDirProbe : Node + { + public override void _Ready() + { + string userDir = OS.GetUserDataDir(); + + // Hand Core the engine-resolved directory — Core is engine-free and never asks Godot itself. + ToolingPaths.Configure(userDir); + + GD.Print("=================================================================="); + GD.Print(" user:// PROBE — islaApocalypse-v2"); + GD.Print("=================================================================="); + GD.Print($"project name : {ProjectSettings.GetSetting("application/config/name")}"); + GD.Print($"custom user dir : {ProjectSettings.GetSetting("application/config/use_custom_user_dir")} " + + $"(\"{ProjectSettings.GetSetting("application/config/custom_user_dir_name")}\")"); + GD.Print($"user:// resolves : {userDir}"); + GD.Print($"globalized : {ProjectSettings.GlobalizePath("user://")}"); + GD.Print("------------------------------------------------------------------"); + GD.Print(" ISOLATION CHECK"); + GD.Print("------------------------------------------------------------------"); + + // The one path this project must never resolve to. + const string forbidden = "app_userdata/islaApocalypse"; + bool clash = userDir.Replace('\\', '/').TrimEnd('/').EndsWith(forbidden, System.StringComparison.Ordinal); + GD.Print(clash + ? " ✗ FAIL — resolves to the OLD prototype's dir. Do not run anything else." + : " ✓ PASS — not the old prototype's app_userdata/islaApocalypse/."); + + GD.Print("------------------------------------------------------------------"); + GD.Print(" RESOLVED TOOLING PATHS (env-overridable)"); + GD.Print("------------------------------------------------------------------"); + GD.Print(ToolingPaths.Describe()); + GD.Print("------------------------------------------------------------------"); + GD.Print($" Core self-check: {MaterialRegistry.TerrainCount} terrain materials, " + + $"{MaterialRegistry.BuildingCount} building materials, " + + $"{RecipeRegistry.Count} recipes (0 is correct — the seam is deliberately empty)."); + GD.Print($" Scale self-check: {new GenerationScale(10240)}"); + GD.Print("=================================================================="); + + GetTree().Quit(clash ? 1 : 0); + } + } +} diff --git a/Tools/Scripts/UserDirProbe.cs.uid b/Tools/Scripts/UserDirProbe.cs.uid new file mode 100644 index 0000000..a323001 --- /dev/null +++ b/Tools/Scripts/UserDirProbe.cs.uid @@ -0,0 +1 @@ +uid://bhqfoi5jqjg2b diff --git a/Tools/batches/README.md b/Tools/batches/README.md new file mode 100644 index 0000000..23e2826 --- /dev/null +++ b/Tools/batches/README.md @@ -0,0 +1,26 @@ +# batches/ — convention only; the data lives under `user://` + +This folder documents the batch layout. **It does not hold batches.** + +Real batch output is written under `ISLA_OUTPUT_DIR` (default `user://output/batches`), resolved by +`Core/Scripts/ToolingPaths.cs`. Generated worlds are large, regenerable, and do not belong in git. + +## Layout + +``` +batches/NN_/ NN = the task number that ran it +├── INDEX.md what varied, what to look at, what was concluded +├── scratch/ intermediates — PERSISTENT, never cleaned +└── _/ one directory per generated world +``` + +`INDEX.md` is not optional. A/B comparisons are browsed by a human, and a flat directory of +same-named PNGs is not browsable. + +## ⚠ No deletions here. Ever. + +`FileSafety.AssertDeletable` refuses any path under the batches root, and it refuses without a +stated reason regardless. **Intermediates persist.** Any deletion that does happen elsewhere is +named explicitly in the run report. + +→ `Tools/README.md`, `Design - Tooling - Iteration and Batching.md` diff --git a/islaApocalypse-v2.csproj b/islaApocalypse-v2.csproj new file mode 100644 index 0000000..2f1f4e3 --- /dev/null +++ b/islaApocalypse-v2.csproj @@ -0,0 +1,21 @@ + + + net8.0 + net9.0 + true + + IslaApocalypse + disable + latest + + + false + + + Major + + diff --git a/islaApocalypse-v2.sln b/islaApocalypse-v2.sln new file mode 100644 index 0000000..1191f7f --- /dev/null +++ b/islaApocalypse-v2.sln @@ -0,0 +1,18 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "islaApocalypse-v2", "islaApocalypse-v2.csproj", "{6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + ExportDebug|Any CPU = ExportDebug|Any CPU + ExportRelease|Any CPU = ExportRelease|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU + {6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU + {6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU + {6E4C1B2A-9A3D-4C7E-8B21-0D5F4A1C7E90}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU + EndGlobalSection +EndGlobal diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..bb5da26 --- /dev/null +++ b/project.godot @@ -0,0 +1,30 @@ +; Engine configuration file. +; Isla Apocalypse — v2 rewrite (D-049). Phase 0 skeleton. +; +; ⚠ user:// ISOLATION — DO NOT WEAKEN. The old prototype is named "islaApocalypse" and owns +; ~/.local/share/godot/app_userdata/islaApocalypse/, which holds the PRESERVED reference seeds, +; blueprints and batches. This project must never resolve there. +; 1. application/config/name is distinct ("islaApocalypse-v2"), so name-derivation alone +; would already give a separate dir. +; 2. use_custom_user_dir + custom_user_dir_name PIN it explicitly, so the isolation survives +; any future display-name change rather than depending on derivation. +; Changing either line moves the runtime data directory. Confirm the resolved path before you do. + +config_version=5 + +[application] + +config/name="islaApocalypse-v2" +config/description="Isla Apocalypse — the v2 rewrite (D-049). Phase 0: clean skeleton." +config/version="0.1.0" +config/use_custom_user_dir=true +config/custom_user_dir_name="islaApocalypse-v2" +config/features=PackedStringArray("4.7", "C#", "Forward Plus") + +[dotnet] + +project/assembly_name="islaApocalypse-v2" + +[rendering] + +renderer/rendering_method="forward_plus"