Release alpha 13 balance and mission tests
This commit is contained in:
1 parent
fc4fcd40a4
commit
026d0bad58
9 files changed
+219
-13
No files matched your search
@@ -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.
|
||||
@@ -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()};
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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 = [];
|
||||
|
||||
Reference in new issue
Block a user