Add server-managed balance configuration
This commit is contained in:
1 parent
67abdf13b0
commit
39ed4c3bf7
12 files changed
+289
-27
No files matched your search
@@ -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}`);
|
||||
@@ -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"); },
|
||||
|
||||
Reference in new issue
Block a user