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
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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