@vibemancer/core 0.1.0
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 -0
- package/dist/chunk-L7Z7OFXD.js +9140 -0
- package/dist/chunk-L7Z7OFXD.js.map +1 -0
- package/dist/index-browser.d.ts +2602 -0
- package/dist/index-browser.js +407 -0
- package/dist/index-browser.js.map +1 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.js +750 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -0
- package/src/bots/berserker/01_Stormchaser.ts +457 -0
- package/src/bots/berserker/02_Stormcaller.ts +417 -0
- package/src/bots/berserker/03_Stormforger.ts +481 -0
- package/src/bots/caster/01_Flamecaller.ts +286 -0
- package/src/bots/caster/02_Pyromancer.ts +350 -0
- package/src/bots/caster/03_Infernalist.ts +492 -0
- package/src/bots/defensive/01_Turtle.ts +151 -0
- package/src/bots/defensive/02_Sentinel.ts +134 -0
- package/src/bots/defensive/03_Golem.ts +357 -0
- package/src/bots/duelist/01_Battlemage.ts +433 -0
- package/src/bots/duelist/02_Warmage.ts +438 -0
- package/src/bots/duelist/03_Archmage.ts +588 -0
- package/src/bots/homing/01_Bonemancer.ts +67 -0
- package/src/bots/homing/02_Lich.ts +356 -0
- package/src/bots/homing/03_Archlich.ts +220 -0
- package/src/bots/index.ts +30 -0
- package/src/bots/kiter/01_Spellspinner.ts +398 -0
- package/src/bots/kiter/02_Spellweaver.ts +378 -0
- package/src/bots/kiter/03_Spellbinder.ts +448 -0
- package/src/bots/melee/01_Shadowblade.ts +270 -0
- package/src/bots/melee/02_Nightblade.ts +437 -0
- package/src/bots/melee/03_Voidblade.ts +582 -0
- package/src/bots/registry.ts +207 -0
- package/src/bots/shared.ts +472 -0
- package/src/bots/sniper/01_Spellshot.ts +385 -0
- package/src/bots/sniper/02_Spelltracer.ts +441 -0
- package/src/bots/sniper/03_Spellseeker.ts +546 -0
- package/src/bots/standalone/Critter.ts +89 -0
- package/src/bots/standalone/Doombringer.ts +91 -0
- package/src/bots/standalone/Hogger.ts +228 -0
- package/src/bots/standalone/Rookie.ts +50 -0
- package/src/bots/standalone/TargetDummy.ts +21 -0
- package/src/bots/test/cheater.ts +405 -0
- package/src/bots/test/crasher.ts +81 -0
- package/src/engine/hooks-runtime.ts +394 -0
- package/src/engine/manual-match.ts +289 -0
- package/src/engine/missile-templates.ts +155 -0
- package/src/engine/optimizer.ts +220 -0
- package/src/engine/params-runtime.ts +189 -0
- package/src/engine/physics.ts +143 -0
- package/src/engine/sandbox-browser.ts +671 -0
- package/src/engine/sandbox-compile.ts +197 -0
- package/src/engine/sandbox-harness.ts +367 -0
- package/src/engine/sandbox.ts +332 -0
- package/src/engine/simulation.ts +828 -0
- package/src/engine/spells.ts +128 -0
- package/src/engine-version.ts +11 -0
- package/src/hooks/action-builders.ts +210 -0
- package/src/hooks/bot-wrapper.ts +84 -0
- package/src/hooks/index.ts +75 -0
- package/src/hooks/state-hooks.ts +354 -0
- package/src/hooks/threat-analysis.ts +365 -0
- package/src/hooks/types.ts +142 -0
- package/src/index-browser.ts +30 -0
- package/src/index.ts +24 -0
- package/src/rules.ts +254 -0
- package/src/stats.ts +262 -0
- package/src/testing.ts +207 -0
- package/src/trace.ts +430 -0
- package/src/types.ts +193 -0
- package/src/utils/angles.ts +47 -0
- package/src/utils/combat.ts +279 -0
- package/src/utils/distance.ts +21 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/movement.ts +108 -0
- package/src/utils/random.ts +65 -0
- package/src/utils/spatial.ts +63 -0
- package/src/utils/targeting.ts +45 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VIBEMANCER - MISSILE TEMPLATES
|
|
3
|
+
*
|
|
4
|
+
* Reusable missile config + AI factories for manual play and bot examples.
|
|
5
|
+
* Each factory clamps its inputs to rules.ts minimums and returns a pure
|
|
6
|
+
* AI function that's safe to register with the engine.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
MISSILE_MIN_DAMAGE,
|
|
11
|
+
MISSILE_MIN_SPEED,
|
|
12
|
+
MISSILE_MIN_DURATION,
|
|
13
|
+
} from '../rules.js';
|
|
14
|
+
import type {MissileAIFunction, MissileConfig} from '../types.js';
|
|
15
|
+
|
|
16
|
+
interface BaseParams
|
|
17
|
+
{
|
|
18
|
+
damage: number;
|
|
19
|
+
speed: number;
|
|
20
|
+
duration: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TurningParams extends BaseParams
|
|
24
|
+
{
|
|
25
|
+
turnRate: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type StraightParams = BaseParams;
|
|
29
|
+
|
|
30
|
+
export type HomingParams = TurningParams;
|
|
31
|
+
|
|
32
|
+
export type AntiHomingParams = TurningParams;
|
|
33
|
+
|
|
34
|
+
export interface SpiralParams extends BaseParams
|
|
35
|
+
{
|
|
36
|
+
spiralRadius: number;
|
|
37
|
+
spiralFreq: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SeekerParams extends TurningParams
|
|
41
|
+
{
|
|
42
|
+
minLockDistance: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MissileTemplate
|
|
46
|
+
{
|
|
47
|
+
config: MissileConfig;
|
|
48
|
+
ai: MissileAIFunction;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clampBase(p: BaseParams): {damage: number; speed: number; duration: number}
|
|
52
|
+
{
|
|
53
|
+
return {
|
|
54
|
+
damage: Math.max(MISSILE_MIN_DAMAGE, p.damage),
|
|
55
|
+
speed: Math.max(MISSILE_MIN_SPEED, p.speed),
|
|
56
|
+
duration: Math.max(MISSILE_MIN_DURATION, Math.floor(p.duration)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fire-and-forget missile. No steering — flies in a straight line.
|
|
62
|
+
*/
|
|
63
|
+
export function straightMissile(p: StraightParams): MissileTemplate
|
|
64
|
+
{
|
|
65
|
+
const base = clampBase(p);
|
|
66
|
+
const config: MissileConfig = {...base, turnRate: 0};
|
|
67
|
+
const ai: MissileAIFunction = () => ({});
|
|
68
|
+
return {config, ai};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Homing missile. Steers toward the enemy each tick.
|
|
73
|
+
*/
|
|
74
|
+
export function homingMissile(p: HomingParams): MissileTemplate
|
|
75
|
+
{
|
|
76
|
+
const base = clampBase(p);
|
|
77
|
+
const config: MissileConfig = {...base, turnRate: p.turnRate};
|
|
78
|
+
const ai: MissileAIFunction = ({worldState}) =>
|
|
79
|
+
{
|
|
80
|
+
const enemy = worldState.enemies[0];
|
|
81
|
+
if (!enemy) return {};
|
|
82
|
+
return {turnToward: enemy.position};
|
|
83
|
+
};
|
|
84
|
+
return {config, ai};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Anti-homing missile. Steers AWAY from the enemy — useful as a feint or
|
|
89
|
+
* area-denial pattern.
|
|
90
|
+
*/
|
|
91
|
+
export function antiHomingMissile(p: AntiHomingParams): MissileTemplate
|
|
92
|
+
{
|
|
93
|
+
const base = clampBase(p);
|
|
94
|
+
const config: MissileConfig = {...base, turnRate: p.turnRate};
|
|
95
|
+
const ai: MissileAIFunction = ({missileState, worldState}) =>
|
|
96
|
+
{
|
|
97
|
+
const enemy = worldState.enemies[0];
|
|
98
|
+
if (!enemy) return {};
|
|
99
|
+
// Reflect the enemy position through the missile to get an "away" target.
|
|
100
|
+
const target = {
|
|
101
|
+
x: missileState.position.x * 2 - enemy.position.x,
|
|
102
|
+
y: missileState.position.y * 2 - enemy.position.y,
|
|
103
|
+
};
|
|
104
|
+
return {turnToward: target};
|
|
105
|
+
};
|
|
106
|
+
return {config, ai};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Spiral missile. Continuously orbits its current heading while advancing.
|
|
111
|
+
* Uses missileState.remainingTicks as a deterministic phase counter so the
|
|
112
|
+
* AI is fully pure (no closure state).
|
|
113
|
+
*/
|
|
114
|
+
export function spiralMissile(p: SpiralParams): MissileTemplate
|
|
115
|
+
{
|
|
116
|
+
const base = clampBase(p);
|
|
117
|
+
// turnRate must be high enough for the spiral to manifest visibly
|
|
118
|
+
const config: MissileConfig = {...base, turnRate: 12};
|
|
119
|
+
const radius = Math.max(1, p.spiralRadius);
|
|
120
|
+
const freq = p.spiralFreq;
|
|
121
|
+
const ai: MissileAIFunction = ({missileState}) =>
|
|
122
|
+
{
|
|
123
|
+
const phase = -missileState.remainingTicks * freq;
|
|
124
|
+
const target = {
|
|
125
|
+
x: missileState.position.x + Math.cos(phase) * radius,
|
|
126
|
+
y: missileState.position.y + Math.sin(phase) * radius,
|
|
127
|
+
};
|
|
128
|
+
return {turnToward: target};
|
|
129
|
+
};
|
|
130
|
+
return {config, ai};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Seeker missile. Homes toward the enemy, but only when farther away than
|
|
135
|
+
* minLockDistance — closer than that, the missile coasts straight (so it
|
|
136
|
+
* doesn't whirl around a target it's about to hit).
|
|
137
|
+
*/
|
|
138
|
+
export function seekerMissile(p: SeekerParams): MissileTemplate
|
|
139
|
+
{
|
|
140
|
+
const base = clampBase(p);
|
|
141
|
+
const config: MissileConfig = {...base, turnRate: p.turnRate};
|
|
142
|
+
const minDist = Math.max(0, p.minLockDistance);
|
|
143
|
+
const minDistSq = minDist * minDist;
|
|
144
|
+
const ai: MissileAIFunction = ({missileState, worldState}) =>
|
|
145
|
+
{
|
|
146
|
+
const enemy = worldState.enemies[0];
|
|
147
|
+
if (!enemy) return {};
|
|
148
|
+
const dx = enemy.position.x - missileState.position.x;
|
|
149
|
+
const dy = enemy.position.y - missileState.position.y;
|
|
150
|
+
const distSq = dx * dx + dy * dy;
|
|
151
|
+
if (distSq < minDistSq) return {};
|
|
152
|
+
return {turnToward: enemy.position};
|
|
153
|
+
};
|
|
154
|
+
return {config, ai};
|
|
155
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VIBEMANCER - OPTIMIZER UTILITIES
|
|
3
|
+
*
|
|
4
|
+
* Core functions used by the offline parameter optimizer script.
|
|
5
|
+
* These handle combo generation, range narrowing, and fight scoring.
|
|
6
|
+
*
|
|
7
|
+
* The optimizer uses multi-pass coordinate descent:
|
|
8
|
+
* - Pass 1: Coarse search across the effective range for each param
|
|
9
|
+
* - Pass 2+: Fine search zoomed into the neighborhood of the best result
|
|
10
|
+
*
|
|
11
|
+
* Parameters can define their search window two ways:
|
|
12
|
+
* - `range`: sliding window centered on current value (value ± range)
|
|
13
|
+
* - `min`/`max`: fixed bounds (old-style)
|
|
14
|
+
*
|
|
15
|
+
* Both can be combined: range defines the search radius, min/max clamp it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {ParamDeclaration} from './params-runtime.js';
|
|
19
|
+
import type {FightResult} from './simulation.js';
|
|
20
|
+
import {WIZARD_HEALTH} from '../rules.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compute the effective min/max search window for a parameter.
|
|
24
|
+
*
|
|
25
|
+
* - If `range` is set: window is `[value - range, value + range]`, clamped by optional min/max
|
|
26
|
+
* - If only `min`/`max` are set: window is `[min, max]` directly
|
|
27
|
+
* - If neither: returns `[value, value]` (no search)
|
|
28
|
+
*/
|
|
29
|
+
export function getEffectiveRange(p: ParamDeclaration): {min: number; max: number}
|
|
30
|
+
{
|
|
31
|
+
if (p.range != null)
|
|
32
|
+
{
|
|
33
|
+
return {
|
|
34
|
+
min: Math.max(p.value - p.range, p.min ?? -Infinity),
|
|
35
|
+
max: Math.min(p.value + p.range, p.max ?? Infinity),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
min: p.min ?? p.value,
|
|
40
|
+
max: p.max ?? p.value,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generate evenly-spaced candidate values for a single parameter.
|
|
46
|
+
*
|
|
47
|
+
* Divides [min, max] into `steps` evenly-spaced values. For example,
|
|
48
|
+
* min=0, max=100, steps=5 produces [0, 25, 50, 75, 100].
|
|
49
|
+
*
|
|
50
|
+
* @param min - Lower bound of search range
|
|
51
|
+
* @param max - Upper bound of search range
|
|
52
|
+
* @param steps - Number of evenly-spaced values to generate (minimum 2)
|
|
53
|
+
* @returns Array of candidate values, sorted ascending
|
|
54
|
+
*/
|
|
55
|
+
export function generateCandidates(min: number, max: number, steps: number): number[]
|
|
56
|
+
{
|
|
57
|
+
if (steps < 2) steps = 2;
|
|
58
|
+
const values: number[] = [];
|
|
59
|
+
for (let i = 0; i < steps; i++)
|
|
60
|
+
{
|
|
61
|
+
values.push(min + (max - min) * i / (steps - 1));
|
|
62
|
+
}
|
|
63
|
+
return values;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Generate all combinations (cartesian product) of candidate values for multiple params.
|
|
68
|
+
*
|
|
69
|
+
* For N params with S1, S2, ... SN steps each, produces S1 × S2 × ... × SN combinations.
|
|
70
|
+
* Each combination is a Record<string, number> mapping param name to value.
|
|
71
|
+
*
|
|
72
|
+
* @param params - Parameter declarations with search ranges and step counts
|
|
73
|
+
* @returns Array of all parameter combinations to evaluate
|
|
74
|
+
*/
|
|
75
|
+
export function generateCombos(params: ParamDeclaration[]): Record<string, number>[]
|
|
76
|
+
{
|
|
77
|
+
if (params.length === 0) return [{}];
|
|
78
|
+
|
|
79
|
+
// Generate candidates per param
|
|
80
|
+
const candidateArrays = params.map((p) =>
|
|
81
|
+
{
|
|
82
|
+
const {min, max} = getEffectiveRange(p);
|
|
83
|
+
return {
|
|
84
|
+
name: p.name,
|
|
85
|
+
values: generateCandidates(min, max, p.steps),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Cartesian product
|
|
90
|
+
let combos: Record<string, number>[] = [{}];
|
|
91
|
+
for (const {name, values} of candidateArrays)
|
|
92
|
+
{
|
|
93
|
+
const next: Record<string, number>[] = [];
|
|
94
|
+
for (const combo of combos)
|
|
95
|
+
{
|
|
96
|
+
for (const value of values)
|
|
97
|
+
{
|
|
98
|
+
next.push({...combo, [name]: value});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
combos = next;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return combos;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Narrow parameter ranges around the best combo found in the previous pass.
|
|
109
|
+
*
|
|
110
|
+
* Centers each param on its best value and shrinks the search range to one gap width.
|
|
111
|
+
* This provides finer resolution in subsequent passes. Hard min/max constraints are preserved.
|
|
112
|
+
*
|
|
113
|
+
* @param params - Original parameter declarations
|
|
114
|
+
* @param best - Best parameter combination from the previous pass
|
|
115
|
+
* @returns New parameter declarations with narrowed ranges for the next pass
|
|
116
|
+
*/
|
|
117
|
+
export function narrowRange(params: ParamDeclaration[], best: Record<string, number>): ParamDeclaration[]
|
|
118
|
+
{
|
|
119
|
+
return params.map((p) =>
|
|
120
|
+
{
|
|
121
|
+
const bestVal = best[p.name] ?? p.value;
|
|
122
|
+
const effectiveSubsteps = p.substeps ?? p.steps;
|
|
123
|
+
|
|
124
|
+
// substeps: 0 — freeze this param at its best value (skip in subsequent passes)
|
|
125
|
+
if (effectiveSubsteps === 0)
|
|
126
|
+
{
|
|
127
|
+
return {...p, value: bestVal, min: bestVal, max: bestVal, range: undefined, steps: 2};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const {min: effMin, max: effMax} = getEffectiveRange(p);
|
|
131
|
+
const gap = (effMax - effMin) / (p.steps - 1);
|
|
132
|
+
const narrowedSteps = effectiveSubsteps;
|
|
133
|
+
|
|
134
|
+
if (p.range != null)
|
|
135
|
+
{
|
|
136
|
+
// Range mode: center on best, shrink range to one gap
|
|
137
|
+
return {...p, value: bestVal, range: gap, steps: narrowedSteps};
|
|
138
|
+
}
|
|
139
|
+
// Fixed min/max mode: narrow bounds around best, clamped to original
|
|
140
|
+
return {
|
|
141
|
+
...p,
|
|
142
|
+
min: Math.max(p.min ?? bestVal, bestVal - gap),
|
|
143
|
+
max: Math.min(p.max ?? bestVal, bestVal + gap),
|
|
144
|
+
steps: narrowedSteps,
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Score a FightResult from one bot's perspective.
|
|
151
|
+
*
|
|
152
|
+
* Returns a continuous score that provides gradient information beyond binary win/loss:
|
|
153
|
+
* - Win: 3.0 base + up to 0.5 HP bonus (higher remaining HP = better)
|
|
154
|
+
* - Draw: 1.0
|
|
155
|
+
* - Loss: 0.0 base + up to 0.5 bonus for low enemy HP (closer fights = better)
|
|
156
|
+
*
|
|
157
|
+
* For a mirrored fight set (bot as wizard-1 AND wizard-2), call this twice
|
|
158
|
+
* and sum the scores for a balanced evaluation.
|
|
159
|
+
*
|
|
160
|
+
* @param result - The fight result to score
|
|
161
|
+
* @returns Continuous score in range [0, 3.5] per match
|
|
162
|
+
*/
|
|
163
|
+
export function scoreFight(result: FightResult): number
|
|
164
|
+
{
|
|
165
|
+
let score = 0;
|
|
166
|
+
|
|
167
|
+
for (const match of result.matches)
|
|
168
|
+
{
|
|
169
|
+
if (match.winner === 'wizard-1')
|
|
170
|
+
{
|
|
171
|
+
// Won: 3.0 base + HP bonus
|
|
172
|
+
score += 3.0 + (match.finalState.health / WIZARD_HEALTH) * 0.5;
|
|
173
|
+
}
|
|
174
|
+
else if (match.winner === 'draw' || match.winner === null)
|
|
175
|
+
{
|
|
176
|
+
score += 1.0;
|
|
177
|
+
}
|
|
178
|
+
else
|
|
179
|
+
{
|
|
180
|
+
// Lost: small bonus for low enemy HP
|
|
181
|
+
const enemyHP = match.finalState.enemies[0]?.health ?? WIZARD_HEALTH;
|
|
182
|
+
score += (1 - enemyHP / WIZARD_HEALTH) * 0.5;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return score;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Score a FightResult from wizard-2's perspective.
|
|
191
|
+
* Same scoring logic as scoreFight but with roles reversed.
|
|
192
|
+
*
|
|
193
|
+
* Use this for tournaments where both sides of a pairing need scoring.
|
|
194
|
+
*/
|
|
195
|
+
export function scoreFightAsWizard2(result: FightResult): number
|
|
196
|
+
{
|
|
197
|
+
let score = 0;
|
|
198
|
+
|
|
199
|
+
for (const match of result.matches)
|
|
200
|
+
{
|
|
201
|
+
if (match.winner === 'wizard-2')
|
|
202
|
+
{
|
|
203
|
+
// Won: 3.0 base + HP bonus (wizard-2's remaining HP)
|
|
204
|
+
const hp = match.finalState.enemies[0]?.health ?? WIZARD_HEALTH;
|
|
205
|
+
score += 3.0 + (hp / WIZARD_HEALTH) * 0.5;
|
|
206
|
+
}
|
|
207
|
+
else if (match.winner === 'draw' || match.winner === null)
|
|
208
|
+
{
|
|
209
|
+
score += 1.0;
|
|
210
|
+
}
|
|
211
|
+
else
|
|
212
|
+
{
|
|
213
|
+
// Lost: small bonus for low wizard-1 HP
|
|
214
|
+
const enemyHP = match.finalState.health;
|
|
215
|
+
score += (1 - enemyHP / WIZARD_HEALTH) * 0.5;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return score;
|
|
220
|
+
}
|