@vibemancer/core 0.1.3 → 0.1.5
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 +2 -2
- package/dist/chunk-W7HWJFQ4.js +10599 -0
- package/dist/chunk-W7HWJFQ4.js.map +1 -0
- package/dist/index-browser.d.ts +1300 -1051
- package/dist/index-browser.js +55 -17
- package/dist/index.d.ts +53 -3
- package/dist/index.js +200 -54
- package/dist/index.js.map +1 -1
- package/package.json +21 -15
- package/src/bots/Hero.ts +867 -0
- package/src/bots/berserker/01_Stormchaser.ts +162 -120
- package/src/bots/berserker/02_Stormcaller.ts +160 -116
- package/src/bots/berserker/03_Stormforger.ts +162 -129
- package/src/bots/caster/01_Flamecaller.ts +126 -84
- package/src/bots/caster/02_Pyromancer.ts +148 -101
- package/src/bots/caster/03_Infernalist.ts +180 -160
- package/src/bots/defensive/01_Turtle.ts +48 -44
- package/src/bots/defensive/02_Sentinel.ts +57 -53
- package/src/bots/defensive/03_Golem.ts +85 -97
- package/src/bots/duelist/01_Battlemage.ts +157 -122
- package/src/bots/duelist/02_Warmage.ts +166 -121
- package/src/bots/duelist/03_Archmage.ts +198 -178
- package/src/bots/homing/01_Bonemancer.ts +20 -32
- package/src/bots/homing/02_Lich.ts +145 -93
- package/src/bots/homing/03_Archlich.ts +129 -60
- package/src/bots/kiter/01_Spellspinner.ts +145 -107
- package/src/bots/kiter/02_Spellweaver.ts +153 -105
- package/src/bots/kiter/03_Spellbinder.ts +168 -123
- package/src/bots/melee/01_Shadowblade.ts +131 -75
- package/src/bots/melee/02_Nightblade.ts +174 -130
- package/src/bots/melee/03_Voidblade.ts +266 -168
- package/src/bots/registry.ts +44 -37
- package/src/bots/shared.ts +185 -25
- package/src/bots/sniper/01_Spellshot.ts +151 -98
- package/src/bots/sniper/02_Spelltracer.ts +173 -128
- package/src/bots/sniper/03_Spellseeker.ts +177 -152
- package/src/bots/standalone/Critter.ts +46 -52
- package/src/bots/standalone/Doombringer.ts +36 -40
- package/src/bots/standalone/Hogger.ts +120 -89
- package/src/bots/standalone/Rookie.ts +21 -23
- package/src/bots/standalone/TargetDummy.ts +5 -6
- package/src/bots/test/cheater.ts +91 -85
- package/src/bots/test/crasher.ts +26 -28
- package/src/engine/bundle-fight.ts +242 -0
- package/src/engine/hooks-runtime.ts +58 -18
- package/src/engine/manual-match.ts +33 -25
- package/src/engine/missile-templates.ts +25 -43
- package/src/engine/optimizer.ts +7 -7
- package/src/engine/params-runtime.ts +3 -3
- package/src/engine/physics.ts +33 -23
- package/src/engine/sandbox-browser.ts +8 -7
- package/src/engine/sandbox-compile.ts +1 -1
- package/src/engine/sandbox-harness.ts +299 -367
- package/src/engine/sandbox.ts +3 -3
- package/src/engine/simulation.ts +291 -76
- package/src/engine/spells.ts +9 -12
- package/src/engine-version.ts +1 -1
- package/src/hooks/action-builders.ts +76 -21
- package/src/hooks/index.ts +17 -9
- package/src/hooks/state-hooks.ts +61 -25
- package/src/hooks/threat-analysis.ts +44 -20
- package/src/hooks/types.ts +62 -5
- package/src/index.ts +2 -0
- package/src/rules.ts +209 -63
- package/src/stats.ts +2 -2
- package/src/testing.ts +6 -30
- package/src/trace.ts +63 -3
- package/src/types.ts +127 -14
- package/src/utils/angles.ts +1 -0
- package/src/utils/combat.ts +98 -6
- package/src/utils/spatial.ts +3 -3
- package/dist/chunk-B7YYYBEC.js +0 -9140
- package/dist/chunk-B7YYYBEC.js.map +0 -1
- package/src/hooks/bot-wrapper.ts +0 -84
package/src/engine/sandbox.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface SandboxOptions
|
|
|
34
34
|
{
|
|
35
35
|
/** Memory limit in MB for the isolate (default: 512). */
|
|
36
36
|
memoryLimitMB?: number;
|
|
37
|
-
/** Timeout in ms for fight/simulate calls (default:
|
|
37
|
+
/** Timeout in ms for fight/simulate calls (default: 60000). */
|
|
38
38
|
timeoutMs?: number;
|
|
39
39
|
/** Options passed to esbuild compilation (aliases, externals). */
|
|
40
40
|
compileOptions?: CompileOptions;
|
|
@@ -86,7 +86,7 @@ export class MatchSandbox
|
|
|
86
86
|
): Promise<MatchSandbox>
|
|
87
87
|
{
|
|
88
88
|
const memoryLimitMB = options?.memoryLimitMB ?? 512;
|
|
89
|
-
const timeoutMs = options?.timeoutMs ??
|
|
89
|
+
const timeoutMs = options?.timeoutMs ?? 60000;
|
|
90
90
|
|
|
91
91
|
// 1. Compile the match bundle
|
|
92
92
|
const bundle = await compileMatchBundle(bot1, bot2, options?.compileOptions);
|
|
@@ -133,7 +133,7 @@ export class MatchSandbox
|
|
|
133
133
|
): Promise<MatchSandbox>
|
|
134
134
|
{
|
|
135
135
|
const memoryLimitMB = options?.memoryLimitMB ?? 512;
|
|
136
|
-
const timeoutMs = options?.timeoutMs ??
|
|
136
|
+
const timeoutMs = options?.timeoutMs ?? 60000;
|
|
137
137
|
|
|
138
138
|
const isolate = new ivm.Isolate({memoryLimit: memoryLimitMB});
|
|
139
139
|
|
package/src/engine/simulation.ts
CHANGED
|
@@ -2,28 +2,30 @@ import {
|
|
|
2
2
|
GameState,
|
|
3
3
|
WizardState,
|
|
4
4
|
ProjectileState,
|
|
5
|
-
WizardFunction,
|
|
6
5
|
WizardActions,
|
|
7
|
-
MissileAIFunction,
|
|
8
6
|
MissileActions,
|
|
9
7
|
GameConfig,
|
|
10
8
|
MissileConfig,
|
|
11
9
|
} from '../types.js';
|
|
10
|
+
import type {SimEvent} from '../types.js';
|
|
11
|
+
import type {WizardFunction, WizardContext, MissileContext, MissileFunction} from '../hooks/types.js';
|
|
12
|
+
import {withWizardContext, withMissileContext} from './hooks-runtime.js';
|
|
13
|
+
import {extractAction, extractMissileAction} from '../hooks/action-builders.js';
|
|
12
14
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
RULES,
|
|
16
|
+
ARENA_SIZE,
|
|
17
|
+
ARENA_MIN,
|
|
18
|
+
ARENA_MAX,
|
|
15
19
|
SPAWN_DISTANCE,
|
|
16
|
-
WIZARD_HEALTH,
|
|
17
20
|
TICKS_PER_SECOND,
|
|
18
21
|
calculateBlinkCooldown,
|
|
19
|
-
BLINK_RANGE,
|
|
20
22
|
WIZARD_RADIUS,
|
|
21
23
|
MATCH_DURATION,
|
|
22
24
|
validateMissileConfig,
|
|
23
25
|
calculateMissileRadius,
|
|
24
26
|
ARENA_WATER_BUFFER,
|
|
25
27
|
} from '../rules.js';
|
|
26
|
-
import {moveWizard, moveProjectile, sweptCircleCollision, clampToArena, resolveWizardCollision} from './physics.js';
|
|
28
|
+
import {moveWizard, moveProjectile, sweptCircleCollision, clampToArena, resolveWizardCollision, isInLava} from './physics.js';
|
|
27
29
|
import {applyDamage, updateShield, startCast, completeCast} from './spells.js';
|
|
28
30
|
import {runWithHooks, resetAllHooks, clearHooks} from './hooks-runtime.js';
|
|
29
31
|
import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
|
|
@@ -32,28 +34,36 @@ import {createRandom, createEntitySeed} from '../utils/random.js';
|
|
|
32
34
|
export interface InternalWizardState extends WizardState
|
|
33
35
|
{
|
|
34
36
|
missileConfig?: MissileConfig;
|
|
35
|
-
missileAI?:
|
|
37
|
+
missileAI?: MissileFunction;
|
|
36
38
|
blinkTarget?: {x: number; y: number};
|
|
37
39
|
// Combat tracking
|
|
38
40
|
damageDealt: number;
|
|
39
41
|
damageTaken: number;
|
|
40
42
|
lastHitTick: number;
|
|
43
|
+
// Knockback velocity (decays over ticks)
|
|
44
|
+
knockbackVx?: number;
|
|
45
|
+
knockbackVy?: number;
|
|
46
|
+
// Delayed knockback (waits for explosion to expand)
|
|
47
|
+
knockbackDelay?: number;
|
|
48
|
+
knockbackPendingVx?: number;
|
|
49
|
+
knockbackPendingVy?: number;
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
|
|
43
53
|
/**
|
|
44
54
|
* Initialize a new match state.
|
|
45
55
|
*/
|
|
46
56
|
export function createInitialState(_seed: number, spawnDist: number = SPAWN_DISTANCE): GameState
|
|
47
57
|
{
|
|
48
|
-
const center = {x:
|
|
58
|
+
const center = {x: ARENA_SIZE / 2, y: ARENA_SIZE / 2};
|
|
49
59
|
const offset = spawnDist / 2;
|
|
50
60
|
|
|
51
61
|
const wizard1: WizardState = {
|
|
52
62
|
id: 'wizard-1',
|
|
53
63
|
position: {x: center.x - offset, y: center.y},
|
|
54
64
|
rotation: 0,
|
|
55
|
-
health: WIZARD_HEALTH,
|
|
56
|
-
maxHealth: WIZARD_HEALTH,
|
|
65
|
+
health: RULES.WIZARD_HEALTH,
|
|
66
|
+
maxHealth: RULES.WIZARD_HEALTH,
|
|
57
67
|
state: 'idle',
|
|
58
68
|
blinkCooldown: 0,
|
|
59
69
|
velocity: {x: 0, y: 0},
|
|
@@ -63,8 +73,8 @@ export function createInitialState(_seed: number, spawnDist: number = SPAWN_DIST
|
|
|
63
73
|
id: 'wizard-2',
|
|
64
74
|
position: {x: center.x + offset, y: center.y},
|
|
65
75
|
rotation: 180,
|
|
66
|
-
health: WIZARD_HEALTH,
|
|
67
|
-
maxHealth: WIZARD_HEALTH,
|
|
76
|
+
health: RULES.WIZARD_HEALTH,
|
|
77
|
+
maxHealth: RULES.WIZARD_HEALTH,
|
|
68
78
|
state: 'idle',
|
|
69
79
|
blinkCooldown: 0,
|
|
70
80
|
velocity: {x: 0, y: 0},
|
|
@@ -85,6 +95,53 @@ export function createInitialState(_seed: number, spawnDist: number = SPAWN_DIST
|
|
|
85
95
|
damageDealt: 0,
|
|
86
96
|
damageTaken: 0,
|
|
87
97
|
lastHitTick: 0,
|
|
98
|
+
events: [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build a WizardContext from the simulation's GameState + GameConfig.
|
|
104
|
+
* This bridges the internal simulation state to the hooks API context.
|
|
105
|
+
*/
|
|
106
|
+
function buildWizardContext(state: GameState, config: GameConfig, random: () => number): WizardContext
|
|
107
|
+
{
|
|
108
|
+
return {
|
|
109
|
+
entityId: 'unused', // overridden by runWithHooks
|
|
110
|
+
tick: state.tick,
|
|
111
|
+
random,
|
|
112
|
+
position: state.position,
|
|
113
|
+
velocity: state.velocity,
|
|
114
|
+
health: state.health,
|
|
115
|
+
maxHealth: state.maxHealth,
|
|
116
|
+
state: state.state,
|
|
117
|
+
castingSpell: state.castingSpell,
|
|
118
|
+
castProgress: state.castProgress,
|
|
119
|
+
castDuration: state.castDuration,
|
|
120
|
+
channelingSpell: state.channelingSpell,
|
|
121
|
+
channelDuration: state.channelDuration,
|
|
122
|
+
gcdRemaining: state.gcdRemaining,
|
|
123
|
+
blinkCooldown: state.blinkCooldown,
|
|
124
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
125
|
+
enemies: state.enemies.map((e) => ({
|
|
126
|
+
id: e.id,
|
|
127
|
+
position: e.position,
|
|
128
|
+
velocity: e.velocity,
|
|
129
|
+
health: e.health,
|
|
130
|
+
state: e.state,
|
|
131
|
+
castingSpell: e.castingSpell,
|
|
132
|
+
castProgress: e.castProgress,
|
|
133
|
+
castDuration: e.castDuration,
|
|
134
|
+
gcdRemaining: e.gcdRemaining,
|
|
135
|
+
channelingSpell: e.channelingSpell,
|
|
136
|
+
channelDuration: e.channelDuration,
|
|
137
|
+
})),
|
|
138
|
+
projectiles: state.projectiles,
|
|
139
|
+
myProjectiles: state.myProjectiles,
|
|
140
|
+
arenaWidth: config.arenaSize.width,
|
|
141
|
+
arenaHeight: config.arenaSize.height,
|
|
142
|
+
damageDealt: state.damageDealt,
|
|
143
|
+
damageTaken: state.damageTaken,
|
|
144
|
+
lastHitTick: state.lastHitTick,
|
|
88
145
|
};
|
|
89
146
|
}
|
|
90
147
|
|
|
@@ -98,17 +155,19 @@ export function tick(
|
|
|
98
155
|
config: GameConfig,
|
|
99
156
|
wizards: InternalWizardState[],
|
|
100
157
|
projectiles: ProjectileState[],
|
|
101
|
-
missileAIs: Map<string,
|
|
158
|
+
missileAIs: Map<string, MissileFunction>,
|
|
102
159
|
matchSeed: number,
|
|
103
160
|
): {
|
|
104
161
|
nextTick: number;
|
|
105
162
|
wizards: InternalWizardState[];
|
|
106
163
|
projectiles: ProjectileState[];
|
|
164
|
+
events: SimEvent[];
|
|
107
165
|
errors: BotError[];
|
|
108
166
|
}
|
|
109
167
|
{
|
|
110
168
|
const nextTick = currentTick + 1;
|
|
111
169
|
const errors: BotError[] = [];
|
|
170
|
+
const events: SimEvent[] = [];
|
|
112
171
|
|
|
113
172
|
// 1. Run wizard AIs with isolated random generators
|
|
114
173
|
// Each wizard gets a fresh random generator seeded by (matchSeed, wizardId, tick)
|
|
@@ -119,11 +178,9 @@ export function tick(
|
|
|
119
178
|
let actions1: WizardActions = {move: {x: 0, y: 0}};
|
|
120
179
|
try
|
|
121
180
|
{
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
random: random1,
|
|
126
|
-
})) ?? {move: {x: 0, y: 0}};
|
|
181
|
+
const stateView1 = getPlayerStateView(0, wizards, projectiles, nextTick);
|
|
182
|
+
const ctx1: WizardContext = buildWizardContext(stateView1, config, random1);
|
|
183
|
+
actions1 = runWithHooks(wizards[0]!.id, () => extractAction(withWizardContext(ctx1, wizard1AI))) ?? {move: {x: 0, y: 0}};
|
|
127
184
|
}
|
|
128
185
|
catch(e)
|
|
129
186
|
{
|
|
@@ -133,11 +190,9 @@ export function tick(
|
|
|
133
190
|
let actions2: WizardActions = {move: {x: 0, y: 0}};
|
|
134
191
|
try
|
|
135
192
|
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
random: random2,
|
|
140
|
-
})) ?? {move: {x: 0, y: 0}};
|
|
193
|
+
const stateView2 = getPlayerStateView(1, wizards, projectiles, nextTick);
|
|
194
|
+
const ctx2: WizardContext = buildWizardContext(stateView2, config, random2);
|
|
195
|
+
actions2 = runWithHooks(wizards[1]!.id, () => extractAction(withWizardContext(ctx2, wizard2AI))) ?? {move: {x: 0, y: 0}};
|
|
141
196
|
}
|
|
142
197
|
catch(e)
|
|
143
198
|
{
|
|
@@ -146,11 +201,39 @@ export function tick(
|
|
|
146
201
|
|
|
147
202
|
const actions = [actions1, actions2];
|
|
148
203
|
|
|
204
|
+
// 1b. Missile guide — freeze wizard, grant invulnerability, override missile steering
|
|
205
|
+
const guideOverrides = new Map<string, {x: number; y: number} | null>();
|
|
206
|
+
const guideInvincibleApplied: number[] = []; // wizard indices where we forced invincible
|
|
207
|
+
actions.forEach((action, i) =>
|
|
208
|
+
{
|
|
209
|
+
if (action.missileGuide)
|
|
210
|
+
{
|
|
211
|
+
const {missileId, direction} = action.missileGuide;
|
|
212
|
+
// Only apply if the missile actually exists and belongs to this wizard
|
|
213
|
+
const missile = projectiles.find((p) => p.id === missileId && p.ownerId === wizards[i]!.id);
|
|
214
|
+
if (missile)
|
|
215
|
+
{
|
|
216
|
+
// Freeze and protect the wizard (only set if not already invincible)
|
|
217
|
+
if (!wizards[i]!.invincible)
|
|
218
|
+
{
|
|
219
|
+
wizards[i]!.invincible = true;
|
|
220
|
+
guideInvincibleApplied.push(i);
|
|
221
|
+
}
|
|
222
|
+
action.move = {x: 0, y: 0};
|
|
223
|
+
// Store the direction override for the missile tick phase
|
|
224
|
+
guideOverrides.set(missileId, direction);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
149
229
|
// 2. Update wizards
|
|
150
230
|
wizards.forEach((wizard, i) =>
|
|
151
231
|
{
|
|
152
232
|
const action = actions[i]!;
|
|
153
233
|
|
|
234
|
+
// Dead wizards do nothing during grace period
|
|
235
|
+
if (wizard.health <= 0) return;
|
|
236
|
+
|
|
154
237
|
// Handle cooldowns
|
|
155
238
|
if (wizard.blinkCooldown > 0)
|
|
156
239
|
{
|
|
@@ -203,6 +286,7 @@ export function tick(
|
|
|
203
286
|
};
|
|
204
287
|
projectiles.push(projectile);
|
|
205
288
|
missileAIs.set(id, wizard.missileAI);
|
|
289
|
+
events.push({type: 'missile-launch', position: {...wizard.position}, damage: validConfig.damage, speed: validConfig.speed, ownerId: wizard.id, rotation: wizard.rotation});
|
|
206
290
|
// Track last missile for warmup system
|
|
207
291
|
wizard.lastMissileConfig = {...validConfig};
|
|
208
292
|
delete wizard.missileConfig;
|
|
@@ -210,16 +294,16 @@ export function tick(
|
|
|
210
294
|
}
|
|
211
295
|
else if (spell === 'blink' && wizard.blinkTarget)
|
|
212
296
|
{
|
|
213
|
-
// Clamp blink target to BLINK_RANGE from current position
|
|
297
|
+
// Clamp blink target to RULES.BLINK_RANGE from current position
|
|
214
298
|
const dx = wizard.blinkTarget.x - wizard.position.x;
|
|
215
299
|
const dy = wizard.blinkTarget.y - wizard.position.y;
|
|
216
300
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
217
301
|
|
|
218
302
|
let targetPos = wizard.blinkTarget;
|
|
219
|
-
if (distance > BLINK_RANGE)
|
|
303
|
+
if (distance > RULES.BLINK_RANGE)
|
|
220
304
|
{
|
|
221
305
|
// Clamp to max range in the same direction
|
|
222
|
-
const scale = BLINK_RANGE / distance;
|
|
306
|
+
const scale = RULES.BLINK_RANGE / distance;
|
|
223
307
|
targetPos = {
|
|
224
308
|
x: wizard.position.x + dx * scale,
|
|
225
309
|
y: wizard.position.y + dy * scale,
|
|
@@ -227,16 +311,25 @@ export function tick(
|
|
|
227
311
|
}
|
|
228
312
|
|
|
229
313
|
// Clamp to arena bounds and calculate actual distance traveled
|
|
230
|
-
const
|
|
314
|
+
const blinkFrom = {...wizard.position};
|
|
231
315
|
wizard.position = clampToArena(targetPos, WIZARD_RADIUS);
|
|
232
|
-
const actualDx = wizard.position.x -
|
|
233
|
-
const actualDy = wizard.position.y -
|
|
316
|
+
const actualDx = wizard.position.x - blinkFrom.x;
|
|
317
|
+
const actualDy = wizard.position.y - blinkFrom.y;
|
|
234
318
|
const actualDistance = Math.sqrt(actualDx * actualDx + actualDy * actualDy);
|
|
235
319
|
wizard.blinkCooldown = calculateBlinkCooldown(actualDistance);
|
|
236
320
|
delete wizard.blinkTarget;
|
|
321
|
+
|
|
322
|
+
events.push({type: 'blink', wizardId: wizard.id, from: blinkFrom, to: {...wizard.position}});
|
|
237
323
|
}
|
|
238
324
|
|
|
325
|
+
const completedSpell = spell;
|
|
239
326
|
completeCast(wizard);
|
|
327
|
+
|
|
328
|
+
// Emit shield-start when wizard transitions to channeling
|
|
329
|
+
if (completedSpell === 'shield')
|
|
330
|
+
{
|
|
331
|
+
events.push({type: 'shield-start', wizardId: wizard.id, position: {...wizard.position}});
|
|
332
|
+
}
|
|
240
333
|
}
|
|
241
334
|
}
|
|
242
335
|
|
|
@@ -244,6 +337,44 @@ export function tick(
|
|
|
244
337
|
const oldPos = wizard.position;
|
|
245
338
|
const move = action.move ?? {x: 0, y: 0};
|
|
246
339
|
wizard.position = moveWizard(wizard, move, 1);
|
|
340
|
+
|
|
341
|
+
// Process delayed knockback (apply impulse after delay expires)
|
|
342
|
+
if (wizard.knockbackDelay !== undefined && wizard.knockbackDelay > 0)
|
|
343
|
+
{
|
|
344
|
+
wizard.knockbackDelay--;
|
|
345
|
+
if (wizard.knockbackDelay <= 0)
|
|
346
|
+
{
|
|
347
|
+
wizard.knockbackVx = (wizard.knockbackVx ?? 0) + (wizard.knockbackPendingVx ?? 0);
|
|
348
|
+
wizard.knockbackVy = (wizard.knockbackVy ?? 0) + (wizard.knockbackPendingVy ?? 0);
|
|
349
|
+
wizard.knockbackDelay = undefined;
|
|
350
|
+
wizard.knockbackPendingVx = undefined;
|
|
351
|
+
wizard.knockbackPendingVy = undefined;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Apply knockback velocity (decays each tick)
|
|
356
|
+
// Clamped to full arena (0-860), NOT playfield — can push into lava zones
|
|
357
|
+
if (wizard.knockbackVx || wizard.knockbackVy)
|
|
358
|
+
{
|
|
359
|
+
const kvx = wizard.knockbackVx ?? 0;
|
|
360
|
+
const kvy = wizard.knockbackVy ?? 0;
|
|
361
|
+
wizard.position = {
|
|
362
|
+
x: Math.max(0, Math.min(ARENA_SIZE, wizard.position.x + kvx)),
|
|
363
|
+
y: Math.max(0, Math.min(ARENA_SIZE, wizard.position.y + kvy)),
|
|
364
|
+
};
|
|
365
|
+
wizard.knockbackVx = kvx * RULES.KNOCKBACK_DECAY;
|
|
366
|
+
wizard.knockbackVy = kvy * RULES.KNOCKBACK_DECAY;
|
|
367
|
+
if (Math.abs(wizard.knockbackVx) < 0.1) wizard.knockbackVx = 0;
|
|
368
|
+
if (Math.abs(wizard.knockbackVy) < 0.1) wizard.knockbackVy = 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Lava border: instant death if wizard is pushed or walks into lava
|
|
372
|
+
if (wizard.health > 0 && !wizard.invincible && isInLava(wizard.position, WIZARD_RADIUS))
|
|
373
|
+
{
|
|
374
|
+
wizard.health = 0;
|
|
375
|
+
events.push({type: 'wizard-lava-death', wizardId: wizard.id, position: {...wizard.position}});
|
|
376
|
+
}
|
|
377
|
+
|
|
247
378
|
wizard.velocity = {
|
|
248
379
|
x: wizard.position.x - oldPos.x,
|
|
249
380
|
y: wizard.position.y - oldPos.y,
|
|
@@ -255,6 +386,7 @@ export function tick(
|
|
|
255
386
|
updateShield(wizard, 1);
|
|
256
387
|
if (action.cancel)
|
|
257
388
|
{
|
|
389
|
+
events.push({type: 'cast-cancel', wizardId: wizard.id, position: {...wizard.position}, spell: 'shield'});
|
|
258
390
|
wizard.state = 'idle';
|
|
259
391
|
delete wizard.channelingSpell;
|
|
260
392
|
delete wizard.channelDuration;
|
|
@@ -270,8 +402,9 @@ export function tick(
|
|
|
270
402
|
}
|
|
271
403
|
else
|
|
272
404
|
{
|
|
273
|
-
|
|
274
|
-
|
|
405
|
+
const castingSpell = action.startCast.spell;
|
|
406
|
+
startCast(wizard, castingSpell, castingSpell === 'missile' ? action.startCast.config : undefined);
|
|
407
|
+
if (castingSpell === 'missile')
|
|
275
408
|
{
|
|
276
409
|
wizard.missileConfig = action.startCast.config;
|
|
277
410
|
wizard.missileAI = action.startCast.missileAI;
|
|
@@ -280,16 +413,19 @@ export function tick(
|
|
|
280
413
|
wizard.rotation = action.startCast.direction;
|
|
281
414
|
}
|
|
282
415
|
}
|
|
283
|
-
else if (
|
|
416
|
+
else if (castingSpell === 'blink')
|
|
284
417
|
{
|
|
285
418
|
wizard.blinkTarget = action.startCast.target;
|
|
286
419
|
}
|
|
420
|
+
|
|
421
|
+
events.push({type: 'cast-start', wizardId: wizard.id, position: {...wizard.position}, spell: castingSpell});
|
|
287
422
|
}
|
|
288
423
|
}
|
|
289
424
|
|
|
290
425
|
// Handle cancel
|
|
291
426
|
if (wizard.state === 'casting' && action.cancel)
|
|
292
427
|
{
|
|
428
|
+
events.push({type: 'cast-cancel', wizardId: wizard.id, position: {...wizard.position}, spell: wizard.castingSpell ?? 'missile'});
|
|
293
429
|
wizard.state = 'idle';
|
|
294
430
|
delete wizard.castingSpell;
|
|
295
431
|
delete wizard.castProgress;
|
|
@@ -307,38 +443,70 @@ export function tick(
|
|
|
307
443
|
const remainingProjectiles: ProjectileState[] = [];
|
|
308
444
|
projectiles.forEach((projectile) =>
|
|
309
445
|
{
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const missileRandom = createRandom(createEntitySeed(matchSeed, projectile.id, nextTick));
|
|
446
|
+
// Check for missile guide override — skip normal AI and use player direction
|
|
447
|
+
const guideDir = guideOverrides.get(projectile.id);
|
|
448
|
+
const isGuided = guideOverrides.has(projectile.id);
|
|
449
|
+
|
|
450
|
+
let targetAngle: number | null = null;
|
|
316
451
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
452
|
+
if (isGuided)
|
|
453
|
+
{
|
|
454
|
+
// Guided missile: convert direction to target angle, or fly straight
|
|
455
|
+
if (guideDir && (guideDir.x !== 0 || guideDir.y !== 0))
|
|
320
456
|
{
|
|
321
|
-
|
|
322
|
-
missileState: projectileView(projectile),
|
|
323
|
-
worldState: getPlayerStateView(ownerIndex, wizards, projectiles, nextTick),
|
|
324
|
-
random: missileRandom,
|
|
325
|
-
})) ?? {};
|
|
457
|
+
targetAngle = (Math.atan2(guideDir.y, guideDir.x) * 180) / Math.PI;
|
|
326
458
|
}
|
|
327
|
-
|
|
459
|
+
// else: direction is null or zero → fly straight (targetAngle stays null)
|
|
460
|
+
}
|
|
461
|
+
else
|
|
462
|
+
{
|
|
463
|
+
const ai = missileAIs.get(projectile.id);
|
|
464
|
+
if (ai)
|
|
328
465
|
{
|
|
329
|
-
|
|
330
|
-
|
|
466
|
+
const ownerIndex = wizards.findIndex((w) => w.id === projectile.ownerId);
|
|
467
|
+
// Each missile gets its own isolated random generator
|
|
468
|
+
const missileRandom = createRandom(createEntitySeed(matchSeed, projectile.id, nextTick));
|
|
469
|
+
|
|
470
|
+
// Build missile context for hooks-style missile AIs
|
|
471
|
+
const pView = projectileView(projectile);
|
|
472
|
+
const missileCtx: MissileContext = {
|
|
473
|
+
position: pView.position,
|
|
474
|
+
rotation: pView.rotation,
|
|
475
|
+
speed: pView.speed,
|
|
476
|
+
turnRate: pView.turnRate,
|
|
477
|
+
damage: pView.damage,
|
|
478
|
+
remainingTicks: pView.remainingTicks,
|
|
479
|
+
ownerId: pView.ownerId,
|
|
480
|
+
worldState: getPlayerStateView(ownerIndex, wizards, projectiles, nextTick),
|
|
481
|
+
random: missileRandom,
|
|
482
|
+
};
|
|
331
483
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
484
|
+
// Wrap missile AI in try-catch - if it throws, missile continues straight (Lesson #21)
|
|
485
|
+
let missileActions: MissileActions = {};
|
|
486
|
+
try
|
|
487
|
+
{
|
|
488
|
+
missileActions = runWithHooks(projectile.id, () =>
|
|
489
|
+
extractMissileAction(withMissileContext(missileCtx, ai)),
|
|
490
|
+
) ?? {};
|
|
491
|
+
}
|
|
492
|
+
catch(e)
|
|
493
|
+
{
|
|
494
|
+
errors.push({tick: nextTick, entityId: projectile.id, message: e instanceof Error ? e.message : String(e)});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
targetAngle = missileActions.turnToward
|
|
498
|
+
? angleTo(projectile.position, missileActions.turnToward)
|
|
499
|
+
: missileActions.turnToAngle !== undefined
|
|
500
|
+
? missileActions.turnToAngle
|
|
501
|
+
: null;
|
|
341
502
|
}
|
|
503
|
+
} // end else (not guided)
|
|
504
|
+
|
|
505
|
+
if (targetAngle !== null)
|
|
506
|
+
{
|
|
507
|
+
const diff = angleDiff(projectile.rotation, targetAngle);
|
|
508
|
+
const turn = Math.max(-projectile.turnRate, Math.min(projectile.turnRate, diff));
|
|
509
|
+
projectile.rotation = normalizeAngle(projectile.rotation + turn);
|
|
342
510
|
}
|
|
343
511
|
|
|
344
512
|
const oldPos = projectile.position;
|
|
@@ -350,7 +518,7 @@ export function tick(
|
|
|
350
518
|
const missileRadius = calculateMissileRadius(projectile.damage);
|
|
351
519
|
wizards.forEach((wizard) =>
|
|
352
520
|
{
|
|
353
|
-
if (wizard.id !== projectile.ownerId && !hit)
|
|
521
|
+
if (wizard.id !== projectile.ownerId && !hit && wizard.health > 0)
|
|
354
522
|
{
|
|
355
523
|
if (sweptCircleCollision(oldPos, projectile.position, missileRadius, wizard.position, WIZARD_RADIUS))
|
|
356
524
|
{
|
|
@@ -367,6 +535,35 @@ export function tick(
|
|
|
367
535
|
{
|
|
368
536
|
owner.damageDealt += actualDamage;
|
|
369
537
|
}
|
|
538
|
+
|
|
539
|
+
events.push({type: 'missile-hit', position: {...projectile.position}, damage: projectile.damage, actualDamage, speed: projectile.speed, ownerId: projectile.ownerId, targetId: wizard.id});
|
|
540
|
+
|
|
541
|
+
// Knockback: delayed impulse (waits for explosion to expand first)
|
|
542
|
+
const knockbackSpeed = Math.max(0, (projectile.damage - RULES.KNOCKBACK_DAMAGE_THRESHOLD) * RULES.KNOCKBACK_SPEED_PER_DAMAGE);
|
|
543
|
+
if (knockbackSpeed > 0)
|
|
544
|
+
{
|
|
545
|
+
const kdx = wizard.position.x - projectile.position.x;
|
|
546
|
+
const kdy = wizard.position.y - projectile.position.y;
|
|
547
|
+
const kdist = Math.sqrt(kdx * kdx + kdy * kdy);
|
|
548
|
+
if (kdist > 0)
|
|
549
|
+
{
|
|
550
|
+
wizard.knockbackDelay = RULES.KNOCKBACK_DELAY;
|
|
551
|
+
wizard.knockbackPendingVx = (kdx / kdist) * knockbackSpeed;
|
|
552
|
+
wizard.knockbackPendingVy = (kdy / kdist) * knockbackSpeed;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Shield block event (actual damage < projectile damage means shield absorbed some)
|
|
557
|
+
if (actualDamage < projectile.damage && wizard.state === 'channeling')
|
|
558
|
+
{
|
|
559
|
+
events.push({type: 'shield-block', wizardId: wizard.id, position: {...wizard.position}, damageBlocked: projectile.damage - actualDamage, damageThrough: actualDamage});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Emit wizard-death if health reached 0
|
|
563
|
+
if (wizard.health <= 0)
|
|
564
|
+
{
|
|
565
|
+
events.push({type: 'wizard-death', wizardId: wizard.id, position: {...wizard.position}});
|
|
566
|
+
}
|
|
370
567
|
}
|
|
371
568
|
}
|
|
372
569
|
});
|
|
@@ -374,9 +571,9 @@ export function tick(
|
|
|
374
571
|
// Check if projectile is beyond the water buffer
|
|
375
572
|
const isOutOfBounds =
|
|
376
573
|
projectile.position.x < -ARENA_WATER_BUFFER ||
|
|
377
|
-
projectile.position.x >
|
|
574
|
+
projectile.position.x > ARENA_SIZE + ARENA_WATER_BUFFER ||
|
|
378
575
|
projectile.position.y < -ARENA_WATER_BUFFER ||
|
|
379
|
-
projectile.position.y >
|
|
576
|
+
projectile.position.y > ARENA_SIZE + ARENA_WATER_BUFFER;
|
|
380
577
|
|
|
381
578
|
if (!hit && projectile.remainingTicks > 0 && !isOutOfBounds)
|
|
382
579
|
{
|
|
@@ -384,15 +581,25 @@ export function tick(
|
|
|
384
581
|
}
|
|
385
582
|
else
|
|
386
583
|
{
|
|
584
|
+
// Emit missile removal events (hit event already emitted above)
|
|
585
|
+
if (isOutOfBounds) events.push({type: 'missile-oob', position: {...projectile.position}});
|
|
586
|
+
else if (projectile.remainingTicks <= 0 && !hit) events.push({type: 'missile-expired', position: {...projectile.position}});
|
|
387
587
|
missileAIs.delete(projectile.id);
|
|
388
588
|
clearHooks(projectile.id);
|
|
389
589
|
}
|
|
390
590
|
});
|
|
391
591
|
|
|
592
|
+
// 4. Clear guide-applied invincibility so it doesn't persist beyond this tick
|
|
593
|
+
for (const idx of guideInvincibleApplied)
|
|
594
|
+
{
|
|
595
|
+
wizards[idx]!.invincible = false;
|
|
596
|
+
}
|
|
597
|
+
|
|
392
598
|
return {
|
|
393
599
|
nextTick,
|
|
394
600
|
wizards,
|
|
395
601
|
projectiles: remainingProjectiles,
|
|
602
|
+
events,
|
|
396
603
|
errors,
|
|
397
604
|
};
|
|
398
605
|
}
|
|
@@ -443,7 +650,7 @@ function cloneProjectile(projectile: ProjectileState): ProjectileState
|
|
|
443
650
|
* Returns a deep clone to prevent mutation of history entries.
|
|
444
651
|
* Used for history recording where independent snapshots are needed.
|
|
445
652
|
*/
|
|
446
|
-
export function getPlayerState(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number): GameState
|
|
653
|
+
export function getPlayerState(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number, events: SimEvent[] = []): GameState
|
|
447
654
|
{
|
|
448
655
|
const me = wizards[playerIndex]!;
|
|
449
656
|
const enemies = wizards.filter((_, i) => i !== playerIndex).map(cloneWizard);
|
|
@@ -473,6 +680,7 @@ export function getPlayerState(playerIndex: number, wizards: InternalWizardState
|
|
|
473
680
|
damageDealt: me.damageDealt,
|
|
474
681
|
damageTaken: me.damageTaken,
|
|
475
682
|
lastHitTick: me.lastHitTick,
|
|
683
|
+
events,
|
|
476
684
|
};
|
|
477
685
|
}
|
|
478
686
|
|
|
@@ -561,6 +769,7 @@ function getPlayerStateView(playerIndex: number, wizards: InternalWizardState[],
|
|
|
561
769
|
damageDealt: me.damageDealt,
|
|
562
770
|
damageTaken: me.damageTaken,
|
|
563
771
|
lastHitTick: me.lastHitTick,
|
|
772
|
+
events: [], // AI views don't need events
|
|
564
773
|
};
|
|
565
774
|
}
|
|
566
775
|
|
|
@@ -712,7 +921,6 @@ export function simulate(
|
|
|
712
921
|
} = {},
|
|
713
922
|
): SimulateResult
|
|
714
923
|
{
|
|
715
|
-
// Validate AI functions (Lesson #1)
|
|
716
924
|
if (typeof wizard1AI !== 'function')
|
|
717
925
|
{
|
|
718
926
|
throw new Error('wizard1AI must be a function');
|
|
@@ -735,11 +943,11 @@ export function simulate(
|
|
|
735
943
|
|
|
736
944
|
// Sanitize spawnDistance - must be positive and fit in arena
|
|
737
945
|
const spawnDistance = Number.isFinite(rawSpawnDistance) && rawSpawnDistance! > 0
|
|
738
|
-
? Math.min(rawSpawnDistance!,
|
|
946
|
+
? Math.min(rawSpawnDistance!, (ARENA_MAX - ARENA_MIN) - 100) // Leave room for wizards within playfield
|
|
739
947
|
: SPAWN_DISTANCE;
|
|
740
948
|
|
|
741
949
|
const config: GameConfig = Object.freeze({
|
|
742
|
-
arenaSize: Object.freeze({width:
|
|
950
|
+
arenaSize: Object.freeze({width: ARENA_SIZE, height: ARENA_SIZE}),
|
|
743
951
|
tickRate: TICKS_PER_SECOND,
|
|
744
952
|
maxTicks,
|
|
745
953
|
});
|
|
@@ -752,10 +960,10 @@ export function simulate(
|
|
|
752
960
|
let wizards: InternalWizardState[] = [
|
|
753
961
|
{
|
|
754
962
|
id: 'wizard-1',
|
|
755
|
-
position: {x:
|
|
963
|
+
position: {x: ARENA_SIZE / 2 - spawnDistance / 2, y: ARENA_SIZE / 2},
|
|
756
964
|
rotation: 0,
|
|
757
|
-
health: WIZARD_HEALTH,
|
|
758
|
-
maxHealth: WIZARD_HEALTH,
|
|
965
|
+
health: RULES.WIZARD_HEALTH,
|
|
966
|
+
maxHealth: RULES.WIZARD_HEALTH,
|
|
759
967
|
state: 'idle',
|
|
760
968
|
blinkCooldown: 0,
|
|
761
969
|
velocity: {x: 0, y: 0},
|
|
@@ -765,10 +973,10 @@ export function simulate(
|
|
|
765
973
|
},
|
|
766
974
|
{
|
|
767
975
|
id: 'wizard-2',
|
|
768
|
-
position: {x:
|
|
976
|
+
position: {x: ARENA_SIZE / 2 + spawnDistance / 2, y: ARENA_SIZE / 2},
|
|
769
977
|
rotation: 180,
|
|
770
|
-
health: WIZARD_HEALTH,
|
|
771
|
-
maxHealth: WIZARD_HEALTH,
|
|
978
|
+
health: RULES.WIZARD_HEALTH,
|
|
979
|
+
maxHealth: RULES.WIZARD_HEALTH,
|
|
772
980
|
state: 'idle',
|
|
773
981
|
blinkCooldown: 0,
|
|
774
982
|
velocity: {x: 0, y: 0},
|
|
@@ -778,9 +986,11 @@ export function simulate(
|
|
|
778
986
|
},
|
|
779
987
|
];
|
|
780
988
|
let projectiles: ProjectileState[] = [];
|
|
781
|
-
const missileAIs = new Map<string,
|
|
989
|
+
const missileAIs = new Map<string, MissileFunction>();
|
|
782
990
|
const allErrors: BotError[] = [];
|
|
783
991
|
|
|
992
|
+
let deathTick: number | null = null;
|
|
993
|
+
|
|
784
994
|
while (currentTick < maxTicks)
|
|
785
995
|
{
|
|
786
996
|
const result = tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, missileAIs, seed);
|
|
@@ -791,11 +1001,16 @@ export function simulate(
|
|
|
791
1001
|
|
|
792
1002
|
if (!skipHistory)
|
|
793
1003
|
{
|
|
794
|
-
// Deep-clone snapshot for history (needed for replay)
|
|
795
|
-
history.push(getPlayerState(0, wizards, projectiles, currentTick));
|
|
1004
|
+
// Deep-clone snapshot for history (needed for replay), including tick events
|
|
1005
|
+
history.push(getPlayerState(0, wizards, projectiles, currentTick, result.events));
|
|
796
1006
|
}
|
|
797
1007
|
|
|
798
|
-
|
|
1008
|
+
// Track death and add grace period (0.5s = 50 ticks at 100 tps)
|
|
1009
|
+
if (!deathTick && (wizards[0]!.health <= 0 || wizards[1]!.health <= 0))
|
|
1010
|
+
{
|
|
1011
|
+
deathTick = currentTick;
|
|
1012
|
+
}
|
|
1013
|
+
if (deathTick && currentTick - deathTick >= 200)
|
|
799
1014
|
{
|
|
800
1015
|
break;
|
|
801
1016
|
}
|