wren/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]>
Wren committed 6bc28c2 Browse files
4 files changed +96 −6
src/arena.ts +21 −2
@@ -46,12 +46,31 @@ export const match = (): MatchState => state;
4646
4747 export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id);
4848
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
4968 /** Sit somebody down in the emptiest team, replacing a bot where it stands. */
5069 export function takeSeat(session: string, name: string): Player | null {
5170 const existing = seats.get(session);
5271 const held = existing ? findPlayer(existing.playerId) : undefined;
5372 if (held) {
54 held.name = name;
73 held.name = freeName(name, held.id);
5574 return held;
5675 }
5776 const humans = { red: 0, blue: 0 };
@@ -61,7 +80,7 @@ export function takeSeat(session: string, name: string): Player | null {
6180 state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot);
6281 if (!seat) return null;
6382 seat.bot = false;
64 seat.name = name;
83 seat.name = freeName(name, seat.id);
6584 seat.think = 0;
6685 seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null };
6786 return seat;
src/match.test.ts +26
@@ -8,6 +8,7 @@ import {
88 DEFUSE_CHECKPOINT,
99 DEFUSE_TIME,
1010 FUSE_TIME,
11 makePlayer,
1112 newMatch,
1213 PLANT_TIME,
1314 ROUND_TIME,
@@ -371,3 +372,28 @@ test("bots play the round: they buy, they take a site and they use their kit", (
371372 expect(state.players.some((p) => p.weapon !== "classic")).toBe(true);
372373 expect(aliveOn(state, "atk").length + aliveOn(state, "def").length).toBeGreaterThan(0);
373374 });
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 = [
6969 "Kit", "Lux", "Mox", "Nell", "Orla", "Pike", "Quill", "Rio", "Sage", "Tove",
7070 ];
7171
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 {
73104 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]!;
75105 return {
76106 id: `bot-${team}-${index}`,
77107 name: `${name}`,
@@ -132,8 +162,10 @@ export function newMatch(seed = Date.now() % 100000): MatchState {
132162 seed,
133163 now: 0,
134164 };
165 const names = dealNames(state);
135166 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]));
137169 startRound(state, true);
138170 say(state, "Round 1 — buy phase");
139171 return state;
src/server.test.ts +14 −1
@@ -6,7 +6,7 @@ import { join } from "node:path";
66 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
77 process.env.PORT = "0";
88 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");
1010 const base = `http://${server.hostname}:${server.port}`;
1111
1212 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 () => {
206206 expect(body).toContain('data-buy="vandal" data-cost="2900"');
207207 expect(body).toContain('id="viewtoggle"');
208208 });
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 });
↑↓ navigate ↵ open esc close

Keyboard shortcuts