File size: 7,516 Bytes
9af0ddd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f53528
 
 
 
 
 
 
 
 
9af0ddd
 
 
 
 
 
 
9f53528
9af0ddd
 
9f53528
 
 
9af0ddd
 
 
 
 
 
 
 
 
 
 
628afc1
9af0ddd
 
 
 
 
 
 
 
 
 
628afc1
9af0ddd
 
 
628afc1
 
 
 
 
9af0ddd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
/* ============================================================
   DUEL CORE β€” the fairness-critical "run one duel match" primitive, extracted out of
   tools/ailab.js (docs/competitions-and-elo.md Phase 1) so a browser Worker can import it in a
   later stage without pulling in ailab.js's ~1600 lines of CLI/printing/argv/search/leaderboard
   code. Everything below is pure: it reads engine/aiDifficulty.js's DIFFICULTY_OPTIONS table and
   drives tools/selfplay.js's createSelfPlayState/runSelfPlayMatch β€” both already DOM-free β€” and
   this file itself has no browser-global reference anywhere (test/duelCore.test.js pins that).

   tools/ailab.js re-imports these three names (pinnedDuelDials, duelSeed, and runDuelMatch under
   its own existing local name `duelRun`) so every one of its own call sites β€” runDuel,
   runRoundRobinSwapped, runSwissBracket, and (through those) runSwappedDuel/runDuelBrackets/the
   `search --tournament-against` integration/the duel+swiss CLI handlers β€” keeps working exactly
   as before, unmodified. runDuel's own header comment in tools/ailab.js is where the FAIRNESS
   argument for why difficulty/apm/micro must be pinned identical for both sides lives; the two
   comments below explain only what each function itself does.
   ============================================================ */

"use strict";

import { DIFFICULTY_OPTIONS } from "../engine/aiDifficulty.js";
import { hashStr } from "../engine/rng.js";
import { createSelfPlayState, runSelfPlayMatch } from "./selfplay.js";
import { playerScore } from "../engine/victory.js";

// difficulty -> the ONE dial object both sides read (apm/micro, derived from the same
// DIFFICULTY_OPTIONS row labWorld's own --apm real already reads for a single side) β€” see
// tools/ailab.js's DUEL header comment for the FAIRNESS argument this exists to satisfy. Exported
// so a test can read back exactly what a run should have used, rather than trusting the CLI flag
// was honoured.
/**
 * @param {string} difficulty
 * @returns {{ difficulty: string, apm: number|null, micro: boolean }}
 */
export function pinnedDuelDials(difficulty) {
  const opt = DIFFICULTY_OPTIONS.find(o => o.mult === difficulty) || DIFFICULTY_OPTIONS.find(o => o.mult === "medium");
  // Echo back opt.mult, NOT the raw input: apm/micro already fall back to medium's values when
  // `difficulty` doesn't match any DIFFICULTY_OPTIONS row, so the reported difficulty has to fall
  // back with them β€” otherwise a typo'd/unknown --difficulty would run medium's dials but LABEL
  // the result with the garbage string, misleading anyone reading a saved --json duel result about
  // which difficulty actually ran.
  return { difficulty: opt.mult, apm: opt.aiApm, micro: !!opt.aiMicro };
}

// Every duel match's seed is derived from (base seed, world, difficulty, BOTH candidate names,
// replicate) β€” reproducible on its own. The pair's names are SORTED into the hash, so
// duelSeed(base, w, d, a, b, rep) and duelSeed(base, w, d, b, a, rep) draw the identical map:
// runSwappedDuel (tools/ailab.js) exists to isolate seat asymmetry β€” its header says "a
// side-symmetry bug would show up as those two disagreeing" β€” and with an order-dependent seed the
// two halves would differ in seat AND map, so a disagreement would be confounded with ordinary
// seed variance and carry no information at all.
//
// `seedKey` OVERRIDES the name-derived half of that hash, and exists for one caller: an
// evolutionary search (tools/genome.js, docs/ai-evolution-design.md Β§8.1). Names in the hash are
// exactly right when the pair IS the experiment β€” two hand-written candidates, compared to each
// other β€” but wrong the moment names are generated per individual, because then a generation's
// every pairing draws its own map set and "the child scored better than its parent did" is partly
// a statement about map luck rather than about the genome. Passing one fixed key for a whole
// generation (or a whole run) pins every pairing in it to the same maps, so genomes are compared
// on identical ground. Omitted, this is byte-for-byte the seed derivation it has always been.
/**
 * @param {number} base
 * @param {string} world
 * @param {string} difficulty
 * @param {string} aName
 * @param {string} bName
 * @param {number} rep
 * @param {string} [seedKey]   replaces the sorted-names segment; omit for today's exact behaviour.
 * @returns {number}
 */
export function duelSeed(base, world, difficulty, aName, bName, rep, seedKey) {
  const pairPart = seedKey != null ? seedKey : [aName, bName].sort().join("|");
  return hashStr(`${base}:duel:${world}:${difficulty}:${pairPart}:${rep}`);
}

// Run ONE match: candidate A as owner "player", candidate B as owner "ai", both spending from the
// SAME `dials` object (see pinnedDuelDials above / tools/ailab.js's FAIRNESS paragraph). `swapAsym`
// exchanges the map's own asym halves (engine/map.js) for THIS match only. Returns a plain result
// row β€” this exact shape is load-bearing: many functions in tools/ailab.js consume rows shaped
// exactly like this, so every field name here must stay byte-for-byte stable.
/**
 * @param {{ world: string, seed: number,
 *   dials: { difficulty: string, apm: number|null, micro: boolean },
 *   aName: string, aStrategy: string, bName: string, bStrategy: string,
 *   aArchetype?: string|null, bArchetype?: string|null,
 *   minutes?: number, swapAsym?: boolean }} cfg
 * @returns {{
 *   world: string, seed: number, difficulty: string, swapAsym: boolean,
 *   aName: string, aStrategy: string, bName: string, bStrategy: string,
 *   aDifficulty: string, bDifficulty: string,
 *   aApm: number|null, bApm: number|null, aMicro: boolean, bMicro: boolean,
 *   winner: "a"|"b"|"draw", winReason: string|null, time: number,
 *   aScore: number, bScore: number, margin: number,
 * }}
 */
export function runDuelMatch({ world, seed, dials, aName, aStrategy, bName, bStrategy, aArchetype, bArchetype, minutes, swapAsym }) {
  const state = createSelfPlayState({
    planetId: world, seed, swapAsym,
    matchTimeLimit: minutes ? minutes * 60 : undefined,
    // aArchetype/bArchetype (docs/competitions-and-elo.md D3): an INPUT dial only β€” never reflected
    // on the returned row below, whose field set is pinned byte-for-byte by test/duelCore.test.js's
    // own exact-shape test (every tools/ailab.js consumer depends on it not growing a field).
    ai: { ...dials, strategy: bStrategy, archetype: bArchetype },
    playerAi: { ...dials, strategy: aStrategy, archetype: aArchetype },
  });
  const result = runSelfPlayMatch(state, minutes ? { maxSeconds: minutes * 60 + 120 } : undefined);
  const aScore = playerScore(state, "player");
  const bScore = playerScore(state, "ai");
  const winner = !result.over ? "draw" : result.winner === "player" ? "a" : "b";
  return {
    world, seed, difficulty: dials.difficulty, swapAsym: !!swapAsym,
    aName, aStrategy, bName, bStrategy,
    // Read back off the actual controllers, not the input β€” the structural-fairness tests assert
    // on exactly these fields, so a bug that let the two dials diverge would show here.
    aDifficulty: state.playerAi.difficulty, bDifficulty: state.ai.difficulty,
    aApm: state.playerAi.apm, bApm: state.ai.apm, aMicro: state.playerAi.micro, bMicro: state.ai.micro,
    winner, winReason: result.winReason, time: +result.time.toFixed(1),
    aScore: +aScore.toFixed(1), bScore: +bScore.toFixed(1), margin: +(aScore - bScore).toFixed(1),
  };
}