diff --git a/ONLINE_SETUP.md b/ONLINE_SETUP.md index bbdc0ac..4c5a95f 100644 --- a/ONLINE_SETUP.md +++ b/ONLINE_SETUP.md @@ -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. diff --git a/README.md b/README.md index cd9da4b..ce045cb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RELEASE_NOTES_0.1.0-alpha.12.md b/RELEASE_NOTES_0.1.0-alpha.12.md new file mode 100644 index 0000000..35fb036 --- /dev/null +++ b/RELEASE_NOTES_0.1.0-alpha.12.md @@ -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. diff --git a/main.gd b/main.gd index b584715..1db3767 100644 --- a/main.gd +++ b/main.gd @@ -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, diff --git a/online_manager.gd b/online_manager.gd index 8d11ee0..3c484f5 100644 --- a/online_manager.gd +++ b/online_manager.gd @@ -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 diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index 3304744..a0d3df8 100644 --- a/packaging/windows/installer.nsi +++ b/packaging/windows/installer.nsi @@ -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" diff --git a/project.godot b/project.godot index e40131e..ac79a9c 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/server/nakama/README.md b/server/nakama/README.md index 080847e..0987245 100644 --- a/server/nakama/README.md +++ b/server/nakama/README.md @@ -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. diff --git a/server/nakama/runtime/build/index.js b/server/nakama/runtime/build/index.js index 5c2ea8b..b91282b 100644 --- a/server/nakama/runtime/build/index.js +++ b/server/nakama/runtime/build/index.js @@ -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.length||!validBuildPosition(state,slot,x,y)||state.credits[slot]=3) return false; - var cost=upgradeCost(state.towers[i]); if (state.credits[slot]=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()=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;ibest)) { 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); diff --git a/server/nakama/tests/balance_duel_test.js b/server/nakama/tests/balance_duel_test.js new file mode 100644 index 0000000..1a2e6c2 --- /dev/null +++ b/server/nakama/tests/balance_duel_test.js @@ -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}`); diff --git a/server/nakama/tests/runtime_sim_test.js b/server/nakama/tests/runtime_sim_test.js index 0d6a106..6b0e6e3 100644 --- a/server/nakama/tests/runtime_sim_test.js +++ b/server/nakama/tests/runtime_sim_test.js @@ -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"); }, diff --git a/tests/smoke_test.gd b/tests/smoke_test.gd index b1c9474..6bc09f6 100644 --- a/tests/smoke_test.gd +++ b/tests/smoke_test.gd @@ -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)