blockyard 0.1.1 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -6,7 +6,7 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.1.1] — 2026-09-17
9
+ ## [0.1.2] — 2026-09-17
10
10
 
11
11
  A day of hardening, and a new game. The headline is the **third AI security audit**, run on
12
12
  2026-09-16 over the whole codebase by four auditors in parallel, and every one of its findings
@@ -18,9 +18,10 @@ implementation; property tests over Bitcoin Core's file formats found and fixed
18
18
  reading undo data. The **skies** were made clear: every board picks the Galaxy or the Earth sky in
19
19
  one place, and the Earth sky gained a warm sun with a halo, a moon that glows through its real
20
20
  phases, and softer clouds. And under Diversions, **Scorched Yard**: Scorched Earth on the block
21
- engine, with the manual's weapons and computer players, up to eight tanks, a cheat mode that draws
21
+ engine, with the manual's weapons and computer players who each wear their own colour and aim like
22
+ people, up to eight tanks, a cheat mode that draws
22
23
  the firing solution, and wind that is a real fluid simulation flowing over the hills. The games now
23
- load only when their page is first opened. 1,068 tests, from 945.
24
+ load only when their page is first opened. 1,070 tests, from 945.
24
25
 
25
26
  ### Upgrading from 0.1.0
26
27
 
@@ -73,6 +74,9 @@ load only when their page is first opened. 1,068 tests, from 945.
73
74
 
74
75
  ### Scorched Yard
75
76
 
77
+ - **Fixed: Scorched Yard's Shooter and Chooser were far too accurate.** Both fired the exact shot the physics search found, with no error: over 200 simulated games the Shooter hit with 60% of its first shots and the Chooser with 95%, as often as the Spoiler the manual calls nearly perfect. Every personality that searches now aims the way a person does, off by an error that grows with the distance and shrinks with each shot at the same target. First-shot hit rates now: Moron and Tosser about 5%, Shooter and Poolshark about 23%, Chooser about 43%, Spoiler about 84%, Cyborg about 76%, and the Shooter closes from 23% to 32% by its third shot. A test holds the order and the bands
78
+ - **Scorched Yard: each personality has its own colour.** A tank took its colour from its seat, so the first opponent was blue and the second green whatever played there. Now You are orange, the Moron yellow, the Shooter blue, the Poolshark cyan, the Tosser green, the Chooser purple, the Spoiler red, the Cyborg pink and the Unknown grey (which gives nothing away about the personality it draws), and a second or third of one kind is a lighter or darker shade. The scoreboard's dot follows the tank's colour too; it had been a colour per seat, so a red Spoiler was listed with a blue dot
79
+ - **Scorched Yard's opponents are shuffled every game.** With the shipped mix, the seats were filled from a fixed list in order, so two opponents were a Shooter and a Tosser in every game. Each new game now deals the six personalities (Shooter, Tosser, Chooser, Spoiler, Cyborg, Poolshark) in a fresh order: two opponents are a different pair each time, up to six are all different kinds, and a seventh starts a new deal. Naming one kind in *Their kind* still fills every seat with it
76
80
  - **Scorched Yard: the rest of the control plan.** *Correcting*: click an enemy tank to mark it; after your shot the panel says how it did against it (*10 over*, *6 short*, *close*, *hit*) and **C** sets the power that miss implies, from your own last shot. *Weapons*: **W** opens a grid of what you own, biggest blast first, with counts and keys; **1–9** pick in the shop's order; **Q** picks your last weapon; the item pills are buttons. *The aim guide*: a ghost of the first fifth of the flight while you aim, or the whole flight, or off. *The fire button* names what it will send, and the last of a weapon sold one at a time asks once before it goes. A press on your own tank turns the barrel without touching the power. The keys are a table. Three new settings: Aim guide, Correction helper, Confirm the last of a weapon
77
81
  - **Scorched Yard: the old wind code is gone.** The four earlier wind pictures (marching particles, dashed currents, a plasma wash, bowing bands), the smoke dye the fluid first carried, and the air clock only they used are deleted, with the tests that covered only them: the wind module drops from about 400 lines to 69, and the air module loses its dye grid. What is drawn is unchanged — the simulated air, its flow lines, and the sky's faint ripple
78
82
  - **Fixed: Scorched Yard's computer players seemed to aim at tanks already destroyed.** A computer player chose its shot only when its thinking pause ran out, so for the whole pause its gauge (and, in cheat mode, its trajectory) still showed its previous shot, usually aimed at the tank it had just destroyed. It now plans the moment its turn begins, against the tanks alive then, and its barrel and power ease from the old aim to the new one while it thinks. Cheat mode draws only your own firing solution. And the random shot that the Moron fires, and that the Shooter and Poolshark fall back on, heads toward a living enemy instead of the middle of the field, which had kept shelling the empty half once the tanks there were gone: in 4,357 simulated shots none now fires away from every tank still standing, against 20 before
package/README.md CHANGED
@@ -47,7 +47,7 @@ top of the node's own ~875 GB of block files.
47
47
  ```bash
48
48
  git clone https://github.com/BobClawblaw/blockyard.git
49
49
  cd blockyard
50
- npm test # optional: 1068 unit tests, all built in
50
+ npm test # optional: 1070 unit tests, all built in
51
51
  npm run setup # reads the node's bitcoin.conf, checks the node, writes config/local.json
52
52
  npm start # builds the address index in the background (a few hours); open https://127.0.0.1:21000
53
53
  # and sign in as admin with the password the first start prints once
@@ -136,7 +136,7 @@ Details in [docs/SECURITY.md](docs/SECURITY.md). To report a vulnerability, see
136
136
  npm run dev # fake node doing a simulated sync, port 18088
137
137
  npm run setup # interactive install: read bitcoin.conf, check the node, write config/local.json
138
138
  npm run check # the same checks (every call timed) against every configured node; exits 1 on a FAIL
139
- npm test # 1068 unit tests (node:test, no dependencies)
139
+ npm test # 1070 unit tests (node:test, no dependencies)
140
140
  npm run smoke # boots the real server and checks the HTTP contract
141
141
  npm run counts:fix # keep the documented test count in step with the suite
142
142
  ```
@@ -146,7 +146,7 @@ RPC etiquette) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for how it fits
146
146
 
147
147
  ## Status
148
148
 
149
- Version **0.1.1** (2026-09-17) — a hardening release after 0.1.0: the third security audit with
149
+ Version **0.1.2** (2026-09-17) — a hardening release after 0.1.0: the third security audit with
150
150
  every finding fixed, a resumable address index build, and Scorched Yard. Pre-release software: the
151
151
  word is meant literally. Releases are published on [npm](https://www.npmjs.com/package/blockyard)
152
152
  as `blockyard`, as [GitHub releases](https://github.com/BobClawblaw/blockyard/releases),
@@ -157,7 +157,7 @@ It ships **hardened**: bound to this machine, sign-in on, HTTPS with a certifica
157
157
  and **zero telemetry** — no outbound connection to anyone but your node until you tick the market
158
158
  polling switch yourself.
159
159
  The test suite is
160
- comprehensive (1068 tests, plus a live smoke run), the monitoring side is solid, and the
160
+ comprehensive (1070 tests, plus a live smoke run), the monitoring side is solid, and the
161
161
  explorer's biggest gap is closed: **address history and balances**, which Bitcoin Core cannot
162
162
  answer at any setting, now come from an **address index BlockYard builds itself** from the
163
163
  node's block and undo files and keeps current as blocks arrive. It is checked against the node
@@ -165,7 +165,7 @@ it is not echoed.
165
165
  npm start
166
166
  ```
167
167
 
168
- The log says `BlockYard 0.1.1 listening on https://127.0.0.1:21000` (its own self-signed
168
+ The log says `BlockYard 0.1.2 listening on https://127.0.0.1:21000` (its own self-signed
169
169
  certificate, made on this first start; the browser warns once and remembers it), then `created the
170
170
  first admin account (admin)` with a generated password **shown once** — copy it, or set
171
171
  `BLOCKYARD_ADMIN_PASSWORD` before the first start to choose it — then `address index: building
@@ -724,7 +724,8 @@ Scorched Earth, the 1991 DOS artillery game, on the block engine: a landscape of
724
724
  craters and falls, tanks that aim by angle and power, wind, and a round that ends when one tank is
725
725
  left. Under **Diversions**, after Blockanoid. You play against two computer opponents out of the
726
726
  box (**Display settings → Scorched Yard → Computer players** fields up to seven, for the original's
727
- eight seats; every tank has its own colour).
727
+ eight seats). Every personality has its own colour, so you can see who you are up against; a second
728
+ or third of one kind is a lighter or darker shade of it.
728
729
 
729
730
  The rules are the original's (docs/PLAN-SCORCHED-YARD.md is the whole plan): the manual's roster
730
731
  of thirty-three weapons and eleven accessories at the manual's prices, packs and blast radii,
@@ -786,21 +787,28 @@ and 5% interest, which the settings allow.
786
787
 
787
788
  ![Scorched Yard](images/scorched.jpg)
788
789
 
789
- Out of the box you face a **mix**, climbing from the easy ones: a Shooter and a Tosser, then a
790
- Chooser, a Spoiler, a Cyborg and a Poolshark as you add seats. **Display settings Scorched Yard
790
+ Out of the box you face a **mix**: every new game deals a Shooter, a Tosser, a Chooser, a Spoiler,
791
+ a Cyborg and a Poolshark in a fresh order, so two opponents are a different pair each time, and up
792
+ to six opponents are all different kinds (a seventh starts a new deal). **Display settings → Scorched Yard
791
793
  → Their kind** fields one kind in every seat instead. They are the manual's eight, and they
792
794
  behave as it says:
793
795
 
794
- | kind | how it plays |
795
- |---|---|
796
- | Moron | picks an angle and a power, and shoots; buys at random |
797
- | Shooter | takes low, straight shots when it has a line of fire, and shoots like a Moron when it does not |
798
- | Poolshark | a Shooter, except that under rubber or spring walls it looks for the bank shot too |
799
- | Tosser | lobs high, then corrects its power from each miss, by halves, until it hits |
800
- | Chooser | tries the straight shot, the lob and the bank, and takes whichever lands closest |
801
- | Spoiler | works the shot out from the wind and gravity and is nearly perfect, with a wobble now and then |
802
- | Cyborg | a Spoiler with a grudge: it fires at whoever hit it last, else at the weakest, else at the leader |
803
- | Unknown | one of the others, drawn afresh each round and never announced |
796
+ | kind | colour | how it plays |
797
+ |---|---|---|
798
+ | Moron | yellow | picks an angle and a power, and shoots; buys at random |
799
+ | Shooter | blue | takes low, straight shots when it has a line of fire, and shoots like a Moron when it does not; its aim is rough at first and closes in on the same target |
800
+ | Poolshark | cyan | a Shooter, except that under rubber or spring walls it looks for the bank shot too |
801
+ | Tosser | green | lobs high, then corrects its power from each miss, by halves, until it hits |
802
+ | Chooser | purple | tries the straight shot, the lob and the bank, and takes whichever lands closest; a steadier hand than the Shooter's, not a perfect one |
803
+ | Spoiler | red | works the shot out from the wind and gravity and is nearly perfect |
804
+ | Cyborg | pink | a Spoiler with a grudge: it fires at whoever hit it last, else at the weakest, else at the leader |
805
+ | Unknown | grey | one of the others, drawn afresh each round and never announced |
806
+
807
+ How often each lands its first shot at a target, measured over hundreds of simulated games: the
808
+ Moron and the Tosser about one in twenty, the Shooter and the Poolshark about one in four, the
809
+ Chooser about two in five, and the Spoiler and the Cyborg about four in five. The Shooter, the
810
+ Poolshark, the Chooser, the Spoiler and the Cyborg aim better with each shot at the same target and
811
+ start afresh each round; the Tosser corrects in its own way. You are orange.
804
812
 
805
813
  All but the Moron use a battery when hurt and raise a shield before firing, and each shops to its
806
814
  own taste between rounds: Shooters buy missiles, Tossers MIRVs and Funky Bombs, Cyborgs the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blockyard",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Live web monitor, 3D block-space viewer, block explorer and markets view for a Bitcoin Core node",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -2001,8 +2001,8 @@ table.sykeys td { padding: 2px 0; }
2001
2001
  padding: 4px 9px; border-radius: 10px; background: rgba(14, 16, 22, .88); border: 1px solid var(--line); color: var(--fg);
2002
2002
  font-size: 12px; white-space: nowrap; pointer-events: none; }
2003
2003
  .sytalk.hidden { display: none; }
2004
- .sydot-0 { background: #f7931a; } .sydot-1 { background: #4d8dff; } .sydot-2 { background: #2ecc8f; }
2005
- .sydot-3 { background: #ef5a5a; } .sydot-4 { background: #c78bff; } .sydot-5 { background: #f0c419; }
2004
+ /* each dot takes its tank's own colour, set through the CSSOM by scorchedyard.js drawTanks (the
2005
+ colour follows the personality, and a class per seat painted the seat's old colour instead) */
2006
2006
  /* the items line: pills, lit when armed */
2007
2007
  .syitems { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 11px; min-height: 18px; }
2008
2008
  .sypill { padding: 2px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--fg-dim); }
@@ -34,6 +34,25 @@ const UNKNOWN_POOL = Object.freeze(['moron', 'shooter', 'poolshark', 'tosser', '
34
34
  // EIGHT SEATS, EIGHT COLOURS (operator, 2026-09-16: "I would like to add up to 8 players total like
35
35
  // the original"). Distinct at a glance on dirt, and none of them the dirt's own browns.
36
36
  export const TANK_COLOURS = Object.freeze(['#f7931a', '#4d8dff', '#2ecc8f', '#ef5a5a', '#c78bff', '#f0c419', '#32d4e0', '#ff86b8']);
37
+ // A COLOUR FOR EACH PERSONALITY (operator, 2026-09-17: "assign a color to each personality"). A tank
38
+ // used to take its colour from its seat, so the first opponent was blue and the second green whatever
39
+ // played there. Now the colour says who you are up against: you are BlockYard orange, and each of
40
+ // the manual's eight has its own. The Unknown has one too, so its colour gives nothing away about
41
+ // the personality it draws each round. A second or third of one kind is a lighter, then a darker,
42
+ // shade of the same colour.
43
+ export const PERSONA_COLOURS = Object.freeze({
44
+ human: '#f7931a', moron: '#f0c419', shooter: '#4d8dff', poolshark: '#32d4e0', tosser: '#2ecc8f',
45
+ chooser: '#c78bff', spoiler: '#ef5a5a', cyborg: '#ff86b8', unknown: '#b7c0cc',
46
+ });
47
+ const mixHex = (hex, to, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - k) + to * k).toString(16).padStart(2, '0')).join('')}`;
48
+ /** The colour of the `nth` tank (1 = the first) of a kind: its own colour, then shades of it. */
49
+ export function colourFor(kind, nth = 1) {
50
+ const base = PERSONA_COLOURS[kind] ?? TANK_COLOURS[0];
51
+ if (nth <= 1) return base;
52
+ const shades = [[255, 0.35], [0, 0.35], [255, 0.6], [0, 0.55], [255, 0.8], [0, 0.7]];
53
+ const [to, k] = shades[(nth - 2) % shades.length];
54
+ return mixHex(base, to, k);
55
+ }
37
56
  export const MAX_PLAYERS = 8;
38
57
  export const MAX_HEALTH = 100;
39
58
  export const V_MAX = 56; // cells/s at power 1000
@@ -192,6 +211,7 @@ export function newGame(players, opts = {}) {
192
211
  kills: 0, score: 0, cash: Number.isFinite(opts.cash) ? opts.cash : START_CASH, damageDealt: 0,
193
212
  lastHitBy: null, // who hurt this tank last (the Cyborg holds a grudge)
194
213
  memory: null, // a computer player's last shot at its target, for correcting
214
+ aimAt: null, // a computer player's shots so far at one target: its aim tightens (scorchedai.js AIM)
195
215
  persona: p.kind ?? 'human', // what an Unknown is playing as this round
196
216
  })),
197
217
  round: 1, rounds: Math.max(1, Math.round(opts.rounds ?? 5)),
@@ -216,7 +236,7 @@ export function startRound(g) {
216
236
  placeTanks(g);
217
237
  for (const t of g.tanks) {
218
238
  t.health = MAX_HEALTH; t.alive = true; t.shield = null; t.armed = { contactTrigger: false, heatGuidance: false };
219
- t.lastHitBy = null; t.memory = null;
239
+ t.lastHitBy = null; t.memory = null; t.aimAt = null;
220
240
  // an Unknown is one of the others, drawn each round and never announced (the manual)
221
241
  t.persona = t.kind === 'unknown' ? UNKNOWN_POOL[Math.floor(g.rnd() * UNKNOWN_POOL.length)] : t.kind;
222
242
  }
@@ -77,6 +77,48 @@ function pickWeapon(tank, target, { thrifty = false } = {}) {
77
77
  return owned.sort((a, b) => WEAPONS[b].damage - WEAPONS[a].damage)[0];
78
78
  }
79
79
 
80
+ // ------------------------------------------------------------------- the aim, as a person aims
81
+ /**
82
+ * HOW WELL EACH OF THEM AIMS (operator, 2026-09-17: "Chooser and Shoot are both too fucking accurate").
83
+ * The search above finds the shot the physics says lands closest, and the Shooter and the Chooser
84
+ * fired exactly that: measured over 200 games, the Shooter hit with 60% of its first shots (every
85
+ * miss was a game with no low line of fire) and the Chooser with 95%, more than the Spoiler the
86
+ * manual calls the one that "gets a perfect shot almost every time". So each personality now
87
+ * aims the way a person does: the found angle and power, off by an error drawn from the game's own
88
+ * stream. The error grows with the distance to the target and shrinks with every shot at the same
89
+ * target, down to a floor, so they close in on you instead of starting perfect.
90
+ *
91
+ * angle standard deviation of the angle error, degrees, at middle distance
92
+ * power standard deviation of the power error, as a fraction of the power
93
+ * learn how much of the error each earlier shot at this target removes
94
+ * floor the least of the error that remains, however many shots
95
+ *
96
+ * Tuned by simulation to first-shot hit rates of about 5% Moron and Tosser, 25-30% Shooter and
97
+ * Poolshark, 40% Chooser and 85% Spoiler and Cyborg (test/scorched.test.js holds the bands).
98
+ */
99
+ export const AIM = Object.freeze({
100
+ shooter: Object.freeze({ angle: 3.5, power: 0.07, learn: 0.22, floor: 0.35 }),
101
+ poolshark: Object.freeze({ angle: 3.5, power: 0.07, learn: 0.22, floor: 0.35 }),
102
+ chooser: Object.freeze({ angle: 2.9, power: 0.055, learn: 0.25, floor: 0.3 }),
103
+ spoiler: Object.freeze({ angle: 0.5, power: 0.008, learn: 0.3, floor: 0.4 }),
104
+ });
105
+ function gauss(rnd) {
106
+ let u = 0;
107
+ while (u === 0) u = rnd();
108
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rnd());
109
+ }
110
+ /** `shot` as this tank would really fire it at `target`, with its personality's error; counts the shot. */
111
+ export function aimed(g, tank, target, shot, profile, rnd = g.rnd) {
112
+ if (!profile) return shot;
113
+ const dist = Math.abs(centreX(target) - centreX(tank)) / COLS; // 0 .. 1
114
+ const before = tank.aimAt?.target === target.id ? tank.aimAt.shots : 0;
115
+ tank.aimAt = { target: target.id, shots: before + 1 };
116
+ const scale = (0.5 + dist) * Math.max(profile.floor, 1 - profile.learn * before);
117
+ const angle = Math.max(0, Math.min(180, shot.angle + gauss(rnd) * profile.angle * scale));
118
+ const power = Math.max(50, Math.min(1000, Math.round(shot.power * (1 + gauss(rnd) * profile.power * scale))));
119
+ return { ...shot, angle, power };
120
+ }
121
+
80
122
  // ------------------------------------------------------------------- the personalities
81
123
  /** The Moron: "pick an angle and power, and shoot". Whatever it happens to own. */
82
124
  export function moron(g, tank, rnd = g.rnd) {
@@ -98,7 +140,7 @@ export function shooter(g, tank, rnd = g.rnd) {
98
140
  if (!target) return moron(g, tank, rnd);
99
141
  const best = solve(g, tank, target, [12, 20, 28, 36], 25);
100
142
  if (!best || best.miss > 12) return { ...moron(g, tank, rnd), weapon: 'babyMissile', fallback: true }; // no line of fire: a Moron's shot
101
- return { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target, { thrifty: best.miss > 4 }) };
143
+ return aimed(g, tank, target, { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target, { thrifty: best.miss > 4 }) }, AIM.shooter, rnd);
102
144
  }
103
145
 
104
146
  /** The Poolshark: a Shooter, unless the walls rebound -- then it looks for the bank shot as well. */
@@ -111,7 +153,7 @@ export function poolshark(g, tank, rnd = g.rnd) {
111
153
  const bank = solve(g, tank, target, [110, 125, 140, 155], 25);
112
154
  const best = [direct, bank].filter(Boolean).sort((a, b) => a.miss - b.miss)[0];
113
155
  if (!best || best.miss > 12) return { ...moron(g, tank, rnd), weapon: 'babyMissile', fallback: true };
114
- return { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target, { thrifty: best.miss > 4 }) };
156
+ return aimed(g, tank, target, { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target, { thrifty: best.miss > 4 }) }, AIM.poolshark, rnd);
115
157
  }
116
158
 
117
159
  /**
@@ -147,7 +189,7 @@ export function chooser(g, tank, rnd = g.rnd) {
147
189
  const bank = (g.walls === 'rubber' || g.walls === 'spring') ? solve(g, tank, target, [115, 135, 155], 25) : null;
148
190
  const best = [low, lob, bank].filter(Boolean).sort((a, b) => a.miss - b.miss)[0];
149
191
  if (!best) return moron(g, tank, rnd);
150
- return { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target) };
192
+ return aimed(g, tank, target, { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target) }, AIM.chooser, rnd);
151
193
  }
152
194
 
153
195
  /** The Spoiler: "taking into account the wind factor and gravity, they will get a perfect shot almost every time". */
@@ -156,9 +198,8 @@ export function spoiler(g, tank, rnd = g.rnd, target = nearest(g, tank)) {
156
198
  const coarse = solve(g, tank, target, [15, 25, 35, 45, 55, 65, 75], 20);
157
199
  const best = coarse ? refine(g, tank, target, coarse) : null;
158
200
  if (!best) return moron(g, tank, rnd);
159
- // "almost": a degree or two of error, drawn from the game's own stream
160
- const wobble = rnd() < 0.15 ? (rnd() - 0.5) * 4 : 0;
161
- return { angle: Math.max(0, Math.min(180, best.angle + wobble)), power: best.power, weapon: pickWeapon(tank, target) };
201
+ // "almost": a small error, drawn from the game's own stream, that tightens as it keeps firing at you
202
+ return aimed(g, tank, target, { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target) }, AIM.spoiler, rnd);
162
203
  }
163
204
 
164
205
  /** The Cyborg: a Spoiler that "attacks tanks who are weakened, winning, or have attacked them". */
@@ -14,7 +14,7 @@ import { paintRipple, skyBrightness } from './scorchedwind.js';
14
14
  import { makeFluid, stepFluid, setSolid, warmFluid, makeTracers, stepTracers, paintTracers } from './scorchedair.js';
15
15
  import {
16
16
  newGame, current, aim, fire, step, settled, nextRound, cycleWeapon, useItem, drive, landTiles, actorTiles, leader, buy,
17
- trajectory, dirtAt, shellLook, simulateShot,
17
+ trajectory, dirtAt, shellLook, simulateShot, colourFor,
18
18
  WEAPONS, WEAPON_ORDER, ITEMS, COLS, ROWS, TANK_W,
19
19
  } from './scorched.js';
20
20
  import { SHOP } from './scorchedshop.js';
@@ -605,11 +605,21 @@ function drawTanks() {
605
605
  if (!box) return;
606
606
  const html = g ? g.tanks.map((t) => {
607
607
  const cur = g.phase !== 'over' && current(g) === t;
608
- return `<div class="sytank${t.alive ? '' : ' dead'}${cur ? ' now' : ''}"><i class="sydot sydot-${t.id}"></i><b>${t.name}</b>`
608
+ return `<div class="sytank${t.alive ? '' : ' dead'}${cur ? ' now' : ''}"><i class="sydot" data-tank="${t.id}"></i><b>${t.name}</b>`
609
609
  + `<meter min="0" max="100" low="34" high="67" optimum="100" value="${t.health}" title="${t.health} health"></meter>`
610
610
  + `<span>${t.alive ? `${t.health}${t.shield ? '🛡' : ''}` : '☠'}</span><small>${t.score} pts · ${t.kills} kills · ${money(t.cash)}</small></div>`;
611
611
  }).join('') : '';
612
612
  setHtml(box, html, 'tanks');
613
+ // THE DOT IS THE TANK'S OWN COLOUR (operator, 2026-09-17, with a capture: the red Spoiler listed
614
+ // with a blue dot). The CSP forbids a style attribute in markup, so the colour was a class per
615
+ // SEAT, which kept painting the seat's old colour once colours followed the personality. The
616
+ // CSSOM is allowed, and it is the tank's colour whatever seat it sits in.
617
+ if (g && box.querySelectorAll) {
618
+ for (const dot of box.querySelectorAll('.sydot[data-tank]')) {
619
+ const colour = g.tanks[Number(dot.dataset.tank)]?.colour;
620
+ if (colour && dot.dataset.colour !== colour) { dot.style?.setProperty?.('background-color', colour); dot.dataset.colour = colour; }
621
+ }
622
+ }
613
623
  }
614
624
 
615
625
  function drawScores(highlightAt = null) {
@@ -902,8 +912,14 @@ function frame(t) {
902
912
  if (g.phase !== 'over') G.raf = requestAnimationFrame(frame);
903
913
  }
904
914
 
905
- // THE OPPONENTS (M3): a mix climbs from the easy ones -- Shooter, Tosser, Chooser, Spoiler,
906
- // Cyborg -- or every seat the one kind the setting names. Named for what they are, the manual's way.
915
+ // THE OPPONENTS (M3): a mix of the manual's personalities, or every seat the one kind the setting
916
+ // names. Named for what they are, the manual's way.
917
+ //
918
+ // THE MIX IS SHUFFLED EACH GAME (operator, 2026-09-17: "the enemies I play against in Scorched Yard
919
+ // are always the same and never randomized"). It used to fill the seats from this list in order, so
920
+ // the shipped two opponents were a Shooter and a Tosser in every game ever played. Now each new game
921
+ // deals the six in a fresh order: the first seats get six different kinds, and a table bigger than
922
+ // the pool deals again from a new shuffle.
907
923
  const MIX = ['shooter', 'tosser', 'chooser', 'spoiler', 'cyborg', 'poolshark'];
908
924
  const NAMES = { moron: 'Moron', shooter: 'Shooter', poolshark: 'Poolshark', tosser: 'Tosser', chooser: 'Chooser', spoiler: 'Spoiler', cyborg: 'Cyborg', unknown: 'Unknown' };
909
925
  /**
@@ -911,14 +927,21 @@ const NAMES = { moron: 'Moron', shooter: 'Shooter', poolshark: 'Poolshark', toss
911
927
  * taken by another computer player, so the war plays itself on a wall. Everything else is the
912
928
  * same game: the same shop, the same rounds, the same rules.
913
929
  */
914
- export function players(t = scorchedOptions(loadSettings())) {
915
- const list = t.demo ? [] : [{ name: 'You', kind: 'human' }];
930
+ export function shuffled(list, rnd = Math.random) {
931
+ const a = list.slice();
932
+ for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
933
+ return a;
934
+ }
935
+ export function players(t = scorchedOptions(loadSettings()), rnd = Math.random) {
936
+ const list = t.demo ? [] : [{ name: 'You', kind: 'human', colour: colourFor('human') }];
916
937
  const seen = {};
917
938
  const seats = t.opponents + (t.demo ? 1 : 0);
939
+ let deck = [];
918
940
  for (let i = 0; i < seats; i++) {
919
- const kind = t.opponentKind === 'mix' ? MIX[i % MIX.length] : t.opponentKind;
941
+ if (t.opponentKind === 'mix' && deck.length === 0) deck = shuffled(MIX, rnd);
942
+ const kind = t.opponentKind === 'mix' ? deck.shift() : t.opponentKind;
920
943
  seen[kind] = (seen[kind] ?? 0) + 1;
921
- list.push({ name: seen[kind] > 1 ? `${NAMES[kind] ?? kind} ${seen[kind]}` : (NAMES[kind] ?? kind), kind });
944
+ list.push({ name: seen[kind] > 1 ? `${NAMES[kind] ?? kind} ${seen[kind]}` : (NAMES[kind] ?? kind), kind, colour: colourFor(kind, seen[kind]) });
922
945
  }
923
946
  return list;
924
947
  }
@@ -315,7 +315,7 @@ export const DEFAULTS = Object.freeze({
315
315
  gridColour: '#2a5a8f',
316
316
  gridBrightness: 1,
317
317
  opponents: 2, // computer players against the one human; up to seven, for the original's eight seats
318
- opponentKind: 'mix', // 'mix' (Shooter, Tosser, Chooser, Spoiler, Cyborg, Poolshark in turn) or one of the manual's eight
318
+ opponentKind: 'mix', // 'mix' (Shooter, Tosser, Chooser, Spoiler, Cyborg and Poolshark, shuffled each game) or one of the manual's eight
319
319
  rounds: 5,
320
320
  walls: 'none', // the manual's default: 'none' | 'concrete' | 'padded' | 'rubber' | 'spring' | 'wrap'
321
321
  wind: 'round', // 'round' (one wind a round, the shipped mode) | 'turn' | 'shot' | 'none'
@@ -705,7 +705,7 @@ const PANEL_GROUPS = Object.freeze([
705
705
  Object.freeze({ key: 'gridBrightness', label: 'Grid intensity', kind: 'range', min: 0, max: 2, step: 0.05, hint: 'How strongly the grid shows; 0 hides it' }),
706
706
  Object.freeze({ key: 'opponents', label: 'Computer players', kind: 'range', min: 1, max: 7, step: 1, hint: 'How many tanks the computer fields against you. Two is the shipped game; seven fills the original\u2019s eight seats' }),
707
707
  Object.freeze({
708
- key: 'opponentKind', label: 'Their kind', kind: 'choice', hint: 'The manual\u2019s personalities: a mix climbs from the easy ones, or every seat the one you name. Moron fires at random; Shooter takes straight shots; Poolshark banks off rubber walls; Tosser lobs and corrects; Chooser picks its method; Spoiler nearly never misses; Cyborg is a Spoiler with a grudge; Unknown is one of them, drawn each round',
708
+ key: 'opponentKind', label: 'Their kind', kind: 'choice', hint: 'The manual\u2019s personalities: a mix dealt in a new order every game, or every seat the one you name. Moron fires at random; Shooter takes straight shots; Poolshark banks off rubber walls; Tosser lobs and corrects; Chooser picks its method; Spoiler nearly never misses; Cyborg is a Spoiler with a grudge; Unknown is one of them, drawn each round',
709
709
  options: Object.freeze([['mix', 'A mix'], ['moron', 'Morons'], ['shooter', 'Shooters'], ['poolshark', 'Poolsharks'], ['tosser', 'Tossers'], ['chooser', 'Choosers'], ['spoiler', 'Spoilers'], ['cyborg', 'Cyborgs'], ['unknown', 'Unknowns']]),
710
710
  }),
711
711
  Object.freeze({ key: 'rounds', label: 'Rounds', kind: 'range', min: 1, max: 10, step: 1, hint: 'A game is this many rounds; the highest score at the end wins' }),
package/scripts/shots.mjs CHANGED
@@ -243,6 +243,12 @@ if (doing('scorched')) {
243
243
  await metrics(1000);
244
244
  await evl(`document.getElementById('syResume')?.click()`);
245
245
  await sleep(1500);
246
+ // A DAYTIME ROUND (2026-09-17). Each round draws its sky hour from the seed (scorchedyard.js
247
+ // ROUND_HOURS[(seed + round*7) % 8]), so a shot taken at whatever the seed gave was as likely
248
+ // night as day; the Earth sky's sun, clouds and the wind's flow lines read best at 15:00 (index 3).
249
+ // The land is already generated, so only the sky's hour follows the new seed.
250
+ await evl(`import('/js/scorchedyard.js').then((m) => { const g = m.currentGame(); if (g) g.seed = g.seed - ((g.seed + g.round * 7) % 8 + 8) % 8 + 3; })`);
251
+ await sleep(2500);
246
252
  await evl(`import('/js/scorchedyard.js').then(async (m) => { const g = m.currentGame(); const t = g.tanks[0]; const ai = await import('/js/scorchedai.js'); const target = ai.nearest(g, t); t.inventory.nuke = 1; t.weapon = 'nuke'; const s = ai.spoiler(g, t, () => 0.9, target); t.angle = s.angle; t.power = s.power; })`);
247
253
  await sleep(300);
248
254
  await evl(`document.getElementById('syFire')?.click()`);