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".
165 lines
4.3 KiB
TypeScript
165 lines
4.3 KiB
TypeScript
/**
|
||
* 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).')
|
||
}
|