@vibemancer/core 0.1.0 → 0.1.2
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-OL7ETV6V.js} +3 -3
- package/dist/chunk-OL7ETV6V.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
|
@@ -1,289 +1,289 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VIBEMANCER - MANUAL MATCH
|
|
3
|
-
*
|
|
4
|
-
* Tick-by-tick match runner used by manual play mode. Wraps the same
|
|
5
|
-
* `tick()` primitive that `simulate()` uses, so stepping forward
|
|
6
|
-
* produces the same trajectory as a batched `simulate()` with the
|
|
7
|
-
* same seed.
|
|
8
|
-
*
|
|
9
|
-
* Extra capabilities over `simulate()`:
|
|
10
|
-
* - step(N) advances N ticks at a time (default 1) — caller controls pacing
|
|
11
|
-
* - replaceMissileAI(id, ai) hot-swaps a missile's AI mid-flight (used by
|
|
12
|
-
* the missile-hijack feature in manual play)
|
|
13
|
-
* - setInvincible(wizardIndex, on) toggles damage immunity per wizard
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import {
|
|
17
|
-
ARENA_HEIGHT,
|
|
18
|
-
ARENA_WIDTH,
|
|
19
|
-
MATCH_DURATION,
|
|
20
|
-
SPAWN_DISTANCE,
|
|
21
|
-
TICKS_PER_SECOND,
|
|
22
|
-
WIZARD_HEALTH,
|
|
23
|
-
} from '../rules.js';
|
|
24
|
-
import type {
|
|
25
|
-
GameConfig,
|
|
26
|
-
GameState,
|
|
27
|
-
MissileAIFunction,
|
|
28
|
-
ProjectileState,
|
|
29
|
-
WizardFunction,
|
|
30
|
-
} from '../types.js';
|
|
31
|
-
import {clearHooks} from './hooks-runtime.js';
|
|
32
|
-
import {
|
|
33
|
-
type BotError,
|
|
34
|
-
type InternalWizardState,
|
|
35
|
-
type MatchWinner,
|
|
36
|
-
type SimulateResult,
|
|
37
|
-
getPlayerState,
|
|
38
|
-
tick,
|
|
39
|
-
} from './simulation.js';
|
|
40
|
-
|
|
41
|
-
export interface ManualMatchOptions
|
|
42
|
-
{
|
|
43
|
-
seed?: number;
|
|
44
|
-
spawnDistance?: number;
|
|
45
|
-
maxTicks?: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface StepResult
|
|
49
|
-
{
|
|
50
|
-
gameState: GameState;
|
|
51
|
-
errors: BotError[];
|
|
52
|
-
done: boolean;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Build the initial pair of internal wizards for a match. Mirrors the
|
|
57
|
-
* inline setup in simulate() so both code paths produce identical state.
|
|
58
|
-
*/
|
|
59
|
-
function createInitialWizards(spawnDistance: number): InternalWizardState[]
|
|
60
|
-
{
|
|
61
|
-
return [
|
|
62
|
-
{
|
|
63
|
-
id: 'wizard-1',
|
|
64
|
-
position: {x: ARENA_WIDTH / 2 - spawnDistance / 2, y: ARENA_HEIGHT / 2},
|
|
65
|
-
rotation: 0,
|
|
66
|
-
health: WIZARD_HEALTH,
|
|
67
|
-
maxHealth: WIZARD_HEALTH,
|
|
68
|
-
state: 'idle',
|
|
69
|
-
blinkCooldown: 0,
|
|
70
|
-
velocity: {x: 0, y: 0},
|
|
71
|
-
damageDealt: 0,
|
|
72
|
-
damageTaken: 0,
|
|
73
|
-
lastHitTick: 0,
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
id: 'wizard-2',
|
|
77
|
-
position: {x: ARENA_WIDTH / 2 + spawnDistance / 2, y: ARENA_HEIGHT / 2},
|
|
78
|
-
rotation: 180,
|
|
79
|
-
health: WIZARD_HEALTH,
|
|
80
|
-
maxHealth: WIZARD_HEALTH,
|
|
81
|
-
state: 'idle',
|
|
82
|
-
blinkCooldown: 0,
|
|
83
|
-
velocity: {x: 0, y: 0},
|
|
84
|
-
damageDealt: 0,
|
|
85
|
-
damageTaken: 0,
|
|
86
|
-
lastHitTick: 0,
|
|
87
|
-
},
|
|
88
|
-
];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export class ManualMatch
|
|
92
|
-
{
|
|
93
|
-
private readonly wizard1AI: WizardFunction;
|
|
94
|
-
private readonly wizard2AI: WizardFunction;
|
|
95
|
-
private readonly seed: number;
|
|
96
|
-
private readonly maxTicks: number;
|
|
97
|
-
private readonly config: GameConfig;
|
|
98
|
-
|
|
99
|
-
private wizards: InternalWizardState[];
|
|
100
|
-
private projectiles: ProjectileState[] = [];
|
|
101
|
-
private missileAIs: Map<string, MissileAIFunction> = new Map();
|
|
102
|
-
/** First-replacement originals for hijacked missiles. Used by restoreMissileAI. */
|
|
103
|
-
private originalMissileAIs: Map<string, MissileAIFunction> = new Map();
|
|
104
|
-
private currentTick = 0;
|
|
105
|
-
private done = false;
|
|
106
|
-
private allErrors: BotError[] = [];
|
|
107
|
-
private history: GameState[];
|
|
108
|
-
|
|
109
|
-
constructor(wizard1AI: WizardFunction, wizard2AI: WizardFunction, options: ManualMatchOptions = {})
|
|
110
|
-
{
|
|
111
|
-
if (typeof wizard1AI !== 'function')
|
|
112
|
-
{
|
|
113
|
-
throw new Error('wizard1AI must be a function');
|
|
114
|
-
}
|
|
115
|
-
if (typeof wizard2AI !== 'function')
|
|
116
|
-
{
|
|
117
|
-
throw new Error('wizard2AI must be a function');
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
this.wizard1AI = wizard1AI;
|
|
121
|
-
this.wizard2AI = wizard2AI;
|
|
122
|
-
|
|
123
|
-
const {
|
|
124
|
-
seed: rawSeed = Date.now(),
|
|
125
|
-
spawnDistance: rawSpawn,
|
|
126
|
-
maxTicks: rawMaxTicks = MATCH_DURATION,
|
|
127
|
-
} = options;
|
|
128
|
-
|
|
129
|
-
this.seed = Number.isFinite(rawSeed) ? Math.floor(rawSeed) & 0x7FFFFFFF : Date.now() & 0x7FFFFFFF;
|
|
130
|
-
this.maxTicks = Number.isFinite(rawMaxTicks) && rawMaxTicks > 0
|
|
131
|
-
? Math.min(Math.floor(rawMaxTicks), 100000)
|
|
132
|
-
: MATCH_DURATION;
|
|
133
|
-
const spawnDistance = Number.isFinite(rawSpawn) && rawSpawn! > 0
|
|
134
|
-
? Math.min(rawSpawn!, Math.min(ARENA_WIDTH, ARENA_HEIGHT) - 100)
|
|
135
|
-
: SPAWN_DISTANCE;
|
|
136
|
-
|
|
137
|
-
this.config = Object.freeze({
|
|
138
|
-
arenaSize: Object.freeze({width: ARENA_WIDTH, height: ARENA_HEIGHT}),
|
|
139
|
-
tickRate: TICKS_PER_SECOND,
|
|
140
|
-
maxTicks: this.maxTicks,
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// Hook state must start clean — bot useState/useRef from a previous
|
|
144
|
-
// match would otherwise leak in.
|
|
145
|
-
clearHooks('wizard-1');
|
|
146
|
-
clearHooks('wizard-2');
|
|
147
|
-
|
|
148
|
-
this.wizards = createInitialWizards(spawnDistance);
|
|
149
|
-
this.history = [getPlayerState(0, this.wizards, this.projectiles, 0)];
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Advance the match by `count` ticks (default 1). Stops early if the
|
|
154
|
-
* match completes mid-batch.
|
|
155
|
-
*/
|
|
156
|
-
step(count = 1): StepResult
|
|
157
|
-
{
|
|
158
|
-
const errorsThisCall: BotError[] = [];
|
|
159
|
-
for (let i = 0; i < count; i++)
|
|
160
|
-
{
|
|
161
|
-
if (this.done) break;
|
|
162
|
-
const result = tick(
|
|
163
|
-
this.currentTick,
|
|
164
|
-
this.wizard1AI,
|
|
165
|
-
this.wizard2AI,
|
|
166
|
-
this.config,
|
|
167
|
-
this.wizards,
|
|
168
|
-
this.projectiles,
|
|
169
|
-
this.missileAIs,
|
|
170
|
-
this.seed,
|
|
171
|
-
);
|
|
172
|
-
this.currentTick = result.nextTick;
|
|
173
|
-
this.wizards = result.wizards;
|
|
174
|
-
this.projectiles = result.projectiles;
|
|
175
|
-
if (result.errors.length > 0)
|
|
176
|
-
{
|
|
177
|
-
this.allErrors.push(...result.errors);
|
|
178
|
-
errorsThisCall.push(...result.errors);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
this.history.push(getPlayerState(0, this.wizards, this.projectiles, this.currentTick));
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
this.wizards[0]!.health <= 0
|
|
185
|
-
|| this.wizards[1]!.health <= 0
|
|
186
|
-
|| this.currentTick >= this.maxTicks
|
|
187
|
-
)
|
|
188
|
-
{
|
|
189
|
-
this.done = true;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
return {
|
|
194
|
-
gameState: this.getGameState(),
|
|
195
|
-
errors: errorsThisCall,
|
|
196
|
-
done: this.done,
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Hot-swap a missile's AI function. Used by the missile-hijack feature
|
|
202
|
-
* in manual play. The first replacement remembers the original AI so
|
|
203
|
-
* `restoreMissileAI` can put it back. Subsequent replacements update
|
|
204
|
-
* the active AI but leave the remembered original alone.
|
|
205
|
-
*
|
|
206
|
-
* No-op if the projectile id doesn't exist.
|
|
207
|
-
*/
|
|
208
|
-
replaceMissileAI(projectileId: string, ai: MissileAIFunction): void
|
|
209
|
-
{
|
|
210
|
-
const existing = this.missileAIs.get(projectileId);
|
|
211
|
-
if (!existing) return;
|
|
212
|
-
if (!this.originalMissileAIs.has(projectileId))
|
|
213
|
-
{
|
|
214
|
-
this.originalMissileAIs.set(projectileId, existing);
|
|
215
|
-
}
|
|
216
|
-
this.missileAIs.set(projectileId, ai);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Restore a previously hijacked missile's original AI function.
|
|
221
|
-
* No-op if the projectile id doesn't exist or was never hijacked.
|
|
222
|
-
*/
|
|
223
|
-
restoreMissileAI(projectileId: string): void
|
|
224
|
-
{
|
|
225
|
-
const original = this.originalMissileAIs.get(projectileId);
|
|
226
|
-
if (!original) return;
|
|
227
|
-
if (!this.missileAIs.has(projectileId)) return;
|
|
228
|
-
this.missileAIs.set(projectileId, original);
|
|
229
|
-
this.originalMissileAIs.delete(projectileId);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Set the invincibility flag for a wizard. Invincible wizards take 0
|
|
234
|
-
* damage from all sources.
|
|
235
|
-
*/
|
|
236
|
-
setInvincible(wizardIndex: 0 | 1, on: boolean): void
|
|
237
|
-
{
|
|
238
|
-
const wizard = this.wizards[wizardIndex];
|
|
239
|
-
if (!wizard) return;
|
|
240
|
-
wizard.invincible = on;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Get the current game state from wizard-1's perspective.
|
|
245
|
-
* Returned object is a deep clone — safe to mutate.
|
|
246
|
-
*/
|
|
247
|
-
getGameState(): GameState
|
|
248
|
-
{
|
|
249
|
-
return getPlayerState(0, this.wizards, this.projectiles, this.currentTick);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
getCurrentTick(): number
|
|
253
|
-
{
|
|
254
|
-
return this.currentTick;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
isComplete(): {done: boolean; winner: MatchWinner}
|
|
258
|
-
{
|
|
259
|
-
return {done: this.done, winner: this.done ? this.computeWinner() : null};
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
getResult(): SimulateResult
|
|
263
|
-
{
|
|
264
|
-
return {
|
|
265
|
-
winner: this.computeWinner(),
|
|
266
|
-
ticks: this.currentTick,
|
|
267
|
-
finalState: this.getGameState(),
|
|
268
|
-
history: this.history,
|
|
269
|
-
errors: this.allErrors,
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
dispose(): void
|
|
274
|
-
{
|
|
275
|
-
clearHooks('wizard-1');
|
|
276
|
-
clearHooks('wizard-2');
|
|
277
|
-
this.history = [];
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
private computeWinner(): MatchWinner
|
|
281
|
-
{
|
|
282
|
-
const w1 = this.wizards[0]!;
|
|
283
|
-
const w2 = this.wizards[1]!;
|
|
284
|
-
if (w1.health <= 0 && w2.health <= 0) return 'draw';
|
|
285
|
-
if (w2.health <= 0) return 'wizard-1';
|
|
286
|
-
if (w1.health <= 0) return 'wizard-2';
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* VIBEMANCER - MANUAL MATCH
|
|
3
|
+
*
|
|
4
|
+
* Tick-by-tick match runner used by manual play mode. Wraps the same
|
|
5
|
+
* `tick()` primitive that `simulate()` uses, so stepping forward
|
|
6
|
+
* produces the same trajectory as a batched `simulate()` with the
|
|
7
|
+
* same seed.
|
|
8
|
+
*
|
|
9
|
+
* Extra capabilities over `simulate()`:
|
|
10
|
+
* - step(N) advances N ticks at a time (default 1) — caller controls pacing
|
|
11
|
+
* - replaceMissileAI(id, ai) hot-swaps a missile's AI mid-flight (used by
|
|
12
|
+
* the missile-hijack feature in manual play)
|
|
13
|
+
* - setInvincible(wizardIndex, on) toggles damage immunity per wizard
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
ARENA_HEIGHT,
|
|
18
|
+
ARENA_WIDTH,
|
|
19
|
+
MATCH_DURATION,
|
|
20
|
+
SPAWN_DISTANCE,
|
|
21
|
+
TICKS_PER_SECOND,
|
|
22
|
+
WIZARD_HEALTH,
|
|
23
|
+
} from '../rules.js';
|
|
24
|
+
import type {
|
|
25
|
+
GameConfig,
|
|
26
|
+
GameState,
|
|
27
|
+
MissileAIFunction,
|
|
28
|
+
ProjectileState,
|
|
29
|
+
WizardFunction,
|
|
30
|
+
} from '../types.js';
|
|
31
|
+
import {clearHooks} from './hooks-runtime.js';
|
|
32
|
+
import {
|
|
33
|
+
type BotError,
|
|
34
|
+
type InternalWizardState,
|
|
35
|
+
type MatchWinner,
|
|
36
|
+
type SimulateResult,
|
|
37
|
+
getPlayerState,
|
|
38
|
+
tick,
|
|
39
|
+
} from './simulation.js';
|
|
40
|
+
|
|
41
|
+
export interface ManualMatchOptions
|
|
42
|
+
{
|
|
43
|
+
seed?: number;
|
|
44
|
+
spawnDistance?: number;
|
|
45
|
+
maxTicks?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface StepResult
|
|
49
|
+
{
|
|
50
|
+
gameState: GameState;
|
|
51
|
+
errors: BotError[];
|
|
52
|
+
done: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the initial pair of internal wizards for a match. Mirrors the
|
|
57
|
+
* inline setup in simulate() so both code paths produce identical state.
|
|
58
|
+
*/
|
|
59
|
+
function createInitialWizards(spawnDistance: number): InternalWizardState[]
|
|
60
|
+
{
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
id: 'wizard-1',
|
|
64
|
+
position: {x: ARENA_WIDTH / 2 - spawnDistance / 2, y: ARENA_HEIGHT / 2},
|
|
65
|
+
rotation: 0,
|
|
66
|
+
health: WIZARD_HEALTH,
|
|
67
|
+
maxHealth: WIZARD_HEALTH,
|
|
68
|
+
state: 'idle',
|
|
69
|
+
blinkCooldown: 0,
|
|
70
|
+
velocity: {x: 0, y: 0},
|
|
71
|
+
damageDealt: 0,
|
|
72
|
+
damageTaken: 0,
|
|
73
|
+
lastHitTick: 0,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'wizard-2',
|
|
77
|
+
position: {x: ARENA_WIDTH / 2 + spawnDistance / 2, y: ARENA_HEIGHT / 2},
|
|
78
|
+
rotation: 180,
|
|
79
|
+
health: WIZARD_HEALTH,
|
|
80
|
+
maxHealth: WIZARD_HEALTH,
|
|
81
|
+
state: 'idle',
|
|
82
|
+
blinkCooldown: 0,
|
|
83
|
+
velocity: {x: 0, y: 0},
|
|
84
|
+
damageDealt: 0,
|
|
85
|
+
damageTaken: 0,
|
|
86
|
+
lastHitTick: 0,
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class ManualMatch
|
|
92
|
+
{
|
|
93
|
+
private readonly wizard1AI: WizardFunction;
|
|
94
|
+
private readonly wizard2AI: WizardFunction;
|
|
95
|
+
private readonly seed: number;
|
|
96
|
+
private readonly maxTicks: number;
|
|
97
|
+
private readonly config: GameConfig;
|
|
98
|
+
|
|
99
|
+
private wizards: InternalWizardState[];
|
|
100
|
+
private projectiles: ProjectileState[] = [];
|
|
101
|
+
private missileAIs: Map<string, MissileAIFunction> = new Map();
|
|
102
|
+
/** First-replacement originals for hijacked missiles. Used by restoreMissileAI. */
|
|
103
|
+
private originalMissileAIs: Map<string, MissileAIFunction> = new Map();
|
|
104
|
+
private currentTick = 0;
|
|
105
|
+
private done = false;
|
|
106
|
+
private allErrors: BotError[] = [];
|
|
107
|
+
private history: GameState[];
|
|
108
|
+
|
|
109
|
+
constructor(wizard1AI: WizardFunction, wizard2AI: WizardFunction, options: ManualMatchOptions = {})
|
|
110
|
+
{
|
|
111
|
+
if (typeof wizard1AI !== 'function')
|
|
112
|
+
{
|
|
113
|
+
throw new Error('wizard1AI must be a function');
|
|
114
|
+
}
|
|
115
|
+
if (typeof wizard2AI !== 'function')
|
|
116
|
+
{
|
|
117
|
+
throw new Error('wizard2AI must be a function');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.wizard1AI = wizard1AI;
|
|
121
|
+
this.wizard2AI = wizard2AI;
|
|
122
|
+
|
|
123
|
+
const {
|
|
124
|
+
seed: rawSeed = Date.now(),
|
|
125
|
+
spawnDistance: rawSpawn,
|
|
126
|
+
maxTicks: rawMaxTicks = MATCH_DURATION,
|
|
127
|
+
} = options;
|
|
128
|
+
|
|
129
|
+
this.seed = Number.isFinite(rawSeed) ? Math.floor(rawSeed) & 0x7FFFFFFF : Date.now() & 0x7FFFFFFF;
|
|
130
|
+
this.maxTicks = Number.isFinite(rawMaxTicks) && rawMaxTicks > 0
|
|
131
|
+
? Math.min(Math.floor(rawMaxTicks), 100000)
|
|
132
|
+
: MATCH_DURATION;
|
|
133
|
+
const spawnDistance = Number.isFinite(rawSpawn) && rawSpawn! > 0
|
|
134
|
+
? Math.min(rawSpawn!, Math.min(ARENA_WIDTH, ARENA_HEIGHT) - 100)
|
|
135
|
+
: SPAWN_DISTANCE;
|
|
136
|
+
|
|
137
|
+
this.config = Object.freeze({
|
|
138
|
+
arenaSize: Object.freeze({width: ARENA_WIDTH, height: ARENA_HEIGHT}),
|
|
139
|
+
tickRate: TICKS_PER_SECOND,
|
|
140
|
+
maxTicks: this.maxTicks,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Hook state must start clean — bot useState/useRef from a previous
|
|
144
|
+
// match would otherwise leak in.
|
|
145
|
+
clearHooks('wizard-1');
|
|
146
|
+
clearHooks('wizard-2');
|
|
147
|
+
|
|
148
|
+
this.wizards = createInitialWizards(spawnDistance);
|
|
149
|
+
this.history = [getPlayerState(0, this.wizards, this.projectiles, 0)];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Advance the match by `count` ticks (default 1). Stops early if the
|
|
154
|
+
* match completes mid-batch.
|
|
155
|
+
*/
|
|
156
|
+
step(count = 1): StepResult
|
|
157
|
+
{
|
|
158
|
+
const errorsThisCall: BotError[] = [];
|
|
159
|
+
for (let i = 0; i < count; i++)
|
|
160
|
+
{
|
|
161
|
+
if (this.done) break;
|
|
162
|
+
const result = tick(
|
|
163
|
+
this.currentTick,
|
|
164
|
+
this.wizard1AI,
|
|
165
|
+
this.wizard2AI,
|
|
166
|
+
this.config,
|
|
167
|
+
this.wizards,
|
|
168
|
+
this.projectiles,
|
|
169
|
+
this.missileAIs,
|
|
170
|
+
this.seed,
|
|
171
|
+
);
|
|
172
|
+
this.currentTick = result.nextTick;
|
|
173
|
+
this.wizards = result.wizards;
|
|
174
|
+
this.projectiles = result.projectiles;
|
|
175
|
+
if (result.errors.length > 0)
|
|
176
|
+
{
|
|
177
|
+
this.allErrors.push(...result.errors);
|
|
178
|
+
errorsThisCall.push(...result.errors);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.history.push(getPlayerState(0, this.wizards, this.projectiles, this.currentTick));
|
|
182
|
+
|
|
183
|
+
if (
|
|
184
|
+
this.wizards[0]!.health <= 0
|
|
185
|
+
|| this.wizards[1]!.health <= 0
|
|
186
|
+
|| this.currentTick >= this.maxTicks
|
|
187
|
+
)
|
|
188
|
+
{
|
|
189
|
+
this.done = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
gameState: this.getGameState(),
|
|
195
|
+
errors: errorsThisCall,
|
|
196
|
+
done: this.done,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Hot-swap a missile's AI function. Used by the missile-hijack feature
|
|
202
|
+
* in manual play. The first replacement remembers the original AI so
|
|
203
|
+
* `restoreMissileAI` can put it back. Subsequent replacements update
|
|
204
|
+
* the active AI but leave the remembered original alone.
|
|
205
|
+
*
|
|
206
|
+
* No-op if the projectile id doesn't exist.
|
|
207
|
+
*/
|
|
208
|
+
replaceMissileAI(projectileId: string, ai: MissileAIFunction): void
|
|
209
|
+
{
|
|
210
|
+
const existing = this.missileAIs.get(projectileId);
|
|
211
|
+
if (!existing) return;
|
|
212
|
+
if (!this.originalMissileAIs.has(projectileId))
|
|
213
|
+
{
|
|
214
|
+
this.originalMissileAIs.set(projectileId, existing);
|
|
215
|
+
}
|
|
216
|
+
this.missileAIs.set(projectileId, ai);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Restore a previously hijacked missile's original AI function.
|
|
221
|
+
* No-op if the projectile id doesn't exist or was never hijacked.
|
|
222
|
+
*/
|
|
223
|
+
restoreMissileAI(projectileId: string): void
|
|
224
|
+
{
|
|
225
|
+
const original = this.originalMissileAIs.get(projectileId);
|
|
226
|
+
if (!original) return;
|
|
227
|
+
if (!this.missileAIs.has(projectileId)) return;
|
|
228
|
+
this.missileAIs.set(projectileId, original);
|
|
229
|
+
this.originalMissileAIs.delete(projectileId);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Set the invincibility flag for a wizard. Invincible wizards take 0
|
|
234
|
+
* damage from all sources.
|
|
235
|
+
*/
|
|
236
|
+
setInvincible(wizardIndex: 0 | 1, on: boolean): void
|
|
237
|
+
{
|
|
238
|
+
const wizard = this.wizards[wizardIndex];
|
|
239
|
+
if (!wizard) return;
|
|
240
|
+
wizard.invincible = on;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get the current game state from wizard-1's perspective.
|
|
245
|
+
* Returned object is a deep clone — safe to mutate.
|
|
246
|
+
*/
|
|
247
|
+
getGameState(): GameState
|
|
248
|
+
{
|
|
249
|
+
return getPlayerState(0, this.wizards, this.projectiles, this.currentTick);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
getCurrentTick(): number
|
|
253
|
+
{
|
|
254
|
+
return this.currentTick;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
isComplete(): {done: boolean; winner: MatchWinner}
|
|
258
|
+
{
|
|
259
|
+
return {done: this.done, winner: this.done ? this.computeWinner() : null};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
getResult(): SimulateResult
|
|
263
|
+
{
|
|
264
|
+
return {
|
|
265
|
+
winner: this.computeWinner(),
|
|
266
|
+
ticks: this.currentTick,
|
|
267
|
+
finalState: this.getGameState(),
|
|
268
|
+
history: this.history,
|
|
269
|
+
errors: this.allErrors,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
dispose(): void
|
|
274
|
+
{
|
|
275
|
+
clearHooks('wizard-1');
|
|
276
|
+
clearHooks('wizard-2');
|
|
277
|
+
this.history = [];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private computeWinner(): MatchWinner
|
|
281
|
+
{
|
|
282
|
+
const w1 = this.wizards[0]!;
|
|
283
|
+
const w2 = this.wizards[1]!;
|
|
284
|
+
if (w1.health <= 0 && w2.health <= 0) return 'draw';
|
|
285
|
+
if (w2.health <= 0) return 'wizard-1';
|
|
286
|
+
if (w1.health <= 0) return 'wizard-2';
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|