diff --git a/Assets/_Project/Editor/BootstrapSceneGenerator.cs b/Assets/_Project/Editor/BootstrapSceneGenerator.cs
index 2a6a961..dfd9e40 100644
--- a/Assets/_Project/Editor/BootstrapSceneGenerator.cs
+++ b/Assets/_Project/Editor/BootstrapSceneGenerator.cs
@@ -286,22 +286,23 @@ private static void EnsureGlutrinneMapAsset()
AssetDatabase.CreateAsset(map, mapPath);
}
+ // Both lists are READ from MatchBootstrap, never restated here.
+ // They used to be literals, and they went stale: this baked the
+ // five-field layout with a comment claiming it was what
+ // MatchBootstrap registers, straight through the map changes of
+ // sprint 21 that took it to fifteen. Nobody caught it because
+ // MapDefinitionSO has no runtime consumer — which makes a wrong
+ // value cheap today and expensive on the day someone reads it.
+ Vector2Int[] hqCentres = MatchBootstrap.CanonicalHqCentreCells;
+ Vector2Int[] fieldCells = MatchBootstrap.CanonicalFieldCells;
+
map.Initialize(
"Glutrinne",
MapBiomeType.Desert,
128,
128,
- // D-107 HQ footprint centres: point mirror p -> 124-p.
- new[] { new Vector2(5f, 5f), new Vector2(119f, 119f) },
- // The five fields MatchBootstrap registers, in canonical id order.
- new[]
- {
- new Vector2(7f, 7f),
- new Vector2(117f, 117f),
- new Vector2(24f, 40f),
- new Vector2(100f, 84f),
- new Vector2(62f, 62f),
- });
+ System.Array.ConvertAll(hqCentres, c => new Vector2(c.x, c.y)),
+ System.Array.ConvertAll(fieldCells, c => new Vector2(c.x, c.y)));
EditorUtility.SetDirty(map);
}
diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs
index acd984b..e687ec2 100644
--- a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs
+++ b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs
@@ -308,7 +308,21 @@ public bool TryGetFieldInitialReserve(ushort fieldId, out long reserveAE)
/// flank 0/1, contested far flank 0/1. Presentation iterates this
/// list so marker and scatter geometry cannot silently omit a field.
///
- public Vector2Int[] AllFieldCells
+ public Vector2Int[] AllFieldCells => CanonicalFieldCells;
+
+ ///
+ /// The same list as , readable WITHOUT a
+ /// live component. Editor tooling that bakes the canonical map reads
+ /// the layout from here instead of restating it: the field table
+ /// already stands in five places by necessity (Unity host, headless
+ /// scenario, three test mirrors — risk R-1 of sprint 21), and a sixth
+ /// copy in BootstrapSceneGenerator drifted silently through two map
+ /// changes before anyone noticed. Its comment still claimed "the five
+ /// fields MatchBootstrap registers" when there were fifteen. A copy
+ /// nobody reads is not harmless; it is a wrong answer waiting for its
+ /// first reader.
+ ///
+ public static Vector2Int[] CanonicalFieldCells
{
get
{
@@ -321,6 +335,20 @@ public Vector2Int[] AllFieldCells
}
}
+ ///
+ /// The two HQ footprint CENTRES in slot order (local, enemy), derived
+ /// from the same slot layouts the match spawns from — so the D-107
+ /// point mirror p -> 124 - p holds by construction rather than by
+ /// a second hand-kept pair of literals. Same purpose as
+ /// : editor tooling reads, it does
+ /// not restate.
+ ///
+ public static Vector2Int[] CanonicalHqCentreCells => new[]
+ {
+ new Vector2Int(LocalLayout.HqOriginX + 1, LocalLayout.HqOriginY + 1),
+ new Vector2Int(EnemyLayout.HqOriginX + 1, EnemyLayout.HqOriginY + 1),
+ };
+
/// Lower-left footprint origin of the human HQ (4, 4).
public Vector2Int LocalHqOrigin => new Vector2Int(LocalPlayerLayout.HqOriginX, LocalPlayerLayout.HqOriginY);
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 881afd5..2acd6a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -355,6 +355,34 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt
### Behoben
+- **Das Architektur-Gate war auf unberührtem `main` rot, und niemand sah es (Paket 22.4).**
+ `run_gate_check.py` führte `Nova.AI.Data` auf Rang 2 neben `Nova.AI` — nach
+ dem Namen einsortiert, nicht nach der Abhängigkeitsrichtung. Die reale Kante
+ `Nova.AI → Nova.AI.Data` ist damit eine Kante innerhalb derselben Schicht, und
+ die verbietet D-061: das Kriterium **G0-B.3 meldete auf jedem Baum eine
+ Verletzung**. Aufgefallen ist es nur, weil das Gate ohnehin nicht läuft.
+ `Nova.AI.Data` referenziert nichts außer `Nova.Core` und wird sonst nur aus
+ Rang 4 gelesen — Rang 1 ist der einzige Rang, der den bestehenden Graphen
+ gültig macht, ohne eine Zeile Code zu bewegen. G0-B.3 ist damit erstmals grün
+- **Die Schichtenkarte des Gates führte zwei Assemblies, die es nicht gibt (Paket 22.4).**
+ `Nova.Presentation.Maps` und `Nova.Presentation.Shaders` haben seit der
+ Zusammenlegung der Präsentationsschicht keine `.asmdef` mehr. Tote Ränge sind
+ nicht harmlos: sie genehmigen einem künftigen Träger dieses Namens still eine
+ Schicht, statt die Entscheidung zu erzwingen. Beide Einträge sind raus, und
+ ein neuer Wächter macht die Klasse dicht — **ein Eintrag in der Karte ohne
+ zugehörige `.asmdef` lässt das Gate jetzt rot werden.** Die Gegenrichtung
+ (eine `.asmdef` ohne Rang) war bereits abgedeckt; niemand hatte je andersherum
+ geschaut
+- **Die sechste Kopie der kanonischen Feldlage ist beseitigt (Paket 22.3).**
+ `BootstrapSceneGenerator` backte eine eigene Fünferliste in `MapDefinitionSO`,
+ mit dem Kommentar „the five fields MatchBootstrap registers" — der seit Paket
+ 21.7 falsch war, weil dort fünfzehn stehen. Statt die Literale nachzuziehen
+ (und damit die Kopie zu behalten) liest der Generator die Lage jetzt über die
+ neuen statischen Leseflächen `MatchBootstrap.CanonicalFieldCells` und
+ `CanonicalHqCentreCells`. Dasselbe gilt für die HQ-Mitten, die dort ebenfalls
+ ein zweites Mal literal standen. Risiko R-1 aus Sprint 21 zählt damit wieder
+ fünf statt sechs Stellen — und die verbleibenden fünf sind alle durch Tests
+ gepinnt
- **Der Gate-Vertrag zeigte auf ein Repository, das es nicht mehr gibt (#14, Stufe 1).**
Das GitHub-Repo heißt seit dem 09.08.2026 `VibecodingGermany/HashKrieg`;
`GateEvidence.schema.json`, `GateAuthorization.schema.json` und
diff --git a/quality/scripts/run_gate_check.py b/quality/scripts/run_gate_check.py
index d8fe3fa..ed0c7b3 100644
--- a/quality/scripts/run_gate_check.py
+++ b/quality/scripts/run_gate_check.py
@@ -74,17 +74,34 @@
COMMIT_SHA_RE = re.compile(r"^[0-9a-f]{40,64}$")
# Architecture layers (D-061): references may only point strictly downwards.
# Nova.Editor is the host layer; test assemblies are handled separately.
+#
+# This map is the AUTHORITY on which layer an assembly sits in, so it must
+# describe the assemblies that actually exist and no others. Both directions
+# are guarded: an asmdef missing from the map trips "unknown assembly in
+# reference graph" below, and a map entry without an asmdef trips the
+# stale-entry check in analyze_asmdef_tree. Until 2026-08-29 the map carried
+# Nova.Presentation.Maps and Nova.Presentation.Shaders, which have had no
+# asmdef since the presentation layer was consolidated — dead ranks nobody
+# noticed, because nothing looked the other way round. A rank that describes
+# nothing is not harmless: it silently pre-approves a layer for whoever
+# reintroduces that name later, instead of making them decide.
ASSEMBLY_RANKS = {
"Nova.Core": 0,
"Nova.Simulation": 1,
+ # Nova.AI.Data sat at rank 2 next to Nova.AI — filed by name, not by
+ # dependency direction. It is the profile data Nova.AI READS, references
+ # nothing but Nova.Core, and is otherwise consumed only from rank 4
+ # (Nova.Presentation.UI). At rank 2 the real edge Nova.AI -> Nova.AI.Data
+ # is a same-layer edge, which D-061 forbids: check_architecture (G0-B.3)
+ # has therefore been reporting a violation on an untouched tree. Rank 1 is
+ # the only rank that makes the existing graph valid without moving a line
+ # of code — every edge into and out of it stays strictly downward.
+ "Nova.AI.Data": 1,
"Nova.AI": 2,
- "Nova.AI.Data": 2,
"Nova.Networking": 2,
"Nova.Data": 2,
"Nova.Gameplay": 3,
"Nova.Presentation": 4,
- "Nova.Presentation.Maps": 4,
- "Nova.Presentation.Shaders": 4,
"Nova.Presentation.UI": 4,
"Nova.Editor": 5,
}
@@ -157,6 +174,25 @@ def analyze_asmdef_tree(base: Path) -> list[str]:
continue
assemblies[name] = document
+ # The map must not describe assemblies that do not exist. The opposite
+ # direction (an asmdef with no rank) is caught per reference below; this
+ # is the direction nothing looked at, and it is how two dead ranks
+ # survived the consolidation of the presentation layer unnoticed. A rank
+ # without an asmdef pre-approves a layer for whoever reintroduces that
+ # name, instead of forcing the decision into the open.
+ #
+ # It fires during a rename, and that is the point: the map is part of the
+ # rename, not a thing that catches up afterwards. What it deliberately
+ # does NOT catch is a rank that is wrong rather than dead — an assembly
+ # filed one layer too low still passes here and is only caught by the
+ # edge checks it then fails to trip.
+ for mapped in sorted(ASSEMBLY_RANKS):
+ if mapped not in assemblies:
+ violations.append(
+ f"{mapped}: ranked in ASSEMBLY_RANKS but no .asmdef defines it "
+ "— drop the entry or add the assembly"
+ )
+
for name, document in sorted(assemblies.items()):
references = document.get("references")
if not isinstance(references, list):