Skip to content

feat(ai): give the skirmish AI a personality per match (D-114) - #144

Merged
cubetribe merged 1 commit into
mainfrom
feat/ai-personality
Oct 17, 2026
Merged

cubetribe merged 1 commit into
mainfrom
feat/ai-personality

Conversation

@cubetribe

Copy link
Copy Markdown
Collaborator

Was & Warum

Jede lokale Partie gegen die KI lief bisher identisch. Drei Ursachen kamen zusammen:

  • Die KI würfelt nicht. Sie ist bewusst zustandslos und ohne Zufall gebaut.
  • Der Seed war fest. Das Menü startete immer mit MatchBootstrap.CanonicalSeed.
  • Das Repertoire ist klein: eine Bauordnung, nur Basisinfanterie.

Stufe A behebt die ersten beiden Ursachen, ohne dass die KI während der Partie würfelt:

  • Frischer Seed je Partie. „Neues Spiel“ und „Neue Runde“ ziehen jedes Mal einen neuen Seed. Tests und Harnesses bleiben beim kanonischen Seed. NOVA_MATCH_SEED=<hex> pinnt einen Seed zum Nachstellen. Netzpartien sind unberührt, ihr Seed kommt vom Relay.
  • Persönlichkeit je Partie. AiPersonality.Derive(seed, slot) ist eine reine Funktion und legt fest:
    • Eröffnung: Ausgewogen, Ansturm (Kaserne vor Raffinerie, kleinere und frühere Welle), Ökonom (dritter Sammler, größere Armee) oder Igel (ein bis zwei Abwehrplattformen Richtung Front);
    • Sammelpunkt: mittig oder um 10 Felder seitlich versetzt;
    • Bauplätze: eine von 8 Suchrichtungen;
    • Profilwerte: kleine Streuung in festen Bändern.
  • Zustand und Nachvollziehbarkeit. Die Persönlichkeit wird beim Aufbau fixiert. Die KI bleibt damit zustandslos und Save/Restore-identisch, der Seed allein reproduziert den Gegner. F3-HUD und Startlog zeigen Persönlichkeit und Seed.
  • Neutral gleich bisher. Die neutrale Persönlichkeit (default) spielt exakt wie r8, der gepinnte Ausgang der kanonischen KI-Partie bleibt unverändert. AiBehaviorId steigt auf r9, der Profil-Hash bleibt gleich.

Vorbild ist C&C Generals, dessen KI unter gleich priorisierten Teams per Spielzufall aus dem Simulations-Seed wählt (offener EA-Quellcode). Übernommen sind nur Konzepte, kein Code. Die Entscheidung steht als D-114 im DecisionLog. D-110 bis D-113 sind auf feat/generals-look-and-feel belegt.

Spielabnahme: zurückgestellt — nicht gespielt

  • Grund: Der Inhaber stellt die Abnahme ausdrücklich zurück. Der Worktree hat keine lokale Art, ein Durchspielen ist bequemer nach dem Merge.
  • Automatisierte Ersatznachweise:
    • dotnet test tools/Nova.SimRunner.Tests: 778/778 grün, davon 10 neu in AiPersonalityTests:
      • Derive ist rein und deckt über 512 Seeds alle Bänder ab;
      • neutral gleich unverändert;
      • keine abgeschaltete Regel wird eingeschaltet;
      • alle 4 Eröffnungen × 3 Flanken × 2 KI-Fraktionen entscheiden die Partie gegen einen passiven Gegner;
      • gleicher Seed ergibt eine gleiche Partie, verschiedene Eröffnungen ergeben verschiedene Partien;
      • geflankte Sammelpunkte liegen im Raster und nicht im Felsring.
    • Unity EditMode: 663/663 grün. Unity PlayMode: 15/15 grün, einschließlich des Hauptmenü-Pfads. Beide lokal per Batchmode ohne -quit gefahren.
  • Restrisiko:
    • Ob sich die Unterschiede im Spiel spürbar anfühlen, ist ungeprüft. Headless gegen einen passiven Gegner entscheiden die Eröffnungen zwischen rund 2.000 und 3.000 Ticks, kanonisch sind es 2.726.
    • Mit nur Infanterie und drei Gebäuden variieren lediglich Bauordnung, Basisbild, Sammelpunkt und Angriffszeitpunkt.
    • Der kanonische Seed (Start ohne Menü) leitet jetzt einen Ansturm-Gegner ab.

Dieser PR ist damit mergebar, aber nicht spielerisch abgenommen und kein Meilenstein-Nachweis.

Betroffene Bereiche

Bereich Dateien
KI AI.Data/AiPersonality.cs (neu), AiBehaviorId.cs, AI/SkirmishAiSystem.cs, AiFactionProfile.cs
Gameplay MatchRunner.cs, MatchBootstrap.cs, Nova.Gameplay.asmdef (+Nova.AI.Data)
Präsentation MainMenuController.cs, DebugHud.cs
Tests AiPersonalityTests.cs (neu), SkirmishAiTests.cs (Host-Spiegel um Persönlichkeit und KI-Fraktion erweitert, ID-Pin r9), Nova.AI.Tests.asmdef (+Nova.AI.Data; nur der Unity-Lauf hat das gefunden)

Changelog: „Die KI spielt nicht mehr jede Partie gleich (D-114, Stufe A)“ unter [Unreleased] / Hinzugefügt.

Nächster Hebel (nicht in diesem PR): Stufe B mit Fahrzeugfabrik, Panzern, Konter-Bau und Tech. Lernen innerhalb der Partie (Stufe C) braucht KI-Gedächtnis im Spielstand und eine eigene Entscheidung.

Checkliste

  • dotnet test tools/Nova.SimRunner.Tests lokal grün (778/778)
  • Zeile unter [Unreleased] in CHANGELOG.md
  • Echte Entscheidung getroffen → D-114 im DecisionLog
  • Bei Simulationsänderung: keine Determinismus-Baseline im selben PR geändert (keine berührt; der kanonische KI-Ausgangspin bleibt unverändert)

Inhaber-PR: Selbst-Merge erst nach grüner Pflicht-CI und dokumentiertem, unabhängigem Read-only-Review (AGENTS.md §3) — das Review steht noch aus.

🤖 Generated with Claude Code

Every local match against the AI played out identically: the AI is
stateless with no PRNG by design, and the menu always started the
canonical seed. Stage A varies the opponent between matches without
drawing randomness during play.

AiPersonality.Derive(seed, slot) is a pure function (XxHash64) that picks
an opening (Balanced, Rush, Economy, Turtle), a flanked staging cell, one
of eight placement-scan orientations and banded profile deltas. It is
fixed at construction like the profile, so the AI stays stateless and
save/restore-identical, and a logged seed reproduces the opponent.

The main menu turns on a fresh seed per local match; tests and
harnesses keep the canonical seed, NOVA_MATCH_SEED pins one, network
matches keep the relay seed. The neutral personality reproduces r8 tick
for tick, so the canonical AI outcome pin is unchanged; AiBehaviorId
moves to r9 with the same profile hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cubetribe
cubetribe merged commit a6858d1 into main Oct 17, 2026
7 checks passed
@cubetribe
cubetribe deleted the feat/ai-personality branch October 17, 2026 14:08
@cubetribe

Copy link
Copy Markdown
Collaborator Author

Review PR #144 – feat(ai): KI-Persönlichkeit je Partie (D-114)

Reviewer: unabhängige Read-only-Session (Claude Opus 5.5), 2026-10-17
Geprüfter Stand: origin/feat/ai-personality @ 6e00c02 gegen main @ 85c912b
Wichtig: Der PR wurde während des Reviews um 14:08:53 UTC gemergt (Squash a6858d1 durch cubetribe).
Der Tree von a6858d1 ist identisch mit 6e00c02 (376784ac…). Alle Befunde gelten 1:1 für main.

Eigene Nachweise

  • dotnet test tools/Nova.SimRunner.Tests -c Release in einem eigenen temporären Checkout (git archive von 6e00c02 im Scratchpad): 778/778 grün.
  • Probe-Programm: AiPersonality.Derive(CanonicalSeed, 1) = Rush, Flanke 0, Layout 3, Armee 9, Squad 4, Rückzug 45. Nur rund 0,11 % aller Seeds (110 von 100.000) ergeben die neutrale Persönlichkeit.
  • Probe-Test (nur im Scratch-Checkout, Harness SkirmishAiTests.BuildMatch, Balanced, Layout 0, passiver Gegner), gezählt wurden Attack-Ziele, die vergeben werden, solange die Welle nicht bereit ist:
KI-Fraktion Flanke Sammelpunkt Chebyshev zum HQ (119,119) Attack-Ziele ohne fertige Welle entschieden
Legion 0 (107,107) 12 0 2726
Legion +10 (117,97) 22 171 2458
Legion −10 (97,117) 22 91 2735
Alliance 0 (107,107) 12 0 2675
Alliance +10 (117,97) 22 222 2322
Alliance −10 (97,117) 22 118 2680
  • Gelände an den echten Flankenzellen und je 9×9 um sie herum: 0 Felszellen (über CanonicalTerrainMirror).

Befunde

B1 · Hoch · Die Flanke schiebt den Sammelpunkt aus dem Wellenring, Wellen und Heimverteidigung laufen dann leer

  • Ort: Assets/_Project/Scripts/AI/SkirmishAiSystem.cs:960 (Flanken-Verschiebung in GetStagingCell) im Zusammenspiel mit SkirmishAiSystem.cs:1181–1188 (IsCommittedToTheWave, Ring = StagingDistanceCells + StagingToleranceCells = 16) und SkirmishAiSystem.cs:1318 (DefendHome nur für Einheiten mit !committed).
  • Mechanik: Die Verschiebung steht senkrecht zur Linie zum Gegner. Auf der diagonalen Glutrinne bewegt sie den Punkt auf beiden Achsen um 10 Felder. Der Sammelpunkt liegt dann bei Chebyshev 22 vom HQ statt 12, also außerhalb des 16er-Rings.
  • Fehlerszenario: In zwei Dritteln aller abgeleiteten Persönlichkeiten (Flanke ±10) gilt jede Einheit als „mit einer früheren Welle draußen“, sobald sie auf dem Weg zum Sammelpunkt die 16 überschreitet. IsFitToMarch wird wahr, die Einheit bekommt Attack und zieht einzeln los. Die Wellenregel (r3) und das Stärkegate (r6) sind damit ausgehebelt; die Probe misst 91 bis 222 Angriffsbefehle ohne fertige Welle, bei Flanke 0 sind es null. Dazu kommt: DefendHome (r8) ruft die am Sammelpunkt wartenden Einheiten nicht mehr zurück, denn sie zählen als „committed“. Und ein Rückzug endet 22 Felder seitlich vom HQ. Die Partie endet trotzdem, deshalb bleibt EveryOpening_FlankAndFaction_FinishesTheMatch gegen den passiven Gegner grün.
  • Behebungsvorschlag: Die verschobene Zelle auf Chebyshev ≤ StagingDistanceCells vom HQ zurückskalieren (drehen statt verschieben, Ganzzahl-Arithmetik). Dazu einen Test, der für jede Flanke Chebyshev(HQ, Sammelpunkt) + Toleranz ≤ Ring prüft, plus den Regressionstest „kein Attack-Ziel, solange die Welle nicht bereit und nichts committed ist“ (siehe Probe).

B2 · Mittel · Der Igel-Heimradius 14 umfasst den Sammelpunkt (12)

  • Ort: Assets/_Project/Scripts/AI.Data/AiPersonality.cs:123 (home = 4, also 10 + 4 = 14). Die verletzte Invariante steht in Assets/_Project/Scripts/AI.Data/AiProfiles.cs:143–145: Der Heimradius liegt bewusst unter dem Sammelpunkt, „so a skirmish at the gathering point does not summon anybody“.
  • Fehlerszenario: Ein sichtbarer bewaffneter Gegner am Sammelpunkt (Chebyshev 12–14 vom HQ) löst unter Igel HomeThreatened aus (SkirmishAiSystem.cs:1038). Alle wartenden Einheiten erhalten DefendHome und laufen 12 Felder zurück zum HQ, weg vom Gegner, der ihre Waffenreichweite (6/7) dann verlässt. Ein Gegner, der bei 13 Feldern parkt, hält die Igel-Welle dauerhaft zu Hause. Nur aus dem Code abgeleitet, nicht simuliert.
  • Vorschlag: Den Heimradius in Apply unter StagingDistanceCells deckeln, oder das Delta auf +1 senken.

B3 · Niedrig (latent) · Ansturm mit Allianz-KI: Verliert sie den Builder, bleibt sie dauerhaft ohne Einkommen

  • Ort: SkirmishAiSystem.cs:906–910 (Ansturm: Kaserne vor Raffinerie) zusammen mit den Kosten, die bei Platzierung und Einreihung sofort voll abgebucht werden.
  • Fehlerszenario: Allianz: 3000 − 450 (Kraftwerk) − 500 (Kaserne) − 700 (Raffinerie) = 1350, danach Infanterie bis zur Kappe 7–9 × 120 → 270–510 AE. Stirbt der Builder, solange die Raffinerie noch Baustelle ist, kostet der Ersatz 800. Ohne Raffinerie gibt es kein Einkommen, die KI steht dauerhaft still. Bei der Legion bleiben 1160 ≥ 650, sie erholt sich. Im ausgelieferten Spiel ist die KI immer Legion (MatchConfig.FactionPerSlot), der Fall also heute nicht erreichbar. Der Test fährt die Allianz-KI aber ausdrücklich. Nur aus dem Code abgeleitet.

B4 · Niedrig · Der Flanken-Test prüft andere Zellen als der Produktionscode

  • Ort: tools/Nova.SimRunner.Tests/AiPersonalityTests.cs:244/254. Der Docstring in SkirmishAiSystem.cs:933 nennt den Test als Pin.
  • Fehlerszenario: Der Test baut die Rechnung nach und setzt dabei den Footprint-Ursprung 118 ein. Der Produktionscode nimmt die HQ-Zelle 119 (GridCellOf der Position). Geprüft werden deshalb (116,96)/(96,116), echt sind (117,97)/(97,117). Beide sind heute felsfrei (Probe), der Test würde eine Abweichung des Produktionspfads aber nicht bemerken.

B5 · Niedrig · NOVA_MATCH_SEED: stiller Rückfall, und der Wert gilt auch außerhalb des Menüs

  • Ort: Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs:826–843, dazu der Docstring :145–153.
  • Fehlerszenario: (a) Ein ungültiger Wert (Tippfehler, mehr als 16 Hexziffern) fällt ohne Warnung auf Frisch- oder Kanon-Seed zurück. Wer eine gemeldete Partie nachstellen will, spielt dann unbemerkt eine andere. Eine dezimale Eingabe wird stillschweigend als Hex gelesen. (b) Die Variable greift auch bei FreshSeedPerLocalMatch = false, also in AutoStart, Harness und PlayMode. Ist sie in der Shell gesetzt, verlassen „Tests und Harnesses“ den kanonischen Seed, und die Hash-Pins schlagen ohne erkennbaren Grund fehl.
  • Vorschlag: Bei einem nicht parsebaren Wert Debug.LogWarning ausgeben; ob die Variable auch ohne Menü-Flag greifen soll, ausdrücklich festlegen.

B6 · Niedrig (Doku) · Docstrings in AiPersonality behaupten mehr, als der Code tut

  • AiPersonality.cs:138: „512 seeds cover every combination of the small ones“. Es gibt 4·3·8·3·3·2 = 1728 Kombinationen; der Test prüft nur jedes Band für sich.
  • Gleiche Stelle: „flank from the next byte“. (h >> 8) % 3 liest die Bits 8–63, nicht ein Byte.
  • AiPersonality.cs:201: Das Beispiel army 8 passt nicht zur Ausgabe, die army delta -3 lautet.

B7 · Niedrig (Doku) · Der CHANGELOG verschweigt den Ansturm-Seed und beschönigt den Sammelpunkt

  • CHANGELOG.md:91 nennt „Die neutrale Persönlichkeit spielt exakt wie bisher“ und den gepinnten Ausgang. Richtig, aber es fehlt der Satz aus D-114 und PR-Text: Der kanonische Seed (Start ohne Menü) spielt jetzt Ansturm.
  • CHANGELOG.md:85 sagt „um zehn Felder zur Seite versetzt“. Tatsächlich sind es auf der Diagonale 10 Felder je Achse, und gesammelt wird dort wegen B1 nicht.

B8 · Info

  • DebugHud.cs:358 zeigt „AI personality Neutral“ auch in Partien ohne KI (Netz).
  • DecisionLog springt von 1.41.0 auf 1.46.0, und D-110 bis D-113 sind auf einem ungemergten Branch reserviert. Bleibt der liegen, bleiben die Lücken.
  • Der Kommentar in TryStartLobbyMatch („both lobby clients must open the identical map“) setzt einen gleichen Seed vor dem Offer voraus. Nach einer Partie mit Frisch-Seed weicht _seed zwischen den Clients ab. Folgenlos, weil WithOffer überschreibt.

Ohne Befund geprüft

  1. Neutral = r8, Bit für Bit: NextBuildingToPlan ist für alle Nicht-Ansturm-Fälle gleich der alten Ternärkette (DefensePlatforms = 0 ergibt Unit). TryFindPlacementSpot mit Layout 0: außen y, innen x, beide aufsteigend, identische Grenzen. GetStagingCell mit Flanke 0 ist unverändert. Der Plattformpfad ist bei neutraler Persönlichkeit nicht erreichbar. Apply(neutral) gibt das Profil unverändert zurück, und AiFactionProfile hält alles in Profile. CanonicalAiOutcomeTests ist unverändert und grün.
  2. Zustandslosigkeit und Determinismus: Personality ist ein readonly struct, im Konstruktor gesetzt, ohne neue veränderliche Felder. Derive ist rein: ASCII-Tag, Seed-Bytes little-endian, Slot, XxHash64, Ganzzahl-Modulo. Kein Fließkomma, keine Hash-Container-Iteration; die Flankenrechnung ist reine Ganzzahl-Arithmetik.
  3. Kein Seed-Leck ins Netz: Alle Netzpfade (Relay per Umgebungsvariable, TryStartNetworkJoin, TryStartLobbyMatch) laufen über WithOffer, das den Seed vor BuildOpening mit dem Relay-Seed überschreibt (MatchConfig.cs:197). Eine Netzpartie darf keinen KI-Slot haben. RestartMatch verweigert Netzpartien, ApplyLocalMatchSeed kehrt bei IsNetworkMatch sofort zurück. Der kanonische Seed bleibt Standard: Das Flag steht auf false, _seed und MatchConfig.Seed sind mit CanonicalSeed vorbelegt.
  4. Keine Hänger gefunden: Ansturm-Legion hat genug Guthaben, die Raffinerie wird in derselben Entscheidung vor der Infanterie geplant. Igel: Strombudget reicht (Legion 110 gegen 41, Allianz 130 gegen 55), die Platzierung fällt auf den Feldanker zurück, und die Plattform steht als letzter Bauschritt, blockiert also nichts. Flankenzellen und ihre Umgebung sind felsfrei.

Gesamturteil

Mergebar: ja, mit Auflage. Determinismus, Netzpfade und die neutrale Gleichheit zu r8 sind sauber; nichts davon blockiert.
Auflage: B1 sollte als nächster PR behoben werden, bevor die zurückgestellte Spielabnahme stattfindet. Sonst bewertet sie in zwei Dritteln der Partien eine ausgehebelte Wellenlogik. B2 gehört in denselben Folge-PR. Da #144 schon gemergt ist, betrifft das jetzt main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant