From 995f3fa94edcf5637927f4187bac10b4787a1a2f Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:58:22 +0100 Subject: [PATCH] The firing order was worth nothing, measured properly (R-259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-258 put a tactic in the bestiary on the strength of a table-side harness — cheapest weapon first, best weapon last, 10.7 points of win rate across 24 orderings. Measured in simulate.mjs, the pipeline every published number comes from, it is worth +0.27 points, with per-seed differences of -1.5 to +1.8 against 2.6 points of noise on a single arm. Zero. The toy lied for two reasons, both mine. Its party had one uniquely valuable weapon (an anti-materiel rifle the borough does not own, so unhalved, worth double anything else) so there was something to sequence around; the frozen party is 4.09, 3.98, 1.43, 1.43 — two near-identical pairs. And the toy fixed the order every round, while the game re-rolls Reaction every round and has no rule for holding an action, which I checked before measuring. The order is not a decision the rules offer, and I had written table advice for it. The page is corrected: the ladder stays, because it is derived and true, and it now says you cannot choose who goes first. What is worth doing, measured the same way: committing a round's attacks to one target, in fights that can actually move (a fight at 0.2% or 100% cannot show an effect and three of my first four samples were pinned there). Against dodge <= 30% focus fire gains 7.6 points, which is just killing attackers sooner; against dodge >= 55% it gains 12.2, so about 4-5 points is the defence ladder and the rest is arithmetic unrelated to dodging. Separating them needed contested fights at both ends of the dodge range — without that control I would have credited all 13.8 points against three redcaps to the ladder, which is R-255's mistake again. runFight gained two hooks, off by default and unused by the baseline: partySequence rearranges the party within the slots initiative already gave them, leaving enemies where they fell so the experiment isolates agent order from who acts before the creature; and partyTargets "focus" concentrates fire. Both roll the same dice, and check-lethality confirms all 47 creatures still fight exactly as recorded. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- docs/BESTIARY.md | 25 ++++++++++++--------- docs/REVIEW_LOG.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++ system.json | 2 +- tools/bestiary.mjs | 24 +++++++++++--------- tools/simulate.mjs | 27 ++++++++++++++++++++--- 6 files changed, 110 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index c46678d..d7f3050 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resol check-lang: OK — en.json, 1018 keys, no leaf/branch collisions check-templates: OK — 19 templates compile check-behaviour: OK — 76 behavioural tests -check-scenarios: OK — 11 scenario files, 78 mechanic tags, every skill named resolves against 62 catalogue entries +check-scenarios: OK — 11 scenario files, 79 mechanic tags, every skill named resolves against 62 catalogue entries check-creatures: OK — 102 actor specs across 8 sources, every characteristic, skill, kit key, talent, species and style resolves, no duplicate keys check-anatomy: OK — 5 body plans, 36 locations, d20 complete in both modes, every location drawn, every kind handled and destructible, 20 plan changes carried 432 wounds with their severity intact, 1 flyer(s) with a survivable landing check-lethality: OK — 47 creatures, every one fighting exactly as recorded against the frozen party diff --git a/docs/BESTIARY.md b/docs/BESTIARY.md index 9b338f2..410fc42 100644 --- a/docs/BESTIARY.md +++ b/docs/BESTIARY.md @@ -73,18 +73,23 @@ and is down to the floor after 2 landing hits. Across 47 creatures: **13** are stripped by 1 landing hit, **25** are stripped by 2 landing hits, **9** are stripped by 3 landing hits. -Three things follow, and together they decide what order a team shoots in: +Two things follow: -- **Only a hit spends a defence.** A miss strips nothing, so the agent who leads off - wants a reliable attack rather than a big one. -- **Each landing hit makes the next one easier**, in the same round only. Attacks made - after the defence is gone are attacking something that cannot get out of the way. -- **So the cheapest reliable weapon fires first and the one you most need to land fires - last.** The opening shots are bait; they exist to make the thing move. Against - anything that halves damage from departmental kit, the weapon the department did not - issue is the one that fires last of all. +- **Only a hit spends a defence.** A miss strips nothing, so a round in which the + party misses twice is one where the creature meets the third shot at full value. +- **Each landing hit makes the next one easier**, in the same round only. The second + agent to connect is worth more than the first, and against the heavy dodgers below + the third is worth more still. Commit a round’s attacks to one target; spreading + them lets every defence reset at full value. -The ones that need the most stripping before your best shot is worth taking: +**What you cannot do is choose who goes first.** Reaction is re-rolled every round, so +over a fight every agent takes the opening slot about equally often, and there is no +rule for holding an action. Arranging the party so its best weapon fires last was +measured in the simulator over six independent seeds and came to nothing +distinguishable from noise. The ladder is a reason to commit a round to one target, +not a reason to choreograph the order. + +The ones that take the most stripping: - **The margin** — dodge 80%, 3 landing hits to strip - **Redcap** — dodge 75%, 3 landing hits to strip diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index b764658..d3fc3ea 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -5481,3 +5481,58 @@ Negative-tested both ways it can fail: a rule change that makes the page stale, hand-edit of the page itself. `update-readme` caught it as "run by the build, not advertised" before I had added it to the list, and the README's count word went from nine to ten on its own, which is R-256 paying for itself. + +## R-259 — the firing order was worth nothing, and R-258 shipped it anyway + +R-258 put a tactic in the bestiary on the strength of a table-side harness: cheapest +weapon first, best weapon last, worth 10.7 points of win rate across 24 orderings. Run in +`simulate.mjs` — the pipeline that produces every published number — it is worth +**+0.27 points**, with per-seed differences from −1.5 to +1.8 against 2.6 points of +seed-to-seed noise on a single arm. It is zero. + +**Why the toy lied.** Two reasons, both mine. + +- That party had one uniquely valuable weapon — an anti-materiel rifle the borough does + not own, so unhalved and worth roughly double anything else. Reordering around it had + something to reorder around. The frozen party's four are worth 4.09, 3.98, 1.43 and + 1.43 expected damage: two near-identical pairs, and nothing to gain by sequencing them. +- The toy used a fixed order every round. **The game re-rolls Reaction every round**, so + over a fight every agent takes the opening slot about equally often. There is no rule + for holding an action — I checked before measuring — so the order is not a choice a + player gets to make. I had written table advice for a decision the rules do not offer. + +**The page is corrected.** The ladder stays, because it is derived and true. What it now +says is that you cannot choose who goes first, and why. + +**What IS worth doing, measured the same way.** Committing a round's attacks to one +target rather than spreading them, in fights that can actually move (spread-fire win rate +between 20% and 80% — a fight at 0.2% or 100% cannot show an effect and three of my first +four samples were pinned there): + +| pack | dodge | spread | focus | gain | +|---|---|---|---|---| +| 2× Cuckoo array | 5% | 65.7% | 73.9% | +8.2 | +| 2× The apple tree man | 10% | 45.0% | 52.0% | +7.0 | +| 3× Spriggan | 45% | 48.9% | 64.9% | +16.0 | +| 2× The yield | 60% | 55.8% | 67.4% | +11.6 | +| 3× Precedent | 65% | 70.9% | 82.2% | +11.3 | +| 3× Redcap | 75% | 38.9% | 52.7% | +13.8 | + +Against low dodge the gain is 7.6 points and is simply killing attackers sooner. Against +high dodge it is 12.2, so about **4–5 points is the defence ladder** and the rest is +arithmetic that has nothing to do with dodging. Splitting it needed contested fights at +both ends of the dodge range; without that control I would have credited the whole 13.8 +against the redcaps to the ladder, which is the same error as R-255's +6.4. + +**The instrument.** `runFight` gained two hooks, both off by default and unused by the +baseline: `partySequence` rearranges the party among the slots initiative already gave +them — leaving enemies where they fell, so the experiment isolates "what order the agents +act in" from "who acts before the creature" — and `partyTargets: "focus"` concentrates +fire. Both roll the same dice as before, so the stream is unchanged, and check-lethality +confirms all 47 creatures still fight exactly as recorded with the hooks present. + +**The pattern.** Third time this session that my explanation was wrong and the experiment +settled it, and the first where the wrong explanation had already shipped to a document a +GM would read. The rule I keep relearning: a measurement taken outside the guarded +pipeline is a hypothesis, not a result, and it does not go in a document until the +pipeline agrees. diff --git a/system.json b/system.json index 8191ebe..2128058 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "ringbrp", "title": "The Custodians", "description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.", - "version": "1.7.13", + "version": "1.7.14", "compatibility": { "minimum": 13, "verified": "14.364" diff --git a/tools/bestiary.mjs b/tools/bestiary.mjs index 9f8c44f..e01fae1 100644 --- a/tools/bestiary.mjs +++ b/tools/bestiary.mjs @@ -168,16 +168,20 @@ md += `\n**V** = Vesh, **C** = Cadence. Everything else is baseline-human anatom + `Across ${all.length} creatures: ` + Object.keys(spread).sort().map(k => `**${spread[k]}** are stripped by ${k} landing hit${k === "1" ? "" : "s"}`).join(", ") + `.\n\n` - + `Three things follow, and together they decide what order a team shoots in:\n\n` - + `- **Only a hit spends a defence.** A miss strips nothing, so the agent who leads off\n` - + ` wants a reliable attack rather than a big one.\n` - + `- **Each landing hit makes the next one easier**, in the same round only. Attacks made\n` - + ` after the defence is gone are attacking something that cannot get out of the way.\n` - + `- **So the cheapest reliable weapon fires first and the one you most need to land fires\n` - + ` last.** The opening shots are bait; they exist to make the thing move. Against\n` - + ` anything that halves damage from departmental kit, the weapon the department did not\n` - + ` issue is the one that fires last of all.\n\n` - + `The ones that need the most stripping before your best shot is worth taking:\n\n` + + `Two things follow:\n\n` + + `- **Only a hit spends a defence.** A miss strips nothing, so a round in which the\n` + + ` party misses twice is one where the creature meets the third shot at full value.\n` + + `- **Each landing hit makes the next one easier**, in the same round only. The second\n` + + ` agent to connect is worth more than the first, and against the heavy dodgers below\n` + + ` the third is worth more still. Commit a round\u2019s attacks to one target; spreading\n` + + ` them lets every defence reset at full value.\n\n` + + `**What you cannot do is choose who goes first.** Reaction is re-rolled every round, so\n` + + `over a fight every agent takes the opening slot about equally often, and there is no\n` + + `rule for holding an action. Arranging the party so its best weapon fires last was\n` + + `measured in the simulator over six independent seeds and came to nothing\n` + + `distinguishable from noise. The ladder is a reason to commit a round to one target,\n` + + `not a reason to choreograph the order.\n\n` + + `The ones that take the most stripping:\n\n` + hardest.map(x => `- **${esc(x.name)}** — dodge ${x.d}%, ${stripsIn(x.d)} landing hits to strip\n`).join("") + `\nIt runs the other way too: agents defend on the same ladder, which is why a second\n` + `attacker in a round is worth so much more than the first, and why anything that gets\n` diff --git a/tools/simulate.mjs b/tools/simulate.mjs index f0f7a9e..9d5318e 100644 --- a/tools/simulate.mjs +++ b/tools/simulate.mjs @@ -303,7 +303,8 @@ function attack(rng, attacker, defender) { } /** One fight to a conclusion. Returns which side stood, and how long it took. */ -export function runFight(rng, partySpecs, enemySpecs, { maxRounds = 40 } = {}) { +export function runFight(rng, partySpecs, enemySpecs, + { maxRounds = 40, partySequence = null, partyTargets = "random" } = {}) { const party = partySpecs.map(s => buildCombatant(s, { side: "party" })); const enemy = enemySpecs.map(s => buildCombatant(s, { side: "enemy" })); const all = [...party, ...enemy]; @@ -318,13 +319,33 @@ export function runFight(rng, partySpecs, enemySpecs, { maxRounds = 40 } = {}) { c.defencesThisRound = 0; c.initiative = c.reactionBase + d(rng, REACTION.die); } - const order = [...all].sort((a, b) => b.initiative - a.initiative); + let order = [...all].sort((a, b) => b.initiative - a.initiative); + + /* R-259. An experiment hook, off by default and never used by the baseline: it + rearranges the PARTY among the slots initiative already gave them, leaving every + enemy where it fell. That isolates "what order the agents act in" from "who acts + before the creature does" — reordering the whole line would measure both at once + and credit the answer to the wrong one. The initiative dice are still rolled + either way, so the random stream is identical with the hook on or off. */ + if (partySequence) { + const slots = order.map((c, i) => [c, i]).filter(([c]) => c.side === "party").map(([, i]) => i); + const seq = partySequence(order.filter(c => c.side === "party")); + order = [...order]; + slots.forEach((slot, k) => { order[slot] = seq[k]; }); + } for (const actor of order) { if (!isFighting(actor)) continue; const foes = (actor.side === "party" ? enemy : party).filter(isFighting); if (!foes.length) break; - attack(rng, actor, foes[Math.floor(rng() * foes.length)]); + /* R-259, second hook, also off by default: "focus" makes the party concentrate on + one enemy — the most hurt of them — instead of each agent picking at random. + The die is rolled either way so the stream does not shift. */ + const pick = Math.floor(rng() * foes.length); + const focus = actor.side === "party" && partyTargets === "focus" + ? foes.reduce((a, b) => (b.hp < a.hp ? b : a)) + : foes[pick]; + attack(rng, actor, focus); } // End of round: anyone at or below zero loses ground on the dying clock.