Extend all 5 towers to 9 upgrade levels, divided into 3 visual and functional evolution tiers: - Tier 1 (L1-3, Yellow): Base stats (damage, range, rate). - Tier 2 (L4-6, Orange): First major ability evolution with pulsing orange aura. - Tier 3 (L7-9, Blue): Final devastating evolution with blue aura. Evolutions per tower: - Arrow Tower: * L4-6 (Orange): Multishot firing 2-3 arrows simultaneously at distinct targets. * L7-9 (Blue): 4-arrow multishot + Poison DoT (up to 28 dmg/s). - Cannon: * L4-6 (Orange): Incendiary shells applying Burn DoT in splash radius. * L7-9 (Blue): Flak shells hitting flying units with full splash + burn damage. - Frost Tower: * L4-6 (Orange): Permafrost - every 3rd pulse completely freezes enemies (speed = 0). * L7-9 (Blue): Shatter - frozen enemies receive +50% damage from all sources. - Tesla Tower: * L4-6 (Orange): Chain lightning applies stun on each hit. * L7-9 (Blue): Lightning Storm - every 4th shot strikes and stuns ALL enemies in range. - Laser Tower: * L4-6 (Orange): Prism refraction splitting the beam onto 2-3 secondary targets. * L7-9 (Blue): Piercing beam penetrating all enemies in a line up to 280px range. Engine & Renderer updates: - Added DoT tick handling (poison/burn) and freeze/stun status effects. - Added visual indicators for frozen enemies (ice spikes) and DoT particles. - Added 3-slot tier-colored pips on tower base plates + pulsating evolution auras. - Updated TowerPanel with 9-star tier display (★★★ yellow, ★★★ orange, ★★★ blue) and dynamic special ability descriptions. Testing & Validation: - Added `scripts/test-evolution.mts` covering all 10 evolved mechanics (all green). - Verified lockstep multiplayer determinism via `scripts/test-mp.mts`. - Verified solo campaign balance preservation via `scripts/sim.mts`. - Full TypeScript typecheck and production build passing.
159 lines
3.9 KiB
TypeScript
159 lines
3.9 KiB
TypeScript
/**
|
||
* Headless balance simulation: a greedy bot plays the game on 'normal'
|
||
* to verify the campaign is winnable (and not trivially easy).
|
||
*
|
||
* Run: npm run sim
|
||
*/
|
||
import { GameEngine } from '../src/game/engine.ts'
|
||
import { TOWERS } from '../src/game/config.ts'
|
||
import type { TowerKind } from '../src/game/types.ts'
|
||
|
||
/** campaign balance stays tuned for 3 levels – evolutions are endless-mode luxury */
|
||
const SIM_MAX_LEVEL = 3
|
||
|
||
const eng = new GameEngine()
|
||
eng.startGame('normal')
|
||
|
||
// predetermined build spots (tile coords) near path hot spots
|
||
const spots: [number, number][] = [
|
||
[8, 5], // center between the two long vertical runs
|
||
[3, 0],
|
||
[7, 0],
|
||
[11, 0],
|
||
[4, 4],
|
||
[12, 6],
|
||
[8, 8],
|
||
[8, 10],
|
||
[12, 10],
|
||
[16, 3],
|
||
[18, 4],
|
||
[15, 0],
|
||
[4, 7],
|
||
[8, 2],
|
||
[12, 3],
|
||
[7, 10],
|
||
[13, 10],
|
||
[1, 4],
|
||
[18, 6],
|
||
[16, 6],
|
||
[5, 0],
|
||
[9, 0],
|
||
[13, 0],
|
||
[8, 6],
|
||
[12, 7],
|
||
[4, 2],
|
||
]
|
||
|
||
// what to build in which order (afterwards: upgrades only)
|
||
const plan: TowerKind[] = [
|
||
'arrow',
|
||
'frost',
|
||
'arrow',
|
||
'cannon',
|
||
'arrow',
|
||
'tesla',
|
||
'frost',
|
||
'arrow',
|
||
'cannon',
|
||
'laser',
|
||
'tesla',
|
||
'laser',
|
||
'frost',
|
||
'cannon',
|
||
'tesla',
|
||
'arrow',
|
||
'laser',
|
||
'frost',
|
||
'cannon',
|
||
'tesla',
|
||
'laser',
|
||
'arrow',
|
||
'arrow',
|
||
'arrow',
|
||
'arrow',
|
||
'arrow',
|
||
]
|
||
|
||
function bot(): void {
|
||
// spend greedily: cheapest sensible action first, repeat
|
||
for (;;) {
|
||
let acted = false
|
||
|
||
// next planned build on the next free spot
|
||
const built = eng.towers.length
|
||
if (built < plan.length) {
|
||
const kind = plan[built]
|
||
const spot = spots[built % spots.length]
|
||
const cost = towerCost(kind)
|
||
if (eng.money >= cost && eng.canPlace(spot[0], spot[1])) {
|
||
if (!eng.placeTower(kind, spot[0], spot[1])) throw new Error('place failed at ' + spot)
|
||
acted = true
|
||
continue
|
||
}
|
||
// spot blocked -> skip this spot for that build
|
||
if (!eng.canPlace(spot[0], spot[1])) {
|
||
// try any other free spot
|
||
const alt = spots.find((s) => eng.canPlace(s[0], s[1]))
|
||
if (alt && eng.money >= cost) {
|
||
eng.placeTower(kind, alt[0], alt[1])
|
||
acted = true
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
|
||
// cheapest upgrade among towers (max 3 levels)
|
||
let bestCost = Infinity
|
||
let bestT: ReturnType<typeof eng.towers.at> = undefined
|
||
for (const t of eng.towers) {
|
||
const c = upgradeCostOf(t.kind, t.level)
|
||
if (c !== null && c < bestCost) {
|
||
bestCost = c
|
||
bestT = t
|
||
}
|
||
}
|
||
if (bestT && bestCost <= eng.money) {
|
||
eng.upgradeTower(bestT)
|
||
acted = true
|
||
continue
|
||
}
|
||
|
||
if (!acted) break
|
||
}
|
||
|
||
// start next wave early for the bonus, like an aggressive player
|
||
if (eng.phase === 'intermission') eng.startWave(true)
|
||
}
|
||
|
||
function towerCost(kind: TowerKind): number {
|
||
return TOWERS[kind].cost
|
||
}
|
||
|
||
function upgradeCostOf(kind: TowerKind, level: number): number | null {
|
||
if (level >= SIM_MAX_LEVEL) return null
|
||
return TOWERS[kind].upgradeCost[level - 1]
|
||
}
|
||
|
||
let lastWave = 0
|
||
let guard = 0
|
||
while (eng.phase !== 'gameover' && eng.phase !== 'victory' && guard < 60 * 60 * 60) {
|
||
eng.update(1 / 60)
|
||
bot()
|
||
if (eng.waveNo !== lastWave) {
|
||
lastWave = eng.waveNo
|
||
const kindCounts: Record<string, number> = {}
|
||
for (const t of eng.towers) kindCounts[t.kind + t.level] = (kindCounts[t.kind + t.level] ?? 0) + 1
|
||
console.log(
|
||
`Welle ${String(eng.waveNo).padStart(2)} | ❤ ${String(eng.lives).padStart(2)} | 🪙 ${String(Math.floor(eng.money)).padStart(4)} | Türme: ${eng.towers.length} ${JSON.stringify(kindCounts)}`,
|
||
)
|
||
}
|
||
guard++
|
||
}
|
||
|
||
console.log('---')
|
||
console.log(`Ergebnis: ${eng.phase === 'victory' ? 'SIEG' : 'NIEDERLAGE'} in Welle ${eng.waveNo}, Leben übrig: ${eng.lives}, Punkte: ${eng.score}`)
|
||
if (eng.phase !== 'victory') {
|
||
console.log(' → Balancing zu schwer! Gegner-Werte abschwächen.')
|
||
} else if (eng.lives > 14) {
|
||
console.log(' → Balancing zu leicht (zu viele Leben übrig).')
|
||
}
|