Compare commits

...

2 Commits

Author SHA1 Message Date
f4b3fe79ae Dzwiek broni 2026-10-17 18:37:56 +02:00
8b50c5f5b9 Zmiany w balansie 2026-10-17 18:28:34 +02:00
51 changed files with 764 additions and 13 deletions

View File

@ -0,0 +1,10 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
[MCPRuntimeServer] port: scanning 6570-6585 (default)
[MCPRuntimeServer] listening on 127.0.0.1:6570
[MCPRegistry] runtime port 6570 registered for c:/users/tomek/documents/godot/randoloot
SIMULATION_PROFILE_DONE: rookie_energy
SIMULATION_PROFILE_DONE: energy
SIMULATION_PROFILE_DONE: machine
SIMULATION_PROFILE_DONE: gamma
BALANCE_SIMULATION_50: DONE 12000 rows

View File

@ -0,0 +1,4 @@
WARNING: Camera2D overridden to physics process mode due to use of physics interpolation.
at: _update_process_callback (scene/2d/camera_2d.cpp:88)
GDScript backtrace (most recent call first):
[0] _ready (res://tests/levels/balance_simulation_50.gd:243)

View File

@ -0,0 +1 @@
[{"profile":"rookie_energy","all_skills":[31,32,33],"first_boss_seconds":[50.9062027119016,54.0371225638878,54.6218465494118],"runs_with_ammo_shortage":0,"total_shortage_boards":0.0,"median_shortage_boards":0,"captain_level_50":42},{"profile":"energy","all_skills":[31,32,33],"first_boss_seconds":[9.93391123528054,12.3722786155386,12.5897881147346],"runs_with_ammo_shortage":0,"total_shortage_boards":0.0,"median_shortage_boards":0,"captain_level_50":42},{"profile":"machine","all_skills":[31,32,33],"first_boss_seconds":[9.93401598876286,12.3722786155386,12.601285739529],"runs_with_ammo_shortage":39,"total_shortage_boards":269.0,"median_shortage_boards":3,"captain_level_50":42},{"profile":"gamma","all_skills":[31,32,33],"first_boss_seconds":[9.93401598876286,12.4288612579985,12.6953388558485],"runs_with_ammo_shortage":8,"total_shortage_boards":28.0,"median_shortage_boards":0,"captain_level_50":42}]

View File

@ -0,0 +1,47 @@
# Balans — zmiany po audycie, 17.10.2026
Wprowadzono zmiany zgodne z decyzjami użytkownika. Farmienie kredytów, przedmiotów i surowców pozostaje dostępne.
## XP
- Progi awansu podniesiono o 20%: 120 + 60 × (poziom kapitana − 1), wcześniej 100 + 50 × (...).
- Bazowe XP za przeciwnika nadal rośnie z poziomem układu: mnożnik 1 + 0,05 × (poziom układu − 1).
- Kapitan do dwóch poziomów ponad układem: pełne XP.
- Przewaga kapitana 3/4/5/6/7 poziomów: odpowiednio 50%, 25%, 12,5%, 6,25%, 3,125% bazowego XP, po przemnożeniu zaokrąglone do całości.
- Przewaga ośmiu poziomów lub więcej: 0 XP, również z ruin i niezależnie od bonusów XP wyposażenia.
- Gdy gracz przejdzie do odpowiednio trudniejszego układu, XP znów jest przyznawane. Kredyty i znaleziska pozostają bez tej redukcji.
- W teście 20 000 powtarzanych zabójstw na poziomie układu 1 kapitan zatrzymał się na poziomie 9. Nie da się tak zdobyć wszystkich 27 rang.
## Amunicja
- Nowa wyprawa: 200 zwykłych pocisków zamiast 50.
- Restart zachowuje bieżący stan, nie dokłada kolejnych 200 sztuk.
- Wrak, który wylosuje nagrodę amunicyjną: 40–60 zwykłych pocisków. Ta nagroda nie jest mnożona przez poziom układu, więc pozostaje bliska 50 także później.
- Częstość losowania tej kategorii nagrody nie została zwiększona.
## Boss
- Na poziomie 1: 240 kadłuba i 96 tarcz zamiast 400 i 160.
- Bazowe obrażenia: 32 zamiast 64.
- Cooldown strzału pomnożony przez 1,25; szybkość ruchu przez 0,8.
- Osłabienie liniowo wygasa przez poziomy 2–5; od poziomu 6 obowiązują standardowe statystyki bossa.
- Zwykli przeciwnicy i elity pozostają przy dotychczasowej sile.
## Weryfikacja
Przeszły testy:
- balance_adjustments_test: nowe progi, zanikanie XP i jego odblokowanie po przejściu dalej, 20 000 powtórnych zabójstw, normalny rozwój do układu 50, start i zachowanie amunicji, rzeczywiste nagrody wraków na poziomach 1 i 50, statystyki i płynna progresja bossa.
- credit_pickup_test: niezależne przyznawanie XP i fizyczne zbieranie kredytów.
- enemy_tiers_test: 1000 poziomów, generowanie spotkań i skład przeciwników.
- git diff --check: bez błędów białych znaków.
Ponownie uruchomiono 240 kampanii modelowych, 12 000 poziomów, z tymi samymi ziarnami i założeniami co w audycie. Plik wyjściowy: balance_simulation_50_after.json. Model nadal nie jest botem sterującym statkiem; przy niedoborze amunicji kontynuuje warunkowo, nie dowodzi wygrania poziomu.
| Wskaźnik | Przed | Po |
|---|---:|---:|
| Komplet obecnych 27 rang, mediana poziomu układu | 29 | 32 |
| Zakres poziomu zdobycia kompletu w próbach | 28–29 | 31–33 |
| Pierwszy boss, model plazmy z 75% trafień, mediana ostrzału | 53,8 s | 12,4 s |
| Pierwszy boss, model plazmy z 45% trafień, mediana ostrzału | 302,2 s | 54,0 s |
| Poziomy z brakami amunicji, wariant maszynowy, na 3000 | 332 | 269 |
| Poziomy z brakami amunicji, wariant gamma, na 3000 | 21 | 28 |
Liczba problematycznych poziomów maszynowego wariantu spadła o około 19%, ale przynajmniej jeden niedobór nadal wystąpił w 39 z 60 prób. Poprawa zaopatrzenia nie gwarantuje więc nieskończonego ostrzału; broń zachowuje przewagę braku kosztu energii i wymaga pilnowania zapasów. Niewielkie pogorszenie wariantu gamma jest wynikiem zmian całego przebiegu, w tym późniejszego zdobywania rang Oszczędnego magazynka; nie zmieniano jego amunicji ani obrażeń.
Progi podniesiono umiarkowanie, zamiast rozciągać istniejące dziewięć umiejętności aż do poziomu 50. Pozostawia to miejsce na planowane dodanie kolejnych umiejętności.

View File

@ -0,0 +1 @@
[{"profile":"energy","level":1,"group_survival":[6.52173913043478,7.05036787356898,13.1939350848702]},{"profile":"energy","level":10,"group_survival":[4.01102377834588,5.52201939100195,10.1540811950208]},{"profile":"energy","level":30,"group_survival":[2.97006536092685,4.80908414167249,16.3574613644245]},{"profile":"energy","level":50,"group_survival":[3.05680111027414,4.60645732115947,16.2500810365801],"iron":[777,900,1041],"titanium":[783,932,1036],"uranium":[758,908,1044],"aluminium":[799,913,1029],"sulfur":[803,929,1094]},{"profile":"machine","level":1,"group_survival":[6.52173913043478,7.03571544900443,13.1939350848702]},{"profile":"machine","level":10,"group_survival":[4.01049301704368,5.45605886160167,10.2112745723193]},{"profile":"machine","level":30,"group_survival":[3.02356479780069,4.78255708322684,16.8260813742186]},{"profile":"machine","level":50,"group_survival":[3.04495300696683,4.58130397797198,16.2500810365801],"iron":[780,895,1041],"titanium":[9,111,346],"uranium":[746,898,1059],"aluminium":[799,912,1039],"sulfur":[15,124,356]},{"profile":"gamma","level":1,"group_survival":[6.52173913043478,7.03571544900443,13.5685294830832]},{"profile":"gamma","level":10,"group_survival":[4.01782181788556,5.51786921476537,10.1540811950208]},{"profile":"gamma","level":30,"group_survival":[3.09375760044777,4.7165180488108,16.6727537526076]},{"profile":"gamma","level":50,"group_survival":[3.04495300696683,4.53938320528597,17.3905413179933],"iron":[777,900,1041],"titanium":[511,663,801],"uranium":[486,644,791],"aluminium":[793,911,1021],"sulfur":[789,948,1087]},{"profile":"rookie_energy","level":1,"group_survival":[6.52173913043478,6.52173913043478,13.0434782608696]},{"profile":"rookie_energy","level":10,"group_survival":[3.69500331142399,4.64928713058482,8.99880894138201]},{"profile":"rookie_energy","level":30,"group_survival":[2.7622432099057,4.07652819426282,9.30532249568145]},{"profile":"rookie_energy","level":50,"group_survival":[2.38521823285148,3.62705909483219,13.5928772251247],"iron":[281,355,449],"titanium":[249,343,450],"uranium":[277,368,440],"aluminium":[256,366,471],"sulfur":[263,383,431]}]

View File

@ -0,0 +1,127 @@
# Audyt balansu do 50. poziomu — 17.10.2026
## Najważniejszy wynik
Balans jest nierówny: trudny, zależny od łupu początek; duża przewaga gospodarcza plazmy; wygaszenie rozwoju umiejętności około 29. poziomu; nadmiar niektórych zasobów w dalszej grze. Najpierw warto poprawić te zależności, zamiast równomiernie zwiększać trudność wszystkich poziomów.
Nie zmieniono parametrów rozgrywki. Dodano wyłącznie narzędzia audytu i wyniki.
## Zakres i ograniczenia
- 240 kampanii modelowych: 60 deterministycznie zasianych prób dla każdego z czterech wariantów, po 50 poziomów. Razem 12 000 rekordów poziomów i 639 pokonanych wrogów w każdej warunkowej kampanii.
- Dodatkowo 600 ścieżek wyboru umiejętności: 300 z priorytetem odblokowania nowych typów i 300 z losowymi wyborami.
- Użyto prawdziwego generatora łupu, losowania addonów, statystyk wyposażenia, przeliczania bonusów statku, ofert i nauki umiejętności, nagród, receptur oraz cen sklepowych. Statystyki przeciwników i ich DPS pochodzą z rzeczywistego skryptu wroga.
- Górnictwo i wraki próbkowano według rozkładów liczby obiektów i nagród z kodu, bez generowania geometrii mapy. Pominięto utracone pozycje przy ewentualnym niepowodzeniu generatora; rzadkie ruiny uproszczono do średnich 15 XP i zwykłej generacji przedmiotu wyższej jakości, bez wymuszania górnych 10% addonów.
- Początkujący: 45% trafień, zebranie 30% dostępnych asteroid, wraków i łupu. Pozostałe warianty: 75% trafień i 75% zbierania. Przyjęto zebranie wszystkich kredytów z pokonanych wrogów.
- Priorytet umiejętności gospodarczych: Górnik, Eksplorator, Rekin biznesu, Oszczędny magazynek, potem pozostałe. Dlatego główny model nie reprezentuje najszybszego odblokowania dziewięciu różnych typów; temu służy dodatkowe 600 ścieżek.
- Start prawdziwą plazmą. Przejście na wybrany typ broni dopiero po jego znalezieniu. Model używa jednego głównego działa; pozostałe sloty broni pozostają puste. Dobiera moduły według jawnego wyniku punktowego w skrypcie, a nie wiedzy o przyszłym łupie.
- Boss jest liczony po pozostałych wrogach, jak w grze. Model szacuje czas zadawania obrażeń na podstawie DPS, celności, energii i regeneracji. Nie steruje statkiem, nie symuluje pocisków, odległości, taranowania ani grupowej walki.
- Aktywne rozszerzenia, aktywne skille, wirus, recykler i defensywne pasywy nie są rozgrywane w modelu walki. Ich użycie może znacząco ułatwić grę. Koszt części na naprawy, dobrowolne używanie rakiet, dronów i powerbanków również nie jest automatycznie odejmowany. Zapasy są więc górnymi bilansami dla oszczędnej gry, a nie prognozą każdego gracza.
- Przy niedoborze amunicji symulacja kontynuuje kampanię warunkowo, aby pokazać dalszą ekonomię. Nie oznacza to wygrania takiego poziomu. Zakupy policzono optymistycznie także z nagród bieżącego poziomu; rzeczywisty sklep jest dostępny dopiero po oczyszczeniu układu. Wykryte braki amunicji są zatem dolną granicą problemu.
- Walka nie przerywa kampanii po hipotetycznej śmierci. To audyt przepływu zasobów i relacji statystyk, a nie 240 udokumentowanych wygranych bota. Nie wylicza wiarygodnego procentu zwycięstw człowieka ani czasu całego przejścia.
## 1. Umiejętności kończą się za wcześnie
Obecnie jest dziewięć umiejętności po trzy rangi, czyli 27 punktów. Wszystkie rangi są osiągalne na 28. poziomie kapitana po zdobyciu 20 250 XP.
W głównych 240 próbach komplet wypadał na poziomach układu 28–29, z medianą 29. Nawet bez żadnych bonusów XP, samymi nagrodami za wrogów, komplet jest dostępny na 29. poziomie układu.
Dodatkowe ścieżki bez addonów XP:
- Wybieranie nowych umiejętności: dziewięć typów odblokowanych na poziomie 10 (mediana), przedział 10.–90. percentyla: 8–15; skrajne próby 8–21.
- Wybory losowe: dziewięć typów na poziomie 21 (mediana), przedział 13–26; skrajne próby 9–27.
- Wszystkie 27 rang: poziom 29 we wszystkich 600 próbach.
Od około 30. do 50. poziomu systemu brakuje więc dalszych decyzji rozwojowych kapitana. Kolejne awanse nadal przyznają punkt, ale funkcja przygotowywania ofert zeruje punkty, kiedy nie ma już dostępnych rang.
**Kierunek:** jeśli celem jest komplet około poziomu 50, próg XP 250 + 125 × (poziom kapitana − 1), zamiast 100 + 50 × (...), daje koszt 27 awansów 50 625 XP. Bazowe nagrody do 50. układu wynoszą 52 041 XP. To sensowny punkt startowy następnego testu, nie zatwierdzona poprawka balansu.
## 2. Kredyty i zasoby — duże rezerwy przy oszczędnej grze
Mediany po warunkowym ukończeniu 50. poziomu:
| Wariant | Kredyty | Surowce | Części | Zwykła amunicja | Rakiety | Powerbanki |
|---|---:|---:|---:|---:|---:|---:|
| Początkujący, plazma | 42 217 | 1827 | 1925 | 797 | 72 | 19 |
| Plazma, 75% trafień | 41 871 | 4589 | 5036 | 1795 | 183 | 49 |
| Broń maszynowa | 28 623 | 2985 | 5028 | 30 | 183 | 49 |
| Działko gamma | 41 703 | 4069 | 5004 | 1808 | 181 | 49 |
Dla gamma pozostawało około 26 sztuk amunicji radiacyjnej. Przedziały 10.–90. percentyla i checkpointy są w pliku JSON podsumowania.
Plazma nie zużywa zwykłej amunicji, a energię odzyskuje bez kosztu. Kredyty mają bardzo niewiele obowiązkowych odpływów: zakupy amunicji zwykłej, radiacyjnej i rakiet. Nie ma regularnego kosztu wymiany sprzętu ani napraw kupowanych za kredyty. Duży stan konta nie oznacza więc, że nagroda za zabójstwo jest sama w sobie za wysoka; problemem jest także brak powodów do wydawania.
Surowce nie skalują się bez końca z poziomem: bazowo asteroida daje 5–7 jednostek, Górnik dodaje do 10. Pełny Górnik podnosi średni uzysk z 6 do 16, czyli 2,67 raza. Zapas rośnie szczególnie wtedy, gdy gracz nie wytwarza potrzebnych sobie rzeczy, bo jego główną bronią jest plazma.
Stałe 10 części za każdy zbędny przedmiot, niezależnie od poziomu i rzadkości, daje dużą podaż napraw. Skrypt naprawczy zużywa jedną część na jeden punkt kadłuba. Zapas 5000 części odpowiada około 50 bazowym kadłubom Szerszenia. Naprawy i aktywne moduły zmniejszą pokazane rezerwy.
Prosta kontrola wrażliwości dla mediany plazmy, tylko w skali całej kampanii:
- Naprawianie średnio 25 punktów kadłuba na poziom zużywa 1250 części; z bilansu około 5036 zostałoby około 3786.
- Dwie rakiety na poziom to 100 rakiet; mediana z wraków daje 183, więc nadal pozostaje rezerwa.
- Jeden start dronów na poziom zużywa 2500 zwykłych pocisków; 1795 sztuk z bilansu plus 15 partii craftu wystarcza w ujęciu sumarycznym, kosztem 90 jednostek minerałów.
- Dwa powerbanki na poziom to 100 sztuk; przy 47 znalezionych brakujące 53 wymagają 318 jednostek minerałów.
Nie jest to pełna druga symulacja: nie uwzględnia momentu znalezienia modułu, chwilowych braków i podziału minerałów na typy. Pokazuje jednak, że rezerwy nie znikają od umiarkowanego używania wyposażenia.
**Kierunek:** dodać wartościowe zastosowania kredytów, ograniczyć stały odzysk części lub powiązać go z przedmiotem i ponownie zmierzyć Górnika. Nie zwiększać wszystkich receptur jednakowo — mogłoby to dodatkowo zaszkodzić broni maszynowej.
## 3. Amunicja: broń maszynowa ma wąskie gardło
Wariant maszynowy miał niedobór finansowalnej amunicji w 39 z 60 kampanii, łącznie na 332 z 3000 poziomów modelu. Mediana to trzy problematyczne poziomy na kampanię; skrajna próba miała 27. Na samym poziomie 10 braki wystąpiły w 19 z 60 prób. To wynik optymistycznej dostępności sklepu — realne braki w trakcie walki mogą być częstsze.
Do 50. poziomu typowe zapotrzebowanie wynosi około 381 zwykłych pocisków na poziom, z uwzględnieniem oszczędności kapitana i ostrzeliwania asteroid. Plazma w tym czasie może zachować około 1800 niewykorzystanych zwykłych pocisków.
Dla gamma odnotowano braki w 8 z 60 kampanii, na 21 z 3000 poziomów. Późniejsze zaopatrzenie zwykle wystarczało z craftu. Gamma miała jednak znacznie dłuższy modelowy czas walki niż plazma.
Dodatkowe sprawdzenie podziału surowców pokazuje ważny szczegół: mediana maszynowego wariantu na poziomie 50 to około 2985 jednostek minerałów łącznie, ale tylko 111 tytanu i 124 siarki, wobec około 895 żelaza, 898 uranu i 912 aluminium. W plazmie każdy rodzaj kończył zwykle w okolicach 900 jednostek. Łączna liczba surowców maskuje więc niedobór konkretnych składników amunicji.
**Kierunek:** poprawić startową dostępność amunicji i możliwość uzyskania jej przed oczyszczeniem układu. Przetestować wydajność craftu dla broni maszynowej osobno, zamiast dalej zwiększać jego koszty wraz z resztą gospodarki.
## 4. Trudność: mocny pierwszy boss, później osłabienie zwykłych przeciwników
Już na poziomie 1:
- Zwykły wróg ma 62,5 kadłuba + 25 tarcz = 87,5 łącznej osłony.
- Boss ma 400 kadłuba + 160 tarcz = 560 osłony, czyli 6,4 raza więcej.
- Boss zadaje 64 obrażenia bazowe na strzał, zwykły wróg 10. Przy broni bez dolnego ograniczenia cooldownu boss strzela dodatkowo dwa razy częściej: do 12,8 raza większy DPS.
- Boss regeneruje łącznie 5,6 punktu kadłuba i tarcz na sekundę. Na poziomie 50 jest to 19,32/s.
Dla plazmy z 75% trafień mediana czasu zadawania obrażeń bossowi wynosiła około 54 s na poziomie 1, 20 s na poziomie 10 i 16 s na poziomie 50. U początkującego z 45% trafień pierwszy boss wymagał około 302 s w modelu, głównie wskutek wyczerpywania energii i regeneracji przeciwnika. To nie jest czas całej misji i nie uwzględnia rozszerzeń ani manewrów.
W pełnej ekspozycji na ogień pierwszego bossa bazowy statek ma tylko około 2,3–4,7 s efektywnej wytrzymałości, zależnie od typu broni wroga. Nie jest to rzeczywisty czas przeżycia ruchomego gracza. Pokazuje jednak dużą różnicę między wymaganą długością ostrzału a tolerancją na błędy.
W 211 z 3000 poziomów wariantu początkującego model wykrył sytuację, w której długotrwały DPS plazmy po zużyciu energii nie przekraczał łącznej regeneracji bossa. To nie dowodzi nieodwracalnej blokady w grze: można się przeładować, użyć banków, słońca, drugiej broni lub rozszerzeń. Oznacza jednak ryzyko pozornie niekończącej się walki dla gracza, który tego jeszcze nie potrafi.
Na poziomie 50 w 58 z 60 prób zwykłej plazmy regeneracja tarcz statku przewyższała teoretyczny DPS przynajmniej jednego pojedynczego zwykłego przeciwnika. Regeneracja gracza rośnie z addonami, a zwykli przeciwnicy tracą znaczenie w pojedynkę. Nie dotyczy to automatycznie całych grup, taranowania ani bossa. Wyłączone w modelu pasywy i odzysk tarcz z trafień mogą jeszcze zwiększać przewagę gracza.
Osobno policzono ekspozycję na równoczesny ogień obu elit i do trzech zwykłych wrogów, po doborze wyposażenia na danym poziomie. To próbka wysokiej presji, a nie stwierdzenie, że taka grupa zawsze jest razem na mapie. Przy pełnej ekspozycji mediana wytrzymałości plazmowego statku wynosiła około 7,1 s na poziomie 1 (czterech wrogów), 5,5 s na poziomie 10 i 4,6 s na poziomie 50 (pięciu). Pokazuje to, że mimo przewagi nad pojedynczym normalnym wrogiem, grupy pozostają groźne. Ruch, rozdzielanie grup i likwidacja pierwszych celów zmieniają ten bilans.
**Kierunek:** osłabić pierwszy skok do bossa, ograniczyć regenerację wroga podczas otrzymywania trafień albo opóźnić jej rozpoczęcie oraz sprawdzić miękki limit/słabsze sumowanie regeneracji tarcz gracza. Następny etap to dynamiczne testy walki grupowej i realne próby sterowania.
Powrót z ucieczki ma obecnie ten sam próg 10% co jej rozpoczęcie. Wróg z 9% kadłuba wraca po około sekundzie regeneracji, a z 9,9% po około 0,1 s. Warto przetestować histerezę, np. ucieczka poniżej 10%, powrót dopiero po 20–25%, zamiast oscylowania wokół jednego progu.
## 5. Śmierć pozwala na nieograniczone farmienie
Restart zachowuje XP, kredyty, części, minerały i ekwipunek, a odtwarza przeciwników, wraki i asteroidy. Zmiana losowania asteroid nie ogranicza ponownego pobierania nagród.
Już poziom 1 zawiera przed bossem dwie elity i dwóch zwykłych wrogów. To bazowo 140 XP i 110 kredytów na próbę, przed bonusami wyposażenia. Zabicie tej grupy i celowa śmierć u bossa może powtarzać się bez limitu. 145 takich prób daje co najmniej 20 250 XP potrzebnych na wszystkie 27 rang, wciąż na pierwszym poziomie systemu. To nie jest prognoza typowego gracza ani szybki sposób na komplet, lecz dowód braku ograniczenia gospodarczego restartów.
**Kierunek:** zdecydować, które nagrody mogą się odradzać po śmierci. Można zachować postęp, ale nagrody przypisać do stałych ID w danym układzie; albo odnawiać część zasobów przy ograniczonej nagrodzie za powtórne zabójstwa. Sama kara kredytowa nie zatrzyma farmienia XP i minerałów.
## Zalecana kolejność prac
1. Zasady nagród po śmierci — usunąć możliwość nieograniczonego powtarzania pełnych nagród.
2. Wczesny boss i regeneracja wrogów — poprawić tolerancję na błędy początkującego.
3. Amunicja do broni maszynowej — zapewnić wykonalną grę przed odblokowaniem sklepu.
4. Rozciągnąć rozwój kapitana do docelowego horyzontu kampanii.
5. Dodać sensowne wydatki kredytowe; zmierzyć części i Górnika z aktywnym używaniem wyposażenia.
6. Dopiero potem regulować trudność późnych poziomów i sumowanie regeneracji gracza.
## Pliki i odtworzenie
- tests/levels/balance_simulation_50.gd oraz .tscn — główna symulacja; ziarno losowań łupu 920001 + numer próby × 7919, ofert 440001 + numer próby × 3571.
- tests/levels/balance_skill_paths.gd oraz .tscn — dodatkowe 600 ścieżek umiejętności.
- artifacts/balance_simulation_50.json — wszystkie 12 000 rekordów oraz wyniki końcowe.
- artifacts/balance_simulation_50_summary.json — checkpointy, 10./50./90. percentyl; wynik końcowy umiejętności dodatkowo minimum i maksimum.
- artifacts/balance_simulation_50_checkpoints.csv — tabela median do dalszej analizy.
- artifacts/balance_skill_paths.json — surowe ścieżki nauki umiejętności.
- artifacts/balance_group_and_minerals.json — kwantyle ekspozycji grupowej i końcowe zapasy każdego minerału.
Uruchom sceny headless w Godot 4.7.2 z projektem jako --path. Symulacja nie modyfikuje plików parametrów rozgrywki. Wyniki odnoszą się do obecnego stanu źródeł; po zmianie balansu należy uruchomić ją ponownie.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,29 @@
"profil","poziom","poziom_kapitana","rangi_umiejetnosci","kredyty","czesci","surowce","amunicja","gamma","rakiety","powerbanki","boss_czas_s","niedobor_amunicji_proby","proby_z_blokada_regeneracji","proby_z_regeneracja_tarczy_wyzsza_niz_DPS_zwyklego"
"rookie_energy","1","3","2","230","0","17","50","0","2","0","302,161","0","0","0"
"rookie_energy","5","7","6","1369","41","102","64","0","3","1","1356,668","0","3","0"
"rookie_energy","10","12","11","3349","151","265","100","0","6","2","84,518","0","18","0"
"rookie_energy","20","21","20","9115","496","645","219","0","19","5","72,538","0","4","6"
"rookie_energy","30","29","27","17264","929","1034","365","0","33","10","45,398","0","0","26"
"rookie_energy","40","37","27","28227","1384","1409","530","0","52","14","40,604","0","0","31"
"rookie_energy","50","46","27","42217","1925","1827","797","0","72","19","36,629","0","0","47"
"energy","1","3","2","230","10","37","50","0","2","0","53,796","0","0","0"
"energy","5","8","7","1384","157","274","88","0","5","2","21,654","0","0","0"
"energy","10","12","11","3373","493","727","164","0","11","6","20,134","0","0","4"
"energy","20","21","20","9157","1391","1696","444","0","44","15","18,779","0","0","26"
"energy","30","29","27","17257","2455","2646","807","0","74","25","17,332","0","0","47"
"energy","40","37","27","28162","3684","3594","1275","0","129","36","16,358","0","0","58"
"energy","50","46","27","41871","5036","4589","1795","0","183","49","15,744","0","0","58"
"machine","1","3","2","230","10","37","50","0","2","0","53,748","1","0","0"
"machine","5","8","7","1369","159","243","58","0","5","2","24,535","13","0","0"
"machine","10","12","11","1215","484","516","24","0","11","6","27,247","19","0","4"
"machine","20","21","20","3447","1399","1157","24","0","44","14","24,802","6","0","31"
"machine","30","29","27","9501","2459","1778","27","0","75","25","21,845","4","0","47"
"machine","40","37","27","17413","3680","2377","32","0","129","37","21,567","1","0","58"
"machine","50","46","27","28623","5028","2985","30","0","183","49","20,398","1","0","58"
"gamma","1","3","2","230","10","37","50","0","2","0","53,748","2","0","0"
"gamma","5","8","7","1373","159","251","88","0","5","2","46,158","5","0","0"
"gamma","10","12","11","3366","487","666","161","13","11","6","47,116","0","0","5"
"gamma","20","21","20","9104","1391","1485","442","23","44","15","50,441","0","0","25"
"gamma","30","29","27","17223","2449","2359","817","24","75","25","46,36","0","0","48"
"gamma","40","37","27","28006","3655","3211","1307","27","127","37","47,536","0","0","58"
"gamma","50","46","27","41703","5004","4069","1808","26","181","49","43,366","0","0","58"
1 profil poziom poziom_kapitana rangi_umiejetnosci kredyty czesci surowce amunicja gamma rakiety powerbanki boss_czas_s niedobor_amunicji_proby proby_z_blokada_regeneracji proby_z_regeneracja_tarczy_wyzsza_niz_DPS_zwyklego
2 rookie_energy 1 3 2 230 0 17 50 0 2 0 302,161 0 0 0
3 rookie_energy 5 7 6 1369 41 102 64 0 3 1 1356,668 0 3 0
4 rookie_energy 10 12 11 3349 151 265 100 0 6 2 84,518 0 18 0
5 rookie_energy 20 21 20 9115 496 645 219 0 19 5 72,538 0 4 6
6 rookie_energy 30 29 27 17264 929 1034 365 0 33 10 45,398 0 0 26
7 rookie_energy 40 37 27 28227 1384 1409 530 0 52 14 40,604 0 0 31
8 rookie_energy 50 46 27 42217 1925 1827 797 0 72 19 36,629 0 0 47
9 energy 1 3 2 230 10 37 50 0 2 0 53,796 0 0 0
10 energy 5 8 7 1384 157 274 88 0 5 2 21,654 0 0 0
11 energy 10 12 11 3373 493 727 164 0 11 6 20,134 0 0 4
12 energy 20 21 20 9157 1391 1696 444 0 44 15 18,779 0 0 26
13 energy 30 29 27 17257 2455 2646 807 0 74 25 17,332 0 0 47
14 energy 40 37 27 28162 3684 3594 1275 0 129 36 16,358 0 0 58
15 energy 50 46 27 41871 5036 4589 1795 0 183 49 15,744 0 0 58
16 machine 1 3 2 230 10 37 50 0 2 0 53,748 1 0 0
17 machine 5 8 7 1369 159 243 58 0 5 2 24,535 13 0 0
18 machine 10 12 11 1215 484 516 24 0 11 6 27,247 19 0 4
19 machine 20 21 20 3447 1399 1157 24 0 44 14 24,802 6 0 31
20 machine 30 29 27 9501 2459 1778 27 0 75 25 21,845 4 0 47
21 machine 40 37 27 17413 3680 2377 32 0 129 37 21,567 1 0 58
22 machine 50 46 27 28623 5028 2985 30 0 183 49 20,398 1 0 58
23 gamma 1 3 2 230 10 37 50 0 2 0 53,748 2 0 0
24 gamma 5 8 7 1373 159 251 88 0 5 2 46,158 5 0 0
25 gamma 10 12 11 3366 487 666 161 13 11 6 47,116 0 0 5
26 gamma 20 21 20 9104 1391 1485 442 23 44 15 50,441 0 0 25
27 gamma 30 29 27 17223 2449 2359 817 24 75 25 46,36 0 0 48
28 gamma 40 37 27 28006 3655 3211 1307 27 127 37 47,536 0 0 58
29 gamma 50 46 27 41703 5004 4069 1808 26 181 49 43,366 0 0 58

View File

@ -0,0 +1,19 @@
[remap]
importer="csv_translation"
type="Translation"
uid="uid://5d86u3fuhdxs"
[deps]
files=["res://artifacts/balance_simulation_50_checkpoints.poziom.translation", "res://artifacts/balance_simulation_50_checkpoints.poziom.translation", "res://artifacts/balance_simulation_50_checkpoints.rangi.translation", "res://artifacts/balance_simulation_50_checkpoints.kredyty.translation", "res://artifacts/balance_simulation_50_checkpoints.czesci.translation", "res://artifacts/balance_simulation_50_checkpoints.surowce.translation", "res://artifacts/balance_simulation_50_checkpoints.amunicja.translation", "res://artifacts/balance_simulation_50_checkpoints.gamma.translation", "res://artifacts/balance_simulation_50_checkpoints.rakiety.translation", "res://artifacts/balance_simulation_50_checkpoints.powerbanki.translation", "res://artifacts/balance_simulation_50_checkpoints.boss.translation", "res://artifacts/balance_simulation_50_checkpoints.niedobor.translation", "res://artifacts/balance_simulation_50_checkpoints.proby.translation", "res://artifacts/balance_simulation_50_checkpoints.proby.translation"]
source_file="res://artifacts/balance_simulation_50_checkpoints.csv"
dest_files=["res://artifacts/balance_simulation_50_checkpoints.poziom.translation", "res://artifacts/balance_simulation_50_checkpoints.poziom.translation", "res://artifacts/balance_simulation_50_checkpoints.rangi.translation", "res://artifacts/balance_simulation_50_checkpoints.kredyty.translation", "res://artifacts/balance_simulation_50_checkpoints.czesci.translation", "res://artifacts/balance_simulation_50_checkpoints.surowce.translation", "res://artifacts/balance_simulation_50_checkpoints.amunicja.translation", "res://artifacts/balance_simulation_50_checkpoints.gamma.translation", "res://artifacts/balance_simulation_50_checkpoints.rakiety.translation", "res://artifacts/balance_simulation_50_checkpoints.powerbanki.translation", "res://artifacts/balance_simulation_50_checkpoints.boss.translation", "res://artifacts/balance_simulation_50_checkpoints.niedobor.translation", "res://artifacts/balance_simulation_50_checkpoints.proby.translation", "res://artifacts/balance_simulation_50_checkpoints.proby.translation"]
[params]
compress=1
delimiter=0
unescape_keys=false
unescape_translations=true

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,10 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
[MCPRuntimeServer] port: scanning 6570-6585 (default)
[MCPRuntimeServer] listening on 127.0.0.1:6570
[MCPRegistry] runtime port 6570 registered for c:/users/tomek/documents/godot/randoloot
SIMULATION_PROFILE_DONE: rookie_energy
SIMULATION_PROFILE_DONE: energy
SIMULATION_PROFILE_DONE: machine
SIMULATION_PROFILE_DONE: gamma
BALANCE_SIMULATION_50: DONE 12000 rows

View File

@ -0,0 +1,4 @@
WARNING: Camera2D overridden to physics process mode due to use of physics interpolation.
at: _update_process_callback (scene/2d/camera_2d.cpp:88)
GDScript backtrace (most recent call first):
[0] _ready (res://tests/levels/balance_simulation_50.gd:243)

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,46 @@
Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org
[ 0% ] first_scan_filesystem | Started Project initialization (5 steps)
[ 0% ] first_scan_filesystem | Scanning file structure...
[ 16% ] first_scan_filesystem | Loading global class names...
[ 33% ] first_scan_filesystem | Verifying GDExtensions...
[ 50% ] first_scan_filesystem | Creating autoload scripts...
[ 66% ] first_scan_filesystem | Initializing plugins...
Godot MCP Toolkit v1.0.3 — by NPGameDev · npgamedev.com
[MCPExtensions] Hot-reload watcher active
[MCPServer] port: scanning 6550-6560 (default)
[MCPServer] session token written to user://addons/godot_mcp_toolkit/project_instance_32cc2271ac67/mcp_token
[MCPServer] listening on 127.0.0.1:6551
[MCPRegistry] registered c:/users/tomek/documents/godot/randoloot on port 6551
[ 83% ] first_scan_filesystem | Starting file scan...
[ DONE ] first_scan_filesystem

[ 0% ] update_scripts_classes | Started Registering global classes... (4 steps)
[ 0% ] update_scripts_classes | WeaponAudio
[ 20% ] update_scripts_classes | 
[ 40% ] update_scripts_classes | 
[ 60% ] update_scripts_classes | 
[ DONE ] update_scripts_classes

[ 0% ] reimport | Started (Re)Importing Assets (1 steps)
[ 0% ] reimport | Preparing files to reimport...
[ 0% ] reimport | Executing pre-reimport operations...
[ 0% ] reimport | radiation.mp3
[ 50% ] reimport | Finalizing Asset Import...
[ DONE ] reimport

[ 0% ] reimport | Started (Re)Importing Assets (1 steps)
[ 0% ] reimport | Executing post-reimport operations...
[ DONE ] reimport

[ 0% ] loading_editor_layout | Started Loading editor (5 steps)
[ 0% ] loading_editor_layout | Loading editor layout...
[ 16% ] loading_editor_layout | Loading docks...
[ 33% ] loading_editor_layout | Reopening scenes...
[ 50% ] loading_editor_layout | Loading central editor layout...
[ 66% ] loading_editor_layout | Loading plugin window layout...
[ 83% ] loading_editor_layout | Editor layout ready.
[ DONE ] loading_editor_layout

[MCPServer] stopped
[MCPRegistry] deregistered c:/users/tomek/documents/godot/randoloot

View File

Binary file not shown.

View File

@ -0,0 +1,19 @@
[remap]
importer="mp3"
type="AudioStreamMP3"
uid="uid://xvk4gbt3m8jk"
path="res://.godot/imported/radiation.mp3-702854fbc0298ea0549de8d1ea05ab90.mp3str"
[deps]
source_file="res://assets/sounds/effects/radiation.mp3"
dest_files=["res://.godot/imported/radiation.mp3-702854fbc0298ea0549de8d1ea05ab90.mp3str"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

View File

@ -14,7 +14,7 @@ stat_suffix = " DMG"
weapon_kind = 4
fire_interval = 0.8
projectile_speed = 430.0
weapon_range = 400.0
weapon_range = 300.0
damage_lower_roll_min = 10
damage_lower_roll_max = 14
damage_spread_roll_min = 4

View File

@ -1,7 +1,7 @@
extends RefCounted
const XP_BASE := 100
const XP_PER_LEVEL := 50
const XP_BASE := 120
const XP_PER_LEVEL := 60
const LEARNING_POINTS_PER_LEVEL := 1
const MAX_SKILL_RANK := 20
const SKILL_RESET_COST_PER_LEVEL := 100
@ -34,3 +34,13 @@ static func skill_bonus(skill: String, rank: int) -> float:
static func xp_required(level: int) -> int:
return XP_BASE + XP_PER_LEVEL * (maxi(1, level) - 1)
# Full XP within two levels; progressively exhausted rewards on easy systems.
static func encounter_xp_factor(captain_level: int, system_level: int) -> float:
var gap := maxi(1, captain_level) - clampi(system_level, 1, 1000)
if gap <= 2:
return 1.0
if gap >= 8:
return 0.0
return pow(0.5, gap - 2)

View File

@ -191,10 +191,13 @@ func add_rewards(xp_amount: int, credit_amount: int) -> void:
prepare_skill_offers()
emit_changed()
func encounter_xp(base_amount: float, system_level: int, xp_multiplier: float = 1.0) -> int:
return maxi(0, roundi(maxf(0.0, base_amount) * maxf(0.0, xp_multiplier) * BALANCE.encounter_xp_factor(level, system_level)))
func reward_enemy(system_level: int, tier: int, xp_multiplier: float, credit_multiplier: float, grant_credits: bool = true) -> Vector2i:
var index := clampi(tier, 0, 2)
var scale_factor := 1.0 + 0.05 * (clampi(system_level, 1, 1000) - 1)
var xp := roundi(BALANCE.KILL_XP[index] * scale_factor * maxf(0.0, xp_multiplier))
var xp := encounter_xp(BALANCE.KILL_XP[index] * scale_factor, system_level, xp_multiplier)
var money := roundi(BALANCE.KILL_CREDITS[index] * scale_factor * maxf(0.0, credit_multiplier))
add_rewards(xp, money if grant_credits else 0)
return Vector2i(xp, money)

View File

@ -110,6 +110,16 @@ func apply_level_settings(level: int, tier: int) -> void:
activation_range = 760.0 * boss_multiplier
weapon_range = 680.0 * boss_multiplier
weapon_cooldown = maxf(0.55, 1.5 / (1.0 + 0.001 * (clampi(level, 1, 1000) - 1))) / boss_multiplier
if tier == 2:
# Ease the first encounter, returning smoothly to standard bosses at level 6.
var intro := clampf(float(level - 1) / 5.0, 0.0, 1.0)
max_hull *= lerpf(0.6, 1.0, intro)
hull = max_hull
max_shields = max_hull * 0.4
shields = max_shields
weapon_damage *= lerpf(0.5, 1.0, intro)
weapon_cooldown *= lerpf(1.25, 1.0, intro)
approach_speed *= lerpf(0.8, 1.0, intro)
$CollisionShape2D.scale = Vector2.ONE * boss_multiplier
if is_instance_valid(_shield_visual):
_shield_visual.scale = Vector2.ONE * get_visual_size() / 64.0

View File

@ -2,7 +2,7 @@ extends RefCounted
class_name WeaponAudio
const SOUND_PATHS := {
ItemDefinition.WeaponKind.GAMMA: "res://assets/sounds/effects/plasma.mp3",
ItemDefinition.WeaponKind.GAMMA: "res://assets/sounds/effects/radiation.mp3",
ItemDefinition.WeaponKind.PLASMA: "res://assets/sounds/effects/plasma.mp3",
ItemDefinition.WeaponKind.MACHINE_GUN: "res://assets/sounds/effects/machine_gun.mp3",
ItemDefinition.WeaponKind.LASER: "res://assets/sounds/effects/laser.mp3",

View File

@ -14,7 +14,7 @@ const MODULE_SLOT_COUNT := 5
const EXTENSION_SLOT_COUNT := 5
const MODULE_CATEGORIES := [ItemDefinition.Category.SHIELD, ItemDefinition.Category.ARMOR, ItemDefinition.Category.ENGINE, ItemDefinition.Category.REACTOR, ItemDefinition.Category.COMPUTER]
const MODULE_NAMES := ["Tarcze", "Pancerz", "Silnik", "Reaktor", "Komputer"]
const STARTING_AMMUNITION := 50
const STARTING_AMMUNITION := 200
const AMMUNITION_CRAFT_AMOUNT := 10
const AMMUNITION_BUY_AMOUNT := 100
const AMMUNITION_BUY_BASE_COST := 60

View File

@ -131,7 +131,7 @@ func claim() -> bool:
queue_free()
return true
elif kind == "ruin":
var xp := roundi(_rng.randi_range(10, 20) * scale)
var xp := GameState.captain.encounter_xp(_rng.randi_range(10, 20) * scale, GameState.system_level)
GameState.captain.add_rewards(xp, 0)
_drop_module(true)
summary = "+%d doświadczenia · +1 moduł" % xp
@ -150,7 +150,7 @@ func claim() -> bool:
inventory.missile_ammunition += amount
summary = "+%d rakiet" % amount
3:
var amount := roundi(_rng.randi_range(5, 15) * scale)
var amount := _rng.randi_range(40, 60)
inventory.ammunition += amount
summary = "+%d amunicji" % amount
inventory.ammunition_changed.emit()

View File

@ -1,7 +1,7 @@
extends RefCounted
const MAX_LEVEL := 1000
const COUNT_POINTS := [Vector2(1, 5), Vector2(10, 10), Vector2(50, 18), Vector2(100, 23), Vector2(250, 29), Vector2(500, 34), Vector2(1000, 40)]
const COUNT_POINTS := [Vector2(1, 11), Vector2(10, 11), Vector2(15, 11), Vector2(50, 18), Vector2(100, 23), Vector2(250, 29), Vector2(500, 34), Vector2(1000, 40)]
const ELITES_PER_LEVEL := 2
static func enemy_count(level: int) -> int:

View File

@ -7,7 +7,7 @@ const PAGES := [
["Sterowanie", "02", "[font_size=26][color=#f1bd79]Opanuj statek[/color][/font_size]\n\n[color=#91dbea][b]W[/b][/color] — ciąg do przodu.\n[color=#91dbea][b]S[/b][/color] — hamowanie; po zatrzymaniu lot wstecz.\n[color=#91dbea][b]A / D[/b][/color] — obracanie statku.\n[color=#91dbea][b]Q / E[/b][/color] — ruch boczny w lewo / prawo.\n[color=#91dbea][b]TAB[/b][/color] — dopalanie: większa prędkość kosztem energii.\n\n[color=#91dbea][b]Kursor myszy[/b][/color] — celowanie.\n[color=#91dbea][b]LPM / PPM / ŚPM[/b][/color] — strzelanie z trzech slotów broni.\n[color=#91dbea][b]1–5[/b][/color] — użycie zamontowanych rozszerzeń.\n[color=#91dbea][b]F1–F5[/b][/color] — aktywne umiejętności kapitana.\n[color=#91dbea][b]R[/b][/color] — użycie powerbanku, jeśli masz go w zapasach.\n\nStatek ma bezwładność. Obrót nie zatrzymuje ruchu — hamuj wcześniej i wykorzystuj ruch boczny, żeby unikać ostrzału."],
["Walka i przetrwanie", "03", "[font_size=26][color=#f1bd79]Wróć z wyprawy[/color][/font_size]\n\n[color=#91dbea][b]Tarcze, kadłub i energia[/b][/color]\nTarcze pochłaniają obrażenia przed kadłubem. Utrata całego kadłuba oznacza zniszczenie statku. Energia zasila część broni oraz dopalanie; odnawia się z czasem, szybciej przy gwieździe.\n\n[color=#91dbea][b]Dobieraj broń do zapasów[/b][/color]\nBroń energetyczna zużywa energię. Działko maszynowe potrzebuje zwykłej amunicji, gamma — napromieniowanej, a wyrzutnie — rakiet. Puste zapasy oznaczają brak strzału. Absorpcja ogranicza obrażenia danego typu.\n\n[color=#91dbea][b]Korzystaj z rozszerzeń[/b][/color]\nMiny atakują obszarowo, flary odciągają uwagę, kamuflaż pomaga zerwać kontakt, teleport przenosi w bezpieczne miejsce, a drony wspierają ostrzał. Doładowanie tarcz działa przy gwieździe. Sprawdzaj koszt i cooldown w opisie.\n\n[color=#f1bd79][b]Uważaj na uciekających przeciwników[/b][/color]\nWróg z kadłubem poniżej 10% ucieka wolniej, ale może zostawić minę. Nie leć bezmyślnie prosto za nim."],
["Łup i wytwarzanie", "04", "[font_size=26][color=#f1bd79]Zbieraj. Porównuj. Twórz.[/color][/font_size]\n\n[color=#91dbea][b]Łup zbierasz przez podlecenie[/b][/color]\nPrzedmioty w pobliżu są przyciągane do statku. Magazyn mieści 20 przedmiotów; przy pełnym magazynie zwolnij miejsce, żeby zebrać kolejny.\n\n[color=#91dbea][b]Asteroidy zostawiają fizyczne odłamki[/b][/color]\nStrzelaj do asteroid. Po zniszczeniu rozpadają się bazowo na 5–7 poszarpanych fragmentów. Każdy to jedna jednostka surowca — musisz go zebrać. Umiejętność górnika zwiększa uzysk.\n\n[color=#91dbea][b]Przeszukuj wraki[/b][/color]\nPodleć do wraku i przytrzymaj E, aby go przeszukać. Możesz znaleźć zapasy lub wyposażenie.\n\n[color=#91dbea][b]Warsztat — przycisk TWÓRZ[/b][/color]\nZ minerałów wytworzysz części, amunicję, rakiety i powerbanki. Receptura pokazuje wymagane składniki oraz ilość produktu.\n\n[color=#91dbea][b]Odzyskuj części[/b][/color]\nNajedź na zbędny przedmiot w magazynie i użyj przycisku rozłożenia. Części przydadzą się do napraw."],
["Statek i kapitan", "05", "[font_size=26][color=#f1bd79]Buduj własny styl gry[/color][/font_size]\n\n[color=#91dbea][b]Wyposażenie[/b][/color]\nKliknij panel statku w prawym górnym rogu i otwórz magazyn. Przeciągaj przedmioty do pasujących slotów: 3 bronie, 5 modułów i 5 rozszerzeń. Aktywne rozszerzenia uruchamiasz klawiszami 1–5; pasywne (oznaczone P) działają automatycznie w tych samych slotach. Najedź kursorem, żeby sprawdzić parametry i porównanie wyposażenia.\n\n[color=#91dbea][b]Nie każdy duplikat jest taki sam[/b][/color]\nBroń i moduły mogą mieć różne parametry oraz addony. Aktywne rozszerzenia losują cooldown od 30 do 5 pełnych sekund. Pasywne mają losowaną siłę efektu: Recykler odnawia tarcze i energię po zebraniu odłamka, Wirus przejmuje uciekających wrogów, a Pancerz z tytanu ogranicza obrażenia przy niskim kadłubie. Krótsze odnowienia są rzadkie — przed rozłożeniem porównaj egzemplarze. Rozszerzenia tego samego typu dzielą aktywne odnowienie.\n\n[color=#91dbea][b]Umiejętności kapitana[/b][/color]\nZa pokonanych wrogów zdobywasz doświadczenie i kredyty. Po awansie wybierasz umiejętność. Pasywne działają stale, aktywne przypisujesz do F1–F5.\n\n[color=#f1bd79][b]Okno umiejętności nie pauzuje gry.[/b][/color]\nOtwieraj je w bezpiecznym miejscu. Przed kolejnym układem uzupełnij zapasy i sprawdź wyposażenie."]
["Statek i kapitan", "05", "[font_size=26][color=#f1bd79]Buduj własny styl gry[/color][/font_size]\n\n[color=#91dbea][b]Wyposażenie[/b][/color]\nKliknij panel statku w prawym górnym rogu i otwórz magazyn. Przeciągaj przedmioty do pasujących slotów: 3 bronie, 5 modułów i 5 rozszerzeń. Aktywne rozszerzenia uruchamiasz klawiszami 1–5; pasywne (oznaczone P) działają automatycznie w tych samych slotach. Najedź kursorem, żeby sprawdzić parametry i porównanie wyposażenia.\n\n[color=#91dbea][b]Nie każdy duplikat jest taki sam[/b][/color]\nBroń i moduły mogą mieć różne parametry oraz addony. Aktywne rozszerzenia losują cooldown od 30 do 5 pełnych sekund. Pasywne mają losowaną siłę efektu: Recykler odnawia tarcze i energię po zebraniu odłamka, Wirus przejmuje uciekających wrogów, a Pancerz z tytanu ogranicza obrażenia przy niskim kadłubie. Krótsze odnowienia są rzadkie — przed rozłożeniem porównaj egzemplarze. Rozszerzenia tego samego typu dzielą aktywne odnowienie.\n\n[color=#91dbea][b]Umiejętności kapitana[/b][/color]\nZa pokonanych wrogów zdobywasz doświadczenie i kredyty. Trudniejsze układy dają więcej bazowego XP. Jeśli kapitan przewyższa poziom układu o ponad 2 poziomy, XP za przeciwników i ruiny maleje; przy przewadze 8 poziomów spada do zera. Kredyty i surowce nadal zbierasz. Po awansie wybierasz umiejętność. Pasywne działają stale, aktywne przypisujesz do F1–F5.\n\n[color=#f1bd79][b]Okno umiejętności nie pauzuje gry.[/b][/color]\nOtwieraj je w bezpiecznym miejscu. Przed kolejnym układem uzupełnij zapasy i sprawdź wyposażenie."]
]
var _panel: PanelContainer
var _reader: RichTextLabel

View File

@ -14,7 +14,7 @@ func _ready() -> void:
ship.get_node("WeaponController").set_physics_process(false)
ship.set("credits_multiplier", 1.5)
var inventory := ship.get_node("ShipInventory") as ShipInventory
assert(inventory.ammunition == 50)
assert(inventory.ammunition == ShipInventory.STARTING_AMMUNITION)
var enemy := ENEMY.instantiate() as CharacterBody2D
enemy.position = Vector2(700, 0)
add_child(enemy)

View File

@ -0,0 +1,92 @@
extends Node
const BAL = preload("res://scripts/captain/captain_balance.gd")
const ENEMY = preload("res://scenes/game/enemy_ship.tscn")
const POI = preload("res://scripts/levels/exploration_poi.gd")
func _ready() -> void:
assert(BAL.xp_required(1) == 120 and BAL.xp_required(2) == 180)
var captain := CaptainProgress.new()
for level in range(1, 1001):
assert(BAL.xp_required(level) == 120 + 60 * (level - 1))
for gap in range(0, 12):
captain.level = 20 + gap
var xp := captain.reward_enemy(20, 0, 1.0, 1.0).x
var expected := roundi(39.0 * BAL.encounter_xp_factor(20 + gap, 20))
assert(xp == expected)
captain.level = 9
assert(captain.encounter_xp(1000000, 1, 1000) == 0)
var money := captain.credits
assert(captain.reward_enemy(1, 0, 1.0, 1.0) == Vector2i(0, 15))
assert(captain.credits == money + 15)
captain = CaptainProgress.new()
for index in 20000:
captain.reward_enemy(1, index % 3, 1.0, 1.0)
assert(captain.level == 9)
assert(captain.encounter_xp(20, 1) == 0)
assert(captain.reward_enemy(9, 0, 1.0, 1.0).x > 0)
# Normal progress still reaches later captain levels.
captain = CaptainProgress.new()
for level in range(1, 51):
for tier in preload("res://scripts/levels/level_balance.gd").enemy_tiers(level):
captain.reward_enemy(level, tier, 1.0, 1.0)
assert(captain.level >= 39 and captain.level <= 41)
print("NORMAL_PROGRESS_CAPTAIN_LEVEL_AT_50: ", captain.level)
GameState.start_new_run()
var ship := preload("res://scenes/game/player_ship.tscn").instantiate()
add_child(ship)
ship.set_physics_process(false)
ship.get_node("WeaponController").set_physics_process(false)
ship.get_node("ExtensionSystems").set_physics_process(false)
var inv: ShipInventory = ship.get_node("ShipInventory")
assert(inv.ammunition == 200)
inv.ammunition = 123
inv.preserve_for_restart()
ship.queue_free()
await get_tree().process_frame
ship = preload("res://scenes/game/player_ship.tscn").instantiate()
add_child(ship)
ship.set_physics_process(false)
ship.get_node("WeaponController").set_physics_process(false)
ship.get_node("ExtensionSystems").set_physics_process(false)
inv = ship.get_node("ShipInventory")
assert(inv.ammunition == 123, "Restart must preserve ammunition, not grant another starting stack")
var ammo_wrecks := 0
for level in [1, 50]:
GameState.system_level = level
for reward_seed in range(1, 50):
var poi := StaticBody2D.new()
poi.set_script(POI)
poi.kind = "wreck"
poi.player = ship
poi.reward_seed = reward_seed
poi.poi_id = "balance:%d:%d" % [level, reward_seed]
add_child(poi)
var state: int = poi._rng.state
var choice: int = poi._rng.randi_range(0, 3)
poi._rng.state = state
if choice == 3:
var before := inv.ammunition
assert(poi.claim())
assert(inv.ammunition - before >= 40 and inv.ammunition - before <= 60)
ammo_wrecks += 1
else:
poi.queue_free()
assert(ammo_wrecks > 10)
var enemy := ENEMY.instantiate()
add_child(enemy)
enemy.set_physics_process(false)
enemy.apply_level_settings(1, 2)
assert(is_equal_approx(enemy.max_hull, 240.0))
assert(is_equal_approx(enemy.max_shields, 96.0))
assert(is_equal_approx(enemy.weapon_damage, 32.0))
assert(is_equal_approx(enemy.approach_speed, 99.2))
var previous_hull: float = enemy.max_hull
for level in range(2, 7):
enemy.apply_level_settings(level, 2)
assert(enemy.max_hull > previous_hull)
previous_hull = enemy.max_hull
assert(is_equal_approx(enemy.max_hull, 500.0))
enemy.apply_level_settings(1, 0)
assert(is_equal_approx(enemy.max_hull, 62.5) and is_equal_approx(enemy.weapon_damage, 10.0))
print("BALANCE_ADJUSTMENTS_TEST: PASS XP thresholds, farm limit, normal progression, starting/restart ammo, actual wreck rewards and smooth boss introduction")
get_tree().quit()

View File

@ -0,0 +1 @@
uid://b21jkaebwdnla

View File

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/levels/balance_adjustments_test.gd" id="1"]
[node name="BalanceAdjustmentsTest" type="Node"]
script = ExtResource("1")

View File

@ -0,0 +1,262 @@
extends Node
# Reproducible Monte Carlo audit. Combat is an analytical estimate, not a movement bot.
const GEN = preload("res://scripts/items/item_generator.gd")
const BAL = preload("res://scripts/levels/level_balance.gd")
const SKILLS = preload("res://scripts/captain/captain_skills.gd")
const RECIPES = preload("res://scripts/items/crafting_recipes.gd")
const RUNS := 60
var ship: CharacterBody2D
var inv: ShipInventory
var enemy: CharacterBody2D
var rows: Array = []
var finishes: Array = []
var weapon_id := ""
var collect := 0.75
var accuracy := 0.75
var shot_demand := 0.0
var mined_this_level := 0
var cash_spent := 0
var ammo_shortages := 0
func item_score(item: ItemInstance) -> float:
if item == null:
return -1.0
var v := 0.0
for key in ["max_hull", "max_shields", "max_energy", "energy_regeneration", "shield_regeneration", "damage_percent", "critical_chance_percent", "critical_damage_percent", "kinetic_absorption", "energy_absorption", "radiation_absorption", "hull_percent", "shield_percent", "experience_percent", "credits_percent"]:
var weight: float = {"energy_regeneration": 10.0, "shield_regeneration": 10.0, "damage_percent": 2.0, "experience_percent": 2.0}.get(key, 1.0)
v += item.get_stat_total(key) * weight
return v
func train() -> void:
var priority := ["miner", "explorer", "business_shark", "efficient_magazine", "lucky", "electrician", "reactor_overload", "tactical_maneuver", "power_redirect"]
while GameState.captain.learning_points > 0:
GameState.captain.prepare_skill_offers()
var selected := ""
for id in priority:
if id in GameState.captain.skill_offers:
selected = id
break
if selected.is_empty() or not GameState.captain.train_skill(selected):
break
func loot(level: int, tier: int, module_only: bool = false) -> void:
var item: ItemInstance
if module_only:
var pool: Array = GEN.DEFINITIONS.filter(func(d): return d.category != ItemDefinition.Category.WEAPON)
item = GEN.generate_loot(level, tier, pool.pick_random(), ship.luck_percent)
else:
item = GEN.generate_loot(level, tier, null, ship.luck_percent)
var equipped := false
if item.definition.category == ItemDefinition.Category.WEAPON and item.definition.item_id == weapon_id:
if inv.weapon_slots[0].definition.item_id != weapon_id or inv.get_weapon_comparison(item, 0).dps_delta > 0.0:
inv.weapon_slots[0] = item
equipped = true
elif item.definition.category in ShipInventory.MODULE_CATEGORIES:
var index: int = ShipInventory.MODULE_CATEGORIES.find(item.definition.category)
if item_score(item) > item_score(inv.module_slots[index]):
equipped = true
if inv.module_slots[index] != null:
GameState.captain.parts += 10
inv.module_slots[index] = item
elif item.definition.category == ItemDefinition.Category.ACTIVE_EXTENSION:
var same := -1
for index in inv.extension_slots.size():
if inv.extension_slots[index] != null and inv.extension_slots[index].definition.item_id == item.definition.item_id:
same = index
if same >= 0:
var old: ItemInstance = inv.extension_slots[same]
if (item.is_passive_extension() and item.get_passive_value() > old.get_passive_value()) or (not item.is_passive_extension() and item.extension_cooldown < old.extension_cooldown):
inv.extension_slots[same] = item
equipped = true
GameState.captain.parts += 10
elif inv.extension_slots.has(null):
inv.extension_slots[inv.extension_slots.find(null)] = item
equipped = true
if not equipped:
GameState.captain.parts += 10
inv._recalculate_ship_stats()
func supplies(level: int) -> void:
mined_this_level = 0
var scale := BAL.power_scale(level)
for index in randi_range(6, 10):
if randf() > collect:
continue
mined_this_level += 1
var mineral: String = RECIPES.MINERALS.pick_random()
GameState.captain.minerals[mineral] += randi_range(5, 7) + roundi(GameState.captain.get_skill_bonus("miner"))
var wrecks: int = randi_range(2, 4) + roundi(GameState.captain.get_skill_bonus("explorer"))
for index in wrecks:
if randf() > collect:
continue
match randi_range(0, 3):
0: GameState.captain.parts += roundi(randi_range(3, 8) * scale)
1: loot(level, 0, true)
2: inv.missile_ammunition += maxi(1, roundi(scale))
3: inv.ammunition += roundi(randi_range(40, 60))
# Rare ruins: mean XP, module generation uses the real loot rules.
if randf() < 0.08 * collect:
GameState.captain.add_rewards(GameState.captain.encounter_xp(15 * scale, level), 0)
loot(level + 1, 2, true)
train()
func top_up(level: int) -> void:
var demand := ceili(shot_demand)
var gamma := weapon_id == "gamma_cannon"
var stock: int = inv.irradiated_ammunition if gamma else inv.ammunition
if weapon_id == "plasma_cannon":
return
var recipe := "irradiated_ammunition" if gamma else "ammunition"
while stock < demand and inv.can_craft(recipe):
inv.craft(recipe)
stock += 50
var cost: int = inv.get_irradiated_ammunition_buy_cost() if gamma else inv.get_ammunition_buy_cost()
var batch := 10 if gamma else 100
while stock < demand and GameState.captain.credits >= cost:
GameState.captain.credits -= cost
cash_spent += cost
stock += batch
if stock < demand:
ammo_shortages += 1
if gamma:
inv.irradiated_ammunition = maxi(0, stock - demand)
else:
inv.ammunition = maxi(0, stock - demand)
func combat(level: int, tier: int) -> Dictionary:
enemy.apply_level_settings(level, tier)
var item: ItemInstance = inv.weapon_slots[0]
var dps: float = inv.get_total_dps() * accuracy
var interval: float = item.definition.fire_interval
var energy_rate: float = item.get_energy_cost() / interval
var sustainable := minf(1.0, ship.energy_regeneration / maxf(0.001, energy_rate))
var profile := DamageTypes.enemy_profile(level, GameState.system_seed)
dps *= 1.0 - profile.absorption[item.definition.get_damage_type()] / 100.0
if item.definition.weapon_kind == ItemDefinition.WeaponKind.GAMMA:
# Existing comparison includes 1 + .25 * interval; no stacking DoT assumed.
pass
var regen: float = (enemy.max_hull + enemy.max_shields) * 0.01
var burst_time: float = ship.max_energy / maxf(0.001, energy_rate - ship.energy_regeneration) if energy_rate > ship.energy_regeneration else 100000.0
var ehp: float = enemy.max_hull + enemy.max_shields
var seconds := ehp / maxf(0.01, dps - regen)
if seconds > burst_time:
var remaining := maxf(0.0, ehp - maxf(0.0, dps - regen) * burst_time)
seconds = burst_time + remaining / maxf(0.01, dps * sustainable - regen)
seconds = minf(3600.0, seconds)
var enemy_dps: float = enemy.get_weapon_dps()
var resistance: float = ship.absorption[DamageTypes.for_weapon(enemy.weapon_kind)] / 100.0
# Stationary exposure baseline. Movement success must be supplied, not inferred.
var incoming := enemy_dps * (1.0 - resistance)
var survival: float = (ship.max_hull + ship.max_shields) / maxf(0.01, incoming - ship.shield_regeneration)
var used := seconds / interval * (1.0 - GameState.captain.get_skill_bonus("efficient_magazine")) if item.definition.weapon_kind in [ItemDefinition.WeaponKind.MACHINE_GUN, ItemDefinition.WeaponKind.GAMMA] else 0.0
if item.definition.item_id != weapon_id:
used = 0.0
return {"seconds": seconds, "survival": survival, "used": used, "sustainable": sustainable, "regen_lock": dps * sustainable <= regen, "enemy_dps": incoming, "required_avoid": 1.0 - clampf((ship.shield_regeneration + (ship.max_hull + ship.max_shields) / maxf(0.01, seconds)) / maxf(0.01, incoming), 0.0, 1.0)}
func group_exposure(level: int) -> Dictionary:
# Upper-pressure sample: both elites plus up to three normal ships firing together.
var tiers: Array[int] = [1, 1]
for index in mini(3, BAL.enemy_count(level) - 3):
tiers.append(0)
var incoming := 0.0
for tier in tiers:
enemy.apply_level_settings(level, tier)
incoming += enemy.get_weapon_dps() * (1.0 - ship.absorption[DamageTypes.for_weapon(enemy.weapon_kind)] / 100.0)
var survival: float = (ship.max_hull + ship.max_shields) / maxf(0.01, incoming - ship.shield_regeneration)
return {"survival": survival, "size": tiers.size(), "dps": incoming}
func run_trial(profile: String, trial: int) -> void:
seed(920001 + trial * 7919)
GameState.start_new_run()
GameState.captain._skill_rng.seed = 440001 + trial * 3571
weapon_id = "machine_gun" if profile == "machine" else ("gamma_cannon" if profile == "gamma" else "plasma_cannon")
collect = 0.3 if profile == "rookie_energy" else 0.75
accuracy = 0.45 if profile == "rookie_energy" else 0.75
inv.weapon_slots.fill(null)
inv.module_slots.fill(null)
inv.extension_slots.fill(null)
inv._add_starter_items()
inv.ammunition = ShipInventory.STARTING_AMMUNITION
inv.irradiated_ammunition = 0
inv.missile_ammunition = 2
inv.energy_banks = 0
inv._recalculate_ship_stats()
cash_spent = 0
ammo_shortages = 0
var all_skills := 0
var all_types := 0
var kills := 0
var earned := 0
for level in range(1, 51):
GameState.system_level = level
GameState.system_seed = 100000 + trial * 53 + level * 997
# Explore before combat. No spending resources on optional missiles/banks.
supplies(level)
shot_demand = 0.0
var fight_seconds := 0.0
var boss_seconds := 0.0
var boss_survival := 0.0
var regen_locks := 0
var boss_avoid := 0.0
var normal_immune := 0
var tiers: Array[int] = BAL.enemy_tiers(level)
tiers.pop_front()
tiers.append(2)
for tier in tiers:
var c := combat(level, tier)
fight_seconds += c.seconds
shot_demand += c.used
if tier == 2:
boss_seconds = c.seconds
boss_survival = c.survival
boss_avoid = c.required_avoid
if tier == 0 and ship.shield_regeneration >= c.enemy_dps:
normal_immune += 1
regen_locks += int(c.regen_lock)
var reward := GameState.captain.reward_enemy(level, tier, ship.experience_multiplier, ship.credits_multiplier)
earned += reward.y
kills += 1
train()
if all_skills == 0 and GameState.captain.get_spent_learning_points() == 27:
all_skills = level
if all_types == 0 and GameState.captain.skills.size() == SKILLS.IDS.size():
all_types = level
if randf() < 0.1 * collect:
inv.energy_banks += 1
if randf() < (0.65 if tier == 0 else (0.9 if tier == 1 else 1.0)) * collect:
loot(level, tier)
# Add mining shots only for kinetic/radiation builds, same hit accuracy.
if weapon_id != "plasma_cannon" and inv.weapon_slots[0].definition.item_id == weapon_id:
shot_demand += float(mined_this_level) * 20.0 * BAL.power_scale(level) / maxf(1, inv.weapon_slots[0].get_stat_total("damage")) / accuracy * (1.0 - GameState.captain.get_skill_bonus("efficient_magazine"))
var group := group_exposure(level)
var before_shortages := ammo_shortages
top_up(level)
var minerals := 0
for count in GameState.captain.minerals.values():
minerals += int(count)
rows.append({"profile": profile, "trial": trial, "level": level, "captain_level": GameState.captain.level, "ranks": GameState.captain.get_spent_learning_points(), "credits": GameState.captain.credits, "earned": earned, "spent": cash_spent, "parts": GameState.captain.parts, "minerals": minerals, "ammunition": inv.ammunition, "gamma": inv.irradiated_ammunition, "missiles": inv.missile_ammunition, "banks": inv.energy_banks, "ammo_demand": shot_demand, "shortage": ammo_shortages > before_shortages, "boss_seconds": boss_seconds, "boss_survival": boss_survival, "boss_required_avoid": boss_avoid, "normal_immune": normal_immune, "fight_seconds": fight_seconds, "regen_locks": regen_locks, "dps": inv.get_total_dps(), "energy_regen": ship.energy_regeneration, "shield_regen": ship.shield_regeneration, "ehp": ship.max_hull + ship.max_shields, "mineral_stocks": GameState.captain.minerals.duplicate(), "group_survival": group.survival, "group_size": group.size, "group_dps": group.dps})
finishes.append({"profile": profile, "trial": trial, "all_skills": all_skills, "all_types": all_types, "shortage_boards": ammo_shortages, "kills": kills})
func _ready() -> void:
GameState.start_new_run()
ship = preload("res://scenes/game/player_ship.tscn").instantiate()
add_child(ship)
inv = ship.get_node("ShipInventory")
ship.set_physics_process(false)
ship.get_node("WeaponController").set_physics_process(false)
ship.get_node("ExtensionSystems").set_physics_process(false)
enemy = preload("res://scenes/game/enemy_ship.tscn").instantiate()
add_child(enemy)
enemy.set_physics_process(false)
for profile in ["rookie_energy", "energy", "machine", "gamma"]:
for trial in RUNS:
run_trial(profile, trial)
print("SIMULATION_PROFILE_DONE: ", profile)
await get_tree().process_frame
var output := {"runs_per_profile": RUNS, "profiles": 4, "levels": 50, "rows": rows, "finishes": finishes}
var output_path := "res://artifacts/balance_simulation_50_after.json" if "--after-balance" in OS.get_cmdline_user_args() else "res://artifacts/balance_simulation_50.json"
var file := FileAccess.open(output_path, FileAccess.WRITE)
file.store_string(JSON.stringify(output))
file.close()
print("BALANCE_SIMULATION_50: DONE ", rows.size(), " rows")
get_tree().quit()

View File

@ -0,0 +1 @@
uid://cda37388p0bsd

View File

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/levels/balance_simulation_50.gd" id="1"]
[node name="BalanceSimulation50" type="Node"]
script = ExtResource("1")

View File

@ -0,0 +1,35 @@
extends Node
const BAL = preload("res://scripts/levels/level_balance.gd")
func _ready() -> void:
var outcomes: Array = []
for style in ["diverse", "random"]:
for trial in 300:
var captain := CaptainProgress.new()
captain._skill_rng.seed = 81723 + trial
var rng := RandomNumberGenerator.new()
rng.seed = 55001 + trial
var all_types := 0
var all_ranks := 0
for level in range(1, 51):
for tier in BAL.enemy_tiers(level):
captain.reward_enemy(level, tier, 1.0, 1.0)
while captain.learning_points > 0:
captain.prepare_skill_offers()
if captain.skill_offers.is_empty():
break
var pick: String = captain.skill_offers[rng.randi_range(0, captain.skill_offers.size() - 1)]
if style == "diverse":
for skill in captain.skill_offers:
if captain.get_skill_rank(skill) < captain.get_skill_rank(pick):
pick = skill
assert(captain.train_skill(pick))
if captain.skills.size() == 9 and all_types == 0:
all_types = level
if captain.get_spent_learning_points() == 27 and all_ranks == 0:
all_ranks = level
outcomes.append({"style": style, "trial": trial, "all_types": all_types, "all_ranks": all_ranks})
var f := FileAccess.open("res://artifacts/balance_skill_paths.json", FileAccess.WRITE)
f.store_string(JSON.stringify(outcomes))
f.close()
print("BALANCE_SKILL_PATHS: PASS 600 seeded skill paths using actual offers, training and XP")
get_tree().quit()

View File

@ -0,0 +1 @@
uid://c8ppmpp1big6q

View File

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/levels/balance_skill_paths.gd" id="1"]
[node name="BalanceSkillPaths" type="Node"]
script = ExtResource("1")

View File

@ -5,6 +5,10 @@ const GENERATOR := preload("res://scripts/levels/system_generator.gd")
const ENEMY := preload("res://scenes/game/enemy_ship.tscn")
func _ready() -> void:
assert(BALANCE.enemy_tiers(1).count(0) == 8)
assert(preload("res://resources/items/gamma_cannon.tres").weapon_range == 300.0)
for level in range(2, 1001):
assert(BALANCE.enemy_count(level) >= BALANCE.enemy_count(level - 1))
for level in range(1, 1001):
var tiers := BALANCE.enemy_tiers(level)
assert(tiers.count(1) == 2 and tiers.count(2) == 1)

View File

@ -59,8 +59,7 @@ func _ready() -> void:
assert(absf(position.x) <= 2380 and absf(position.y) <= 1580)
for other_index in range(index + 1, enemies.size()):
assert(position.distance_to(enemies[other_index]["position"]) >= GENERATOR.ENEMY_MIN_SEPARATION)
if level <= 10:
assert(enemies[index]["tier"] == (2 if index == 0 else 0))
assert(enemies[index]["tier"] == BALANCE.enemy_tiers(level)[index])
assert(observed_counts.size() == 5)
assert(observed_ship_types.size() == GENERATOR.ALIEN_SHIPS.TEXTURES.size())
print("SYSTEM_GENERATION_TEST: PASS (420 layouts, deterministic retries, planets, safe spawn and level balance 1–1000)")