diff --git a/README.md b/README.md index ce045cb..6ef8de7 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.12.md`. +Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.13.md`. ## Werbung und Datenschutz diff --git a/RELEASE_NOTES_0.1.0-alpha.13.md b/RELEASE_NOTES_0.1.0-alpha.13.md new file mode 100644 index 0000000..0cbbbc5 --- /dev/null +++ b/RELEASE_NOTES_0.1.0-alpha.13.md @@ -0,0 +1,18 @@ +# Void Bastion 0.1.0-alpha.13 + +## Balance- und Onlinekorrekturen + +- Der Online-Server übernimmt den ausgewählten Schwierigkeitsgrad „Leicht“ jetzt korrekt, statt ihn auf „Mittel“ zurückzusetzen. +- Flak-Schaden pro Treffer: 20 → 22. +- Belagerer-Grundhülle: 740 → 620. Das wirkt sich auch auf die daraus berechneten Schilde aus. +- Gespeicherte Balancewerte aus Schema 1 werden beim ersten Lesen auf Schema 2 migriert. Nur Felder, die noch exakt den alten Flak-/Belagerer-Standardwerten entsprechen, werden angepasst; andere eigene Admin-Balancewerte bleiben erhalten. +- Ein reproduzierbarer 15-Wellen-Missionstest ergänzt die vorhandenen Runtime- und Turm-Gegner-Duelle. + +## Prüfumfang + +- `node server/nakama/tests/runtime_sim_test.js` +- `node server/nakama/tests/balance_mission_test.js` +- `node server/nakama/tests/balance_duel_test.js` +- PHP-Syntaxprüfung der internen Statistikseite + +Der Missionslauf ist ein einfacher Level-1-Referenz-Bot ohne Upgrades und kein Ersatz für Spieltests mit echten Spielern. diff --git a/main.gd b/main.gd index 1db3767..a402531 100644 --- a/main.gd +++ b/main.gd @@ -202,7 +202,7 @@ const TOWER_DATA := [ {"name": "PLASMA", "cost": 230, "damage": 34.0, "range": 240.0, "rate": 1.05, "shot_speed": 480.0, "damage_type": "PLASMA-FLÄCHE 92", "target_class": "both", "both_damage_scale": 0.72, "color": Color("e27bff"), "unlock_cost": 18, "description": "Mehrzweckwaffe mit reduziertem Schaden."}, {"name": "EMP-PULS", "cost": 210, "damage": 20.0, "range": 215.0, "rate": 0.92, "shot_speed": 540.0, "damage_type": "EMP-FLÄCHE 118 · ALLE", "target_class": "both", "both_damage_scale": 1.0, "color": Color("77e8ff"), "unlock_cost": 24, "description": "Impuls trifft alle Luft- und Bodenziele im Radius mit 20 Schaden."}, {"name": "RAILGUN", "cost": 300, "damage": 77.0, "range": 330.0, "rate": 1.85, "shot_speed": 1450.0, "damage_type": "PIERCING-STRAHL", "target_class": "both", "both_damage_scale": 0.70, "color": Color("c86bff"), "unlock_cost": 30, "description": "Lila Durchschlagsstrahl trifft alle Gegner auf seiner Schusslinie."}, - {"name": "FLAK", "cost": 210, "damage": 20.0, "range": 230.0, "rate": 0.42, "shot_speed": 920.0, "damage_type": "LUFT-EINZELZIEL", "target_class": "air", "color": Color("ff737c"), "unlock_cost": 0, "description": "Schnelle Abfangkanone gegen gegnerische Luftschiffe."} + {"name": "FLAK", "cost": 210, "damage": 22.0, "range": 230.0, "rate": 0.42, "shot_speed": 920.0, "damage_type": "LUFT-EINZELZIEL", "target_class": "air", "color": Color("ff737c"), "unlock_cost": 0, "description": "Schnelle Abfangkanone gegen gegnerische Luftschiffe."} ] const ENEMY_DATA := [ @@ -211,7 +211,7 @@ const ENEMY_DATA := [ {"name": "KREUZER", "category": "air", "hp": 520.0, "speed": 43.0, "reward": 78, "size": 112.0, "breach_damage": 3, "shield_resistance": 0.28}, {"name": "ABFANGJAEGER", "category": "air", "hp": 52.0, "speed": 178.0, "reward": 22, "size": 48.0, "breach_damage": 2, "shield_resistance": 0.28}, {"name": "SCHILDKREUZER", "category": "air", "hp": 260.0, "speed": 58.0, "reward": 52, "size": 92.0, "breach_damage": 2, "shield_resistance": 0.18, "default_shield": true}, - {"name": "BELAGERER", "category": "air", "hp": 740.0, "speed": 48.0, "reward": 108, "size": 128.0, "breach_damage": 4, "shield_resistance": 0.28}, + {"name": "BELAGERER", "category": "air", "hp": 620.0, "speed": 48.0, "reward": 108, "size": 128.0, "breach_damage": 4, "shield_resistance": 0.28}, {"name": "SPAEHER-ROVER", "category": "ground", "hp": 88.0, "speed": 142.0, "reward": 22, "size": 54.0, "breach_damage": 1, "shield_resistance": 0.28}, {"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} diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index a0d3df8..f6a06d2 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.12" +!define PRODUCT_VERSION "0.1.0-alpha.13" !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 ac79a9c..63e4022 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.12" +config/version="0.1.0-alpha.13" 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 0987245..ed9d61f 100644 --- a/server/nakama/README.md +++ b/server/nakama/README.md @@ -47,6 +47,6 @@ Die Server-zu-Server-RPC `list_online_diagnostics` liefert mit `{}` eine kompakt - `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. +Balancing-Regressionstests: `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; `node tests/balance_mission_test.js` simuliert 15-Wellen-Missionen mit festem Zufallsstart, Turmwahl nach Gegnerzusammensetzung und Level-1-Türmen. Der Missionstest ist ein strenger automatischer Referenz-Bot ohne Upgrades, keine Vorhersage menschlicher Spielergebnisse. Die Werte des Bots sollen als Ausgangspunkt für Balancevergleiche dienen, nicht als alleiniges Freigabekriterium. Alle Tests müssen nach Balanceänderungen erneut laufen. 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 b91282b..f40911c 100644 --- a/server/nakama/runtime/build/index.js +++ b/server/nakama/runtime/build/index.js @@ -1,4 +1,4 @@ -var GAME_VERSION = "0.25"; +var GAME_VERSION = "0.26"; var MAP_COUNT = 7; var DIFFICULTY_COUNT = 3; var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"]; @@ -32,7 +32,7 @@ var TOWERS = [ {cost:230,damage:34,range:240,cooldown:1.05,targetClass:"both",bothScale:0.72}, {cost:210,damage:20,range:215,cooldown:0.92,targetClass:"both",bothScale:1}, {cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70}, - {cost:210,damage:20,range:230,cooldown:0.42,targetClass:"air"} + {cost:210,damage:22,range:230,cooldown:0.42,targetClass:"air"} ]; var ENEMIES = [ {category:"air",hp:70,speed:105,reward:18,breachDamage:1}, @@ -40,7 +40,7 @@ var ENEMIES = [ {category:"air",hp:520,speed:43,reward:78,breachDamage:3}, {category:"air",hp:52,speed:178,reward:22,breachDamage:2}, {category:"air",hp:260,speed:58,reward:52,breachDamage:2}, - {category:"air",hp:740,speed:48,reward:108,breachDamage:4}, + {category:"air",hp:620,speed:48,reward:108,breachDamage:4}, {category:"ground",hp:88,speed:142,reward:22,breachDamage:1}, {category:"ground",hp:390,speed:61,reward:61,breachDamage:3}, {category:"ground",hp:980,speed:36,reward:132,breachDamage:5} @@ -52,7 +52,7 @@ var DIFFICULTIES = [ ]; function defaultBalanceConfig() { - return {schema:1,towers:JSON.parse(JSON.stringify(TOWERS)),enemies:JSON.parse(JSON.stringify(ENEMIES))}; + return {schema:2,towers:JSON.parse(JSON.stringify(TOWERS)),enemies:JSON.parse(JSON.stringify(ENEMIES))}; } function boundedNumber(value, fallback, minimum, maximum, integer) { var number = Number(value); @@ -88,13 +88,28 @@ function sanitizeBalanceConfig(input) { shieldResistance:boundedNumber(candidate.shieldResistance,base.shieldResistance,0.01,1,false) }; }); - return {schema:1,towers:towers,enemies:enemies}; + return {schema:2,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); } + try { + var storedConfig = record.value.config; + config = sanitizeBalanceConfig(storedConfig); + updatedAt = Number(record.value.updated_at||0); + if (Number(storedConfig.schema || 1) < 2) { + var legacyTower = storedConfig.towers[6] || {}; + var legacyBoss = storedConfig.enemies[5] || {}; + if (Number(legacyTower.damage) === 20) config.towers[6].damage = 22; + if (Number(legacyBoss.hp) === 740) config.enemies[5].hp = 620; + updatedAt = Date.now(); + if (nk && typeof nk.storageWrite === "function") { + try { nk.storageWrite([{collection:BALANCE_COLLECTION,key:BALANCE_KEY,userId:SYSTEM_USER_ID,value:{config:config,updated_at:updatedAt},permissionRead:0,permissionWrite:0,version:record.version||"*"}]); } + catch (writeError) { updatedAt = Number(record.value.updated_at||0); } + } + } + } catch (error) { config = defaultBalanceConfig(); } } return {config:config,updated_at:updatedAt}; @@ -490,7 +505,8 @@ function matchmakerMatched(ctx, logger, nk, matches) { return nk.matchCreate(MATCH_MODULE,{matchedUsers:matches.length,difficulty:difficulty}); } function initialState(params) { - var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1)))); + var requestedDifficulty = params.difficulty === undefined || params.difficulty === null ? 1 : Number(params.difficulty); + var difficulty = Math.max(0,Math.min(2,Math.floor(isFinite(requestedDifficulty) ? requestedDifficulty : 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,balance:defaultBalanceConfig()}; } diff --git a/server/nakama/tests/balance_mission_test.js b/server/nakama/tests/balance_mission_test.js new file mode 100644 index 0000000..66355f9 --- /dev/null +++ b/server/nakama/tests/balance_mission_test.js @@ -0,0 +1,149 @@ +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 seedRandom(seed) { + let value = seed >>> 0; + return function () { + value = (value * 1664525 + 1013904223) >>> 0; + return value / 0x100000000; + }; +} + +function samplePath(route) { + const samples = []; + for (let i = 0; i < route.length - 1; i++) { + const [ax, ay] = route[i], [bx, by] = route[i + 1]; + const length = Math.hypot(bx - ax, by - ay); + const steps = Math.max(1, Math.ceil(length / 40)); + for (let step = 0; step < steps; step++) { + const ratio = step / steps; + samples.push([ax + (bx - ax) * ratio, ay + (by - ay) * ratio, length / steps]); + } + } + return samples; +} + +const pathSamples = [samplePath(context.PATHS[0]), samplePath(context.PATHS[1])]; +const areaFactor = [1, 1, 1.65, 1.6, 1.9, 1.35, 1]; + +function nextWaveComposition(state, wave) { + const schedule = context.buildWaveSchedule(state, wave, context.wavePopulation(state, wave)); + const counts = Array(context.ENEMIES.length).fill(0); + for (const entry of schedule) counts[entry.type] += 1; + return { schedule, counts }; +} + +function chooseTowerType(state, composition, fixedType) { + if (fixedType !== null) return fixedType; + let bestType = 1, bestScore = -1; + for (let towerType = 0; towerType < context.TOWERS.length; towerType++) { + const tower = context.TOWERS[towerType]; + let score = 0; + for (let enemyType = 0; enemyType < context.ENEMIES.length; enemyType++) { + const count = composition.counts[enemyType]; + if (!count) continue; + const enemy = context.ENEMIES[enemyType]; + if (tower.targetClass !== "both" && tower.targetClass !== enemy.category) continue; + const damage = tower.damage * (tower.targetClass === "both" ? tower.bothScale || 1 : 1); + const exposedDps = damage / tower.cooldown; + const shieldChance = enemyType === 4 ? 1 : 0.25; + const shieldMultiplier = towerType === 1 || towerType === 5 ? 1 : 1 - shieldChance * (1 - (enemy.shieldResistance || 0.28)); + const bossWeight = composition.schedule.some(entry => entry.type === enemyType && entry.boss) ? 1.2 : 1; + score += count * Math.sqrt(enemy.hp) * exposedDps * shieldMultiplier * areaFactor[towerType] * bossWeight; + } + score /= Math.max(1, tower.cost); + if (score > bestScore) { bestScore = score; bestType = towerType; } + } + return bestType; +} + +function choosePosition(state, owner, range) { + const xMin = owner === 0 ? 120 : 1520; + const xMax = owner === 0 ? 1440 : 2840; + let best = null, bestScore = -1; + for (let y = 120; y <= 680; y += 80) for (let x = xMin; x <= xMax; x += 80) { + if (!context.validBuildPosition(state, owner, x, y)) continue; + const rangeSq = range * range; + let score = 0; + for (const [px, py, weight] of pathSamples[owner]) { + const covered = state.towers.some(tower => tower.owner === owner && + context.distanceSquared(tower.x, tower.y, px, py) <= context.towerRange(state, tower) ** 2); + if (!covered && context.distanceSquared(x, y, px, py) <= rangeSq) score += weight; + } + if (score > bestScore) { bestScore = score; best = [x, y]; } + } + return best; +} + +function runMission(difficulty, fixedType = null) { + let random = seedRandom(0xB45710 + difficulty * 7919 + (fixedType === null ? 0 : fixedType * 97)); + context.Math = Object.create(Math); + context.Math.random = () => random(); + const state = context.initialState({ difficulty, mode: "balance_test" }); + state.started = true; + let wavesCompleted = 0, totalSpawned = 0, totalBreached = 0; + const trace = []; + const maxTicksPerWave = 24000; + + for (let wave = 1; wave <= 15 && !state.gameOver; wave++) { + const composition = nextWaveComposition(state, wave); + for (let owner = 0; owner < 2; owner++) { + let guard = 32; + while (guard-- > 0) { + const towerType = chooseTowerType(state, composition, fixedType); + const tower = context.TOWERS[towerType]; + if (state.credits[owner] < tower.cost) break; + const position = choosePosition(state, owner, tower.range); + if (!position) break; + state.credits[owner] -= tower.cost; + state.towers.push({ id: state.nextTowerId++, type: towerType, x: position[0], y: position[1], owner, level: 1, cooldown: 0.15, angle: owner === 0 ? 0 : Math.PI }); + } + } + if (!context.startWave(state)) break; + const beforeSpawn = state.nextEnemyId; + let tick = 0; + while (state.waveActive && !state.gameOver && tick++ < maxTicksPerWave) { + context.updateWave(state, 0.1, { broadcastMessage() {} }); + context.updateEnemies(state, 0.1); + context.updateTowers(state, 0.1); + } + totalSpawned += state.nextEnemyId - beforeSpawn; + totalBreached += Math.max(0, 30 - state.reactorLives); + trace.push({wave, lives: state.reactorLives, spawned: state.nextEnemyId - beforeSpawn, towers: state.towers.length, credits: [...state.credits], enemyLeft: state.enemies.length, towerTypes: state.towers.map(t => t.type)}); + if (tick >= maxTicksPerWave) break; + if (!state.gameOver) wavesCompleted++; + } + return { difficulty, fixedType, wavesCompleted, reactorLives: state.reactorLives, trace, + towers: state.towers.length, spawned: totalSpawned, remainingEnemies: state.enemies.length, + credits: state.credits.reduce((sum, value) => sum + value, 0) }; +} + +const baseline = []; +for (let difficulty = 0; difficulty < context.DIFFICULTIES.length; difficulty++) { + baseline.push(runMission(difficulty)); +} +const towerRuns = []; +for (let tower = 0; tower < context.TOWERS.length; tower++) { + for (let difficulty = 0; difficulty < context.DIFFICULTIES.length; difficulty++) { + towerRuns.push(runMission(difficulty, tower)); + } +} + +assert.strictEqual(baseline.length, 3); +assert.ok(towerRuns.length === context.TOWERS.length * 3); +assert.ok(baseline.every(result => result.spawned > 0), "mission simulations must use the real wave scheduler"); +console.log("BALANCE MISSION RESULTS (Nakama wave/combat code, fixed-seed greedy build planner, level-1 towers)"); +console.log("planner: " + baseline.map(r => `D${r.difficulty} ${r.wavesCompleted}/15 waves, reactor ${r.reactorLives}, ${r.towers} towers`).join(" | ")); +for (let tower = 0; tower < context.TOWERS.length; tower++) { + const values = towerRuns.filter(result => result.fixedType === tower) + .map(r => `D${r.difficulty} ${r.wavesCompleted}/15, R${r.reactorLives}, ${r.towers}T`).join(" | "); + console.log(`tower ${tower} (${context.TOWERS[tower].targetClass}): ${values}`); +} +console.log(`BALANCE_MISSION_TEST_OK scenarios=${baseline.length + towerRuns.length}`); diff --git a/server/nakama/tests/runtime_sim_test.js b/server/nakama/tests/runtime_sim_test.js index 6b0e6e3..7517f8c 100644 --- a/server/nakama/tests/runtime_sim_test.js +++ b/server/nakama/tests/runtime_sim_test.js @@ -22,6 +22,10 @@ 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."); +assert.strictEqual(context.TOWERS[6].damage, 22, "The air-only Flak tower should receive its focused damage tuning."); +assert.strictEqual(context.ENEMIES[5].hp, 620, "The first aerial boss base hull should use the reduced balance value."); +assert.strictEqual(context.initialState({ difficulty: 0 }).difficulty, 0, "Easy must not be coerced to the default difficulty."); +assert.strictEqual(context.initialState({ difficulty: 0 }).credits[0], context.DIFFICULTIES[0].startCredits); const balanceStorage = new Map(); let balanceVersion = 0; const balanceNk = { @@ -44,6 +48,25 @@ assert.strictEqual(JSON.parse(context.rpcSetGameBalance({}, {}, balanceNk, JSON. 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 legacyStorage = new Map(); +const legacyKey = `game_balance:${context.SYSTEM_USER_ID}:global`; +const legacyConfig = JSON.parse(JSON.stringify(context.defaultBalanceConfig())); +legacyConfig.schema = 1; +legacyConfig.towers[0].damage = 31; +legacyConfig.towers[6].damage = 20; +legacyConfig.enemies[5].hp = 740; +legacyStorage.set(legacyKey, { collection: "game_balance", key: "global", userId: context.SYSTEM_USER_ID, + value: { config: legacyConfig, updated_at: 1 }, permissionRead: 0, permissionWrite: 0, version: "7" }); +const migrationNk = { + storageRead(ops) { return ops.map(op => legacyStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); }, + storageWrite(ops) { for (const op of ops) legacyStorage.set(`${op.collection}:${op.userId}:${op.key}`, { ...op, version: "8" }); } +}; +const migratedBalance = context.readBalanceConfig(migrationNk); +assert.strictEqual(migratedBalance.config.schema, 2); +assert.strictEqual(migratedBalance.config.towers[0].damage, 31, "Balance migrations must preserve unrelated admin tuning."); +assert.strictEqual(migratedBalance.config.towers[6].damage, 22, "Unchanged legacy Flak defaults should migrate to the new value."); +assert.strictEqual(migratedBalance.config.enemies[5].hp, 620, "Unchanged legacy boss HP should migrate to the new value."); +assert.strictEqual(legacyStorage.get(legacyKey).value.config.schema, 2, "Migrated balance should persist for future matches."); const hostId = "11111111-1111-1111-1111-111111111111"; const friendId = "22222222-2222-2222-2222-222222222222"; const sentInvites = [];