@vibemancer/core 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -28
- package/dist/{chunk-L7Z7OFXD.js → chunk-7VDJHO22.js} +3 -3
- package/dist/chunk-7VDJHO22.js.map +1 -0
- package/dist/index-browser.d.ts +1 -1
- package/dist/index-browser.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +79 -78
- package/src/bots/berserker/01_Stormchaser.ts +457 -457
- package/src/bots/berserker/02_Stormcaller.ts +417 -417
- package/src/bots/berserker/03_Stormforger.ts +481 -481
- package/src/bots/caster/01_Flamecaller.ts +286 -286
- package/src/bots/caster/02_Pyromancer.ts +350 -350
- package/src/bots/caster/03_Infernalist.ts +492 -492
- package/src/bots/defensive/01_Turtle.ts +151 -151
- package/src/bots/defensive/02_Sentinel.ts +134 -134
- package/src/bots/defensive/03_Golem.ts +357 -357
- package/src/bots/duelist/01_Battlemage.ts +433 -433
- package/src/bots/duelist/02_Warmage.ts +438 -438
- package/src/bots/duelist/03_Archmage.ts +588 -588
- package/src/bots/homing/01_Bonemancer.ts +67 -67
- package/src/bots/homing/02_Lich.ts +356 -356
- package/src/bots/homing/03_Archlich.ts +220 -220
- package/src/bots/kiter/01_Spellspinner.ts +398 -398
- package/src/bots/kiter/02_Spellweaver.ts +378 -378
- package/src/bots/kiter/03_Spellbinder.ts +448 -448
- package/src/bots/melee/01_Shadowblade.ts +270 -270
- package/src/bots/melee/02_Nightblade.ts +437 -437
- package/src/bots/melee/03_Voidblade.ts +582 -582
- package/src/bots/sniper/01_Spellshot.ts +385 -385
- package/src/bots/sniper/02_Spelltracer.ts +441 -441
- package/src/bots/sniper/03_Spellseeker.ts +546 -546
- package/src/bots/standalone/Critter.ts +89 -89
- package/src/bots/standalone/Doombringer.ts +91 -91
- package/src/bots/standalone/Hogger.ts +228 -228
- package/src/bots/standalone/Rookie.ts +50 -50
- package/src/bots/standalone/TargetDummy.ts +21 -21
- package/src/bots/test/cheater.ts +405 -405
- package/src/bots/test/crasher.ts +81 -81
- package/src/engine/hooks-runtime.ts +394 -394
- package/src/engine/manual-match.ts +289 -289
- package/src/engine/missile-templates.ts +155 -155
- package/src/engine/physics.ts +143 -143
- package/src/engine/simulation.ts +828 -828
- package/src/engine/spells.ts +128 -128
- package/src/engine-version.ts +1 -1
- package/src/index.ts +23 -23
- package/src/rules.ts +254 -254
- package/src/types.ts +193 -193
- package/src/utils/index.ts +6 -6
- package/dist/chunk-L7Z7OFXD.js.map +0 -1
package/src/engine/simulation.ts
CHANGED
|
@@ -1,828 +1,828 @@
|
|
|
1
|
-
import {
|
|
2
|
-
GameState,
|
|
3
|
-
WizardState,
|
|
4
|
-
ProjectileState,
|
|
5
|
-
WizardFunction,
|
|
6
|
-
WizardActions,
|
|
7
|
-
MissileAIFunction,
|
|
8
|
-
MissileActions,
|
|
9
|
-
GameConfig,
|
|
10
|
-
MissileConfig,
|
|
11
|
-
} from '../types.js';
|
|
12
|
-
import {
|
|
13
|
-
ARENA_WIDTH,
|
|
14
|
-
ARENA_HEIGHT,
|
|
15
|
-
SPAWN_DISTANCE,
|
|
16
|
-
WIZARD_HEALTH,
|
|
17
|
-
TICKS_PER_SECOND,
|
|
18
|
-
calculateBlinkCooldown,
|
|
19
|
-
BLINK_RANGE,
|
|
20
|
-
WIZARD_RADIUS,
|
|
21
|
-
MATCH_DURATION,
|
|
22
|
-
validateMissileConfig,
|
|
23
|
-
calculateMissileRadius,
|
|
24
|
-
ARENA_WATER_BUFFER,
|
|
25
|
-
} from '../rules.js';
|
|
26
|
-
import {moveWizard, moveProjectile, sweptCircleCollision, clampToArena, resolveWizardCollision} from './physics.js';
|
|
27
|
-
import {applyDamage, updateShield, startCast, completeCast} from './spells.js';
|
|
28
|
-
import {runWithHooks, resetAllHooks, clearHooks} from './hooks-runtime.js';
|
|
29
|
-
import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
|
|
30
|
-
import {createRandom, createEntitySeed} from '../utils/random.js';
|
|
31
|
-
|
|
32
|
-
export interface InternalWizardState extends WizardState
|
|
33
|
-
{
|
|
34
|
-
missileConfig?: MissileConfig;
|
|
35
|
-
missileAI?: MissileAIFunction;
|
|
36
|
-
blinkTarget?: {x: number; y: number};
|
|
37
|
-
// Combat tracking
|
|
38
|
-
damageDealt: number;
|
|
39
|
-
damageTaken: number;
|
|
40
|
-
lastHitTick: number;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Initialize a new match state.
|
|
45
|
-
*/
|
|
46
|
-
export function createInitialState(_seed: number, spawnDist: number = SPAWN_DISTANCE): GameState
|
|
47
|
-
{
|
|
48
|
-
const center = {x: ARENA_WIDTH / 2, y: ARENA_HEIGHT / 2};
|
|
49
|
-
const offset = spawnDist / 2;
|
|
50
|
-
|
|
51
|
-
const wizard1: WizardState = {
|
|
52
|
-
id: 'wizard-1',
|
|
53
|
-
position: {x: center.x - offset, y: center.y},
|
|
54
|
-
rotation: 0,
|
|
55
|
-
health: WIZARD_HEALTH,
|
|
56
|
-
maxHealth: WIZARD_HEALTH,
|
|
57
|
-
state: 'idle',
|
|
58
|
-
blinkCooldown: 0,
|
|
59
|
-
velocity: {x: 0, y: 0},
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const wizard2: WizardState = {
|
|
63
|
-
id: 'wizard-2',
|
|
64
|
-
position: {x: center.x + offset, y: center.y},
|
|
65
|
-
rotation: 180,
|
|
66
|
-
health: WIZARD_HEALTH,
|
|
67
|
-
maxHealth: WIZARD_HEALTH,
|
|
68
|
-
state: 'idle',
|
|
69
|
-
blinkCooldown: 0,
|
|
70
|
-
velocity: {x: 0, y: 0},
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
tick: 0,
|
|
75
|
-
position: wizard1.position,
|
|
76
|
-
rotation: wizard1.rotation,
|
|
77
|
-
health: wizard1.health,
|
|
78
|
-
maxHealth: wizard1.maxHealth,
|
|
79
|
-
state: wizard1.state,
|
|
80
|
-
blinkCooldown: wizard1.blinkCooldown,
|
|
81
|
-
velocity: wizard1.velocity,
|
|
82
|
-
enemies: [wizard2],
|
|
83
|
-
projectiles: [],
|
|
84
|
-
myProjectiles: [],
|
|
85
|
-
damageDealt: 0,
|
|
86
|
-
damageTaken: 0,
|
|
87
|
-
lastHitTick: 0,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Process one game tick.
|
|
93
|
-
*/
|
|
94
|
-
export function tick(
|
|
95
|
-
currentTick: number,
|
|
96
|
-
wizard1AI: WizardFunction,
|
|
97
|
-
wizard2AI: WizardFunction,
|
|
98
|
-
config: GameConfig,
|
|
99
|
-
wizards: InternalWizardState[],
|
|
100
|
-
projectiles: ProjectileState[],
|
|
101
|
-
missileAIs: Map<string, MissileAIFunction>,
|
|
102
|
-
matchSeed: number,
|
|
103
|
-
): {
|
|
104
|
-
nextTick: number;
|
|
105
|
-
wizards: InternalWizardState[];
|
|
106
|
-
projectiles: ProjectileState[];
|
|
107
|
-
errors: BotError[];
|
|
108
|
-
}
|
|
109
|
-
{
|
|
110
|
-
const nextTick = currentTick + 1;
|
|
111
|
-
const errors: BotError[] = [];
|
|
112
|
-
|
|
113
|
-
// 1. Run wizard AIs with isolated random generators
|
|
114
|
-
// Each wizard gets a fresh random generator seeded by (matchSeed, wizardId, tick)
|
|
115
|
-
const random1 = createRandom(createEntitySeed(matchSeed, wizards[0]!.id, nextTick));
|
|
116
|
-
const random2 = createRandom(createEntitySeed(matchSeed, wizards[1]!.id, nextTick));
|
|
117
|
-
|
|
118
|
-
// Wrap AI calls in try-catch - if AI throws, wizard does nothing (Lesson #21)
|
|
119
|
-
let actions1: WizardActions = {move: {x: 0, y: 0}};
|
|
120
|
-
try
|
|
121
|
-
{
|
|
122
|
-
actions1 = runWithHooks(wizards[0]!.id, () => wizard1AI({
|
|
123
|
-
state: getPlayerStateView(0, wizards, projectiles, nextTick),
|
|
124
|
-
config,
|
|
125
|
-
random: random1,
|
|
126
|
-
})) ?? {move: {x: 0, y: 0}};
|
|
127
|
-
}
|
|
128
|
-
catch(e)
|
|
129
|
-
{
|
|
130
|
-
errors.push({tick: nextTick, entityId: 'wizard-1', message: e instanceof Error ? e.message : String(e)});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
let actions2: WizardActions = {move: {x: 0, y: 0}};
|
|
134
|
-
try
|
|
135
|
-
{
|
|
136
|
-
actions2 = runWithHooks(wizards[1]!.id, () => wizard2AI({
|
|
137
|
-
state: getPlayerStateView(1, wizards, projectiles, nextTick),
|
|
138
|
-
config,
|
|
139
|
-
random: random2,
|
|
140
|
-
})) ?? {move: {x: 0, y: 0}};
|
|
141
|
-
}
|
|
142
|
-
catch(e)
|
|
143
|
-
{
|
|
144
|
-
errors.push({tick: nextTick, entityId: 'wizard-2', message: e instanceof Error ? e.message : String(e)});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const actions = [actions1, actions2];
|
|
148
|
-
|
|
149
|
-
// 2. Update wizards
|
|
150
|
-
wizards.forEach((wizard, i) =>
|
|
151
|
-
{
|
|
152
|
-
const action = actions[i]!;
|
|
153
|
-
|
|
154
|
-
// Handle cooldowns
|
|
155
|
-
if (wizard.blinkCooldown > 0)
|
|
156
|
-
{
|
|
157
|
-
wizard.blinkCooldown--;
|
|
158
|
-
}
|
|
159
|
-
if (wizard.state === 'gcd_locked' && wizard.gcdRemaining !== undefined)
|
|
160
|
-
{
|
|
161
|
-
wizard.gcdRemaining--;
|
|
162
|
-
if (wizard.gcdRemaining <= 0)
|
|
163
|
-
{
|
|
164
|
-
wizard.state = 'idle';
|
|
165
|
-
delete wizard.gcdRemaining;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Auto-aim: face the enemy by default, bot can override with aimDirection
|
|
170
|
-
const enemy = wizards[1 - i]!;
|
|
171
|
-
if (action.aimDirection !== undefined && Number.isFinite(action.aimDirection))
|
|
172
|
-
{
|
|
173
|
-
wizard.rotation = action.aimDirection;
|
|
174
|
-
}
|
|
175
|
-
else
|
|
176
|
-
{
|
|
177
|
-
wizard.rotation = angleTo(wizard.position, enemy.position);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Handle casting
|
|
181
|
-
if (wizard.state === 'casting' && wizard.castProgress !== undefined && wizard.castDuration !== undefined)
|
|
182
|
-
{
|
|
183
|
-
wizard.castProgress++;
|
|
184
|
-
if (wizard.castProgress >= wizard.castDuration)
|
|
185
|
-
{
|
|
186
|
-
const spell = wizard.castingSpell;
|
|
187
|
-
|
|
188
|
-
if (spell === 'missile' && wizard.missileConfig && wizard.missileAI)
|
|
189
|
-
{
|
|
190
|
-
// Validate missile config to enforce minimums (Lesson #9)
|
|
191
|
-
const validConfig = validateMissileConfig(wizard.missileConfig);
|
|
192
|
-
const id = `missile-${wizard.id}-${nextTick}`;
|
|
193
|
-
const projectile: ProjectileState = {
|
|
194
|
-
id,
|
|
195
|
-
type: 'missile',
|
|
196
|
-
ownerId: wizard.id,
|
|
197
|
-
position: {...wizard.position},
|
|
198
|
-
rotation: wizard.rotation, // Use wizard's rotation at time of firing
|
|
199
|
-
speed: validConfig.speed,
|
|
200
|
-
turnRate: validConfig.turnRate,
|
|
201
|
-
damage: validConfig.damage,
|
|
202
|
-
remainingTicks: validConfig.duration,
|
|
203
|
-
};
|
|
204
|
-
projectiles.push(projectile);
|
|
205
|
-
missileAIs.set(id, wizard.missileAI);
|
|
206
|
-
// Track last missile for warmup system
|
|
207
|
-
wizard.lastMissileConfig = {...validConfig};
|
|
208
|
-
delete wizard.missileConfig;
|
|
209
|
-
delete wizard.missileAI;
|
|
210
|
-
}
|
|
211
|
-
else if (spell === 'blink' && wizard.blinkTarget)
|
|
212
|
-
{
|
|
213
|
-
// Clamp blink target to BLINK_RANGE from current position
|
|
214
|
-
const dx = wizard.blinkTarget.x - wizard.position.x;
|
|
215
|
-
const dy = wizard.blinkTarget.y - wizard.position.y;
|
|
216
|
-
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
217
|
-
|
|
218
|
-
let targetPos = wizard.blinkTarget;
|
|
219
|
-
if (distance > BLINK_RANGE)
|
|
220
|
-
{
|
|
221
|
-
// Clamp to max range in the same direction
|
|
222
|
-
const scale = BLINK_RANGE / distance;
|
|
223
|
-
targetPos = {
|
|
224
|
-
x: wizard.position.x + dx * scale,
|
|
225
|
-
y: wizard.position.y + dy * scale,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Clamp to arena bounds and calculate actual distance traveled
|
|
230
|
-
const oldPos = wizard.position;
|
|
231
|
-
wizard.position = clampToArena(targetPos, WIZARD_RADIUS);
|
|
232
|
-
const actualDx = wizard.position.x - oldPos.x;
|
|
233
|
-
const actualDy = wizard.position.y - oldPos.y;
|
|
234
|
-
const actualDistance = Math.sqrt(actualDx * actualDx + actualDy * actualDy);
|
|
235
|
-
wizard.blinkCooldown = calculateBlinkCooldown(actualDistance);
|
|
236
|
-
delete wizard.blinkTarget;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
completeCast(wizard);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Handle movement (default to no movement if bot didn't provide move)
|
|
244
|
-
const oldPos = wizard.position;
|
|
245
|
-
const move = action.move ?? {x: 0, y: 0};
|
|
246
|
-
wizard.position = moveWizard(wizard, move, 1);
|
|
247
|
-
wizard.velocity = {
|
|
248
|
-
x: wizard.position.x - oldPos.x,
|
|
249
|
-
y: wizard.position.y - oldPos.y,
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
// Handle shield — cancel goes to idle (no GCD)
|
|
253
|
-
if (wizard.state === 'channeling')
|
|
254
|
-
{
|
|
255
|
-
updateShield(wizard, 1);
|
|
256
|
-
if (action.cancel)
|
|
257
|
-
{
|
|
258
|
-
wizard.state = 'idle';
|
|
259
|
-
delete wizard.channelingSpell;
|
|
260
|
-
delete wizard.channelDuration;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Handle start cast
|
|
265
|
-
if (wizard.state === 'idle' && action.startCast)
|
|
266
|
-
{
|
|
267
|
-
if (action.startCast.spell === 'blink' && wizard.blinkCooldown > 0)
|
|
268
|
-
{
|
|
269
|
-
// Cannot blink yet
|
|
270
|
-
}
|
|
271
|
-
else
|
|
272
|
-
{
|
|
273
|
-
startCast(wizard, action.startCast.spell, action.startCast.spell === 'missile' ? action.startCast.config : undefined);
|
|
274
|
-
if (action.startCast.spell === 'missile')
|
|
275
|
-
{
|
|
276
|
-
wizard.missileConfig = action.startCast.config;
|
|
277
|
-
wizard.missileAI = action.startCast.missileAI;
|
|
278
|
-
if (action.startCast.direction !== undefined)
|
|
279
|
-
{
|
|
280
|
-
wizard.rotation = action.startCast.direction;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
else if (action.startCast.spell === 'blink')
|
|
284
|
-
{
|
|
285
|
-
wizard.blinkTarget = action.startCast.target;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Handle cancel
|
|
291
|
-
if (wizard.state === 'casting' && action.cancel)
|
|
292
|
-
{
|
|
293
|
-
wizard.state = 'idle';
|
|
294
|
-
delete wizard.castingSpell;
|
|
295
|
-
delete wizard.castProgress;
|
|
296
|
-
delete wizard.castDuration;
|
|
297
|
-
delete wizard.missileConfig;
|
|
298
|
-
delete wizard.missileAI;
|
|
299
|
-
delete wizard.blinkTarget;
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
// 2b. Resolve wizard body collision (push apart if overlapping)
|
|
304
|
-
resolveWizardCollision(wizards[0]!, wizards[1]!);
|
|
305
|
-
|
|
306
|
-
// 3. Update projectiles
|
|
307
|
-
const remainingProjectiles: ProjectileState[] = [];
|
|
308
|
-
projectiles.forEach((projectile) =>
|
|
309
|
-
{
|
|
310
|
-
const ai = missileAIs.get(projectile.id);
|
|
311
|
-
if (ai)
|
|
312
|
-
{
|
|
313
|
-
const ownerIndex = wizards.findIndex((w) => w.id === projectile.ownerId);
|
|
314
|
-
// Each missile gets its own isolated random generator
|
|
315
|
-
const missileRandom = createRandom(createEntitySeed(matchSeed, projectile.id, nextTick));
|
|
316
|
-
|
|
317
|
-
// Wrap missile AI in try-catch - if it throws, missile continues straight (Lesson #21)
|
|
318
|
-
let missileActions: MissileActions = {};
|
|
319
|
-
try
|
|
320
|
-
{
|
|
321
|
-
missileActions = runWithHooks(projectile.id, () => ai({
|
|
322
|
-
missileState: projectileView(projectile),
|
|
323
|
-
worldState: getPlayerStateView(ownerIndex, wizards, projectiles, nextTick),
|
|
324
|
-
random: missileRandom,
|
|
325
|
-
})) ?? {};
|
|
326
|
-
}
|
|
327
|
-
catch(e)
|
|
328
|
-
{
|
|
329
|
-
errors.push({tick: nextTick, entityId: projectile.id, message: e instanceof Error ? e.message : String(e)});
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (missileActions.turnToward)
|
|
333
|
-
{
|
|
334
|
-
const targetAngle = angleTo(projectile.position, missileActions.turnToward);
|
|
335
|
-
const diff = angleDiff(projectile.rotation, targetAngle);
|
|
336
|
-
// Negative turnRate = anti-homing (turns away from target)
|
|
337
|
-
const absTurnRate = Math.abs(projectile.turnRate);
|
|
338
|
-
const effectiveDiff = projectile.turnRate >= 0 ? diff : -diff;
|
|
339
|
-
const turn = Math.max(-absTurnRate, Math.min(absTurnRate, effectiveDiff));
|
|
340
|
-
projectile.rotation = normalizeAngle(projectile.rotation + turn);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const oldPos = projectile.position;
|
|
345
|
-
projectile.position = moveProjectile(projectile, 1);
|
|
346
|
-
projectile.remainingTicks--;
|
|
347
|
-
|
|
348
|
-
// Check collisions
|
|
349
|
-
let hit = false;
|
|
350
|
-
const missileRadius = calculateMissileRadius(projectile.damage);
|
|
351
|
-
wizards.forEach((wizard) =>
|
|
352
|
-
{
|
|
353
|
-
if (wizard.id !== projectile.ownerId && !hit)
|
|
354
|
-
{
|
|
355
|
-
if (sweptCircleCollision(oldPos, projectile.position, missileRadius, wizard.position, WIZARD_RADIUS))
|
|
356
|
-
{
|
|
357
|
-
const actualDamage = applyDamage(wizard, projectile.damage);
|
|
358
|
-
hit = true;
|
|
359
|
-
|
|
360
|
-
// Track damage for combat tracking hooks
|
|
361
|
-
wizard.damageTaken += actualDamage;
|
|
362
|
-
wizard.lastHitTick = nextTick;
|
|
363
|
-
|
|
364
|
-
// Find the owner and track their damage dealt
|
|
365
|
-
const owner = wizards.find((w) => w.id === projectile.ownerId);
|
|
366
|
-
if (owner)
|
|
367
|
-
{
|
|
368
|
-
owner.damageDealt += actualDamage;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
// Check if projectile is beyond the water buffer
|
|
375
|
-
const isOutOfBounds =
|
|
376
|
-
projectile.position.x < -ARENA_WATER_BUFFER ||
|
|
377
|
-
projectile.position.x > ARENA_WIDTH + ARENA_WATER_BUFFER ||
|
|
378
|
-
projectile.position.y < -ARENA_WATER_BUFFER ||
|
|
379
|
-
projectile.position.y > ARENA_HEIGHT + ARENA_WATER_BUFFER;
|
|
380
|
-
|
|
381
|
-
if (!hit && projectile.remainingTicks > 0 && !isOutOfBounds)
|
|
382
|
-
{
|
|
383
|
-
remainingProjectiles.push(projectile);
|
|
384
|
-
}
|
|
385
|
-
else
|
|
386
|
-
{
|
|
387
|
-
missileAIs.delete(projectile.id);
|
|
388
|
-
clearHooks(projectile.id);
|
|
389
|
-
}
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
return {
|
|
393
|
-
nextTick,
|
|
394
|
-
wizards,
|
|
395
|
-
projectiles: remainingProjectiles,
|
|
396
|
-
errors,
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* Deep clone a wizard state to prevent mutation.
|
|
402
|
-
*/
|
|
403
|
-
function cloneWizard(wizard: WizardState): WizardState
|
|
404
|
-
{
|
|
405
|
-
return {
|
|
406
|
-
id: wizard.id,
|
|
407
|
-
position: {...wizard.position},
|
|
408
|
-
rotation: wizard.rotation,
|
|
409
|
-
health: wizard.health,
|
|
410
|
-
maxHealth: wizard.maxHealth,
|
|
411
|
-
state: wizard.state,
|
|
412
|
-
castingSpell: wizard.castingSpell,
|
|
413
|
-
castProgress: wizard.castProgress,
|
|
414
|
-
castDuration: wizard.castDuration,
|
|
415
|
-
channelingSpell: wizard.channelingSpell,
|
|
416
|
-
channelDuration: wizard.channelDuration,
|
|
417
|
-
gcdRemaining: wizard.gcdRemaining,
|
|
418
|
-
blinkCooldown: wizard.blinkCooldown,
|
|
419
|
-
velocity: {...wizard.velocity},
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Deep clone a projectile state to prevent mutation.
|
|
425
|
-
*/
|
|
426
|
-
function cloneProjectile(projectile: ProjectileState): ProjectileState
|
|
427
|
-
{
|
|
428
|
-
return {
|
|
429
|
-
id: projectile.id,
|
|
430
|
-
type: projectile.type,
|
|
431
|
-
ownerId: projectile.ownerId,
|
|
432
|
-
position: {...projectile.position},
|
|
433
|
-
rotation: projectile.rotation,
|
|
434
|
-
speed: projectile.speed,
|
|
435
|
-
turnRate: projectile.turnRate,
|
|
436
|
-
damage: projectile.damage,
|
|
437
|
-
remainingTicks: projectile.remainingTicks,
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Get the game state from a specific player's perspective.
|
|
443
|
-
* Returns a deep clone to prevent mutation of history entries.
|
|
444
|
-
* Used for history recording where independent snapshots are needed.
|
|
445
|
-
*/
|
|
446
|
-
export function getPlayerState(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number): GameState
|
|
447
|
-
{
|
|
448
|
-
const me = wizards[playerIndex]!;
|
|
449
|
-
const enemies = wizards.filter((_, i) => i !== playerIndex).map(cloneWizard);
|
|
450
|
-
const clonedProjectiles = projectiles.map(cloneProjectile);
|
|
451
|
-
|
|
452
|
-
return {
|
|
453
|
-
tick,
|
|
454
|
-
position: {...me.position},
|
|
455
|
-
rotation: me.rotation,
|
|
456
|
-
health: me.health,
|
|
457
|
-
maxHealth: me.maxHealth,
|
|
458
|
-
state: me.state,
|
|
459
|
-
castingSpell: me.castingSpell,
|
|
460
|
-
castProgress: me.castProgress,
|
|
461
|
-
castDuration: me.castDuration,
|
|
462
|
-
channelingSpell: me.channelingSpell,
|
|
463
|
-
channelDuration: me.channelDuration,
|
|
464
|
-
gcdRemaining: me.gcdRemaining,
|
|
465
|
-
blinkCooldown: me.blinkCooldown,
|
|
466
|
-
velocity: {...me.velocity},
|
|
467
|
-
lastMissileConfig: me.lastMissileConfig ? {...me.lastMissileConfig} : undefined,
|
|
468
|
-
warmupMultiplier: me.warmupMultiplier,
|
|
469
|
-
enemies,
|
|
470
|
-
projectiles: clonedProjectiles,
|
|
471
|
-
myProjectiles: clonedProjectiles.filter((p) => p.ownerId === me.id),
|
|
472
|
-
// Combat tracking
|
|
473
|
-
damageDealt: me.damageDealt,
|
|
474
|
-
damageTaken: me.damageTaken,
|
|
475
|
-
lastHitTick: me.lastHitTick,
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* Create a lightweight read-only view of a projectile.
|
|
481
|
-
* Clones only the mutable sub-objects (position) to prevent
|
|
482
|
-
* bot code from corrupting internal simulation state.
|
|
483
|
-
* Scalar fields are safe since they're copied by value.
|
|
484
|
-
*/
|
|
485
|
-
function projectileView(p: ProjectileState): ProjectileState
|
|
486
|
-
{
|
|
487
|
-
return {
|
|
488
|
-
id: p.id,
|
|
489
|
-
type: p.type,
|
|
490
|
-
ownerId: p.ownerId,
|
|
491
|
-
position: {x: p.position.x, y: p.position.y},
|
|
492
|
-
rotation: p.rotation,
|
|
493
|
-
speed: p.speed,
|
|
494
|
-
turnRate: p.turnRate,
|
|
495
|
-
damage: p.damage,
|
|
496
|
-
remainingTicks: p.remainingTicks,
|
|
497
|
-
};
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* Extract public WizardState fields from an InternalWizardState.
|
|
502
|
-
* Clones position and velocity ({x,y} spreads) to prevent bot code
|
|
503
|
-
* from mutating internal simulation state. Scalar fields are safe.
|
|
504
|
-
*/
|
|
505
|
-
function extractWizardView(wizard: InternalWizardState): WizardState
|
|
506
|
-
{
|
|
507
|
-
return {
|
|
508
|
-
id: wizard.id,
|
|
509
|
-
position: {x: wizard.position.x, y: wizard.position.y},
|
|
510
|
-
rotation: wizard.rotation,
|
|
511
|
-
health: wizard.health,
|
|
512
|
-
maxHealth: wizard.maxHealth,
|
|
513
|
-
state: wizard.state,
|
|
514
|
-
castingSpell: wizard.castingSpell,
|
|
515
|
-
castProgress: wizard.castProgress,
|
|
516
|
-
castDuration: wizard.castDuration,
|
|
517
|
-
channelingSpell: wizard.channelingSpell,
|
|
518
|
-
channelDuration: wizard.channelDuration,
|
|
519
|
-
gcdRemaining: wizard.gcdRemaining,
|
|
520
|
-
blinkCooldown: wizard.blinkCooldown,
|
|
521
|
-
velocity: {x: wizard.velocity.x, y: wizard.velocity.y},
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Get a lightweight read-only view of game state for AI calls.
|
|
527
|
-
* Clones only the small mutable sub-objects (position, velocity as {x,y})
|
|
528
|
-
* to prevent bot code from corrupting internal simulation state.
|
|
529
|
-
* Scalar fields are copied by value and safe from mutation.
|
|
530
|
-
*
|
|
531
|
-
* This is much faster than getPlayerState() (which deep-clones everything)
|
|
532
|
-
* because it only spreads the tiny {x,y} objects, not full deep clones.
|
|
533
|
-
*/
|
|
534
|
-
function getPlayerStateView(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number): GameState
|
|
535
|
-
{
|
|
536
|
-
const me = wizards[playerIndex]!;
|
|
537
|
-
const enemies = wizards.filter((_, i) => i !== playerIndex).map(extractWizardView);
|
|
538
|
-
const projViews = projectiles.map(projectileView);
|
|
539
|
-
|
|
540
|
-
return {
|
|
541
|
-
tick,
|
|
542
|
-
position: {x: me.position.x, y: me.position.y},
|
|
543
|
-
rotation: me.rotation,
|
|
544
|
-
health: me.health,
|
|
545
|
-
maxHealth: me.maxHealth,
|
|
546
|
-
state: me.state,
|
|
547
|
-
castingSpell: me.castingSpell,
|
|
548
|
-
castProgress: me.castProgress,
|
|
549
|
-
castDuration: me.castDuration,
|
|
550
|
-
channelingSpell: me.channelingSpell,
|
|
551
|
-
channelDuration: me.channelDuration,
|
|
552
|
-
gcdRemaining: me.gcdRemaining,
|
|
553
|
-
blinkCooldown: me.blinkCooldown,
|
|
554
|
-
velocity: {x: me.velocity.x, y: me.velocity.y},
|
|
555
|
-
lastMissileConfig: me.lastMissileConfig ? {...me.lastMissileConfig} : undefined,
|
|
556
|
-
warmupMultiplier: me.warmupMultiplier,
|
|
557
|
-
enemies,
|
|
558
|
-
projectiles: projViews,
|
|
559
|
-
myProjectiles: projViews.filter((p) => p.ownerId === me.id),
|
|
560
|
-
// Combat tracking
|
|
561
|
-
damageDealt: me.damageDealt,
|
|
562
|
-
damageTaken: me.damageTaken,
|
|
563
|
-
lastHitTick: me.lastHitTick,
|
|
564
|
-
};
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
/** Winner of a single match: a wizard ID, 'draw' (simultaneous kill), or null (timeout). */
|
|
568
|
-
export type MatchWinner = 'wizard-1' | 'wizard-2' | 'draw' | null;
|
|
569
|
-
|
|
570
|
-
/** Winner of a fight (aggregate): a wizard ID or 'draw'. Never null. */
|
|
571
|
-
export type FightWinner = 'wizard-1' | 'wizard-2' | 'draw';
|
|
572
|
-
|
|
573
|
-
/**
|
|
574
|
-
* Result of a simulation.
|
|
575
|
-
*/
|
|
576
|
-
/** A runtime error captured from a bot or missile AI function. */
|
|
577
|
-
export interface BotError
|
|
578
|
-
{
|
|
579
|
-
tick: number;
|
|
580
|
-
entityId: string;
|
|
581
|
-
message: string;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
export interface SimulateResult
|
|
585
|
-
{
|
|
586
|
-
/** 'wizard-1'/'wizard-2' = killed opponent, 'draw' = simultaneous kill, null = timeout */
|
|
587
|
-
winner: MatchWinner;
|
|
588
|
-
ticks: number;
|
|
589
|
-
finalState: GameState;
|
|
590
|
-
history: GameState[];
|
|
591
|
-
/** Runtime errors thrown by bot or missile AI functions (empty if no errors). */
|
|
592
|
-
errors: BotError[];
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
* Result of a fight (best-of-5 at different spawn distances).
|
|
597
|
-
*/
|
|
598
|
-
export interface FightResult
|
|
599
|
-
{
|
|
600
|
-
wizard1Wins: number;
|
|
601
|
-
wizard2Wins: number;
|
|
602
|
-
draws: number;
|
|
603
|
-
/** Winner of the fight: 'wizard-1', 'wizard-2', or 'draw' (never null) */
|
|
604
|
-
winner: FightWinner;
|
|
605
|
-
/**
|
|
606
|
-
* Individual match results (one per spawn distance, non-swapped only).
|
|
607
|
-
* Used for visual playback in the web viewer. Scoring includes both sides.
|
|
608
|
-
*/
|
|
609
|
-
matches: SimulateResult[];
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/** Spawn distances for the 5 matches in a fight (creates butterfly effect variation) */
|
|
613
|
-
export const FIGHT_SPAWN_DISTANCES = [500, 550, 600, 650, 700];
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Run a fight: 10 matches (5 spawn distances × 2 sides) between two bots.
|
|
617
|
-
* Each spawn distance is played twice — once with each bot on each side —
|
|
618
|
-
* to ensure results are independent of starting position.
|
|
619
|
-
*
|
|
620
|
-
* The `matches` array contains only the 5 non-swapped matches (for visual playback).
|
|
621
|
-
* The scoring aggregates (wizard1Wins, wizard2Wins, draws) include all 10 matches.
|
|
622
|
-
*
|
|
623
|
-
* This is the standard way to determine who wins a matchup.
|
|
624
|
-
* Used by both the tournament system and the visual UI.
|
|
625
|
-
*/
|
|
626
|
-
export function fight(
|
|
627
|
-
wizard1AI: WizardFunction,
|
|
628
|
-
wizard2AI: WizardFunction,
|
|
629
|
-
options: {
|
|
630
|
-
seed?: number;
|
|
631
|
-
maxTicks?: number;
|
|
632
|
-
} = {},
|
|
633
|
-
): FightResult
|
|
634
|
-
{
|
|
635
|
-
const matches: SimulateResult[] = [];
|
|
636
|
-
let wizard1Wins = 0;
|
|
637
|
-
let wizard2Wins = 0;
|
|
638
|
-
let draws = 0;
|
|
639
|
-
|
|
640
|
-
for (const spawnDistance of FIGHT_SPAWN_DISTANCES)
|
|
641
|
-
{
|
|
642
|
-
// Normal side: wizard1 on left, wizard2 on right
|
|
643
|
-
const result = simulate(wizard1AI, wizard2AI, {
|
|
644
|
-
seed: options.seed,
|
|
645
|
-
maxTicks: options.maxTicks,
|
|
646
|
-
spawnDistance,
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
matches.push(result);
|
|
650
|
-
|
|
651
|
-
if (result.winner === 'wizard-1')
|
|
652
|
-
{
|
|
653
|
-
wizard1Wins++;
|
|
654
|
-
}
|
|
655
|
-
else if (result.winner === 'wizard-2')
|
|
656
|
-
{
|
|
657
|
-
wizard2Wins++;
|
|
658
|
-
}
|
|
659
|
-
else
|
|
660
|
-
{
|
|
661
|
-
draws++;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// Swapped side: wizard2 on left, wizard1 on right
|
|
665
|
-
// skipHistory: swapped matches are only for scoring, not visual playback
|
|
666
|
-
const swapped = simulate(wizard2AI, wizard1AI, {
|
|
667
|
-
seed: options.seed,
|
|
668
|
-
maxTicks: options.maxTicks,
|
|
669
|
-
spawnDistance,
|
|
670
|
-
skipHistory: true,
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
// Don't push swapped match to matches array (it's only for scoring)
|
|
674
|
-
if (swapped.winner === 'wizard-1')
|
|
675
|
-
{
|
|
676
|
-
// wizard-1 in swapped match = wizard2 from original perspective
|
|
677
|
-
wizard2Wins++;
|
|
678
|
-
}
|
|
679
|
-
else if (swapped.winner === 'wizard-2')
|
|
680
|
-
{
|
|
681
|
-
wizard1Wins++;
|
|
682
|
-
}
|
|
683
|
-
else
|
|
684
|
-
{
|
|
685
|
-
draws++;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
const winner: FightWinner = wizard1Wins > wizard2Wins ? 'wizard-1'
|
|
690
|
-
: wizard2Wins > wizard1Wins ? 'wizard-2'
|
|
691
|
-
: 'draw';
|
|
692
|
-
|
|
693
|
-
return {wizard1Wins, wizard2Wins, draws, winner, matches};
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Run a full match simulation.
|
|
698
|
-
*
|
|
699
|
-
* @param options.skipHistory - When true, skips recording per-tick history snapshots.
|
|
700
|
-
* This dramatically improves performance (no deep cloning per tick) and is used
|
|
701
|
-
* by the optimizer and fight() scoring. The returned history array will be empty
|
|
702
|
-
* and finalState will still be populated.
|
|
703
|
-
*/
|
|
704
|
-
export function simulate(
|
|
705
|
-
wizard1AI: WizardFunction,
|
|
706
|
-
wizard2AI: WizardFunction,
|
|
707
|
-
options: {
|
|
708
|
-
maxTicks?: number;
|
|
709
|
-
seed?: number;
|
|
710
|
-
spawnDistance?: number;
|
|
711
|
-
skipHistory?: boolean;
|
|
712
|
-
} = {},
|
|
713
|
-
): SimulateResult
|
|
714
|
-
{
|
|
715
|
-
// Validate AI functions (Lesson #1)
|
|
716
|
-
if (typeof wizard1AI !== 'function')
|
|
717
|
-
{
|
|
718
|
-
throw new Error('wizard1AI must be a function');
|
|
719
|
-
}
|
|
720
|
-
if (typeof wizard2AI !== 'function')
|
|
721
|
-
{
|
|
722
|
-
throw new Error('wizard2AI must be a function');
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Sanitize options (Lesson #3)
|
|
726
|
-
const {maxTicks: rawMaxTicks = MATCH_DURATION, seed: rawSeed = Date.now(), spawnDistance: rawSpawnDistance, skipHistory = false} = options;
|
|
727
|
-
|
|
728
|
-
// Sanitize seed - ensure valid 32-bit integer
|
|
729
|
-
const seed = Number.isFinite(rawSeed) ? Math.floor(rawSeed) & 0x7FFFFFFF : Date.now() & 0x7FFFFFFF;
|
|
730
|
-
|
|
731
|
-
// Sanitize maxTicks - ensure positive integer with reasonable limit
|
|
732
|
-
const maxTicks = Number.isFinite(rawMaxTicks) && rawMaxTicks > 0
|
|
733
|
-
? Math.min(Math.floor(rawMaxTicks), 100000) // Cap at 100k ticks (~16 min)
|
|
734
|
-
: MATCH_DURATION;
|
|
735
|
-
|
|
736
|
-
// Sanitize spawnDistance - must be positive and fit in arena
|
|
737
|
-
const spawnDistance = Number.isFinite(rawSpawnDistance) && rawSpawnDistance! > 0
|
|
738
|
-
? Math.min(rawSpawnDistance!, Math.min(ARENA_WIDTH, ARENA_HEIGHT) - 100) // Leave room for wizards
|
|
739
|
-
: SPAWN_DISTANCE;
|
|
740
|
-
|
|
741
|
-
const config: GameConfig = Object.freeze({
|
|
742
|
-
arenaSize: Object.freeze({width: ARENA_WIDTH, height: ARENA_HEIGHT}),
|
|
743
|
-
tickRate: TICKS_PER_SECOND,
|
|
744
|
-
maxTicks,
|
|
745
|
-
});
|
|
746
|
-
|
|
747
|
-
resetAllHooks();
|
|
748
|
-
|
|
749
|
-
const initialState = createInitialState(seed, spawnDistance);
|
|
750
|
-
const history: GameState[] = skipHistory ? [] : [initialState];
|
|
751
|
-
let currentTick = 0;
|
|
752
|
-
let wizards: InternalWizardState[] = [
|
|
753
|
-
{
|
|
754
|
-
id: 'wizard-1',
|
|
755
|
-
position: {x: ARENA_WIDTH / 2 - spawnDistance / 2, y: ARENA_HEIGHT / 2},
|
|
756
|
-
rotation: 0,
|
|
757
|
-
health: WIZARD_HEALTH,
|
|
758
|
-
maxHealth: WIZARD_HEALTH,
|
|
759
|
-
state: 'idle',
|
|
760
|
-
blinkCooldown: 0,
|
|
761
|
-
velocity: {x: 0, y: 0},
|
|
762
|
-
damageDealt: 0,
|
|
763
|
-
damageTaken: 0,
|
|
764
|
-
lastHitTick: 0,
|
|
765
|
-
},
|
|
766
|
-
{
|
|
767
|
-
id: 'wizard-2',
|
|
768
|
-
position: {x: ARENA_WIDTH / 2 + spawnDistance / 2, y: ARENA_HEIGHT / 2},
|
|
769
|
-
rotation: 180,
|
|
770
|
-
health: WIZARD_HEALTH,
|
|
771
|
-
maxHealth: WIZARD_HEALTH,
|
|
772
|
-
state: 'idle',
|
|
773
|
-
blinkCooldown: 0,
|
|
774
|
-
velocity: {x: 0, y: 0},
|
|
775
|
-
damageDealt: 0,
|
|
776
|
-
damageTaken: 0,
|
|
777
|
-
lastHitTick: 0,
|
|
778
|
-
},
|
|
779
|
-
];
|
|
780
|
-
let projectiles: ProjectileState[] = [];
|
|
781
|
-
const missileAIs = new Map<string, MissileAIFunction>();
|
|
782
|
-
const allErrors: BotError[] = [];
|
|
783
|
-
|
|
784
|
-
while (currentTick < maxTicks)
|
|
785
|
-
{
|
|
786
|
-
const result = tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, missileAIs, seed);
|
|
787
|
-
currentTick = result.nextTick;
|
|
788
|
-
wizards = result.wizards;
|
|
789
|
-
projectiles = result.projectiles;
|
|
790
|
-
if (result.errors.length > 0) allErrors.push(...result.errors);
|
|
791
|
-
|
|
792
|
-
if (!skipHistory)
|
|
793
|
-
{
|
|
794
|
-
// Deep-clone snapshot for history (needed for replay)
|
|
795
|
-
history.push(getPlayerState(0, wizards, projectiles, currentTick));
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
if (wizards[0]!.health <= 0 || wizards[1]!.health <= 0)
|
|
799
|
-
{
|
|
800
|
-
break;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
let winner: MatchWinner = null;
|
|
805
|
-
if (wizards[0]!.health <= 0 && wizards[1]!.health <= 0)
|
|
806
|
-
{
|
|
807
|
-
winner = 'draw';
|
|
808
|
-
}
|
|
809
|
-
else if (wizards[1]!.health <= 0)
|
|
810
|
-
{
|
|
811
|
-
winner = 'wizard-1';
|
|
812
|
-
}
|
|
813
|
-
else if (wizards[0]!.health <= 0)
|
|
814
|
-
{
|
|
815
|
-
winner = 'wizard-2';
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
// Always create final state snapshot (needed for result)
|
|
819
|
-
const finalState = getPlayerState(0, wizards, projectiles, currentTick);
|
|
820
|
-
|
|
821
|
-
return {
|
|
822
|
-
winner,
|
|
823
|
-
ticks: currentTick,
|
|
824
|
-
finalState,
|
|
825
|
-
history: skipHistory ? [] : history,
|
|
826
|
-
errors: allErrors,
|
|
827
|
-
};
|
|
828
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
GameState,
|
|
3
|
+
WizardState,
|
|
4
|
+
ProjectileState,
|
|
5
|
+
WizardFunction,
|
|
6
|
+
WizardActions,
|
|
7
|
+
MissileAIFunction,
|
|
8
|
+
MissileActions,
|
|
9
|
+
GameConfig,
|
|
10
|
+
MissileConfig,
|
|
11
|
+
} from '../types.js';
|
|
12
|
+
import {
|
|
13
|
+
ARENA_WIDTH,
|
|
14
|
+
ARENA_HEIGHT,
|
|
15
|
+
SPAWN_DISTANCE,
|
|
16
|
+
WIZARD_HEALTH,
|
|
17
|
+
TICKS_PER_SECOND,
|
|
18
|
+
calculateBlinkCooldown,
|
|
19
|
+
BLINK_RANGE,
|
|
20
|
+
WIZARD_RADIUS,
|
|
21
|
+
MATCH_DURATION,
|
|
22
|
+
validateMissileConfig,
|
|
23
|
+
calculateMissileRadius,
|
|
24
|
+
ARENA_WATER_BUFFER,
|
|
25
|
+
} from '../rules.js';
|
|
26
|
+
import {moveWizard, moveProjectile, sweptCircleCollision, clampToArena, resolveWizardCollision} from './physics.js';
|
|
27
|
+
import {applyDamage, updateShield, startCast, completeCast} from './spells.js';
|
|
28
|
+
import {runWithHooks, resetAllHooks, clearHooks} from './hooks-runtime.js';
|
|
29
|
+
import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
|
|
30
|
+
import {createRandom, createEntitySeed} from '../utils/random.js';
|
|
31
|
+
|
|
32
|
+
export interface InternalWizardState extends WizardState
|
|
33
|
+
{
|
|
34
|
+
missileConfig?: MissileConfig;
|
|
35
|
+
missileAI?: MissileAIFunction;
|
|
36
|
+
blinkTarget?: {x: number; y: number};
|
|
37
|
+
// Combat tracking
|
|
38
|
+
damageDealt: number;
|
|
39
|
+
damageTaken: number;
|
|
40
|
+
lastHitTick: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Initialize a new match state.
|
|
45
|
+
*/
|
|
46
|
+
export function createInitialState(_seed: number, spawnDist: number = SPAWN_DISTANCE): GameState
|
|
47
|
+
{
|
|
48
|
+
const center = {x: ARENA_WIDTH / 2, y: ARENA_HEIGHT / 2};
|
|
49
|
+
const offset = spawnDist / 2;
|
|
50
|
+
|
|
51
|
+
const wizard1: WizardState = {
|
|
52
|
+
id: 'wizard-1',
|
|
53
|
+
position: {x: center.x - offset, y: center.y},
|
|
54
|
+
rotation: 0,
|
|
55
|
+
health: WIZARD_HEALTH,
|
|
56
|
+
maxHealth: WIZARD_HEALTH,
|
|
57
|
+
state: 'idle',
|
|
58
|
+
blinkCooldown: 0,
|
|
59
|
+
velocity: {x: 0, y: 0},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const wizard2: WizardState = {
|
|
63
|
+
id: 'wizard-2',
|
|
64
|
+
position: {x: center.x + offset, y: center.y},
|
|
65
|
+
rotation: 180,
|
|
66
|
+
health: WIZARD_HEALTH,
|
|
67
|
+
maxHealth: WIZARD_HEALTH,
|
|
68
|
+
state: 'idle',
|
|
69
|
+
blinkCooldown: 0,
|
|
70
|
+
velocity: {x: 0, y: 0},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
tick: 0,
|
|
75
|
+
position: wizard1.position,
|
|
76
|
+
rotation: wizard1.rotation,
|
|
77
|
+
health: wizard1.health,
|
|
78
|
+
maxHealth: wizard1.maxHealth,
|
|
79
|
+
state: wizard1.state,
|
|
80
|
+
blinkCooldown: wizard1.blinkCooldown,
|
|
81
|
+
velocity: wizard1.velocity,
|
|
82
|
+
enemies: [wizard2],
|
|
83
|
+
projectiles: [],
|
|
84
|
+
myProjectiles: [],
|
|
85
|
+
damageDealt: 0,
|
|
86
|
+
damageTaken: 0,
|
|
87
|
+
lastHitTick: 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Process one game tick.
|
|
93
|
+
*/
|
|
94
|
+
export function tick(
|
|
95
|
+
currentTick: number,
|
|
96
|
+
wizard1AI: WizardFunction,
|
|
97
|
+
wizard2AI: WizardFunction,
|
|
98
|
+
config: GameConfig,
|
|
99
|
+
wizards: InternalWizardState[],
|
|
100
|
+
projectiles: ProjectileState[],
|
|
101
|
+
missileAIs: Map<string, MissileAIFunction>,
|
|
102
|
+
matchSeed: number,
|
|
103
|
+
): {
|
|
104
|
+
nextTick: number;
|
|
105
|
+
wizards: InternalWizardState[];
|
|
106
|
+
projectiles: ProjectileState[];
|
|
107
|
+
errors: BotError[];
|
|
108
|
+
}
|
|
109
|
+
{
|
|
110
|
+
const nextTick = currentTick + 1;
|
|
111
|
+
const errors: BotError[] = [];
|
|
112
|
+
|
|
113
|
+
// 1. Run wizard AIs with isolated random generators
|
|
114
|
+
// Each wizard gets a fresh random generator seeded by (matchSeed, wizardId, tick)
|
|
115
|
+
const random1 = createRandom(createEntitySeed(matchSeed, wizards[0]!.id, nextTick));
|
|
116
|
+
const random2 = createRandom(createEntitySeed(matchSeed, wizards[1]!.id, nextTick));
|
|
117
|
+
|
|
118
|
+
// Wrap AI calls in try-catch - if AI throws, wizard does nothing (Lesson #21)
|
|
119
|
+
let actions1: WizardActions = {move: {x: 0, y: 0}};
|
|
120
|
+
try
|
|
121
|
+
{
|
|
122
|
+
actions1 = runWithHooks(wizards[0]!.id, () => wizard1AI({
|
|
123
|
+
state: getPlayerStateView(0, wizards, projectiles, nextTick),
|
|
124
|
+
config,
|
|
125
|
+
random: random1,
|
|
126
|
+
})) ?? {move: {x: 0, y: 0}};
|
|
127
|
+
}
|
|
128
|
+
catch(e)
|
|
129
|
+
{
|
|
130
|
+
errors.push({tick: nextTick, entityId: 'wizard-1', message: e instanceof Error ? e.message : String(e)});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let actions2: WizardActions = {move: {x: 0, y: 0}};
|
|
134
|
+
try
|
|
135
|
+
{
|
|
136
|
+
actions2 = runWithHooks(wizards[1]!.id, () => wizard2AI({
|
|
137
|
+
state: getPlayerStateView(1, wizards, projectiles, nextTick),
|
|
138
|
+
config,
|
|
139
|
+
random: random2,
|
|
140
|
+
})) ?? {move: {x: 0, y: 0}};
|
|
141
|
+
}
|
|
142
|
+
catch(e)
|
|
143
|
+
{
|
|
144
|
+
errors.push({tick: nextTick, entityId: 'wizard-2', message: e instanceof Error ? e.message : String(e)});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const actions = [actions1, actions2];
|
|
148
|
+
|
|
149
|
+
// 2. Update wizards
|
|
150
|
+
wizards.forEach((wizard, i) =>
|
|
151
|
+
{
|
|
152
|
+
const action = actions[i]!;
|
|
153
|
+
|
|
154
|
+
// Handle cooldowns
|
|
155
|
+
if (wizard.blinkCooldown > 0)
|
|
156
|
+
{
|
|
157
|
+
wizard.blinkCooldown--;
|
|
158
|
+
}
|
|
159
|
+
if (wizard.state === 'gcd_locked' && wizard.gcdRemaining !== undefined)
|
|
160
|
+
{
|
|
161
|
+
wizard.gcdRemaining--;
|
|
162
|
+
if (wizard.gcdRemaining <= 0)
|
|
163
|
+
{
|
|
164
|
+
wizard.state = 'idle';
|
|
165
|
+
delete wizard.gcdRemaining;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Auto-aim: face the enemy by default, bot can override with aimDirection
|
|
170
|
+
const enemy = wizards[1 - i]!;
|
|
171
|
+
if (action.aimDirection !== undefined && Number.isFinite(action.aimDirection))
|
|
172
|
+
{
|
|
173
|
+
wizard.rotation = action.aimDirection;
|
|
174
|
+
}
|
|
175
|
+
else
|
|
176
|
+
{
|
|
177
|
+
wizard.rotation = angleTo(wizard.position, enemy.position);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Handle casting
|
|
181
|
+
if (wizard.state === 'casting' && wizard.castProgress !== undefined && wizard.castDuration !== undefined)
|
|
182
|
+
{
|
|
183
|
+
wizard.castProgress++;
|
|
184
|
+
if (wizard.castProgress >= wizard.castDuration)
|
|
185
|
+
{
|
|
186
|
+
const spell = wizard.castingSpell;
|
|
187
|
+
|
|
188
|
+
if (spell === 'missile' && wizard.missileConfig && wizard.missileAI)
|
|
189
|
+
{
|
|
190
|
+
// Validate missile config to enforce minimums (Lesson #9)
|
|
191
|
+
const validConfig = validateMissileConfig(wizard.missileConfig);
|
|
192
|
+
const id = `missile-${wizard.id}-${nextTick}`;
|
|
193
|
+
const projectile: ProjectileState = {
|
|
194
|
+
id,
|
|
195
|
+
type: 'missile',
|
|
196
|
+
ownerId: wizard.id,
|
|
197
|
+
position: {...wizard.position},
|
|
198
|
+
rotation: wizard.rotation, // Use wizard's rotation at time of firing
|
|
199
|
+
speed: validConfig.speed,
|
|
200
|
+
turnRate: validConfig.turnRate,
|
|
201
|
+
damage: validConfig.damage,
|
|
202
|
+
remainingTicks: validConfig.duration,
|
|
203
|
+
};
|
|
204
|
+
projectiles.push(projectile);
|
|
205
|
+
missileAIs.set(id, wizard.missileAI);
|
|
206
|
+
// Track last missile for warmup system
|
|
207
|
+
wizard.lastMissileConfig = {...validConfig};
|
|
208
|
+
delete wizard.missileConfig;
|
|
209
|
+
delete wizard.missileAI;
|
|
210
|
+
}
|
|
211
|
+
else if (spell === 'blink' && wizard.blinkTarget)
|
|
212
|
+
{
|
|
213
|
+
// Clamp blink target to BLINK_RANGE from current position
|
|
214
|
+
const dx = wizard.blinkTarget.x - wizard.position.x;
|
|
215
|
+
const dy = wizard.blinkTarget.y - wizard.position.y;
|
|
216
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
217
|
+
|
|
218
|
+
let targetPos = wizard.blinkTarget;
|
|
219
|
+
if (distance > BLINK_RANGE)
|
|
220
|
+
{
|
|
221
|
+
// Clamp to max range in the same direction
|
|
222
|
+
const scale = BLINK_RANGE / distance;
|
|
223
|
+
targetPos = {
|
|
224
|
+
x: wizard.position.x + dx * scale,
|
|
225
|
+
y: wizard.position.y + dy * scale,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Clamp to arena bounds and calculate actual distance traveled
|
|
230
|
+
const oldPos = wizard.position;
|
|
231
|
+
wizard.position = clampToArena(targetPos, WIZARD_RADIUS);
|
|
232
|
+
const actualDx = wizard.position.x - oldPos.x;
|
|
233
|
+
const actualDy = wizard.position.y - oldPos.y;
|
|
234
|
+
const actualDistance = Math.sqrt(actualDx * actualDx + actualDy * actualDy);
|
|
235
|
+
wizard.blinkCooldown = calculateBlinkCooldown(actualDistance);
|
|
236
|
+
delete wizard.blinkTarget;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
completeCast(wizard);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Handle movement (default to no movement if bot didn't provide move)
|
|
244
|
+
const oldPos = wizard.position;
|
|
245
|
+
const move = action.move ?? {x: 0, y: 0};
|
|
246
|
+
wizard.position = moveWizard(wizard, move, 1);
|
|
247
|
+
wizard.velocity = {
|
|
248
|
+
x: wizard.position.x - oldPos.x,
|
|
249
|
+
y: wizard.position.y - oldPos.y,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Handle shield — cancel goes to idle (no GCD)
|
|
253
|
+
if (wizard.state === 'channeling')
|
|
254
|
+
{
|
|
255
|
+
updateShield(wizard, 1);
|
|
256
|
+
if (action.cancel)
|
|
257
|
+
{
|
|
258
|
+
wizard.state = 'idle';
|
|
259
|
+
delete wizard.channelingSpell;
|
|
260
|
+
delete wizard.channelDuration;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Handle start cast
|
|
265
|
+
if (wizard.state === 'idle' && action.startCast)
|
|
266
|
+
{
|
|
267
|
+
if (action.startCast.spell === 'blink' && wizard.blinkCooldown > 0)
|
|
268
|
+
{
|
|
269
|
+
// Cannot blink yet
|
|
270
|
+
}
|
|
271
|
+
else
|
|
272
|
+
{
|
|
273
|
+
startCast(wizard, action.startCast.spell, action.startCast.spell === 'missile' ? action.startCast.config : undefined);
|
|
274
|
+
if (action.startCast.spell === 'missile')
|
|
275
|
+
{
|
|
276
|
+
wizard.missileConfig = action.startCast.config;
|
|
277
|
+
wizard.missileAI = action.startCast.missileAI;
|
|
278
|
+
if (action.startCast.direction !== undefined)
|
|
279
|
+
{
|
|
280
|
+
wizard.rotation = action.startCast.direction;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else if (action.startCast.spell === 'blink')
|
|
284
|
+
{
|
|
285
|
+
wizard.blinkTarget = action.startCast.target;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Handle cancel
|
|
291
|
+
if (wizard.state === 'casting' && action.cancel)
|
|
292
|
+
{
|
|
293
|
+
wizard.state = 'idle';
|
|
294
|
+
delete wizard.castingSpell;
|
|
295
|
+
delete wizard.castProgress;
|
|
296
|
+
delete wizard.castDuration;
|
|
297
|
+
delete wizard.missileConfig;
|
|
298
|
+
delete wizard.missileAI;
|
|
299
|
+
delete wizard.blinkTarget;
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// 2b. Resolve wizard body collision (push apart if overlapping)
|
|
304
|
+
resolveWizardCollision(wizards[0]!, wizards[1]!);
|
|
305
|
+
|
|
306
|
+
// 3. Update projectiles
|
|
307
|
+
const remainingProjectiles: ProjectileState[] = [];
|
|
308
|
+
projectiles.forEach((projectile) =>
|
|
309
|
+
{
|
|
310
|
+
const ai = missileAIs.get(projectile.id);
|
|
311
|
+
if (ai)
|
|
312
|
+
{
|
|
313
|
+
const ownerIndex = wizards.findIndex((w) => w.id === projectile.ownerId);
|
|
314
|
+
// Each missile gets its own isolated random generator
|
|
315
|
+
const missileRandom = createRandom(createEntitySeed(matchSeed, projectile.id, nextTick));
|
|
316
|
+
|
|
317
|
+
// Wrap missile AI in try-catch - if it throws, missile continues straight (Lesson #21)
|
|
318
|
+
let missileActions: MissileActions = {};
|
|
319
|
+
try
|
|
320
|
+
{
|
|
321
|
+
missileActions = runWithHooks(projectile.id, () => ai({
|
|
322
|
+
missileState: projectileView(projectile),
|
|
323
|
+
worldState: getPlayerStateView(ownerIndex, wizards, projectiles, nextTick),
|
|
324
|
+
random: missileRandom,
|
|
325
|
+
})) ?? {};
|
|
326
|
+
}
|
|
327
|
+
catch(e)
|
|
328
|
+
{
|
|
329
|
+
errors.push({tick: nextTick, entityId: projectile.id, message: e instanceof Error ? e.message : String(e)});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (missileActions.turnToward)
|
|
333
|
+
{
|
|
334
|
+
const targetAngle = angleTo(projectile.position, missileActions.turnToward);
|
|
335
|
+
const diff = angleDiff(projectile.rotation, targetAngle);
|
|
336
|
+
// Negative turnRate = anti-homing (turns away from target)
|
|
337
|
+
const absTurnRate = Math.abs(projectile.turnRate);
|
|
338
|
+
const effectiveDiff = projectile.turnRate >= 0 ? diff : -diff;
|
|
339
|
+
const turn = Math.max(-absTurnRate, Math.min(absTurnRate, effectiveDiff));
|
|
340
|
+
projectile.rotation = normalizeAngle(projectile.rotation + turn);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const oldPos = projectile.position;
|
|
345
|
+
projectile.position = moveProjectile(projectile, 1);
|
|
346
|
+
projectile.remainingTicks--;
|
|
347
|
+
|
|
348
|
+
// Check collisions
|
|
349
|
+
let hit = false;
|
|
350
|
+
const missileRadius = calculateMissileRadius(projectile.damage);
|
|
351
|
+
wizards.forEach((wizard) =>
|
|
352
|
+
{
|
|
353
|
+
if (wizard.id !== projectile.ownerId && !hit)
|
|
354
|
+
{
|
|
355
|
+
if (sweptCircleCollision(oldPos, projectile.position, missileRadius, wizard.position, WIZARD_RADIUS))
|
|
356
|
+
{
|
|
357
|
+
const actualDamage = applyDamage(wizard, projectile.damage);
|
|
358
|
+
hit = true;
|
|
359
|
+
|
|
360
|
+
// Track damage for combat tracking hooks
|
|
361
|
+
wizard.damageTaken += actualDamage;
|
|
362
|
+
wizard.lastHitTick = nextTick;
|
|
363
|
+
|
|
364
|
+
// Find the owner and track their damage dealt
|
|
365
|
+
const owner = wizards.find((w) => w.id === projectile.ownerId);
|
|
366
|
+
if (owner)
|
|
367
|
+
{
|
|
368
|
+
owner.damageDealt += actualDamage;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Check if projectile is beyond the water buffer
|
|
375
|
+
const isOutOfBounds =
|
|
376
|
+
projectile.position.x < -ARENA_WATER_BUFFER ||
|
|
377
|
+
projectile.position.x > ARENA_WIDTH + ARENA_WATER_BUFFER ||
|
|
378
|
+
projectile.position.y < -ARENA_WATER_BUFFER ||
|
|
379
|
+
projectile.position.y > ARENA_HEIGHT + ARENA_WATER_BUFFER;
|
|
380
|
+
|
|
381
|
+
if (!hit && projectile.remainingTicks > 0 && !isOutOfBounds)
|
|
382
|
+
{
|
|
383
|
+
remainingProjectiles.push(projectile);
|
|
384
|
+
}
|
|
385
|
+
else
|
|
386
|
+
{
|
|
387
|
+
missileAIs.delete(projectile.id);
|
|
388
|
+
clearHooks(projectile.id);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
nextTick,
|
|
394
|
+
wizards,
|
|
395
|
+
projectiles: remainingProjectiles,
|
|
396
|
+
errors,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Deep clone a wizard state to prevent mutation.
|
|
402
|
+
*/
|
|
403
|
+
function cloneWizard(wizard: WizardState): WizardState
|
|
404
|
+
{
|
|
405
|
+
return {
|
|
406
|
+
id: wizard.id,
|
|
407
|
+
position: {...wizard.position},
|
|
408
|
+
rotation: wizard.rotation,
|
|
409
|
+
health: wizard.health,
|
|
410
|
+
maxHealth: wizard.maxHealth,
|
|
411
|
+
state: wizard.state,
|
|
412
|
+
castingSpell: wizard.castingSpell,
|
|
413
|
+
castProgress: wizard.castProgress,
|
|
414
|
+
castDuration: wizard.castDuration,
|
|
415
|
+
channelingSpell: wizard.channelingSpell,
|
|
416
|
+
channelDuration: wizard.channelDuration,
|
|
417
|
+
gcdRemaining: wizard.gcdRemaining,
|
|
418
|
+
blinkCooldown: wizard.blinkCooldown,
|
|
419
|
+
velocity: {...wizard.velocity},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Deep clone a projectile state to prevent mutation.
|
|
425
|
+
*/
|
|
426
|
+
function cloneProjectile(projectile: ProjectileState): ProjectileState
|
|
427
|
+
{
|
|
428
|
+
return {
|
|
429
|
+
id: projectile.id,
|
|
430
|
+
type: projectile.type,
|
|
431
|
+
ownerId: projectile.ownerId,
|
|
432
|
+
position: {...projectile.position},
|
|
433
|
+
rotation: projectile.rotation,
|
|
434
|
+
speed: projectile.speed,
|
|
435
|
+
turnRate: projectile.turnRate,
|
|
436
|
+
damage: projectile.damage,
|
|
437
|
+
remainingTicks: projectile.remainingTicks,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Get the game state from a specific player's perspective.
|
|
443
|
+
* Returns a deep clone to prevent mutation of history entries.
|
|
444
|
+
* Used for history recording where independent snapshots are needed.
|
|
445
|
+
*/
|
|
446
|
+
export function getPlayerState(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number): GameState
|
|
447
|
+
{
|
|
448
|
+
const me = wizards[playerIndex]!;
|
|
449
|
+
const enemies = wizards.filter((_, i) => i !== playerIndex).map(cloneWizard);
|
|
450
|
+
const clonedProjectiles = projectiles.map(cloneProjectile);
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
tick,
|
|
454
|
+
position: {...me.position},
|
|
455
|
+
rotation: me.rotation,
|
|
456
|
+
health: me.health,
|
|
457
|
+
maxHealth: me.maxHealth,
|
|
458
|
+
state: me.state,
|
|
459
|
+
castingSpell: me.castingSpell,
|
|
460
|
+
castProgress: me.castProgress,
|
|
461
|
+
castDuration: me.castDuration,
|
|
462
|
+
channelingSpell: me.channelingSpell,
|
|
463
|
+
channelDuration: me.channelDuration,
|
|
464
|
+
gcdRemaining: me.gcdRemaining,
|
|
465
|
+
blinkCooldown: me.blinkCooldown,
|
|
466
|
+
velocity: {...me.velocity},
|
|
467
|
+
lastMissileConfig: me.lastMissileConfig ? {...me.lastMissileConfig} : undefined,
|
|
468
|
+
warmupMultiplier: me.warmupMultiplier,
|
|
469
|
+
enemies,
|
|
470
|
+
projectiles: clonedProjectiles,
|
|
471
|
+
myProjectiles: clonedProjectiles.filter((p) => p.ownerId === me.id),
|
|
472
|
+
// Combat tracking
|
|
473
|
+
damageDealt: me.damageDealt,
|
|
474
|
+
damageTaken: me.damageTaken,
|
|
475
|
+
lastHitTick: me.lastHitTick,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Create a lightweight read-only view of a projectile.
|
|
481
|
+
* Clones only the mutable sub-objects (position) to prevent
|
|
482
|
+
* bot code from corrupting internal simulation state.
|
|
483
|
+
* Scalar fields are safe since they're copied by value.
|
|
484
|
+
*/
|
|
485
|
+
function projectileView(p: ProjectileState): ProjectileState
|
|
486
|
+
{
|
|
487
|
+
return {
|
|
488
|
+
id: p.id,
|
|
489
|
+
type: p.type,
|
|
490
|
+
ownerId: p.ownerId,
|
|
491
|
+
position: {x: p.position.x, y: p.position.y},
|
|
492
|
+
rotation: p.rotation,
|
|
493
|
+
speed: p.speed,
|
|
494
|
+
turnRate: p.turnRate,
|
|
495
|
+
damage: p.damage,
|
|
496
|
+
remainingTicks: p.remainingTicks,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Extract public WizardState fields from an InternalWizardState.
|
|
502
|
+
* Clones position and velocity ({x,y} spreads) to prevent bot code
|
|
503
|
+
* from mutating internal simulation state. Scalar fields are safe.
|
|
504
|
+
*/
|
|
505
|
+
function extractWizardView(wizard: InternalWizardState): WizardState
|
|
506
|
+
{
|
|
507
|
+
return {
|
|
508
|
+
id: wizard.id,
|
|
509
|
+
position: {x: wizard.position.x, y: wizard.position.y},
|
|
510
|
+
rotation: wizard.rotation,
|
|
511
|
+
health: wizard.health,
|
|
512
|
+
maxHealth: wizard.maxHealth,
|
|
513
|
+
state: wizard.state,
|
|
514
|
+
castingSpell: wizard.castingSpell,
|
|
515
|
+
castProgress: wizard.castProgress,
|
|
516
|
+
castDuration: wizard.castDuration,
|
|
517
|
+
channelingSpell: wizard.channelingSpell,
|
|
518
|
+
channelDuration: wizard.channelDuration,
|
|
519
|
+
gcdRemaining: wizard.gcdRemaining,
|
|
520
|
+
blinkCooldown: wizard.blinkCooldown,
|
|
521
|
+
velocity: {x: wizard.velocity.x, y: wizard.velocity.y},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Get a lightweight read-only view of game state for AI calls.
|
|
527
|
+
* Clones only the small mutable sub-objects (position, velocity as {x,y})
|
|
528
|
+
* to prevent bot code from corrupting internal simulation state.
|
|
529
|
+
* Scalar fields are copied by value and safe from mutation.
|
|
530
|
+
*
|
|
531
|
+
* This is much faster than getPlayerState() (which deep-clones everything)
|
|
532
|
+
* because it only spreads the tiny {x,y} objects, not full deep clones.
|
|
533
|
+
*/
|
|
534
|
+
function getPlayerStateView(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number): GameState
|
|
535
|
+
{
|
|
536
|
+
const me = wizards[playerIndex]!;
|
|
537
|
+
const enemies = wizards.filter((_, i) => i !== playerIndex).map(extractWizardView);
|
|
538
|
+
const projViews = projectiles.map(projectileView);
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
tick,
|
|
542
|
+
position: {x: me.position.x, y: me.position.y},
|
|
543
|
+
rotation: me.rotation,
|
|
544
|
+
health: me.health,
|
|
545
|
+
maxHealth: me.maxHealth,
|
|
546
|
+
state: me.state,
|
|
547
|
+
castingSpell: me.castingSpell,
|
|
548
|
+
castProgress: me.castProgress,
|
|
549
|
+
castDuration: me.castDuration,
|
|
550
|
+
channelingSpell: me.channelingSpell,
|
|
551
|
+
channelDuration: me.channelDuration,
|
|
552
|
+
gcdRemaining: me.gcdRemaining,
|
|
553
|
+
blinkCooldown: me.blinkCooldown,
|
|
554
|
+
velocity: {x: me.velocity.x, y: me.velocity.y},
|
|
555
|
+
lastMissileConfig: me.lastMissileConfig ? {...me.lastMissileConfig} : undefined,
|
|
556
|
+
warmupMultiplier: me.warmupMultiplier,
|
|
557
|
+
enemies,
|
|
558
|
+
projectiles: projViews,
|
|
559
|
+
myProjectiles: projViews.filter((p) => p.ownerId === me.id),
|
|
560
|
+
// Combat tracking
|
|
561
|
+
damageDealt: me.damageDealt,
|
|
562
|
+
damageTaken: me.damageTaken,
|
|
563
|
+
lastHitTick: me.lastHitTick,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Winner of a single match: a wizard ID, 'draw' (simultaneous kill), or null (timeout). */
|
|
568
|
+
export type MatchWinner = 'wizard-1' | 'wizard-2' | 'draw' | null;
|
|
569
|
+
|
|
570
|
+
/** Winner of a fight (aggregate): a wizard ID or 'draw'. Never null. */
|
|
571
|
+
export type FightWinner = 'wizard-1' | 'wizard-2' | 'draw';
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Result of a simulation.
|
|
575
|
+
*/
|
|
576
|
+
/** A runtime error captured from a bot or missile AI function. */
|
|
577
|
+
export interface BotError
|
|
578
|
+
{
|
|
579
|
+
tick: number;
|
|
580
|
+
entityId: string;
|
|
581
|
+
message: string;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export interface SimulateResult
|
|
585
|
+
{
|
|
586
|
+
/** 'wizard-1'/'wizard-2' = killed opponent, 'draw' = simultaneous kill, null = timeout */
|
|
587
|
+
winner: MatchWinner;
|
|
588
|
+
ticks: number;
|
|
589
|
+
finalState: GameState;
|
|
590
|
+
history: GameState[];
|
|
591
|
+
/** Runtime errors thrown by bot or missile AI functions (empty if no errors). */
|
|
592
|
+
errors: BotError[];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Result of a fight (best-of-5 at different spawn distances).
|
|
597
|
+
*/
|
|
598
|
+
export interface FightResult
|
|
599
|
+
{
|
|
600
|
+
wizard1Wins: number;
|
|
601
|
+
wizard2Wins: number;
|
|
602
|
+
draws: number;
|
|
603
|
+
/** Winner of the fight: 'wizard-1', 'wizard-2', or 'draw' (never null) */
|
|
604
|
+
winner: FightWinner;
|
|
605
|
+
/**
|
|
606
|
+
* Individual match results (one per spawn distance, non-swapped only).
|
|
607
|
+
* Used for visual playback in the web viewer. Scoring includes both sides.
|
|
608
|
+
*/
|
|
609
|
+
matches: SimulateResult[];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Spawn distances for the 5 matches in a fight (creates butterfly effect variation) */
|
|
613
|
+
export const FIGHT_SPAWN_DISTANCES = [500, 550, 600, 650, 700];
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Run a fight: 10 matches (5 spawn distances × 2 sides) between two bots.
|
|
617
|
+
* Each spawn distance is played twice — once with each bot on each side —
|
|
618
|
+
* to ensure results are independent of starting position.
|
|
619
|
+
*
|
|
620
|
+
* The `matches` array contains only the 5 non-swapped matches (for visual playback).
|
|
621
|
+
* The scoring aggregates (wizard1Wins, wizard2Wins, draws) include all 10 matches.
|
|
622
|
+
*
|
|
623
|
+
* This is the standard way to determine who wins a matchup.
|
|
624
|
+
* Used by both the tournament system and the visual UI.
|
|
625
|
+
*/
|
|
626
|
+
export function fight(
|
|
627
|
+
wizard1AI: WizardFunction,
|
|
628
|
+
wizard2AI: WizardFunction,
|
|
629
|
+
options: {
|
|
630
|
+
seed?: number;
|
|
631
|
+
maxTicks?: number;
|
|
632
|
+
} = {},
|
|
633
|
+
): FightResult
|
|
634
|
+
{
|
|
635
|
+
const matches: SimulateResult[] = [];
|
|
636
|
+
let wizard1Wins = 0;
|
|
637
|
+
let wizard2Wins = 0;
|
|
638
|
+
let draws = 0;
|
|
639
|
+
|
|
640
|
+
for (const spawnDistance of FIGHT_SPAWN_DISTANCES)
|
|
641
|
+
{
|
|
642
|
+
// Normal side: wizard1 on left, wizard2 on right
|
|
643
|
+
const result = simulate(wizard1AI, wizard2AI, {
|
|
644
|
+
seed: options.seed,
|
|
645
|
+
maxTicks: options.maxTicks,
|
|
646
|
+
spawnDistance,
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
matches.push(result);
|
|
650
|
+
|
|
651
|
+
if (result.winner === 'wizard-1')
|
|
652
|
+
{
|
|
653
|
+
wizard1Wins++;
|
|
654
|
+
}
|
|
655
|
+
else if (result.winner === 'wizard-2')
|
|
656
|
+
{
|
|
657
|
+
wizard2Wins++;
|
|
658
|
+
}
|
|
659
|
+
else
|
|
660
|
+
{
|
|
661
|
+
draws++;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Swapped side: wizard2 on left, wizard1 on right
|
|
665
|
+
// skipHistory: swapped matches are only for scoring, not visual playback
|
|
666
|
+
const swapped = simulate(wizard2AI, wizard1AI, {
|
|
667
|
+
seed: options.seed,
|
|
668
|
+
maxTicks: options.maxTicks,
|
|
669
|
+
spawnDistance,
|
|
670
|
+
skipHistory: true,
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// Don't push swapped match to matches array (it's only for scoring)
|
|
674
|
+
if (swapped.winner === 'wizard-1')
|
|
675
|
+
{
|
|
676
|
+
// wizard-1 in swapped match = wizard2 from original perspective
|
|
677
|
+
wizard2Wins++;
|
|
678
|
+
}
|
|
679
|
+
else if (swapped.winner === 'wizard-2')
|
|
680
|
+
{
|
|
681
|
+
wizard1Wins++;
|
|
682
|
+
}
|
|
683
|
+
else
|
|
684
|
+
{
|
|
685
|
+
draws++;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const winner: FightWinner = wizard1Wins > wizard2Wins ? 'wizard-1'
|
|
690
|
+
: wizard2Wins > wizard1Wins ? 'wizard-2'
|
|
691
|
+
: 'draw';
|
|
692
|
+
|
|
693
|
+
return {wizard1Wins, wizard2Wins, draws, winner, matches};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Run a full match simulation.
|
|
698
|
+
*
|
|
699
|
+
* @param options.skipHistory - When true, skips recording per-tick history snapshots.
|
|
700
|
+
* This dramatically improves performance (no deep cloning per tick) and is used
|
|
701
|
+
* by the optimizer and fight() scoring. The returned history array will be empty
|
|
702
|
+
* and finalState will still be populated.
|
|
703
|
+
*/
|
|
704
|
+
export function simulate(
|
|
705
|
+
wizard1AI: WizardFunction,
|
|
706
|
+
wizard2AI: WizardFunction,
|
|
707
|
+
options: {
|
|
708
|
+
maxTicks?: number;
|
|
709
|
+
seed?: number;
|
|
710
|
+
spawnDistance?: number;
|
|
711
|
+
skipHistory?: boolean;
|
|
712
|
+
} = {},
|
|
713
|
+
): SimulateResult
|
|
714
|
+
{
|
|
715
|
+
// Validate AI functions (Lesson #1)
|
|
716
|
+
if (typeof wizard1AI !== 'function')
|
|
717
|
+
{
|
|
718
|
+
throw new Error('wizard1AI must be a function');
|
|
719
|
+
}
|
|
720
|
+
if (typeof wizard2AI !== 'function')
|
|
721
|
+
{
|
|
722
|
+
throw new Error('wizard2AI must be a function');
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Sanitize options (Lesson #3)
|
|
726
|
+
const {maxTicks: rawMaxTicks = MATCH_DURATION, seed: rawSeed = Date.now(), spawnDistance: rawSpawnDistance, skipHistory = false} = options;
|
|
727
|
+
|
|
728
|
+
// Sanitize seed - ensure valid 32-bit integer
|
|
729
|
+
const seed = Number.isFinite(rawSeed) ? Math.floor(rawSeed) & 0x7FFFFFFF : Date.now() & 0x7FFFFFFF;
|
|
730
|
+
|
|
731
|
+
// Sanitize maxTicks - ensure positive integer with reasonable limit
|
|
732
|
+
const maxTicks = Number.isFinite(rawMaxTicks) && rawMaxTicks > 0
|
|
733
|
+
? Math.min(Math.floor(rawMaxTicks), 100000) // Cap at 100k ticks (~16 min)
|
|
734
|
+
: MATCH_DURATION;
|
|
735
|
+
|
|
736
|
+
// Sanitize spawnDistance - must be positive and fit in arena
|
|
737
|
+
const spawnDistance = Number.isFinite(rawSpawnDistance) && rawSpawnDistance! > 0
|
|
738
|
+
? Math.min(rawSpawnDistance!, Math.min(ARENA_WIDTH, ARENA_HEIGHT) - 100) // Leave room for wizards
|
|
739
|
+
: SPAWN_DISTANCE;
|
|
740
|
+
|
|
741
|
+
const config: GameConfig = Object.freeze({
|
|
742
|
+
arenaSize: Object.freeze({width: ARENA_WIDTH, height: ARENA_HEIGHT}),
|
|
743
|
+
tickRate: TICKS_PER_SECOND,
|
|
744
|
+
maxTicks,
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
resetAllHooks();
|
|
748
|
+
|
|
749
|
+
const initialState = createInitialState(seed, spawnDistance);
|
|
750
|
+
const history: GameState[] = skipHistory ? [] : [initialState];
|
|
751
|
+
let currentTick = 0;
|
|
752
|
+
let wizards: InternalWizardState[] = [
|
|
753
|
+
{
|
|
754
|
+
id: 'wizard-1',
|
|
755
|
+
position: {x: ARENA_WIDTH / 2 - spawnDistance / 2, y: ARENA_HEIGHT / 2},
|
|
756
|
+
rotation: 0,
|
|
757
|
+
health: WIZARD_HEALTH,
|
|
758
|
+
maxHealth: WIZARD_HEALTH,
|
|
759
|
+
state: 'idle',
|
|
760
|
+
blinkCooldown: 0,
|
|
761
|
+
velocity: {x: 0, y: 0},
|
|
762
|
+
damageDealt: 0,
|
|
763
|
+
damageTaken: 0,
|
|
764
|
+
lastHitTick: 0,
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
id: 'wizard-2',
|
|
768
|
+
position: {x: ARENA_WIDTH / 2 + spawnDistance / 2, y: ARENA_HEIGHT / 2},
|
|
769
|
+
rotation: 180,
|
|
770
|
+
health: WIZARD_HEALTH,
|
|
771
|
+
maxHealth: WIZARD_HEALTH,
|
|
772
|
+
state: 'idle',
|
|
773
|
+
blinkCooldown: 0,
|
|
774
|
+
velocity: {x: 0, y: 0},
|
|
775
|
+
damageDealt: 0,
|
|
776
|
+
damageTaken: 0,
|
|
777
|
+
lastHitTick: 0,
|
|
778
|
+
},
|
|
779
|
+
];
|
|
780
|
+
let projectiles: ProjectileState[] = [];
|
|
781
|
+
const missileAIs = new Map<string, MissileAIFunction>();
|
|
782
|
+
const allErrors: BotError[] = [];
|
|
783
|
+
|
|
784
|
+
while (currentTick < maxTicks)
|
|
785
|
+
{
|
|
786
|
+
const result = tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, missileAIs, seed);
|
|
787
|
+
currentTick = result.nextTick;
|
|
788
|
+
wizards = result.wizards;
|
|
789
|
+
projectiles = result.projectiles;
|
|
790
|
+
if (result.errors.length > 0) allErrors.push(...result.errors);
|
|
791
|
+
|
|
792
|
+
if (!skipHistory)
|
|
793
|
+
{
|
|
794
|
+
// Deep-clone snapshot for history (needed for replay)
|
|
795
|
+
history.push(getPlayerState(0, wizards, projectiles, currentTick));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (wizards[0]!.health <= 0 || wizards[1]!.health <= 0)
|
|
799
|
+
{
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
let winner: MatchWinner = null;
|
|
805
|
+
if (wizards[0]!.health <= 0 && wizards[1]!.health <= 0)
|
|
806
|
+
{
|
|
807
|
+
winner = 'draw';
|
|
808
|
+
}
|
|
809
|
+
else if (wizards[1]!.health <= 0)
|
|
810
|
+
{
|
|
811
|
+
winner = 'wizard-1';
|
|
812
|
+
}
|
|
813
|
+
else if (wizards[0]!.health <= 0)
|
|
814
|
+
{
|
|
815
|
+
winner = 'wizard-2';
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Always create final state snapshot (needed for result)
|
|
819
|
+
const finalState = getPlayerState(0, wizards, projectiles, currentTick);
|
|
820
|
+
|
|
821
|
+
return {
|
|
822
|
+
winner,
|
|
823
|
+
ticks: currentTick,
|
|
824
|
+
finalState,
|
|
825
|
+
history: skipHistory ? [] : history,
|
|
826
|
+
errors: allErrors,
|
|
827
|
+
};
|
|
828
|
+
}
|