Skip to content

feat(ai): add strength-based wave gate - #72

Merged
cubetribe merged 4 commits into
VibecodingGermany:mainfrom
arn-c0de:feat/ai-strength-wave-gate-de
Aug 9, 2026
Merged

feat(ai): add strength-based wave gate#72
cubetribe merged 4 commits into
VibecodingGermany:mainfrom
arn-c0de:feat/ai-strength-wave-gate-de

Conversation

@arn-c0de

@arn-c0de arn-c0de commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Was & Warum

Die Wellenlogik bewertet eine „volle Welle“ bisher ausschließlich nach der Anzahl gesammelter Einheiten. Das bildet die tatsächliche Kampfstärke der Fraktionen nicht ab: Zwölf Allianz-Schützen entsprechen 1200 Kampfpunkten, zwölf Legions-Rekruten nur 528. Damit greift die Legion unter derselben Kopfzahlregel mit rund 44 % der Stärke an, die dieselbe Regel der Allianz gibt.

Dieser PR führt deshalb CombatStrength als deterministische Ganzzahlbewertung einer Einheit ein:

Schaden * Leben / Feuerintervall

Zusätzlich erhält das KI-Profil waveStrengthPoints. Damit kann die Wellenfreigabe statt ausschließlich der Kopfzahl auch die aufsummierte Kampfstärke der Einheiten im Sammelring prüfen.

Die neue Regel ist im ausgelieferten Profil absichtlich noch schlafend. Bei der aktuellen Armeeobergrenze entscheidet das Tor weiterhin exakt wie die bisherige Kopfzahlregel. Die kanonische Partie bleibt deshalb byteidentisch.

Technische Details, Tests und Laborbefunde

Die Berechnung von CombatStrength verwendet genau eine Ganzzahldivision mit festgelegter Abschneidung. Unbewaffnete Rollen ergeben ohne Sonderfall automatisch 0.

Der ausgelieferte Wert für waveStrengthPoints beträgt 1200; 0 deaktiviert die neue Regel bitgenau.

Die Schwellwertarithmetik liegt in WaveStrengthGate als reine Funktion. Dadurch lassen sich auch Zustände direkt testen, die eine vollständige Partie nicht zuverlässig auf Bestellung erzeugt, darunter:

  • mehr lebende Einheiten als die aktuelle Armeeobergrenze zulässt,
  • eine gerade zerstörte Kaserne,
  • ein durch die Stärkeschwelle statt durch die Kopfzahl begrenztes Tor.

Die Trennung folgt aus einer Mutationsprobe: Solange die Arithmetik im System selbst vergraben war, blieb die Testsuite sowohl beim Entfernen des Negativ-Clamps als auch beim vollständigen Ignorieren von waveStrengthPoints grün.

Die bestehende r5-Regel wird auf die Punktelogik übertragen. Der Schwellwert bleibt auf das begrenzt, was die Produktion unter der aktuellen Armeeobergrenze noch liefern kann. Ein Überlebender außerhalb des Sammelrings darf die nächste Welle damit nicht auf Verstärkung warten lassen, die aufgrund fehlender Produktionskapazität gar nicht mehr gebaut werden kann.

Kredite gehen bewusst nicht in diese Entscheidung ein. Zahlungsunfähigkeit ist ein vorübergehender Zustand; ein von der aktuellen Kasse abhängiges Tor würde die Armee bei jeder Prüfkadenz erneut einordnen. Das entspricht der bereits dokumentierten Problematik aus Journal V002.

Ein Test hält den schlafenden Zustand der neuen Regel ausdrücklich fest: Bei elf Schützen liegen 1100 Punkte gegen eine Schwelle von 1200 vor, gleichzeitig ist aber noch ein Platz unter der Armeeobergrenze frei. Dadurch entscheidet das Tor weiterhin exakt wie die bisherige Kopfzahlregel.

Die kanonische Partie bleibt byteidentisch:

  • Endzustand: Tick 2548, Hash 0x14472B2B943ED2BB
  • KI-gegen-KI-Laborlauf: Tick 5773, Hash 0x2B34B4E194257940

Nur der Bezeichner bewegt sich.

Die Reserve zur Aktivierung beträgt neun Punkte pro Schütze. Der entsprechende Test berechnet diesen Wert aus CombatStrength, statt eine Konstante abzuschreiben. Damit reagiert der Test auch auf spätere Änderungen der Waffenwerte; bereits ein zusätzlicher Schadenspunkt würde die neue Klausel aktivieren.

Ziel dieses PRs ist nicht, die Armeeobergrenze selbst zu verändern, sondern Wellenstärke und Kopfzahl voneinander zu entkoppeln. Erst dadurch kann targetArmySize später unabhängig verändert werden.

MatchRunner überschreibt derzeit vier Profilwerte mit eigenen Literalen, darunter targetArmySize, und gehört zum Netzstrang. Ein neuer Test prüft deshalb den Quelltext von MatchRunner gegen AiProfiles und schlägt fehl, sobald diese vier Werte auseinanderlaufen. Diese Lücke wurde von den bisherigen Wächtern nicht erfasst.

Laborbefund zur späteren Weiterarbeit

  • nur Armeeobergrenze erhöhen: eigene Verluste 51 → 64, Austausch 45 → 34
  • Stärketor plus Obergrenze 30: Entscheidung bei Tick 5005 statt 5773, 23 eigene Verluste, Austausch 139

Das ist ausdrücklich keine Änderung dieses PRs. Vor einer solchen Aktivierung gehört außerdem eine Abbruchregel vor die neue Konfiguration: Im Labor steigt die Aussetzzeit am Sammelpunkt von 3.502 auf 12.326 Einheit-Ticks je 1.000, einzelne Einheiten warten dabei bis zu 3.214 Ticks.

Gespielte Partie

Im laufenden Spiel gespielt. Eine Partie mit diesem Stand zeigt keinen Unterschied zum bisherigen Verhalten – und genau das ist das erwartete Ergebnis, weil das Tor bei der ausgelieferten Armeeobergrenze nachweislich identisch zur Kopfzahl entscheidet.

Die gespielte Partie bestätigt damit die Neutralität; sie belegt keine Verbesserung. Sichtbar wird der Effekt erst mit den darauf aufbauenden Änderungen.

Checkliste

  • dotnet test tools/Nova.SimRunner.Tests lokal grün — 649/649
  • Zeile unter [Unreleased] in CHANGELOG.md
  • Echte Entscheidung getroffen? → D-ID im DecisionLog, sonst streichen — gestrichen: keine Inhaberentscheidung getroffen. Offen bleiben die Armeeobergrenze, die als Literal in MatchRunner liegt, und die Frage, ob ein KI-Profil überhaupt in den Match-Fingerprint eingehen soll. Letzteres nennt AiBehaviorId selbst eine Inhaberentscheidung mit Auswirkungen auf Simulation und Replays; dieser PR beantwortet sie ausdrücklich nicht.
  • Bei Simulationsänderung: keine Determinismus-Baseline im selben PR geändert

Determinismusprüfung: Exit 0. Keine Baseline geändert.

Externe Beiträge

  • I agree to the Contributor License Agreement

Die Welle marschierte auf einer ANZAHL gesammelter Einheiten, und eine
Anzahl weiss nicht, was eine Einheit wert ist. Zwölf Allianz-Schützen
wiegen 1200 Kampfpunkte, zwölf Legions-Rekruten 528 — dieselbe Regel,
dasselbe Wort "volle Welle", und die Legion greift mit 44 % der Stärke
an, die sie der Allianz gibt. Die Referenzpartie hat das in der
Verlustspalte stehen: 51 gegen 23.

CombatStrength bewertet eine Einheit als Schaden * Leben /
Feuerintervall: ganzzahlig, eine Division, eine festgeschriebene
Abschneidung, und unbewaffnete Rollen fallen ohne Sonderfall auf 0. Das
neue Profilfeld waveStrengthPoints (ausgeliefert 1200, 0 schaltet die
Regel bitgenau ab) summiert diese Punkte über die Einheiten im
Sammelring, statt sie zu zählen.

WaveStrengthGate hält die Schwellwert-Arithmetik als reine Funktion, und
zwar als eigener Typ, damit sie an den Zuständen prüfbar ist, die eine
Partie nicht auf Bestellung erzeugt: mehr Einheiten am Leben als die
Kappe erlaubt, eine gerade zerstörte Kaserne, eine Schwelle die statt
der Decke bindet. Der Grund ist eine Mutationsprobe — mit der Arithmetik
im System vergraben blieb die ganze Suite grün, sowohl als der
Negativ-Clamp gelöscht als auch als waveStrengthPoints ganz ignoriert
wurde.

Die r5-Regel wandert in Punkten mit: der Schwellwert bleibt gegen das
gekappt, was die Produktion noch liefern kann, damit ein Überlebender
ausserhalb des Rings die nächste Welle nicht blockiert. Ein freier Kopf
zählt nur, solange eine Kaserne hineinbauen kann — sonst wartet die
Welle die Partie aus auf Verstärkung, die niemand bauen kann, und das
ist die r5-Blockade eine Ebene höher. Kredite gehen bewusst nicht ein:
pleite ist vorübergehend, und ein Tor, das mit der Kasse flackert,
ordnet die Armee jede Kadenz neu (Journal V002).

AUSGELIEFERT IST DIE REGEL SCHLAFEND, UND EIN TEST NAGELT DAS FEST. Die
Punktklausel kann nur entscheiden, solange ein Kopf der Armeeobergrenze
frei ist — elf Schützen, 1100 Punkte, gegen eine Schwelle von 1200 — das
Tor entscheidet also exakt wie die Kopfzahl, und die kanonische Partie
ist byteidentisch (Endzustands-Pin unverändert bei Tick 2548 /
0x14472B2B943ED2BB; der KI-gegen-KI-Lauf des Labors ebenso bei 5773 /
0x2B34B4E194257940, nur der Bezeichner bewegt sich). Die Reserve beträgt
neun Punkte pro Schütze, deshalb rechnet der Test sie aus CombatStrength
statt aus einer abgeschriebenen Zahl: Waffenwerte sind der Auftrag genau
dieses Strangs, und ein Schadenspunkt mehr würde das Tor wecken.

Was die Revision bringt, ist die Entkopplung — der Schwellwert der Welle
ist keine an die Produktionsobergrenze gekoppelte Kopfzahl mehr, und das
ist die Voraussetzung dafür, diese Obergrenze überhaupt zu bewegen. Sie
zu bewegen ist nicht Sache dieses Strangs: MatchRunner überschreibt vier
Profilwerte mit eigenen Literalen, targetArmySize darunter, und diese
Datei gehört dem Netzstrang. Ein neuer Test liest MatchRunners Quelltext
und wird rot, wenn die vier von AiProfiles abweichen — das hat vorher
nichts geprüft, und beide bestehenden Wächter sehen von je einer Seite
an der Lücke vorbei.

Einseitig im Labor gemessen: die Obergrenze ALLEIN anzuheben macht die
Legion schlechter (eigene Verluste 51 -> 64, Austausch 45 -> 34); mit
dem Tor und Obergrenze 30 entscheidet derselbe Sitz SCHNELLER als heute
(5005 gegen 5773 Ticks) bei 23 eigenen Verlusten und Austausch 139. Die
Rückfrage und die vollständige Kurve stehen im PR-Text — samt der
Warnung, dass eine Abbruchregel davor gehört.

Im laufenden Spiel ungesehen. Eine Labormessung ist Diagnose, kein
Nachweis.

Tests 649/649, Determinismus Exit 0, keine Baseline angefasst.
…bung ziehen

Das Repo schreibt ueberwiegend mit ß — in docs/ 1240 zu 30, im CHANGELOG
54 zu 10 —, der eigene Eintrag trug an zwei Stellen die Schweizer
Schreibung. Betroffen sind nur diese beiden Woerter im eigenen Absatz;
die aelteren Eintraege des Maintainers, die stellenweise ebenfalls "ss"
schreiben, bleiben unangetastet.

Keine inhaltliche Aenderung, keine Codeaenderung. Tests 649/649.
@cubetribe

Copy link
Copy Markdown
Collaborator

Von Maintainer-Seite geprüft — Determinismus, Schreibhoheit und Handwerk. Der Code ist in Ordnung, und die Trennung ist sauber eingehalten. Drei Punkte, die vor dem Merge geklärt sein sollten, plus eine Frage.

Was geprüft wurde und passt

Determinismus. Der ganze Diff auf float/double/Mathf/DateTime/System.Random/OrderBy/Dictionary durchsucht — ein einziger Treffer, und der steht in einem Kommentar. Die Kette ist durchgehend ganzzahlig, die Division ist vorher abgesichert, und der Überlauf wird auf int.MaxValue gesättigt statt umzubrechen. Die Sättigungsrichtung ist dabei die konservative: eine übergelaufene Stärke läge als schwache Armee vor. Die einzige neue Aggregation ist eine Summe, also reihenfolgeunabhängig.

Schreibhoheit. Alle 14 Dateien liegen im Terrain des Einheitenstrangs. Simulation/Definitions/ wird nur gelesen. Transport-Verträge, Fingerprint und die vier Determinismus-Baselines sind unangetastet. CombatStrength und WaveStrengthGate sind statische Hilfstypen aus dem bereits registrierten SkirmishAiSystem — das ist genau Weg 1 der Regel „Neue Systeme", MatchRunner bleibt unberührt.

Tests. 429 Zeilen neu, dazu die Ergänzungen an den bestehenden Dateien. Paket B5 ist erfüllt, und die Tests halten das beabsichtigte Verhalten fest statt nur den Ist-Zustand.

Drei Punkte zur Klärung

1. Beschreibung und Changelog widersprechen sich.

  • PR-Beschreibung, §Gespielte Partie: „Im laufenden Spiel gespielt."
  • CHANGELOG-Eintrag in diesem PR: „Im laufenden Spiel ungesehen."

In diesem Projekt ist die gespielte Beobachtung Nachweispflicht, deshalb muss die Zeile eindeutig sein. Nach der Beschreibung zu urteilen ist „gespielt, zeigt erwartungsgemäß keinen Unterschied" das Richtige — dann müsste der Changelog nachziehen.

2. Die Laborzahlen passen nicht zusammen. Die Beschreibung nennt Stärketor plus Obergrenze 30 mit Austausch 139; der Changelog Obergrenze 36 mit Austausch 45 → 90. Vermutlich zwei verschiedene Läufe — nebeneinander liest es sich aber als Widerspruch.

3. Ein Test hier bricht, wenn der Netzstrang seine eigene Datei ändert. MatchRunnerPassesTheSameFourNumbersTheShippedProfileCarries liest Gameplay/Match/MatchRunner.cs per File.ReadAllText und prüft vier Literale per Regex. Lesen ist kein Schreiben, und die Begründung im PR ist nachvollziehbar — aber damit entsteht eine Kopplung, die die Schreibhoheitstabelle nicht kennt. Wir haben dafür ein eigenes Issue aufgemacht; für diesen PR ist es kein Blocker, es soll nur nicht stillschweigend passieren.

Eine Frage

Bei leerem Sammelring und voll ausgeschöpfter Armeeobergrenze (gathered = 0, committed = armyCap) fällt die Schwelle auf 0 und IsReady liefert true — r5 lieferte dort durch den Boden reachable < 1 → 1 noch false. Der Fall ist erkannt, begründet und in AnEmptyRingAtASpentCapIsAZeroThreshold festgehalten.

Die Begründung („mit null gesammelten Einheiten gibt es niemanden, den das Flag schicken könnte") klingt schlüssig, aber wir konnten sie von der Aufrufseite aus nicht selbst bestätigen. Kannst du kurz sagen, was hinter WaveReady in dem Zustand tatsächlich passiert?

Zwei Lücken auf unserer Seite, die durch diesen PR sichtbar wurden

Beide sind nicht dir anzulasten, im Gegenteil — die erste hast du selbst im Quelltext vermerkt, statt sie zu übergehen. Danke dafür.

  • NoFloatInSimulationTests scannt Scripts/AI/** nicht. Eigenes Issue.
  • tools/Nova.SimRunner.Tests/ hat in der Schreibhoheitstabelle keinen Eigentümer. Eigenes Issue.

Zum roten Check

external-contributor-review prüft CLA-Bestätigung plus eine Freigabe auf dem aktuellen Head-Commit — das liegt bei uns, nicht bei dir. Alle fünf übrigen Checks sind grün.

…becodingGermany#72)

Zwei Fehler, beide vom Maintainer in der Pruefung gefunden.

1. Die Zeile "Im laufenden Spiel ungesehen" widersprach der
   PR-Beschreibung, die eine gespielte Partie meldet. "Ungesehen" ist in
   diesem Repo der Fachbegriff fuer "kein Nachweis erbracht", nicht fuer
   "nichts war zu sehen" — beides nebeneinander laesst offen, welche
   Aussage gilt. Es gilt die gespielte Partie: kein sichtbarer
   Unterschied, wie vorhergesagt, also Bestaetigung der Neutralitaet und
   ausdruecklich kein Beleg fuer eine Verbesserung.

2. Die Laborzahlen stammten aus einem ueberholten Lauf: Obergrenze 36 mit
   Austausch 45 -> 90, waehrend die PR-Beschreibung Obergrenze 30 mit
   Austausch 139 nennt. Der Eintrag traegt jetzt dieselbe Stellung wie
   die Beschreibung, samt der Warnung, dass eine Abbruchregel vor die
   Obergrenze gehoert (Aussetzzeit 3.502 -> 12.326 Einheit-Ticks je
   1.000).

Keine Codeaenderung. Tests 649/649.
@arn-c0de

arn-c0de commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Punkt 1 und 2

Punkt 1 und 2 waren echte Fehler von mir, behoben in da5106f.
HEAD steht jetzt darauf. Eure Freigabe muss neu erteilt werden.

1. Gespielte Partie

Es gilt die Beschreibung.

Der Changelog trug „ungesehen" aus einer Fassung, in der noch niemand gespielt hatte. Gespielt wurde, kein Unterschied zu sehen. Das bestätigt die Byte-Identität und belegt ausdrücklich keine Verbesserung.

Der Changelog sagt das jetzt genauso.

2. Laborzahlen

Richtig vermutet: zwei Läufe.

Der Changelog trug noch Obergrenze 36 (45 → 90), die Beschreibung schon 30 (Austausch 139).

Ich hatte die Empfehlung auf 30 umgestellt. 30 folgt aus dem Schwellwert statt aus dem Kurvenmaximum. Ich hatte dabei nur die Beschreibung nachgezogen.

Jetzt steht überall 30.

3. Kopplung an MatchRunner.cs

#75 trifft es genau.

Der Test ist nicht tragend für die Regel, sondern nur der Wächter über die Drift der vier Literale.

Wenn euch die Kopplung zu weit geht, nehme ich ihn heraus. Am Verhalten hängt nichts.


Zur Frage: WaveReady bei leerem Sammelring

Genau eine Lesestelle im ganzen Repo: SkirmishAiSystem.cs:979

bool marches = !retreats
    && (posture.StagingCellX < 0
        || posture.WaveReady
        || IsCommittedToTheWave(in unit, hqCellX, hqCellY));

gathered == 0 heißt per Konstruktion, dass IsCommittedToTheWave für jedes Element von combatUnits wahr ist:

  • Die Zählschleife (:642–654) inkrementiert gathered genau im else-Zweig dieses Prädikats.
  • Die Zuweisungsschleife (:489–495) läuft über dieselbe Liste mit denselben Argumenten.

Der dritte Term ist damit für jede Einheit erfüllt, die einen Befehl bekommt.

Messung

WaveReady wurde in diesem Zustand zwangsweise auf false gesetzt:

Messung Ohne Eingriff Flag umgedreht
Testsuite 649/649 649/649
Kanonische Laborpartie 5773 / 0x2B34B4E194257940 identisch
Legionssitz, Obergrenze 30 5005 / 0x88C9F0FDCF98EC11 identisch

Der zweite Lauf ist der, in dem der Zustand am häufigsten auftritt.
Probe nicht eingecheckt.

Das Flag ist dort also nicht „fälschlich true", sondern wirkungslos.
Der Unterschied zu r5 ist real, aber nicht beobachtbar.

Wenn ihr eine Zusicherung statt eines Nachweises wollt, baue ich den Test dazu.


Zu #74

Gern von mir, falls ihr wollt:

  • "AI"
  • "AI.Data"

in ScannedRoots beider Kopien.

Ich habe es nicht selbst gemacht, weil die EditMode-Kopie unter Assets/Tests/ keinem Strang zugeordnet ist. Das wäre dieselbe stille Grenzüberschreitung wie bei Punkt 3.

# Conflicts:
#	tools/Nova.SimRunner.Tests/SkirmishAiTests.cs
@cubetribe
cubetribe self-requested a review August 9, 2026 19:20
@cubetribe
cubetribe merged commit d6f9060 into VibecodingGermany:main Aug 9, 2026
7 of 8 checks passed
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.

2 participants