TRXTD/scripts/sim.mts
Tronax aa68343ac2
feat(balance): 30-wave campaign, 5 new enemy types, steeper per-wave scaling
The campaign was too easy: a handful of un-upgraded towers stopped
everything. This makes the game substantially harder across three axes.

Campaign extended from 20 to 30 hand-tuned waves (endless now starts at
31). Victory messages, HUD label (via store.totalWaves synced from
TOTAL_WAVES), start screen, lobby and README updated.

Five new enemy types debut in later waves, each with a hand-drawn
canvas look:
- Brute (wave 8): fast tank hybrid, 130 HP, 2 lives
- Phantom (wave 12): fast flyer with real HP, 2 lives
- Scorpion (wave 14): very fast ground, 2 lives
- Golem (wave 18): walking bunker, 560 HP, 3 lives
- Dragon (wave 22): flying boss, 950 HP, 3 lives, gets its own top
  boss health bar like the boss

Per-wave HP scaling steepened (1 + 0.16m + 0.025m², was 0.18m + 0.02m²):
wave 10 now ~4.6x (was 4.2x), wave 20 ~13.2x (was 11.6x), wave 30 ~29x.
Late waves mix the new types into heavy compositions; wave 30 is the
final wall (3 bosses, 2 dragons, 4 golems, 12 scorpions). Endless waves
scale all ten types with bosses every 5 and dragons from endless+2.

Balance validated with the headless greedy-bot simulation: the bot
(capped around tower level 5 on fixed spots) previously trivially won
the campaign and now dies at wave 29 — clearing wave 30 requires
evolved towers (level 6+), research bonuses and good placement. The
sim docs were updated to describe this new tuning philosophy.

New render smoke test (npm test): draws all ten enemy kinds through
the real renderer with a stub 2D context, two frames each with slow/
DoT/flash effects active, guarding every drawEnemy code path. 55 checks
green, build clean. Browser-verified: campaign starts and HUD shows
"Welle x/30".
2026-08-17 20:29:47 +02:00

165 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Headless balance simulation: a greedy bot plays the campaign on 'normal'
* as a difficulty baseline. The bot uses predetermined build spots and a
* naive greedy upgrade policy (it plateaus around tower level 5), so where
* it dies marks the difficulty wall for un-evolved towers. The 30-wave
* campaign is tuned so that clearing it requires evolved towers (level 6+),
* research bonuses and good placement the bot dying late (wave ~28+) is
* expected and healthy; dying before ~wave 15 would mean the mid-game is
* overtuned.
*
* 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'
/** upper tower level bound for the greedy bot (it rarely reaches it) */
const SIM_MAX_LEVEL = 9
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(' → Greedy-Baseline gescheitert. Tod vor ~Welle 15 wäre zu schwer, spät (~28+) ist Ziel.')
} else if (eng.lives > 14) {
console.log(' → Balancing zu leicht (zu viele Leben übrig).')
}