diff --git a/docs/agent-fighter.md b/docs/agent-fighter.md index 6ba9abd..fc3b4ed 100644 --- a/docs/agent-fighter.md +++ b/docs/agent-fighter.md @@ -36,6 +36,39 @@ default it fails if either player produces zero controller decisions; use `--min-decisions`, `--min-decisions-per-player`, `--min-total-damage`, and `--min-rounds` for stricter local or CI checks. +## Seeded local-policy decisions + +Pass `--seed ` to seed the local policy when the headless runner starts +the server: + +```bash +bun --cwd examples/agent-fighter headless --seed 42 --matches 3 --duration-ms 10000 +``` + +The JSON summary's `localPolicy` block records the seed and RNG version when +the runner controls the spawned server. The seed changes only the local +policy's randomized close-range and approach choices. It does not seed the +arena, browser, timers, or OpenAI Responses API. If an OpenAI decision is used, +its result is outside this seeded path. + +For the same agent, the same seed and RNG/policy version produce the same local +decision sequence when given the same observation sequence. This does not +promise a repeatable whole match: browser timing, simulation observations, +runtime behavior, and software changes can alter that sequence. Recheck results +when the policy, RNG version, Bun/runtime, or relevant configuration changes. + +`--seed` is rejected together with `--url`, because the runner cannot control +an already-running server. To seed an external server, set the variable before +starting it, then omit `--seed` from the runner command: + +```bash +OPENCONTROLLER_AGENT_FIGHTER_SEED=42 bun --cwd examples/agent-fighter start +bun --cwd examples/agent-fighter headless --url http://127.0.0.1:5173 +``` + +When targeting an external server, the runner summary marks the local policy as +external with an unknown random source and omits a seed it did not set. + Agents start stopped by default. Start, stop, and reset controls live on the controller telemetry page. The OpenAI decision loop is also guarded by `OPENCONTROLLER_OPENAI_ACTIONS_PER_MINUTE` to prevent runaway action volume. diff --git a/examples/agent-fighter/README.md b/examples/agent-fighter/README.md index 73310af..1176c36 100644 --- a/examples/agent-fighter/README.md +++ b/examples/agent-fighter/README.md @@ -43,6 +43,25 @@ catches broken controller connections without depending on combat outcomes. Use `--min-decisions`, `--min-decisions-per-player`, `--min-total-damage`, and `--min-rounds` for stronger local or CI regression checks. +To seed local-policy randomness on a server started by the runner: + +```bash +bun --cwd examples/agent-fighter headless --seed 42 --matches 3 --duration-ms 10000 +``` + +The summary records the seed and RNG version under `localPolicy`. The seed +affects only randomized local-policy decisions, not arena/browser randomness, +timers, or OpenAI Responses API outcomes. For the same agent, seed, RNG/policy +version, and observation sequence, the local decision sequence repeats. This +does not make full matches repeatable because observations and timing can +change. Recheck results after policy, RNG, runtime, or relevant configuration +changes. + +`--seed` requires a runner-spawned server and is rejected with `--url`. For an +external server, set `OPENCONTROLLER_AGENT_FIGHTER_SEED=42` before startup; the +runner will mark that server's RNG as unknown and will not report a seed it did +not control. + Agents start stopped by default. Use the controller telemetry page to start, stop, or reset the duel. The server also enforces `OPENCONTROLLER_OPENAI_ACTIONS_PER_MINUTE` so an active browser cannot accidentally diff --git a/examples/agent-fighter/__tests__/seeded-policy.test.ts b/examples/agent-fighter/__tests__/seeded-policy.test.ts new file mode 100644 index 0000000..58f2be4 --- /dev/null +++ b/examples/agent-fighter/__tests__/seeded-policy.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { + localPolicyRunMetadata, + managementForRunnerSummary, + parseHeadlessArgs, +} from "../headless-options"; +import { + createLocalPolicyRandom, + decideLocally, + localPolicySeedLimit, + parseLocalPolicySeed, +} from "../local-policy"; + +const closeRangeObservation = { + mode: "playing", + me: { hp: 100, x: 200 }, + enemy: { x: 250, attacking: false }, +}; + +describe("seeded Agent Fighter local policy", () => { + test("repeats decisions for a seed and the same observation sequence", () => { + const firstRandom = createLocalPolicyRandom(1234, "player-1"); + const secondRandom = createLocalPolicyRandom(1234, "player-1"); + + const first = Array.from({ length: 20 }, () => + decideLocally("player-1", closeRangeObservation, firstRandom), + ); + const second = Array.from({ length: 20 }, () => + decideLocally("player-1", closeRangeObservation, secondRandom), + ); + + expect(first).toEqual(second); + }); + + test("different seeds can change a randomized close-range choice", () => { + const first = decideLocally( + "player-1", + closeRangeObservation, + createLocalPolicyRandom(0, "player-1"), + ); + const second = decideLocally( + "player-1", + closeRangeObservation, + createLocalPolicyRandom(2, "player-1"), + ); + + expect(first).toBe("heavy"); + expect(second).toBe("light"); + }); + + test("seeded choices do not use or replace global Math.random", () => { + const originalRandom = Math.random; + Math.random = () => { + throw new Error("seeded policy unexpectedly used global Math.random"); + }; + try { + expect( + decideLocally( + "player-1", + closeRangeObservation, + createLocalPolicyRandom(0, "player-1"), + ), + ).toBe("heavy"); + } finally { + Math.random = originalRandom; + } + }); + + test("validates local policy seeds and the runner's control boundary", () => { + expect(parseLocalPolicySeed(undefined)).toBeUndefined(); + expect(parseLocalPolicySeed("0")).toBe(0); + expect(parseLocalPolicySeed(String(localPolicySeedLimit))).toBe( + localPolicySeedLimit, + ); + expect(() => parseLocalPolicySeed("-1")).toThrow("integer between 0"); + expect(() => parseLocalPolicySeed("1.5")).toThrow("integer between 0"); + expect(() => parseLocalPolicySeed("4294967296")).toThrow( + "integer between 0", + ); + + const seededOptions = parseHeadlessArgs(["--seed", "42"]); + expect(seededOptions.seed).toBe(42); + expect(localPolicyRunMetadata(seededOptions)).toMatchObject({ + server: "runner-spawned", + randomSource: "mulberry32-v1", + seed: 42, + }); + const externalOptions = parseHeadlessArgs([ + "--url", + "http://localhost:5173", + ]); + expect(localPolicyRunMetadata(externalOptions)).toMatchObject({ + server: "external", + randomSource: "unknown-external-server", + }); + expect(localPolicyRunMetadata(externalOptions)).not.toHaveProperty("seed"); + expect( + managementForRunnerSummary( + { + localPolicy: { seed: 99, randomSource: "mulberry32-v1" }, + }, + externalOptions, + )?.localPolicy, + ).toEqual({ + server: "external", + randomSource: "unknown-external-server", + seedScope: + "The headless runner did not configure this external server's seed.", + }); + expect(() => + parseHeadlessArgs(["--url", "http://localhost:5173", "--seed", "42"]), + ).toThrow("--seed requires a runner-spawned server"); + }); +}); diff --git a/examples/agent-fighter/headless-options.ts b/examples/agent-fighter/headless-options.ts new file mode 100644 index 0000000..d77d078 --- /dev/null +++ b/examples/agent-fighter/headless-options.ts @@ -0,0 +1,227 @@ +import { localPolicyRngVersion, localPolicySeedLimit } from "./local-policy"; + +export type RunnerOptions = { + durationMs: number; + matches: number; + matchGapMs: number; + minDecisions: number; + minDecisionsPerPlayer: number; + minRounds: number; + minTotalDamage: number; + pollMs: number; + startupTimeoutMs: number; + port: number; + seed?: number; + url?: string; + output?: string; + headed: boolean; + verbose: boolean; +}; + +export type LocalPolicyRunMetadata = { + server: "runner-spawned" | "external"; + randomSource: string; + seedScope: string; + seed?: number; +}; + +export function parseHeadlessArgs(args: string[]): RunnerOptions { + const options: RunnerOptions = { + durationMs: 15_000, + matches: 1, + matchGapMs: 250, + minDecisions: 1, + minDecisionsPerPlayer: 1, + minRounds: 0, + minTotalDamage: 0, + pollMs: 250, + startupTimeoutMs: 6_000, + port: 5173, + headed: false, + verbose: false, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + switch (arg) { + case "--duration-ms": + options.durationMs = readPositiveNumber(args[++index], arg); + break; + case "--matches": + options.matches = readPositiveNumber(args[++index], arg); + break; + case "--match-gap-ms": + options.matchGapMs = readNonNegativeNumber(args[++index], arg); + break; + case "--min-decisions": + options.minDecisions = readNonNegativeNumber(args[++index], arg); + break; + case "--min-decisions-per-player": + options.minDecisionsPerPlayer = readNonNegativeNumber( + args[++index], + arg, + ); + break; + case "--min-rounds": + options.minRounds = readNonNegativeNumber(args[++index], arg); + break; + case "--min-total-damage": + options.minTotalDamage = readNonNegativeNumber(args[++index], arg); + break; + case "--poll-ms": + options.pollMs = readPositiveNumber(args[++index], arg); + break; + case "--startup-timeout-ms": + options.startupTimeoutMs = readPositiveNumber(args[++index], arg); + break; + case "--port": + options.port = readPositiveNumber(args[++index], arg); + break; + case "--seed": + options.seed = readSeed(args[++index], arg); + break; + case "--url": + options.url = readRequiredValue(args[++index], arg); + break; + case "--output": + options.output = readRequiredValue(args[++index], arg); + break; + case "--headed": + options.headed = true; + break; + case "--verbose": + options.verbose = true; + break; + case "--help": + printHeadlessHelp(); + process.exit(0); + break; + default: + throw new Error(`Unknown option: ${arg}`); + } + } + + if (options.url && options.seed !== undefined) { + throw new Error( + "--seed requires a runner-spawned server; remove --url or configure the external server with OPENCONTROLLER_AGENT_FIGHTER_SEED", + ); + } + return options; +} + +export function localPolicyRunMetadata( + options: RunnerOptions, +): LocalPolicyRunMetadata { + if (options.url) { + return { + server: "external", + randomSource: "unknown-external-server", + seedScope: + "Local-policy random branches only; the runner did not configure an external server seed.", + }; + } + return { + server: "runner-spawned", + randomSource: + options.seed === undefined ? "Math.random" : localPolicyRngVersion, + seedScope: + "Local-policy random branches only; does not seed arena/browser randomness or external OpenAI decisions.", + ...(options.seed === undefined ? {} : { seed: options.seed }), + }; +} + +export function managementForRunnerSummary( + management: Record | undefined, + options: RunnerOptions, +): Record | undefined { + if (!management || !options.url) { + return management; + } + + const reportedPolicy = management.localPolicy; + const externalPolicy = + typeof reportedPolicy === "object" && + reportedPolicy !== null && + !Array.isArray(reportedPolicy) + ? { ...(reportedPolicy as Record) } + : {}; + delete externalPolicy.seed; + + return { + ...management, + localPolicy: { + ...externalPolicy, + server: "external", + randomSource: "unknown-external-server", + seedScope: + "The headless runner did not configure this external server's seed.", + }, + }; +} + +export function printHeadlessHelp(): void { + console.log(`OpenController Agent Fighter Headless Runner + +Usage: + bun --cwd examples/agent-fighter headless [options] + +Options: + --duration-ms Match runtime before summarizing (default: 15000) + --matches Number of matches in the series (default: 1) + --match-gap-ms Delay between matches (default: 250) + --min-decisions Fail if total decisions are below count (default: 1) + --min-decisions-per-player + Fail if either player has fewer decisions (default: 1) + --min-rounds Fail if completed rounds are below count (default: 0) + --min-total-damage Fail if total HP damage is below value (default: 0) + --poll-ms Telemetry polling interval (default: 250) + --startup-timeout-ms Time to wait for spawned server (default: 6000) + --port Port for spawned server (default: 5173) + --seed Seed local-policy random choices on a spawned server + --url Use an already-running server instead of spawning + --output Write JSON summary to a file + --headed Show the Chromium window + --verbose Print server and browser diagnostics + --help Show this help +`); +} + +function readSeed(value: string | undefined, option: string): number { + const parsed = Number(readRequiredValue(value, option)); + if ( + !Number.isInteger(parsed) || + parsed < 0 || + parsed > localPolicySeedLimit + ) { + throw new Error( + `${option} must be an integer between 0 and ${localPolicySeedLimit}`, + ); + } + return parsed; +} + +function readPositiveNumber(value: string | undefined, option: string): number { + const parsed = Number(readRequiredValue(value, option)); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${option} must be a positive number`); + } + return Math.round(parsed); +} + +function readNonNegativeNumber( + value: string | undefined, + option: string, +): number { + const parsed = Number(readRequiredValue(value, option)); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`${option} must be zero or a positive number`); + } + return Math.round(parsed); +} + +function readRequiredValue(value: string | undefined, option: string): string { + if (!value || value.startsWith("--")) { + throw new Error(`${option} requires a value`); + } + return value; +} diff --git a/examples/agent-fighter/headless.ts b/examples/agent-fighter/headless.ts index a39eec3..6cd5f39 100644 --- a/examples/agent-fighter/headless.ts +++ b/examples/agent-fighter/headless.ts @@ -1,26 +1,16 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { type Browser, chromium, type Page } from "playwright"; +import { + type LocalPolicyRunMetadata, + localPolicyRunMetadata, + managementForRunnerSummary, + parseHeadlessArgs, + type RunnerOptions, +} from "./headless-options"; type PlayerId = "player-1" | "player-2"; -type RunnerOptions = { - durationMs: number; - matches: number; - matchGapMs: number; - minDecisions: number; - minDecisionsPerPlayer: number; - minRounds: number; - minTotalDamage: number; - pollMs: number; - startupTimeoutMs: number; - port: number; - url?: string; - output?: string; - headed: boolean; - verbose: boolean; -}; - type ArenaSnapshot = { mode?: string; timeRemaining?: number; @@ -85,6 +75,7 @@ type SeriesSummary = { matchDurationMs: number; pollMs: number; startedServer: boolean; + localPolicy: LocalPolicyRunMetadata; totalDurationMs: number; aggregate: { roundsCompleted: number; @@ -100,7 +91,7 @@ type SeriesSummary = { management?: Record; }; -const options = parseArgs(Bun.argv.slice(2)); +const options = parseHeadlessArgs(Bun.argv.slice(2)); const baseUrl = options.url ?? `http://127.0.0.1:${options.port}`; let serverProcess: Bun.Subprocess | undefined; let browser: Browser | undefined; @@ -172,13 +163,15 @@ async function runSeries( `${baseUrl}/telemetry`, ); const finalArena = stoppedTelemetry.arena ?? match.finalArena; + const management = managementForRunnerSummary( + stoppedTelemetry.management, + options, + ); matches.push({ ...match, damage: damageFromArena(finalArena), ...(finalArena ? { finalArena } : {}), - ...(stoppedTelemetry.management - ? { management: stoppedTelemetry.management } - : {}), + ...(management ? { management } : {}), }); if (index + 1 < options.matches && options.matchGapMs > 0) { @@ -189,18 +182,20 @@ async function runSeries( const aggregate = aggregateMatches(matches); const quality = evaluateQuality(aggregate, options); const latest = matches.at(-1); + const management = latest?.management; return { baseUrl, matchCount: options.matches, matchDurationMs: options.durationMs, pollMs: options.pollMs, startedServer: !options.url, + localPolicy: localPolicyRunMetadata(options), totalDurationMs: Date.now() - startedAt, aggregate, quality, matches, ...(latest?.finalArena ? { finalArena: latest.finalArena } : {}), - ...(latest?.management ? { management: latest.management } : {}), + ...(management ? { management } : {}), }; } @@ -237,6 +232,7 @@ async function runMatch( (event) => typeof event.timestamp === "number" && event.timestamp >= startedAt, ); + const management = managementForRunnerSummary(latest.management, options); return { matchIndex, @@ -253,7 +249,7 @@ async function runMatch( bySource: countBySource(events), }, ...(latest.arena ? { finalArena: latest.arena } : {}), - ...(latest.management ? { management: latest.management } : {}), + ...(management ? { management } : {}), }; } @@ -396,6 +392,8 @@ function startServer(options: RunnerOptions): Bun.Subprocess { OPENCONTROLLER_AGENT_TICK_MS: String( Bun.env.OPENCONTROLLER_AGENT_TICK_MS ?? "16", ), + OPENCONTROLLER_AGENT_FIGHTER_SEED: + options.seed === undefined ? "" : String(options.seed), }, }); drainStream(proc.stdout, "server", options.verbose); @@ -520,134 +518,6 @@ function normalizeWinner(value: unknown): PlayerId | null { return value === "player-1" || value === "player-2" ? value : null; } -function parseArgs(args: string[]): RunnerOptions { - const options: RunnerOptions = { - durationMs: 15_000, - matches: 1, - matchGapMs: 250, - minDecisions: 1, - minDecisionsPerPlayer: 1, - minRounds: 0, - minTotalDamage: 0, - pollMs: 250, - startupTimeoutMs: 6_000, - port: 5173, - headed: false, - verbose: false, - }; - - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - switch (arg) { - case "--duration-ms": - options.durationMs = readPositiveNumber(args[++index], arg); - break; - case "--matches": - options.matches = readPositiveNumber(args[++index], arg); - break; - case "--match-gap-ms": - options.matchGapMs = readNonNegativeNumber(args[++index], arg); - break; - case "--min-decisions": - options.minDecisions = readNonNegativeNumber(args[++index], arg); - break; - case "--min-decisions-per-player": - options.minDecisionsPerPlayer = readNonNegativeNumber( - args[++index], - arg, - ); - break; - case "--min-rounds": - options.minRounds = readNonNegativeNumber(args[++index], arg); - break; - case "--min-total-damage": - options.minTotalDamage = readNonNegativeNumber(args[++index], arg); - break; - case "--poll-ms": - options.pollMs = readPositiveNumber(args[++index], arg); - break; - case "--startup-timeout-ms": - options.startupTimeoutMs = readPositiveNumber(args[++index], arg); - break; - case "--port": - options.port = readPositiveNumber(args[++index], arg); - break; - case "--url": - options.url = readRequiredValue(args[++index], arg); - break; - case "--output": - options.output = readRequiredValue(args[++index], arg); - break; - case "--headed": - options.headed = true; - break; - case "--verbose": - options.verbose = true; - break; - case "--help": - printHelp(); - process.exit(0); - break; - default: - throw new Error(`Unknown option: ${arg}`); - } - } - - return options; -} - -function readPositiveNumber(value: string | undefined, option: string): number { - const parsed = Number(readRequiredValue(value, option)); - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error(`${option} must be a positive number`); - } - return Math.round(parsed); -} - -function readNonNegativeNumber( - value: string | undefined, - option: string, -): number { - const parsed = Number(readRequiredValue(value, option)); - if (!Number.isFinite(parsed) || parsed < 0) { - throw new Error(`${option} must be zero or a positive number`); - } - return Math.round(parsed); -} - -function readRequiredValue(value: string | undefined, option: string): string { - if (!value || value.startsWith("--")) { - throw new Error(`${option} requires a value`); - } - return value; -} - -function printHelp(): void { - console.log(`OpenController Agent Fighter Headless Runner - -Usage: - bun --cwd examples/agent-fighter headless [options] - -Options: - --duration-ms Match runtime before summarizing (default: 15000) - --matches Number of matches in the series (default: 1) - --match-gap-ms Delay between matches (default: 250) - --min-decisions Fail if total decisions are below count (default: 1) - --min-decisions-per-player - Fail if either player has fewer decisions (default: 1) - --min-rounds Fail if completed rounds are below count (default: 0) - --min-total-damage Fail if total HP damage is below value (default: 0) - --poll-ms Telemetry polling interval (default: 250) - --startup-timeout-ms Time to wait for spawned server (default: 6000) - --port Port for spawned server (default: 5173) - --url Use an already-running server instead of spawning - --output Write JSON summary to a file - --headed Show the Chromium window - --verbose Print server and browser diagnostics - --help Show this help -`); -} - function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/examples/agent-fighter/local-policy.ts b/examples/agent-fighter/local-policy.ts new file mode 100644 index 0000000..6a2bc5a --- /dev/null +++ b/examples/agent-fighter/local-policy.ts @@ -0,0 +1,94 @@ +export type PlayerId = "player-1" | "player-2"; + +export type FighterAction = + | "advance" + | "retreat" + | "jump" + | "light" + | "heavy" + | "block" + | "dash" + | "special" + | "neutral"; + +export type LocalPolicyObservation = { + mode: string; + me?: { hp: number; x: number } | undefined; + enemy?: { x: number; attacking: boolean } | undefined; +}; + +export type PolicyRandom = () => number; + +export const localPolicyRngVersion = "mulberry32-v1" as const; +export const localPolicySeedLimit = 0xffff_ffff; + +/** Create a local-policy random stream without modifying global Math.random. */ +export function createLocalPolicyRandom( + seed: number | undefined, + streamId: string, +): PolicyRandom { + if (seed === undefined) { + return Math.random; + } + + let state = (seed ^ hashString(streamId)) >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 0x1_0000_0000; + }; +} + +export function parseLocalPolicySeed( + value: string | undefined, +): number | undefined { + if (value === undefined || value === "") { + return undefined; + } + const seed = Number(value); + if (!Number.isInteger(seed) || seed < 0 || seed > localPolicySeedLimit) { + throw new Error( + `Local policy seed must be an integer between 0 and ${localPolicySeedLimit}`, + ); + } + return seed; +} + +export function decideLocally( + playerId: PlayerId, + observation: LocalPolicyObservation, + random: PolicyRandom, +): FighterAction { + const me = observation.me; + const enemy = observation.enemy; + if (!me || !enemy || observation.mode !== "playing") { + return "neutral"; + } + const distance = Math.abs(enemy.x - me.x); + if (me.hp < 28 && distance < 110 && enemy.attacking) { + return "block"; + } + if (distance > 430) { + return "special"; + } + if (distance > 150) { + return playerId === "player-1" ? "advance" : "dash"; + } + if (enemy.attacking && distance < 120) { + return "retreat"; + } + if (distance < 76) { + return random() > 0.62 ? "heavy" : "light"; + } + return random() > 0.72 ? "jump" : "advance"; +} + +function hashString(value: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash = Math.imul(hash ^ value.charCodeAt(index), 0x01000193); + } + return hash >>> 0; +} diff --git a/examples/agent-fighter/server.ts b/examples/agent-fighter/server.ts index 3b54f52..d40f63e 100644 --- a/examples/agent-fighter/server.ts +++ b/examples/agent-fighter/server.ts @@ -14,8 +14,16 @@ import { resolveProfile, } from "@opencontroller/core"; import type { ServerWebSocket } from "bun"; +import { + createLocalPolicyRandom, + decideLocally, + type FighterAction, + localPolicyRngVersion, + type PlayerId, + type PolicyRandom, + parseLocalPolicySeed, +} from "./local-policy"; -type PlayerId = "player-1" | "player-2"; type ClientKind = "game" | "controller"; type SocketData = { kind: ClientKind; @@ -51,16 +59,6 @@ type ArenaSnapshot = { }>; projectiles: Array<{ ownerId: PlayerId; x: number; y: number; vx: number }>; }; -type FighterAction = - | "advance" - | "retreat" - | "jump" - | "light" - | "heavy" - | "block" - | "dash" - | "special" - | "neutral"; type AgentRuntime = { id: PlayerId; controller: Controller; @@ -96,6 +94,11 @@ type ManagementState = { openAiDecisionMs: number; agentTickMs: number; openAiEnabled: boolean; + localPolicy: { + randomSource: string; + seedScope: string; + seed?: number; + }; }; type ArenaSnapshotRow = { value: string }; type RecentAgentEventRow = { @@ -114,6 +117,13 @@ type RecentAgentEventRow = { }; const playerIds = ["player-1", "player-2"] as const; +const localPolicySeed = parseLocalPolicySeed( + Bun.env.OPENCONTROLLER_AGENT_FIGHTER_SEED, +); +const localPolicyRandoms: Record = { + "player-1": createLocalPolicyRandom(localPolicySeed, "player-1"), + "player-2": createLocalPolicyRandom(localPolicySeed, "player-2"), +}; const publicDir = join(import.meta.dir, "src"); const dataDir = join(import.meta.dir, "data"); mkdirSync(dataDir, { recursive: true }); @@ -231,6 +241,12 @@ const managementState: ManagementState = { openAiDecisionMs, agentTickMs, openAiEnabled: Boolean(Bun.env.OPENAI_API_KEY), + localPolicy: { + randomSource: + localPolicySeed === undefined ? "Math.random" : localPolicyRngVersion, + seedScope: "local-policy-random-branches-only", + ...(localPolicySeed === undefined ? {} : { seed: localPolicySeed }), + }, }; for (const playerId of playerIds) { @@ -1092,13 +1108,23 @@ async function decideAndAct(agent: AgentRuntime): Promise { agent.history, ); } else { - decision = decideLocallyWithRationale(agent.id, observation, "local"); + decision = decideLocallyWithRationale( + agent.id, + observation, + "local", + localPolicyRandoms[agent.id], + ); } if (!decision && Bun.env.OPENAI_API_KEY) { return; } - decision ??= decideLocallyWithRationale(agent.id, observation, "local"); + decision ??= decideLocallyWithRationale( + agent.id, + observation, + "local", + localPolicyRandoms[agent.id], + ); const controls = commandsForAction(agent.id, decision.action, observation); recordAgentDecision({ ...decision, @@ -1260,41 +1286,13 @@ function extractOutputText(payload: unknown): string { return chunks.join(""); } -function decideLocally( - playerId: PlayerId, - observation: ReturnType, -): FighterAction { - const me = observation.me; - const enemy = observation.enemy; - if (!me || !enemy || observation.mode !== "playing") { - return "neutral"; - } - const distance = Math.abs(enemy.x - me.x); - const enemyAttacking = enemy.attacking; - if (me.hp < 28 && distance < 110 && enemyAttacking) { - return "block"; - } - if (distance > 430) { - return "special"; - } - if (distance > 150) { - return playerId === "player-1" ? "advance" : "dash"; - } - if (enemyAttacking && distance < 120) { - return "retreat"; - } - if (distance < 76) { - return Math.random() > 0.62 ? "heavy" : "light"; - } - return Math.random() > 0.72 ? "jump" : "advance"; -} - function decideLocallyWithRationale( playerId: PlayerId, observation: ReturnType, source: DecisionSource, + random: PolicyRandom, ): DecisionResult { - const action = decideLocally(playerId, observation); + const action = decideLocally(playerId, observation, random); return { action, rationale: localRationale(action, observation, source), diff --git a/examples/agent-fighter/tsconfig.json b/examples/agent-fighter/tsconfig.json index de8b3dc..461fa65 100644 --- a/examples/agent-fighter/tsconfig.json +++ b/examples/agent-fighter/tsconfig.json @@ -5,7 +5,12 @@ "outDir": "dist", "composite": true }, - "include": ["server.ts", "headless.ts"], + "include": [ + "server.ts", + "headless.ts", + "headless-options.ts", + "local-policy.ts" + ], "references": [ { "path": "../../packages/core" diff --git a/package.json b/package.json index eac70cf..d200b11 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "clean": "rm -rf packages/*/dist packages/*/tsconfig.tsbuildinfo examples/*/dist examples/*/tsconfig.tsbuildinfo .turbo output", "dev:fighter": "bun --cwd examples/agent-fighter dev", "dev:overlay": "bun --cwd examples/obs-overlay dev", - "test": "bun test packages/core/src/__tests__ packages/cli/src/__tests__ packages/native/src/__tests__ packages/native-linux-uinput/src/__tests__ packages/native-windows-virtual-gamepad/src/__tests__ packages/native-macos-driverkit/src/__tests__", + "test": "bun test packages/core/src/__tests__ packages/cli/src/__tests__ packages/native/src/__tests__ packages/native-linux-uinput/src/__tests__ packages/native-windows-virtual-gamepad/src/__tests__ packages/native-macos-driverkit/src/__tests__ examples/agent-fighter/__tests__", "check:dry-run-examples": "bun run examples/basic-dry-run/check.ts", "typecheck": "tsc -b", "pack:check": "node scripts/check-npm-packages.mjs",