pike/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Deal bot names without replacement, so nobody reads as two people
Every seat drew its name independently — (spacing + uniform(0..19)) mod 20, which is uniform whatever the spacing — so ten seats out of twenty names collided in most matches. That is a birthday problem, not a spacing bug. The names are now shuffled once with the match seed and dealt, which cannot collide; a seat made on its own falls back to its own slot, which is distinct across a roster too. A thousand rosters in the test, no duplicate. It matters here because this pull put a kill feed in front of it: the round loop writes "Kit killed Kit" by name, and two different Kits read as a team kill this game cannot have. A person picking a name somebody already holds gets a number after it, inside the sixteen characters a name is allowed. Co-Authored-By: Claude Opus 5 <[email protected]>
4 files
changed +96
−6
src/arena.ts +21 −2
| @@ -46,12 +46,31 @@ export const match = (): MatchState => state; | ||
| 46 | 46 | |
| 47 | 47 | export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id); |
| 48 | 48 | |
| 49 | /** | |
| 50 | * A name nobody else in this match is using. | |
| 51 | * | |
| 52 | * The kill feed, the round log and the scoreboard all address people by | |
| 53 | * name: two players called Kit make a line that reads as a team kill this | |
| 54 | * game cannot have. The bots are dealt distinct names; a person who picks | |
| 55 | * one that is taken gets a number after it, inside the sixteen characters a | |
| 56 | * name is allowed. | |
| 57 | */ | |
| 58 | function freeName(want: string, exceptId: string): string { | |
| 59 | const taken = new Set(state.players.filter((p) => p.id !== exceptId).map((p) => p.name)); | |
| 60 | if (!taken.has(want)) return want; | |
| 61 | for (let n = 2; n < 100; n++) { | |
| 62 | const tried = `${want.slice(0, 13)} ${n}`; | |
| 63 | if (!taken.has(tried)) return tried; | |
| 64 | } | |
| 65 | return want; | |
| 66 | } | |
| 67 | ||
| 49 | 68 | /** Sit somebody down in the emptiest team, replacing a bot where it stands. */ |
| 50 | 69 | export function takeSeat(session: string, name: string): Player | null { |
| 51 | 70 | const existing = seats.get(session); |
| 52 | 71 | const held = existing ? findPlayer(existing.playerId) : undefined; |
| 53 | 72 | if (held) { |
| 54 | held.name = name; | |
| 73 | held.name = freeName(name, held.id); | |
| 55 | 74 | return held; |
| 56 | 75 | } |
| 57 | 76 | const humans = { red: 0, blue: 0 }; |
| @@ -61,7 +80,7 @@ export function takeSeat(session: string, name: string): Player | null { | ||
| 61 | 80 | state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot); |
| 62 | 81 | if (!seat) return null; |
| 63 | 82 | seat.bot = false; |
| 64 | seat.name = name; | |
| 83 | seat.name = freeName(name, seat.id); | |
| 65 | 84 | seat.think = 0; |
| 66 | 85 | seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null }; |
| 67 | 86 | return seat; |
src/match.test.ts +26
| @@ -8,6 +8,7 @@ import { | ||
| 8 | 8 | DEFUSE_CHECKPOINT, |
| 9 | 9 | DEFUSE_TIME, |
| 10 | 10 | FUSE_TIME, |
| 11 | makePlayer, | |
| 11 | 12 | newMatch, |
| 12 | 13 | PLANT_TIME, |
| 13 | 14 | ROUND_TIME, |
| @@ -371,3 +372,28 @@ test("bots play the round: they buy, they take a site and they use their kit", ( | ||
| 371 | 372 | expect(state.players.some((p) => p.weapon !== "classic")).toBe(true); |
| 372 | 373 | expect(aliveOn(state, "atk").length + aliveOn(state, "def").length).toBeGreaterThan(0); |
| 373 | 374 | }); |
| 375 | ||
| 376 | ||
| 377 | test("no match ever has two players with the same name", () => { | |
| 378 | // The kill feed, the round log and the scoreboard all address people by | |
| 379 | // name; two Kits read as a team kill this game cannot have. A thousand | |
| 380 | // rosters, no collision. | |
| 381 | for (let seed = 0; seed < 1000; seed++) { | |
| 382 | const names = newMatch(seed).players.map((p) => p.name); | |
| 383 | expect(names.length).toBe(10); | |
| 384 | expect(new Set(names).size).toBe(10); | |
| 385 | } | |
| 386 | }); | |
| 387 | ||
| 388 | test("a seat made on its own still gets a name of its own", () => { | |
| 389 | const state = newMatch(1234); | |
| 390 | const names = new Set<string>(); | |
| 391 | for (const team of ["red", "blue"] as const) | |
| 392 | for (let i = 0; i < 5; i++) names.add(makePlayer(state, team, i).name); | |
| 393 | expect(names.size).toBe(10); | |
| 394 | }); | |
| 395 | ||
| 396 | test("the deal is the seeded shuffle, so the same match replays the same", () => { | |
| 397 | expect(newMatch(77).players.map((p) => p.name)).toEqual(newMatch(77).players.map((p) => p.name)); | |
| 398 | expect(newMatch(77).players.map((p) => p.name)).not.toEqual(newMatch(78).players.map((p) => p.name)); | |
| 399 | }); |
src/match.ts +35 −3
| @@ -69,9 +69,39 @@ const BOT_NAMES = [ | ||
| 69 | 69 | "Kit", "Lux", "Mox", "Nell", "Orla", "Pike", "Quill", "Rio", "Sage", "Tove", |
| 70 | 70 | ]; |
| 71 | 71 | |
| 72 | export function makePlayer(state: MatchState, team: Team, index: number): Player { | |
| 72 | /** | |
| 73 | * Twenty names, shuffled, dealt to the ten seats — drawn without replacement. | |
| 74 | * | |
| 75 | * A name is how a person is addressed: in the kill feed, in the round log, on | |
| 76 | * the scoreboard. Two bots called Kit make "Kit killed Kit", which reads as a | |
| 77 | * team kill that this game cannot even have. Picking each name independently | |
| 78 | * collided in most matches — a uniform pick over twenty names for ten seats | |
| 79 | * is a birthday problem, and no arithmetic spacing survives adding a uniform | |
| 80 | * term to it. Shuffling once and dealing cannot collide at all. | |
| 81 | */ | |
| 82 | export function dealNames(state: MatchState, count = TEAMS.length * ROSTER): string[] { | |
| 83 | const bag = BOT_NAMES.slice(); | |
| 84 | for (let i = bag.length - 1; i > 0; i--) { | |
| 85 | const j = Math.floor(random(state) * (i + 1)); | |
| 86 | const swap = bag[i]!; | |
| 87 | bag[i] = bag[j]!; | |
| 88 | bag[j] = swap; | |
| 89 | } | |
| 90 | return bag.slice(0, Math.min(count, bag.length)); | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * One seat. The name is dealt by the caller so a whole roster is drawn | |
| 95 | * without replacement; on its own it falls back to the seat's own slot, | |
| 96 | * which is distinct for every seat in a match too. | |
| 97 | */ | |
| 98 | export function makePlayer( | |
| 99 | state: MatchState, | |
| 100 | team: Team, | |
| 101 | index: number, | |
| 102 | name = BOT_NAMES[(TEAMS.indexOf(team) * ROSTER + index) % BOT_NAMES.length]!, | |
| 103 | ): Player { | |
| 73 | 104 | const agent = ["ember", "veil", "sable", "mender", "rook"][index % 5]!; |
| 74 | const name = BOT_NAMES[(TEAMS.indexOf(team) * 7 + index * 3 + Math.floor(random(state) * 20)) % BOT_NAMES.length]!; | |
| 75 | 105 | return { |
| 76 | 106 | id: `bot-${team}-${index}`, |
| 77 | 107 | name: `${name}`, |
| @@ -132,8 +162,10 @@ export function newMatch(seed = Date.now() % 100000): MatchState { | ||
| 132 | 162 | seed, |
| 133 | 163 | now: 0, |
| 134 | 164 | }; |
| 165 | const names = dealNames(state); | |
| 135 | 166 | for (const team of TEAMS) |
| 136 | for (let i = 0; i < ROSTER; i++) state.players.push(makePlayer(state, team, i)); | |
| 167 | for (let i = 0; i < ROSTER; i++) | |
| 168 | state.players.push(makePlayer(state, team, i, names[state.players.length])); | |
| 137 | 169 | startRound(state, true); |
| 138 | 170 | say(state, "Round 1 — buy phase"); |
| 139 | 171 | return state; |
src/server.test.ts +14 −1
| @@ -6,7 +6,7 @@ import { join } from "node:path"; | ||
| 6 | 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 7 | 7 | process.env.PORT = "0"; |
| 8 | 8 | const { server } = await import("./server.ts"); |
| 9 | const { match, snapshot, advance } = await import("./arena.ts"); | |
| 9 | const { match, snapshot, advance, takeSeat } = await import("./arena.ts"); | |
| 10 | 10 | const base = `http://${server.hostname}:${server.port}`; |
| 11 | 11 | |
| 12 | 12 | test("serves a live match on the first paint, with no script needed", async () => { |
| @@ -206,3 +206,16 @@ test("the page carries the first-run card and the prompt line", async () => { | ||
| 206 | 206 | expect(body).toContain('data-buy="vandal" data-cost="2900"'); |
| 207 | 207 | expect(body).toContain('id="viewtoggle"'); |
| 208 | 208 | }); |
| 209 | ||
| 210 | test("two people who pick the same name do not end up with the same name", () => { | |
| 211 | // Everything that addresses a player — the kill feed, the round log, the | |
| 212 | // scoreboard — does it by name, so the match may never hold two of one. | |
| 213 | const first = takeSeat("clash-a", "Twin")!; | |
| 214 | const second = takeSeat("clash-b", "Twin")!; | |
| 215 | expect(first.name).toBe("Twin"); | |
| 216 | expect(second.name).not.toBe("Twin"); | |
| 217 | expect(second.name.startsWith("Twin")).toBe(true); | |
| 218 | expect(second.name.length).toBeLessThanOrEqual(16); | |
| 219 | const names = match().players.map((p) => p.name); | |
| 220 | expect(new Set(names).size).toBe(names.length); | |
| 221 | }); |