Add server-managed balance configuration

This commit is contained in:
Emporex committed 2026-10-11 10:47:13 +02:00
1 parent 67abdf13b0
commit 39ed4c3bf7
12 files changed
+289 -27

No files matched your search

+4
View File
@@ -30,6 +30,10 @@ Die Serverfunktion `private_stats` ist ausschließlich über den geheimen Nakama
Die geschützte Statistikseite kann außerdem Freischaltcodes für alle Türme, alle Karten oder beides erstellen. `create_unlock_code` ist nur Server-zu-Server mit dem Nakama-HTTP-Schlüssel verfügbar. Codes werden über ihren SHA-256-Schlüssel nachgeschlagen; zusätzlich liegt der Klartextcode für die geschützte Admin-Übersicht in einem privaten Nakama-Speicherobjekt mit `permissionRead:0` und `permissionWrite:0`. Das Spiel kann diese Objekte nicht lesen. `list_unlock_codes` liefert Code, Freischaltumfang, optionale Bemerkung, Erstellungszeit und Einlösungszähler ausschließlich für Server-zu-Server-Aufrufe zurück. Die Statistikseite zeigt daraus „Offen“, „Teilweise eingelöst“ oder „Eingelöst“; die Bemerkung ist optional und auf 160 Zeichen begrenzt. Hinweise können z. B. Empfänger beschreiben und sollten keine unnötigen privaten Daten enthalten. Bereits vor dieser Erweiterung erzeugte Codes behalten ihren Status, aber ihr Klartext kann nicht rekonstruiert werden.
Das Einheiten-Balancing liegt einheitlich im privaten Nakama-Speicher (`game_balance/global`). Die geschützte Statistikseite liest und ändert es über die nur mit `NAKAMA_HTTP_KEY` aufrufbaren RPCs `get_game_balance` und `set_game_balance`; der HTTP-Schlüssel wird nie an Clients ausgeliefert. Angemeldete Spielclients rufen `get_game_balance` beim Start auf, cachen die letzte gültige Konfiguration lokal und verwenden sie auch offline. Jede neue Online-Partie lädt dieselbe Konfiguration serverseitig bei `matchInit`; bereits laufende Partien bleiben unverändert. Falls der Server unerreichbar ist, gelten der letzte lokale Cache bzw. die eingebauten Ausgangswerte. Änderung des Balancings erfordert dadurch keine neue Spiel-EXE, aber das Runtime-Bundle auf dem Nakama-Server und die interne PHP-Seite müssen zunächst aktualisiert werden.
`server/nakama/tests/balance_duel_test.js` ist ein reproduzierbarer Referenztest, der die echte Nakama-Kampf-Simulation nutzt und alle zulässigen Turm-/Gegner-Kombinationen sowie den ersten Boss bewertet. Er ist ein gezielter 1-gegen-1-Wegtest (ein Level-1-Turm, keine Upgrades/Unterstützung), kein Ersatz für komplette Wellen- und Spielertests. Ergebnisse daher als Warnsignal und Vergleich zwischen Versionen verwenden, nicht als alleinige automatische Balancing-Entscheidung.
Pro Konto gelten höchstens zehn Codeversuche in fünf Minuten. Die Spieler lösen Codes im Menü über `redeem_unlock_code` ein; die Freischaltungen liegen am Onlinekonto und werden nach einer Anmeldung über `get_promo_unlocks` wiederhergestellt. Für die Codeverwaltung müssen sowohl das Nakama-Runtime-Bundle als auch die PHP-Statistikseite aktualisiert werden. Der Nakama-HTTP-Schlüssel bleibt ausschließlich auf Unraid und im privaten Webhosting-Verzeichnis.
Im Hauptmenü ist eine Ranglistenansicht für die aktuell gewählte Karte und Schwierigkeit eingebaut. Sie zeigt Mission und Endlosmodus getrennt, löst aktuelle öffentliche Profilnamen auf und hebt den eigenen Eintrag hervor.
+1 -1
View File
@@ -93,7 +93,7 @@ Der mitgelieferte Logiktest lässt sich bei Bedarf mit `godot --headless --path
Für reproduzierbare Windows-Builds steht `packaging/windows/build.ps1` bereit. Godot und NSIS werden als Parameter übergeben; das Skript exportiert das Spiel, erstellt den Installer und gibt anschließend Größe und SHA-256-Prüfsumme aus. Die manuellen Freigabeprüfungen stehen in `ALPHA_TEST_CHECKLIST.md`.
Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.11.md`.
Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.12.md`.
## Werbung und Datenschutz
+14
View File
@@ -0,0 +1,14 @@
# Void Bastion 0.1.0-alpha.12
## Balance-Konfiguration und Online-Korrekturen
- Türme und Gegner verwenden für Offline-Partien dieselbe zentrale Balance-Konfiguration wie für neue Online-Partien.
- Angemeldete Spieler laden freigegebene Werte automatisch; der letzte gültige Stand wird lokal zwischengespeichert und bei fehlender Verbindung weiterverwendet.
- Laufende Online-Partien behalten ihren beim Start geladenen Balance-Schnappschuss.
- Der erste Boss hat weniger Hüllen- und Schildpunkte.
- Die interne Statistikseite kann Balance-Werte für Türme und Gegner bearbeiten.
## Hinweise
- Das Duellwerkzeug ist eine gezielte Einzelkonfrontation und kein vollständiger automatisierter Durchspieltest. Das Spielbalancing sollte zusätzlich mit echten Missionsläufen geprüft werden.
- Balance-Änderungen auf dem Server wirken auf neue Online-Partien sofort und auf Offline-Partien nach dem nächsten Start mit erreichbarem Server; bei Offline-Nutzung ohne Verbindung gelten Cache oder eingebaute Werte.
+63 -3
View File
@@ -216,6 +216,7 @@ const ENEMY_DATA := [
{"name": "STURMPANZER", "category": "ground", "hp": 390.0, "speed": 61.0, "reward": 61, "size": 92.0, "breach_damage": 3, "shield_resistance": 0.28},
{"name": "BELAGERUNGSLAEUFER", "category": "ground", "hp": 980.0, "speed": 36.0, "reward": 132, "size": 124.0, "breach_damage": 5, "shield_resistance": 0.28}
]
const BALANCE_CACHE_PATH := "user://balance_cache.json"
var path_points := PackedVector2Array()
var path_cells: Dictionary = {}
@@ -405,6 +406,7 @@ var ui_language := "de"
func _ready() -> void:
_load_balance_cache()
_select_map(selected_map)
_load_progress()
_load_audio_settings()
@@ -539,10 +541,65 @@ func _create_promo_code_input() -> void:
func _on_online_authenticated(_user_id: String, _username: String) -> void:
var balance_response: Dictionary = await online_manager.fetch_game_balance()
var balance_config = balance_response.get("config", {})
if balance_config is Dictionary and not balance_config.is_empty():
_save_balance_cache(balance_config)
if game_mode in ["menu", "promo_code", "store", "leaderboard", "profile", "multiplayer_menu", "friends", "room_code_entry", "matchmaking"]:
_apply_balance_config(balance_config)
var entitlements: Dictionary = await online_manager.get_promo_unlocks()
_apply_promo_unlocks(entitlements)
func _load_balance_cache() -> void:
if not FileAccess.file_exists(BALANCE_CACHE_PATH):
return
var file := FileAccess.open(BALANCE_CACHE_PATH, FileAccess.READ)
if file == null:
return
var parsed = JSON.parse_string(file.get_as_text())
if parsed is Dictionary:
_apply_balance_config(parsed)
func _save_balance_cache(config: Dictionary) -> void:
var file := FileAccess.open(BALANCE_CACHE_PATH, FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(config))
func _apply_balance_config(config: Dictionary) -> bool:
var towers_config = config.get("towers", [])
var enemies_config = config.get("enemies", [])
if not towers_config is Array or towers_config.size() != TOWER_DATA.size() or not enemies_config is Array or enemies_config.size() != ENEMY_DATA.size():
return false
for values in towers_config:
if not values is Dictionary or str(values.get("targetClass", "both")) not in ["air", "ground", "both"]:
return false
for values in enemies_config:
if not values is Dictionary:
return false
for index in TOWER_DATA.size():
var values: Dictionary = towers_config[index]
var tower: Dictionary = TOWER_DATA[index]
tower["cost"] = clampi(int(values.get("cost", tower.cost)), 10, 50000)
tower["damage"] = clampf(float(values.get("damage", tower.damage)), 0.1, 10000.0)
tower["range"] = clampf(float(values.get("range", tower.range)), 20.0, 1200.0)
tower["rate"] = clampf(float(values.get("cooldown", tower.rate)), 0.04, 20.0)
var target_class := str(values.get("targetClass", tower.get("target_class", "both")))
tower["target_class"] = target_class
tower["both_damage_scale"] = clampf(float(values.get("bothScale", tower.get("both_damage_scale", 1.0))), 0.05, 2.0)
for index in ENEMY_DATA.size():
var values: Dictionary = enemies_config[index]
var enemy: Dictionary = ENEMY_DATA[index]
enemy["hp"] = clampf(float(values.get("hp", enemy.hp)), 1.0, 1000000.0)
enemy["speed"] = clampf(float(values.get("speed", enemy.speed)), 1.0, 1500.0)
enemy["reward"] = clampi(int(values.get("reward", enemy.reward)), 0, 50000)
enemy["breach_damage"] = clampi(int(values.get("breachDamage", enemy.breach_damage)), 1, 100)
enemy["shield_resistance"] = clampf(float(values.get("shieldResistance", enemy.shield_resistance)), 0.01, 1.0)
return true
func _apply_promo_unlocks(entitlements: Dictionary) -> void:
if bool(entitlements.get("all_towers", false)):
unlocked_towers = []
@@ -654,9 +711,12 @@ func _spawn_enemy() -> void:
var type := int(schedule_entry.get("type", _enemy_type_for_wave(wave, randf(), is_boss)))
wave_spawn_index += 1
var data: Dictionary = ENEMY_DATA[type]
var health_scale := _enemy_health_scale(wave, is_boss) * enemy_strength_multiplier
var base_health_scale := _enemy_health_scale(wave, is_boss)
var first_boss_hull_scale := 0.5 if is_boss and wave == 5 else 1.0
var health_scale := base_health_scale * enemy_strength_multiplier * first_boss_hull_scale
var shield_scale := base_health_scale * enemy_strength_multiplier
var has_shield := bool(data.get("default_shield", false)) or (wave >= 4 and randf() < minf(0.42, 0.10 + wave * 0.018 + selected_difficulty * 0.06))
var max_shield_hp: float = data.hp * health_scale * 0.55 if has_shield else 0.0
var max_shield_hp: float = data.hp * shield_scale * 0.55 if has_shield else 0.0
if is_boss and wave == 5:
max_shield_hp *= 0.5
var route_id := 0
@@ -673,7 +733,7 @@ func _spawn_enemy() -> void:
"route_id": route_id,
"hp": data.hp * health_scale,
"max_hp": data.hp * health_scale,
"base_max_hp": data.hp * _enemy_health_scale(wave, is_boss),
"base_max_hp": data.hp * base_health_scale * first_boss_hull_scale,
"speed": data.speed * (1.0 + wave * 0.012) * (0.82 if is_boss else 1.0) * float(_difficulty().speed),
"reward": int((data.reward + wave) * (4 if is_boss else 1) * float(_difficulty().income)),
"angle": 0.0,
+7
View File
@@ -394,6 +394,13 @@ func server_info() -> Dictionary:
return _parse_rpc_result(result)
func fetch_game_balance() -> Dictionary:
if not is_authenticated():
return {}
var result = await client.rpc_async(session, "get_game_balance", "{}")
return _parse_rpc_result(result)
func submit_run(record: Dictionary) -> bool:
if not is_authenticated():
return false
+1 -1
View File
@@ -6,7 +6,7 @@ RequestExecutionLevel user
!include "LogicLib.nsh"
!define PRODUCT_NAME "Void Bastion"
!define PRODUCT_VERSION "0.1.0-alpha.11"
!define PRODUCT_VERSION "0.1.0-alpha.12"
!define PRODUCT_PUBLISHER "GerGames"
!define PRODUCT_WEBSITE "https://void-bastion.gergames.de/"
!define PRODUCT_EXE "Void-Bastion.exe"
+1 -1
View File
@@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Void Bastion"
config/version="0.1.0-alpha.11"
config/version="0.1.0-alpha.12"
run/main_scene="res://main.tscn"
config/features=PackedStringArray("4.7")
config/icon="res://assets/app_icon.png"
+3
View File
@@ -44,6 +44,9 @@ Die Server-zu-Server-RPC `list_online_diagnostics` liefert mit `{}` eine kompakt
- `record_activity`: nur für angemeldete Spieler; aktualisiert die pseudonyme Aktivität sowie aggregierte Plattform- und Versionswerte.
- `private_stats`: nur als Server-zu-Server-Aufruf mit `NAKAMA_HTTP_KEY`; liefert die zusammengefassten Werte für die interne Homepage.
- `get_game_balance`: authentifizierte Spielclients erhalten die freigegebene Balance-Konfiguration; `set_game_balance`: ausschließlich Server-zu-Server mit `NAKAMA_HTTP_KEY` darf sie ändern. Die Werte gelten für neue Online-Partien; `matchInit` lädt einen privaten Snapshot in den Match-Zustand.
- `create_unlock_code`, `list_unlock_codes` und `delete_unlock_code`: nur Server-zu-Server mit `NAKAMA_HTTP_KEY`; verwalten private Freischaltcodes inklusive Einlösungsstatus und optionaler Admin-Bemerkung. Löschen ist nur bei null Einlösungen möglich; eine versionsgebundene Löschung schützt vor einer gleichzeitigen Einlösung.
Balancing-Regressionstest: `node tests/runtime_sim_test.js` prüft Runtime- und RPC-Verhalten; `node tests/balance_duel_test.js` spielt reproduzierbare Einzelduelle mit der echten Server-Kampflogik durch. Der Duelltest ist eine Referenzmessung, kein vollständiger Durchspiel-Bot.
Der Code-Lookup verwendet einen SHA-256-Schlüssel. Für die private Admin-Übersicht wird der Klartextcode zusätzlich in einem Nakama-Speicherobjekt mit `permissionRead:0` und `permissionWrite:0` gehalten; Spielclients können dieses Objekt nicht lesen. Bemerkungen sind auf 160 Zeichen beschränkt. Die echte Webhosting-Konfiguration mit dem HTTP-Schlüssel darf nicht in Git abgelegt werden.
+92 -19
View File
@@ -15,6 +15,8 @@ var PROMO_ATTEMPT_COLLECTION = "unlock_attempts";
var DIAG_PARTICIPANT_COLLECTION = "online_diag_participants";
var DIAG_REPORT_COLLECTION = "online_diag_reports";
var DIAG_RETENTION_MS = 14 * 86400000;
var BALANCE_COLLECTION = "game_balance";
var BALANCE_KEY = "global";
var SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
var OP_SNAPSHOT = 1, OP_EVENT = 2, OP_BUILD = 10, OP_UPGRADE = 11, OP_SELL = 12, OP_START_WAVE = 13, OP_READY = 14;
var TICK_RATE = 10, SNAPSHOT_TICKS = 1, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
@@ -49,6 +51,70 @@ var DIFFICULTIES = [
{hp:1.72,speed:1.15,count:1.48,income:0.84,spawnDelay:0.82,startCredits:400}
];
function defaultBalanceConfig() {
return {schema:1,towers:JSON.parse(JSON.stringify(TOWERS)),enemies:JSON.parse(JSON.stringify(ENEMIES))};
}
function boundedNumber(value, fallback, minimum, maximum, integer) {
var number = Number(value);
if (!isFinite(number)) number = fallback;
number = Math.max(minimum, Math.min(maximum, number));
return integer ? Math.floor(number) : number;
}
function sanitizeBalanceConfig(input) {
var defaults = defaultBalanceConfig();
if (!input || !Array.isArray(input.towers) || input.towers.length !== defaults.towers.length ||
!Array.isArray(input.enemies) || input.enemies.length !== defaults.enemies.length) throw new Error("invalid balance schema");
var towers = defaults.towers.map(function(base, index) {
var candidate = input.towers[index] || {};
var targetClass = String(candidate.targetClass || base.targetClass);
if (["air","ground","both"].indexOf(targetClass) < 0) targetClass = base.targetClass;
return {
cost:boundedNumber(candidate.cost,base.cost,10,50000,true),
damage:boundedNumber(candidate.damage,base.damage,0.1,10000,false),
range:boundedNumber(candidate.range,base.range,20,1200,false),
cooldown:boundedNumber(candidate.cooldown,base.cooldown,0.04,20,false),
targetClass:targetClass,
bothScale:boundedNumber(candidate.bothScale,base.bothScale===undefined?1:base.bothScale,0.05,2,false)
};
});
var enemies = defaults.enemies.map(function(base, index) {
var candidate = input.enemies[index] || {};
return {
category:base.category,
hp:boundedNumber(candidate.hp,base.hp,1,1000000,false),
speed:boundedNumber(candidate.speed,base.speed,1,1500,false),
reward:boundedNumber(candidate.reward,base.reward,0,50000,true),
breachDamage:boundedNumber(candidate.breachDamage,base.breachDamage,1,100,true),
shieldResistance:boundedNumber(candidate.shieldResistance,base.shieldResistance,0.01,1,false)
};
});
return {schema:1,towers:towers,enemies:enemies};
}
function readBalanceConfig(nk) {
var record = nk && typeof nk.storageRead === "function" ? readStorageRecord(nk,BALANCE_COLLECTION,BALANCE_KEY,SYSTEM_USER_ID) : null;
var config = defaultBalanceConfig(), updatedAt = 0;
if (record && record.value && record.value.config) {
try { config = sanitizeBalanceConfig(record.value.config); updatedAt = Number(record.value.updated_at||0); }
catch (error) { config = defaultBalanceConfig(); }
}
return {config:config,updated_at:updatedAt};
}
function rpcGetGameBalance(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
return JSON.stringify(readBalanceConfig(nk));
}
function rpcSetGameBalance(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); }
var config = sanitizeBalanceConfig(request.config);
var record = readStorageRecord(nk,BALANCE_COLLECTION,BALANCE_KEY,SYSTEM_USER_ID);
var updatedAt = Date.now();
var write = {collection:BALANCE_COLLECTION,key:BALANCE_KEY,userId:SYSTEM_USER_ID,value:{config:config,updated_at:updatedAt},permissionRead:0,permissionWrite:0,version:record&&record.version?record.version:"*"};
nk.storageWrite([write]);
return JSON.stringify({accepted:true,updated_at:updatedAt,config:config});
}
function clampInteger(value, minimum, maximum) {
var parsed = Math.floor(Number(value));
if (!isFinite(parsed) || parsed < minimum || parsed > maximum) throw new Error("numeric value outside allowed range");
@@ -426,10 +492,10 @@ function matchmakerMatched(ctx, logger, nk, matches) {
function initialState(params) {
var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1))));
var credits = DIFFICULTIES[difficulty].startCredits;
return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],shots:[],lastSnapshotTick:0,lastLoopStartMs:0};
return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],shots:[],lastSnapshotTick:0,lastLoopStartMs:0,balance:defaultBalanceConfig()};
}
function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); }
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); state.balance=readBalanceConfig(nk).config; return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:state.slots[presence.userId]!==undefined||Object.keys(state.slots).length<2}; }
function firstFreeSlot(state) {
var used={},ids=Object.keys(state.slots);
@@ -485,10 +551,12 @@ function matchLeave(ctx,logger,nk,dispatcher,tick,state,presences) {
dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{reconnect_seconds:60}); broadcastSnapshot(dispatcher,state,tick); return {state:state};
}
function parseMessage(nk,message) { try { return JSON.parse(nk.binaryToString(message.data)||"{}"); } catch (error) { return null; } }
function towerDamage(t) { return TOWERS[t.type].damage*(1+(t.level-1)*0.52); }
function towerRange(t) { return TOWERS[t.type].range*(1+(t.level-1)*0.08); }
function towerCooldown(t) { return TOWERS[t.type].cooldown/(1+(t.level-1)*0.12); }
function upgradeCost(t) { return Math.floor(TOWERS[t.type].cost*(0.72+t.level*0.28)); }
function towerInfo(state,type) { return state.balance && state.balance.towers[type] || TOWERS[type]; }
function enemyInfo(state,type) { return state.balance && state.balance.enemies[type] || ENEMIES[type]; }
function towerDamage(state,t) { return towerInfo(state,t.type).damage*(1+(t.level-1)*0.52); }
function towerRange(state,t) { return towerInfo(state,t.type).range*(1+(t.level-1)*0.08); }
function towerCooldown(state,t) { return towerInfo(state,t.type).cooldown/(1+(t.level-1)*0.12); }
function upgradeCost(state,t) { return Math.floor(towerInfo(state,t.type).cost*(0.72+t.level*0.28)); }
function distanceSquared(ax,ay,bx,by) { var dx=ax-bx,dy=ay-by; return dx*dx+dy*dy; }
function distanceToSegment(px,py,ax,ay,bx,by) {
var abx=bx-ax,aby=by-ay,len=abx*abx+aby*aby,t=len>0?((px-ax)*abx+(py-ay)*aby)/len:0;
@@ -506,20 +574,20 @@ function validBuildPosition(state,slot,x,y) {
}
function handleBuild(state,slot,payload) {
var type=Math.floor(Number(payload.tower_type)),x=Number(payload.x),y=Number(payload.y);
if (!isFinite(x)||!isFinite(y)||type<0||type>=TOWERS.length||!validBuildPosition(state,slot,x,y)||state.credits[slot]<TOWERS[type].cost) return false;
state.credits[slot]-=TOWERS[type].cost;
if (!isFinite(x)||!isFinite(y)||type<0||type>=TOWERS.length||!validBuildPosition(state,slot,x,y)||state.credits[slot]<towerInfo(state,type).cost) return false;
state.credits[slot]-=towerInfo(state,type).cost;
state.towers.push({id:state.nextTowerId++,x:x,y:y,type:type,level:1,owner:slot,cooldown:0.15,angle:slot===0?0:Math.PI}); return true;
}
function ownedTowerIndex(state,slot,id) { for (var i=0;i<state.towers.length;i++) if (state.towers[i].id===id&&state.towers[i].owner===slot) return i; return -1; }
function handleUpgrade(state,slot,payload) {
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id)));
if (i<0||state.towers[i].level>=3) return false;
var cost=upgradeCost(state.towers[i]); if (state.credits[slot]<cost) return false;
var cost=upgradeCost(state,state.towers[i]); if (state.credits[slot]<cost) return false;
state.credits[slot]-=cost; state.towers[i].level+=1; return true;
}
function handleSell(state,slot,payload) {
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id))); if (i<0) return false;
var tower=state.towers[i]; state.credits[slot]+=Math.floor(TOWERS[tower.type].cost*(0.55+tower.level*0.12)); state.towers.splice(i,1); return true;
var tower=state.towers[i]; state.credits[slot]+=Math.floor(towerInfo(state,tower.type).cost*(0.55+tower.level*0.12)); state.towers.splice(i,1); return true;
}
function enemyTypeForWave(wave,roll,difficulty,boss) {
if (boss) return wave>=10?8:5;
@@ -555,13 +623,14 @@ function startWave(state) {
function spawnEnemy(state) {
var entry=state.waveSchedule.length?state.waveSchedule.shift():{type:0,boss:false};
var boss=entry.boss,type=entry.type;
var base=ENEMIES[type],d=DIFFICULTIES[state.difficulty];
var base=enemyInfo(state,type),d=DIFFICULTIES[state.difficulty];
var scale=(1+Math.max(0,state.wave-1)*0.20+Math.max(0,state.wave-3)*0.09+((state.difficulty===2)?Math.max(0,state.wave-5)*0.055:0))*(boss?3.25:1)*d.hp;
var hullScale=scale*(boss&&state.wave===5?0.5:1);
var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId];
var hasShield=type===4||(state.wave>=4&&Math.random()<Math.min(0.42,0.10+state.wave*0.018+state.difficulty*0.06));
var maxShieldHp=hasShield?base.hp*scale*0.55:0;
if (boss && state.wave === 5) maxShieldHp *= 0.5;
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss,shield:hasShield,shield_hp:maxShieldHp,max_shield_hp:maxShieldHp,shield_resistance:type===4?0.18:0.28});
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*hullScale,max_hp:base.hp*hullScale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss,shield:hasShield,shield_hp:maxShieldHp,max_shield_hp:maxShieldHp,shield_resistance:base.shieldResistance});
}
function applyDamageToEnemy(enemy,damage,towerType) {
var applied=damage;
@@ -582,7 +651,7 @@ function updateEnemies(state,delta) {
e.angle=Math.atan2(dy,dx);
if (dist<=Math.max(9,e.speed*delta)) {
e.x=target[0]; e.y=target[1]; e.path_index+=1;
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(ENEMIES[e.type].breachDamage+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(enemyInfo(state,e.type).breachDamage+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
} else { e.x+=dx/dist*e.speed*delta; e.y+=dy/dist*e.speed*delta; }
}
}
@@ -590,19 +659,19 @@ function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,R
function updateTowers(state,delta) {
state.shots=state.shots||[];
for (var i=0;i<state.towers.length;i++) {
var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1,bestShield=false;
var t=state.towers[i],tower=towerInfo(state,t.type); t.cooldown-=delta; var range=towerRange(state,t),targetIndex=-1,best=-1,bestShield=false;
for (var j=0;j<state.enemies.length;j++) {
var candidate=state.enemies[j];
if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[candidate.type].category)&&distanceSquared(t.x,t.y,candidate.x,candidate.y)<=range*range) {
if ((tower.targetClass==="both"||tower.targetClass===enemyInfo(state,candidate.type).category)&&distanceSquared(t.x,t.y,candidate.x,candidate.y)<=range*range) {
var p=enemyProgress(candidate),shieldPriority=t.type===1&&candidate.shield===true;
if (targetIndex<0||(shieldPriority&&!bestShield)||(shieldPriority===bestShield&&p>best)) { best=p; bestShield=shieldPriority; targetIndex=j; }
}
}
if (targetIndex<0) continue;
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5].indexOf(t.type)>=0?Math.PI:0);
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5,6].indexOf(t.type)>=0?Math.PI:0);
if (t.cooldown>0) continue;
state.shots.push({tower_id:t.id,type:t.type,from_x:t.x,from_y:t.y,to_x:target.x,to_y:target.y});
var damage=towerDamage(t)*(TOWERS[t.type].targetClass==="both"?TOWERS[t.type].bothScale:1);
var damage=towerDamage(state,t)*(tower.targetClass==="both"?tower.bothScale:1);
if (t.type===5) {
var aimLength=Math.sqrt(distanceSquared(t.x,t.y,target.x,target.y)),dx=(target.x-t.x)/aimLength,dy=(target.y-t.y)/aimLength,rangeSquared=range*range;
for (var railIndex=state.enemies.length-1;railIndex>=0;railIndex--) {
@@ -614,9 +683,9 @@ function updateTowers(state,delta) {
} else if (t.type===2||t.type===3||t.type===4) {
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:1);
var tx=target.x,ty=target.y,targetId=target.id;
for (var k=state.enemies.length-1;k>=0;k--) if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[state.enemies[k].type].category)&&distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { applyDamageToEnemy(state.enemies[k],state.enemies[k].id===targetId?damage:damage*splash,t.type); if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
for (var k=state.enemies.length-1;k>=0;k--) if ((tower.targetClass==="both"||tower.targetClass===enemyInfo(state,state.enemies[k].type).category)&&distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { applyDamageToEnemy(state.enemies[k],state.enemies[k].id===targetId?damage:damage*splash,t.type); if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
} else { applyDamageToEnemy(target,damage,t.type); if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
t.cooldown=towerCooldown(t);
t.cooldown=towerCooldown(state,t);
}
}
function updateWave(state,delta,dispatcher) {
@@ -762,6 +831,10 @@ function InitModule(ctx,logger,nk,initializer) {
initializer.registerRpc("server_info",rpcServerInfo);
initializer.registerRpc("record_activity",rpcRecordActivity);
initializer.registerRpc("private_stats",rpcPrivateStats);
initializer.registerRpc("get_game_balance",rpcGetGameBalance);
initializer.registerRpc("set_game_balance",rpcSetGameBalance);
initializer.registerRpc("get_game_balance",rpcGetGameBalance);
initializer.registerRpc("set_game_balance",rpcSetGameBalance);
initializer.registerRpc("create_unlock_code",rpcCreateUnlockCode);
initializer.registerRpc("list_unlock_codes",rpcListUnlockCodes);
initializer.registerRpc("delete_unlock_code",rpcDeleteUnlockCode);
+66
View File
@@ -0,0 +1,66 @@
const fs = require("fs");
const vm = require("vm");
const path = require("path");
const assert = require("assert");
const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8");
const context = { console, Math, Date, JSON, isFinite };
vm.createContext(context);
vm.runInContext(source, context);
function duel(towerType, enemyType, wave = 1, boss = false, difficulty = 1) {
const base = context.ENEMIES[enemyType];
const waveScale = (1 + Math.max(0, wave - 1) * 0.20 + Math.max(0, wave - 3) * 0.09 + (difficulty === 2 ? Math.max(0, wave - 5) * 0.055 : 0)) * (boss ? 3.25 : 1) * context.DIFFICULTIES[difficulty].hp;
const hullScale = waveScale * (boss && wave === 5 ? 0.5 : 1);
const shielded = enemyType === 4 || (boss && wave === 5);
const shield = shielded ? base.hp * waveScale * 0.55 * (boss && wave === 5 ? 0.5 : 1) : 0;
const testedEnemy = { id: 1, type: enemyType, x: -40, y: 120, path_index: 1, route_id: 0,
hp: base.hp * hullScale, max_hp: base.hp * hullScale, speed: base.speed * (1 + wave * 0.012) * (boss ? 0.82 : 1) * context.DIFFICULTIES[difficulty].speed,
reward: base.reward, shield: shield > 0, shield_hp: shield, max_shield_hp: shield,
shield_resistance: base.shieldResistance, boss };
const state = {
wave,
balance: { towers: context.TOWERS, enemies: context.ENEMIES },
towers: [{ id: 1, type: towerType, x: 540, y: 220, owner: 0, level: 1, cooldown: 0, angle: 0 }],
enemies: [testedEnemy],
shots: [], credits: [0, 0], reactorLives: 30, gameOver: false, waveActive: true, spawnRemaining: 0
};
const tick = 0.1;
let elapsed = 0;
for (; elapsed < 90 && state.enemies.length; elapsed += tick) {
context.updateEnemies(state, tick);
context.updateTowers(state, tick);
}
const alive = state.enemies.length > 0;
const breached = state.reactorLives < 30;
const remainingHp = Math.max(0, testedEnemy.hp);
return { alive, killed: !alive && !breached && remainingHp <= 0, remainingHp, seconds: elapsed,
breached, damageTaken: base.hp * hullScale - remainingHp };
}
const rows = [];
let evaluated = 0;
for (let tower = 0; tower < context.TOWERS.length; tower++) {
const towerData = context.TOWERS[tower];
const results = [];
for (let enemy = 0; enemy < context.ENEMIES.length; enemy++) {
const enemyData = context.ENEMIES[enemy];
if (towerData.targetClass !== "both" && towerData.targetClass !== enemyData.category) continue;
const regular = duel(tower, enemy);
const late = duel(tower, enemy, 15, false, 2);
evaluated++;
results.push(`${enemy}:${regular.killed ? `kill ${regular.seconds.toFixed(1)}s` : `leaks ${Math.round(regular.remainingHp)} HP`}/${late.killed ? `H W15 kill ${late.seconds.toFixed(1)}s` : `H W15 leaks ${Math.round(late.remainingHp)} HP`}`);
}
rows.push(`${towerData.targetClass.padEnd(6)} tower ${tower}: ${results.join(" | ")}`);
}
const bossResults = [];
for (let tower = 0; tower < context.TOWERS.length; tower++) {
if (context.TOWERS[tower].targetClass !== "air" && context.TOWERS[tower].targetClass !== "both") continue;
const result = duel(tower, 5, 5, true, 1);
bossResults.push(`tower ${tower}: ${result.killed ? `killed in ${result.seconds.toFixed(1)}s` : `reaches reactor with ${Math.round(result.remainingHp)} HP`}`);
}
assert.ok(evaluated >= 20, "the matrix must cover all valid tower/enemy matchups");
console.log("BALANCE DUEL RESULTS (one level-1 tower, one enemy, fixed path and no reinvestment)");
console.log(rows.join("\n"));
console.log("WAVE 5 FIRST BOSS (single-tower reference): " + bossResults.join(" | "));
console.log(`BALANCE_DUEL_TEST_OK matchups=${evaluated}`);
+36 -2
View File
@@ -22,6 +22,28 @@ assert.ok(context.TOWERS[3].bothScale < 1, "Universal towers should trade damage
assert.strictEqual(context.TOWERS[4].damage, 20, "EMP damage should be tuned down to about 20.");
assert.strictEqual(context.TOWERS[4].targetClass, "both", "EMP should cover both air and ground units.");
assert.strictEqual(context.TOWERS[6].targetClass, "air", "The Flak tower must only target air units.");
const balanceStorage = new Map();
let balanceVersion = 0;
const balanceNk = {
storageRead(ops) { return ops.map(op => balanceStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
storageWrite(ops) {
for (const op of ops) {
balanceVersion += 1;
balanceStorage.set(`${op.collection}:${op.userId}:${op.key}`, { ...op, version: String(balanceVersion) });
}
}
};
assert.throws(() => context.rpcGetGameBalance({}, {}, balanceNk, "{}"), /authentication required/);
const adminBalance = JSON.parse(context.rpcGetGameBalance({ userId: "pilot-b" }, {}, balanceNk, "{}"));
assert.strictEqual(adminBalance.config.towers[0].damage, 22);
const editedBalance = JSON.parse(JSON.stringify(adminBalance.config));
editedBalance.towers[0].damage = 31;
editedBalance.enemies[0].hp = 90;
assert.throws(() => context.rpcSetGameBalance({ userId: "pilot-b" }, {}, balanceNk, JSON.stringify({ config: editedBalance })), /server-to-server/);
assert.strictEqual(JSON.parse(context.rpcSetGameBalance({}, {}, balanceNk, JSON.stringify({ config: editedBalance }))).accepted, true);
assert.strictEqual(JSON.parse(context.rpcGetGameBalance({ userId: "pilot-b" }, {}, balanceNk, "{}")).config.towers[0].damage, 31);
assert.strictEqual(context.matchInit({}, {}, balanceNk, { difficulty: 1 }).state.balance.enemies[0].hp, 90);
assert.throws(() => context.rpcSetGameBalance({}, {}, balanceNk, JSON.stringify({ config: { towers: [], enemies: [] } })), /invalid balance schema/);
const hostId = "11111111-1111-1111-1111-111111111111";
const friendId = "22222222-2222-2222-2222-222222222222";
const sentInvites = [];
@@ -366,8 +388,11 @@ context.Math.random = () => 0;
context.spawnEnemy(bossState);
context.Math.random = originalRandom;
assert.ok(bossState.enemies[0].shield, "The first boss shield test needs a shielded ship.");
assert.strictEqual(bossState.enemies[0].max_shield_hp, bossState.enemies[0].max_hp * 0.275,
"Wave-five mothership shields must be half their former strength.");
assert.ok(Math.abs(bossState.enemies[0].max_hp -
context.ENEMIES[5].hp * (1 + 4 * 0.20 + 2 * 0.09) * 3.25 * context.DIFFICULTIES[1].hp * 0.5,
) < 0.0001, "Wave-five mothership hull should be reduced by 50%.");
assert.ok(Math.abs(bossState.enemies[0].max_shield_hp - bossState.enemies[0].max_hp * 0.55) < 0.0001,
"The wave-five shield remains at its already-halved absolute strength while hull is reduced.");
const laterBossState = context.initialState({ difficulty: 1 });
laterBossState.wave = 10;
laterBossState.waveSchedule = [{ type: 8, boss: true }];
@@ -377,6 +402,15 @@ context.Math.random = originalRandom;
assert.strictEqual(laterBossState.enemies[0].max_shield_hp, laterBossState.enemies[0].max_hp * 0.55,
"Later bosses retain their original shield balance.");
const flakAimState = {
towers: [{ id: 1, type: 6, x: 0, y: 0, owner: 0, level: 1, cooldown: 1, angle: 0 }],
enemies: [{ id: 1, type: 0, x: 100, y: 0, hp: 100, shield: false }],
shots: []
};
context.updateTowers(flakAimState, 0.1);
assert.ok(Math.abs(flakAimState.towers[0].angle - Math.PI) < 0.0001,
"Flak visualization must include the 180-degree sprite orientation correction.");
const diagnosticStore = new Map();
const diagnosticNk = {
sha256Hash(value) { return crypto.createHash("sha256").update(value).digest("hex"); },
+1
View File
@@ -378,6 +378,7 @@ func _run() -> void:
var boss: Dictionary = game.get("enemies")[-1]
assert(boss.boss, "Jede fünfte Welle sollte ein Kommandoschiff erzeugen.")
assert(boss.type == 5, "Das Kommandoschiff sollte die schwere Belagerer-Klasse verwenden.")
assert(is_equal_approx(float(boss.max_hp), float(boss.base_max_hp)), "Die Hülle des ersten Bosses muss im neuen Testbalancing halbiert sein.")
game.call("_start_game")
var crystals_before: int = game.get("core_crystals")
game.set("wave", 15)