diff --git a/README.md b/README.md index dc86103..4244471 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,11 @@ changing underneath a running server. | | | |---|---| | **Roles** | 25 postings, each with a key characteristic, trained skills, talents and its own kit | -| **Catalogue** | 175 items — 118 gear, 30 weapons, 13 armour, 14 vehicles | +| **Catalogue** | 229 items — 123 gear, 62 weapons, 25 armour, 19 vehicles | | **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything | -| **Icons** | 231, all generated from `tools/icons.mjs`; no external art | +| **Icons** | 285, all generated from `tools/icons.mjs`; no external art | | **Compendia** | pregens, NPCs, skills, weapons, armour, gear, vehicles, powers, tables, rules, a starter case | -| **Rules journal** | 8 pages, generated from `rules.mjs` so the book cannot drift from the code | +| **Rules journal** | 12 pages, generated from `rules.mjs` so the book cannot drift from the code | --- @@ -92,6 +92,16 @@ Only a critical stops a critical. **Access is a record.** There is no hacking skill and nothing to break into — see below. +**A weapon's fire mode is a decision.** Slow weapons shoot better and then make you +work; fast ones offer a second shot at the cost of both; rapid ones fire bursts; beams +can be held on the target for damage instead of hits. A weapon that cannot fire a +burst cannot be made to. + +**Sights and lamps apply themselves.** A sight is graded by range band — a telescopic +sight is a penalty inside ten metres — and a carried lamp buys the dark back to dim, +never to daylight, and tells everyone where you are. Neither is a thing the player +selects: both are facts about what is in the pack. + --- ## Borrowed Authority — the access subsystem @@ -129,7 +139,7 @@ and nothing in this game spends anything to converge them back. ## Repository layout ``` -ringbrp.mjs the system: sheets, engine, hooks (~4,900 lines) +ringbrp.mjs the system: sheets, engine, hooks (~5,500 lines) rules.mjs THE SINGLE AUTHORITY for every rule formula system.json Foundry manifest template.json data model for every Actor and Item type @@ -163,7 +173,7 @@ one set of numbers and played under another; this makes that impossible to ship. The build runs all three guards before it writes anything: ``` -check-rules: 40 rules, 12 files scanned, 132 formulas verified -check-kits: 25 roles, 162 catalogue items, every kit key resolves -check-lang: 738 keys, no leaf/branch collisions +check-rules: 76 rules, 12 files scanned, 230 formulas verified +check-kits: 25 roles, 211 catalogue items, every kit key resolves +check-lang: 878 keys, no leaf/branch collisions ``` diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 070e3d1..9792145 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -4211,3 +4211,82 @@ is the retuned extraction rule working end to end. all four agents. Only the Field Lead ever had it — the other three had never been awarded it, and the probe could not tell the difference between a talent removed and a talent never held. The amendments actually made were three, not four. + +## Fire modes, fitted kit, and a rail instead of a strip + +Four requests, and the first of them turned over the usual stone. + +**R-231. Every weapon has carried a fire mode since the packs were first built, and +nothing has ever read it.** A musket could rip a ten-round burst; "rapid" bought a +weapon precisely nothing. `FIRE_MODE` now gates the burst rules and carries its own +attack modifier, so the mode is a decision rather than a label: **slow** +10% and then +you work for the next one, **single** the baseline, **fast** a second shot at −20% to +*both*, **rapid** bursts, **beam** +5% and it can be held. The gate is checked inside +`burstAttack` rather than only in the dialog, so no macro or harness can route round it. + +**R-232. `fast` was not a fire mode at all.** Five weapons carried it — longbow, sling, +javelin, the Webley and the Lee-Enfield — and the system's list of modes did not +include it, so the item sheet's dropdown had no matching option and re-saving one of +those weapons silently blanked its mode. The list was hand-written next to the rule +instead of derived from it. It is derived now, and the guard that should have caught +this was the one I had to fix in R-236. + +**R-233. `burstAttack` was reachable from nothing.** The automatic-fire rules shipped +complete and could not be used from any sheet. It is now the resolution path the fire +selector chooses, and it takes the dialog's whole modifier stack rather than rebuilding +its own — the first version charged the player twice for the same burst. + +**R-234. Any weapon with a Coherence cost could not be attacked with at all.** The +handler read the weapon, awaited the Coherence charge, and then read the weapon +*again* — but `currentTarget` is only set while an event is being dispatched, so the +second read returned null and the attack died on a TypeError with nothing shown to the +player. The Redaction lance, the Unmaker and every piece of transposed materiel were +unusable, and the failure was completely silent. Resolve once, before anything is +awaited. + +**R-235. The damage card was the last one wearing Foundry's stock dice styling.** It +posted a bare Roll and let the core template dress it — grey boxes on a white panel in +the middle of a navy sheet. It renders through the module's own card now, with the Roll +still riding on the message so the dice stay inspectable and Dice So Nice still +animates. + +**R-236. The duplicate-rule guard reported an honest table as a copy.** `FIRE_MODE` has +`slow` and `fast`; so does the entirely unrelated `speedBands` vocabulary, and two +shared key names was enough to fail the build. Every real instance this guard has ever +caught duplicated the *whole* table, so it now wants a majority of the keys — and +prints partial overlaps rather than swallowing them. + +**Sights and lamps.** A sight is not a flat bonus, which is why most systems end up +with everybody carrying the same scope. It is graded by range band, so **a telescopic +sight is a −10% liability inside ten metres** and worth +20% at extreme, and it only +counts if it *fits* — the scope and the thermal sight are rifle furniture. Because the +band is already known by the time the modifier is wanted, the sight applies itself: +there is nothing to select and nothing to forget. The dark now costs something, a +carried lamp buys it back to dim and never to daylight, and the card says plainly that +the lamp shows them exactly where you are. + +**R-237. "Automatic" that needs a re-import is not automatic.** Agents created before +those fields existed carried a Field lamp whose `lightSource` was the template default +of false — the lamp sat in the pack doing nothing. A migration stamps fitted gear from +the compendium on load, matched on `flags.ringbrp.key` rather than on display name, +because the fuzzy-name match this project used before once mapped a climbing kit to +breaching charges. + +**My own error, caught by playing it.** I first costed a sustained beam as +`multiplier × energyPerShot`, which made a locked beam cost 60 charge from a 20-charge +cell — unfirable, and the failure was a warning the player would read as a bug. The +magazine is spent one per shot everywhere else in the system, so the cost is in shots: +a locked beam burns six. Worth noting that `energyPerShot` itself is written by the +pack builder and **read by nothing** — the same defect again, left alone here because +wiring it would change every energy weapon's economy. + +**The tabs.** Eight of them across the top of an 820px sheet compressed to 11px type +and were genuinely hard to read. They are a rail down the side now: one line each, +larger type, a solid block for the active tab, and counts as badges. The sheet is 152px +wider to pay for the rail so nothing else got tighter, and the skills list is driven by +the space actually available rather than by a window-width breakpoint that was +measuring the wrong box. + +Also noted, not fixed: fourteen rules are imported into `ringbrp.mjs` and read nowhere +in it. They are live rules with real readers in the rulebook generator and the guards, +so this is dead *imports* rather than dead rules — but it is the same smell. diff --git a/icons/sight_irons.svg b/icons/sight_irons.svg new file mode 100644 index 0000000..448c432 --- /dev/null +++ b/icons/sight_irons.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/sight_reflex.svg b/icons/sight_reflex.svg new file mode 100644 index 0000000..3bc49e5 --- /dev/null +++ b/icons/sight_reflex.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/sight_scope.svg b/icons/sight_scope.svg new file mode 100644 index 0000000..419181e --- /dev/null +++ b/icons/sight_scope.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/sight_thermal.svg b/icons/sight_thermal.svg new file mode 100644 index 0000000..8727f1c --- /dev/null +++ b/icons/sight_thermal.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/weapon_lamp.svg b/icons/weapon_lamp.svg new file mode 100644 index 0000000..8e73833 --- /dev/null +++ b/icons/weapon_lamp.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/lang/en.json b/lang/en.json index 9845edd..f5aeb7b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -836,5 +836,45 @@ "RINGBRP.Salvage.Title": "Salvage", "RINGBRP.Salvage.Found": "Left lying about — {era}", "RINGBRP.Salvage.None": "Nothing of that era in the catalogue ({era}).", - "RINGBRP.Gen.Cancel": "Cancel" + "RINGBRP.Gen.Cancel": "Cancel", + "RINGBRP.Fire.Shot": "Single shot", + "RINGBRP.Fire.Rounds": "{n} rounds, {hits} connect", + "RINGBRP.Fire.Adds": "adds {dice} damage", + "RINGBRP.Fire.Second": "Two shots", + "RINGBRP.Fire.SecondEffect": "both hurried", + "RINGBRP.Fire.Reloading": "{name} — reloading, {n} round(s)", + "RINGBRP.Fire.slow.Label": "Slow", + "RINGBRP.Fire.single.Label": "Single", + "RINGBRP.Fire.fast.Label": "Fast", + "RINGBRP.Fire.rapid.Label": "Rapid", + "RINGBRP.Fire.beam.Label": "Beam", + "RINGBRP.Fire.slow.Note": "One shot, then work. Nothing about this weapon rewards haste, and it is fired by somebody who knows that.", + "RINGBRP.Fire.single.Note": "One aimed shot a round. The ordinary case, and the baseline everything else is measured against.", + "RINGBRP.Fire.fast.Note": "The action will give you a second shot if you will accept both of them being worse.", + "RINGBRP.Fire.rapid.Note": "Bursts. One roll, and how well it went decides how much of the burst arrived.", + "RINGBRP.Fire.beam.Note": "No recoil, no lead, no drop — and it can be held on the target for damage instead of hits.", + "RINGBRP.Sustain.tap": "Tap", + "RINGBRP.Sustain.hold": "Held beam", + "RINGBRP.Sustain.lock": "Locked beam", + "RINGBRP.Sight.none": "No sight", + "RINGBRP.Sight.irons": "Aperture sights", + "RINGBRP.Sight.reflex": "Reflex sight", + "RINGBRP.Sight.telescopic": "Telescopic sight", + "RINGBRP.Sight.thermal": "Thermal sight", + "RINGBRP.Sight.LampEffect": "lit, and visible", + "RINGBRP.Sight.Revealed": "{name} shows them exactly where you are", + "RINGBRP.Lighting.lit": "Lit", + "RINGBRP.Lighting.dim": "Dim", + "RINGBRP.Lighting.dark": "Dark", + "RINGBRP.Lighting.pitch": "Pitch dark", + "RINGBRP.Dialog.Fire": "Fire", + "RINGBRP.Dialog.Lighting": "Light", + "RINGBRP.Dialog.Fitted": "Applies itself", + "RINGBRP.Notify.NoBurst": "{name} cannot fire a burst.", + "RINGBRP.Notify.NotEnoughCharge": "Not enough charge to hold the beam that long.", + "RINGBRP.Notify.SecondUsed": "That second shot has already been taken this round.", + "RINGBRP.Chat.Damage": "damage", + "RINGBRP.Chat.Sustained": "sustained", + "RINGBRP.Chat.DamageMod": "damage mod", + "RINGBRP.Notify.GearFitted": "Fitted {n} item(s) that were carried before sights and lamps did anything." } diff --git a/packs/armour/000003.log b/packs/armour/000003.log new file mode 100644 index 0000000..5b23416 Binary files /dev/null and b/packs/armour/000003.log differ diff --git a/packs/armour/000005.ldb b/packs/armour/000005.ldb deleted file mode 100644 index b406921..0000000 Binary files a/packs/armour/000005.ldb and /dev/null differ diff --git a/packs/armour/000006.log b/packs/armour/000006.log deleted file mode 100644 index 9d3f468..0000000 Binary files a/packs/armour/000006.log and /dev/null differ diff --git a/packs/armour/CURRENT b/packs/armour/CURRENT index cacca75..1a84852 100644 --- a/packs/armour/CURRENT +++ b/packs/armour/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/armour/LOG b/packs/armour/LOG index 45ee788..481972f 100644 --- a/packs/armour/LOG +++ b/packs/armour/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.417544 17416f000 Recovering log #3 -2026/08/11-08:06:46.417572 17416f000 Level-0 table #5: started -2026/08/11-08:06:46.417699 17416f000 Level-0 table #5: 3269 bytes OK -2026/08/11-08:06:46.417964 17416f000 Delete type=0 #3 -2026/08/11-08:06:46.417996 17416f000 Delete type=3 #2 +2026/08/11-08:56:03.947107 16e763000 Delete type=3 #1 diff --git a/packs/armour/LOG.old b/packs/armour/LOG.old deleted file mode 100644 index f2eefe9..0000000 --- a/packs/armour/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.255757 171d3f000 Delete type=3 #1 diff --git a/packs/armour/MANIFEST-000002 b/packs/armour/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/armour/MANIFEST-000002 differ diff --git a/packs/armour/MANIFEST-000004 b/packs/armour/MANIFEST-000004 deleted file mode 100644 index 1faa5e9..0000000 Binary files a/packs/armour/MANIFEST-000004 and /dev/null differ diff --git a/packs/expedition/000003.log b/packs/expedition/000003.log new file mode 100644 index 0000000..263295b Binary files /dev/null and b/packs/expedition/000003.log differ diff --git a/packs/expedition/000005.ldb b/packs/expedition/000005.ldb deleted file mode 100644 index 4ff460d..0000000 Binary files a/packs/expedition/000005.ldb and /dev/null differ diff --git a/packs/expedition/000006.log b/packs/expedition/000006.log deleted file mode 100644 index 268b187..0000000 Binary files a/packs/expedition/000006.log and /dev/null differ diff --git a/packs/expedition/CURRENT b/packs/expedition/CURRENT index cacca75..1a84852 100644 --- a/packs/expedition/CURRENT +++ b/packs/expedition/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/expedition/LOG b/packs/expedition/LOG index 43baa34..a59eb01 100644 --- a/packs/expedition/LOG +++ b/packs/expedition/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.424344 175993000 Recovering log #3 -2026/08/11-08:06:46.424368 175993000 Level-0 table #5: started -2026/08/11-08:06:46.424513 175993000 Level-0 table #5: 4129 bytes OK -2026/08/11-08:06:46.424759 175993000 Delete type=0 #3 -2026/08/11-08:06:46.424801 175993000 Delete type=3 #2 +2026/08/11-08:56:03.980151 16f77b000 Delete type=3 #1 diff --git a/packs/expedition/LOG.old b/packs/expedition/LOG.old deleted file mode 100644 index f9488bd..0000000 --- a/packs/expedition/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.282139 171d3f000 Delete type=3 #1 diff --git a/packs/expedition/MANIFEST-000002 b/packs/expedition/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/expedition/MANIFEST-000002 differ diff --git a/packs/expedition/MANIFEST-000004 b/packs/expedition/MANIFEST-000004 deleted file mode 100644 index 47fbcd4..0000000 Binary files a/packs/expedition/MANIFEST-000004 and /dev/null differ diff --git a/packs/gear/000003.log b/packs/gear/000003.log new file mode 100644 index 0000000..349523a Binary files /dev/null and b/packs/gear/000003.log differ diff --git a/packs/gear/000005.ldb b/packs/gear/000005.ldb deleted file mode 100644 index fedde60..0000000 Binary files a/packs/gear/000005.ldb and /dev/null differ diff --git a/packs/gear/000006.log b/packs/gear/000006.log deleted file mode 100644 index 4b150b6..0000000 Binary files a/packs/gear/000006.log and /dev/null differ diff --git a/packs/gear/CURRENT b/packs/gear/CURRENT index cacca75..1a84852 100644 --- a/packs/gear/CURRENT +++ b/packs/gear/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/gear/LOG b/packs/gear/LOG index b0ae8ed..7a5bd92 100644 --- a/packs/gear/LOG +++ b/packs/gear/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.418514 17497b000 Recovering log #3 -2026/08/11-08:06:46.418608 17497b000 Level-0 table #5: started -2026/08/11-08:06:46.418879 17497b000 Level-0 table #5: 27954 bytes OK -2026/08/11-08:06:46.419164 17497b000 Delete type=0 #3 -2026/08/11-08:06:46.419211 17497b000 Delete type=3 #2 +2026/08/11-08:56:03.949621 16f77b000 Delete type=3 #1 diff --git a/packs/gear/LOG.old b/packs/gear/LOG.old deleted file mode 100644 index dd3db13..0000000 --- a/packs/gear/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.257347 170d27000 Delete type=3 #1 diff --git a/packs/gear/MANIFEST-000002 b/packs/gear/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/gear/MANIFEST-000002 differ diff --git a/packs/gear/MANIFEST-000004 b/packs/gear/MANIFEST-000004 deleted file mode 100644 index 97d9316..0000000 Binary files a/packs/gear/MANIFEST-000004 and /dev/null differ diff --git a/packs/npcs/000003.log b/packs/npcs/000003.log new file mode 100644 index 0000000..c13442d Binary files /dev/null and b/packs/npcs/000003.log differ diff --git a/packs/npcs/000005.ldb b/packs/npcs/000005.ldb deleted file mode 100644 index 434f292..0000000 Binary files a/packs/npcs/000005.ldb and /dev/null differ diff --git a/packs/npcs/000006.log b/packs/npcs/000006.log deleted file mode 100644 index a8f297d..0000000 Binary files a/packs/npcs/000006.log and /dev/null differ diff --git a/packs/npcs/CURRENT b/packs/npcs/CURRENT index cacca75..1a84852 100644 --- a/packs/npcs/CURRENT +++ b/packs/npcs/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/npcs/LOG b/packs/npcs/LOG index db03d48..28f4fea 100644 --- a/packs/npcs/LOG +++ b/packs/npcs/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.407760 17497b000 Recovering log #3 -2026/08/11-08:06:46.408666 17497b000 Level-0 table #5: started -2026/08/11-08:06:46.410003 17497b000 Level-0 table #5: 223489 bytes OK -2026/08/11-08:06:46.410364 17497b000 Delete type=0 #3 -2026/08/11-08:06:46.410398 17497b000 Delete type=3 #2 +2026/08/11-08:56:03.966944 16ef6f000 Delete type=3 #1 diff --git a/packs/npcs/LOG.old b/packs/npcs/LOG.old deleted file mode 100644 index 0863b55..0000000 --- a/packs/npcs/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.270516 17051b000 Delete type=3 #1 diff --git a/packs/npcs/MANIFEST-000002 b/packs/npcs/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/npcs/MANIFEST-000002 differ diff --git a/packs/npcs/MANIFEST-000004 b/packs/npcs/MANIFEST-000004 deleted file mode 100644 index c64c94d..0000000 Binary files a/packs/npcs/MANIFEST-000004 and /dev/null differ diff --git a/packs/powers/000003.log b/packs/powers/000003.log new file mode 100644 index 0000000..d723043 Binary files /dev/null and b/packs/powers/000003.log differ diff --git a/packs/powers/000005.ldb b/packs/powers/000005.ldb deleted file mode 100644 index 7f540c6..0000000 Binary files a/packs/powers/000005.ldb and /dev/null differ diff --git a/packs/powers/000006.log b/packs/powers/000006.log deleted file mode 100644 index 43e306f..0000000 Binary files a/packs/powers/000006.log and /dev/null differ diff --git a/packs/powers/CURRENT b/packs/powers/CURRENT index cacca75..1a84852 100644 --- a/packs/powers/CURRENT +++ b/packs/powers/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/powers/LOG b/packs/powers/LOG index 2844da0..208cfc8 100644 --- a/packs/powers/LOG +++ b/packs/powers/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.415299 17416f000 Recovering log #3 -2026/08/11-08:06:46.415324 17416f000 Level-0 table #5: started -2026/08/11-08:06:46.415442 17416f000 Level-0 table #5: 3295 bytes OK -2026/08/11-08:06:46.415703 17416f000 Delete type=0 #3 -2026/08/11-08:06:46.415749 17416f000 Delete type=3 #2 +2026/08/11-08:56:03.974409 16e763000 Delete type=3 #1 diff --git a/packs/powers/LOG.old b/packs/powers/LOG.old deleted file mode 100644 index 79d7e77..0000000 --- a/packs/powers/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.277189 170d27000 Delete type=3 #1 diff --git a/packs/powers/MANIFEST-000002 b/packs/powers/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/powers/MANIFEST-000002 differ diff --git a/packs/powers/MANIFEST-000004 b/packs/powers/MANIFEST-000004 deleted file mode 100644 index 20de082..0000000 Binary files a/packs/powers/MANIFEST-000004 and /dev/null differ diff --git a/packs/pregens/000003.log b/packs/pregens/000003.log new file mode 100644 index 0000000..5ed9a47 Binary files /dev/null and b/packs/pregens/000003.log differ diff --git a/packs/pregens/000005.ldb b/packs/pregens/000005.ldb deleted file mode 100644 index d646217..0000000 Binary files a/packs/pregens/000005.ldb and /dev/null differ diff --git a/packs/pregens/000006.log b/packs/pregens/000006.log deleted file mode 100644 index 71e3acd..0000000 Binary files a/packs/pregens/000006.log and /dev/null differ diff --git a/packs/pregens/CURRENT b/packs/pregens/CURRENT index cacca75..1a84852 100644 --- a/packs/pregens/CURRENT +++ b/packs/pregens/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/pregens/LOG b/packs/pregens/LOG index dde3e2b..0888ece 100644 --- a/packs/pregens/LOG +++ b/packs/pregens/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.405358 17497b000 Recovering log #3 -2026/08/11-08:06:46.405609 17497b000 Level-0 table #5: started -2026/08/11-08:06:46.406137 17497b000 Level-0 table #5: 57435 bytes OK -2026/08/11-08:06:46.406463 17497b000 Delete type=0 #3 -2026/08/11-08:06:46.406496 17497b000 Delete type=3 #2 +2026/08/11-08:56:03.956900 16ff87000 Delete type=3 #1 diff --git a/packs/pregens/LOG.old b/packs/pregens/LOG.old deleted file mode 100644 index d41e962..0000000 --- a/packs/pregens/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.262897 171d3f000 Delete type=3 #1 diff --git a/packs/pregens/MANIFEST-000002 b/packs/pregens/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/pregens/MANIFEST-000002 differ diff --git a/packs/pregens/MANIFEST-000004 b/packs/pregens/MANIFEST-000004 deleted file mode 100644 index a27f077..0000000 Binary files a/packs/pregens/MANIFEST-000004 and /dev/null differ diff --git a/packs/rules/000003.log b/packs/rules/000003.log new file mode 100644 index 0000000..347e0ec Binary files /dev/null and b/packs/rules/000003.log differ diff --git a/packs/rules/000005.ldb b/packs/rules/000005.ldb deleted file mode 100644 index e811734..0000000 Binary files a/packs/rules/000005.ldb and /dev/null differ diff --git a/packs/rules/000006.log b/packs/rules/000006.log deleted file mode 100644 index 72eda75..0000000 Binary files a/packs/rules/000006.log and /dev/null differ diff --git a/packs/rules/CURRENT b/packs/rules/CURRENT index cacca75..1a84852 100644 --- a/packs/rules/CURRENT +++ b/packs/rules/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/rules/LOG b/packs/rules/LOG index bbc40ff..d9c2440 100644 --- a/packs/rules/LOG +++ b/packs/rules/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.421106 17497b000 Recovering log #3 -2026/08/11-08:06:46.421169 17497b000 Level-0 table #5: started -2026/08/11-08:06:46.421431 17497b000 Level-0 table #5: 28916 bytes OK -2026/08/11-08:06:46.421607 17497b000 Delete type=0 #3 -2026/08/11-08:06:46.421634 17497b000 Delete type=3 #2 +2026/08/11-08:56:03.978547 16ff87000 Delete type=3 #1 diff --git a/packs/rules/LOG.old b/packs/rules/LOG.old deleted file mode 100644 index d977710..0000000 --- a/packs/rules/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.280795 171533000 Delete type=3 #1 diff --git a/packs/rules/MANIFEST-000002 b/packs/rules/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/rules/MANIFEST-000002 differ diff --git a/packs/rules/MANIFEST-000004 b/packs/rules/MANIFEST-000004 deleted file mode 100644 index 2c6a051..0000000 Binary files a/packs/rules/MANIFEST-000004 and /dev/null differ diff --git a/packs/skills/000003.log b/packs/skills/000003.log new file mode 100644 index 0000000..e23ff19 Binary files /dev/null and b/packs/skills/000003.log differ diff --git a/packs/skills/000005.ldb b/packs/skills/000005.ldb deleted file mode 100644 index d613d26..0000000 Binary files a/packs/skills/000005.ldb and /dev/null differ diff --git a/packs/skills/000006.log b/packs/skills/000006.log deleted file mode 100644 index aa7dd7d..0000000 Binary files a/packs/skills/000006.log and /dev/null differ diff --git a/packs/skills/CURRENT b/packs/skills/CURRENT index cacca75..1a84852 100644 --- a/packs/skills/CURRENT +++ b/packs/skills/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/skills/LOG b/packs/skills/LOG index 409b3dc..6e545bf 100644 --- a/packs/skills/LOG +++ b/packs/skills/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.413121 17416f000 Recovering log #3 -2026/08/11-08:06:46.413179 17416f000 Level-0 table #5: started -2026/08/11-08:06:46.413357 17416f000 Level-0 table #5: 7486 bytes OK -2026/08/11-08:06:46.413587 17416f000 Delete type=0 #3 -2026/08/11-08:06:46.413622 17416f000 Delete type=3 #2 +2026/08/11-08:56:03.938794 16e763000 Delete type=3 #1 diff --git a/packs/skills/LOG.old b/packs/skills/LOG.old deleted file mode 100644 index 713f3ca..0000000 --- a/packs/skills/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.250466 17051b000 Delete type=3 #1 diff --git a/packs/skills/MANIFEST-000002 b/packs/skills/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/skills/MANIFEST-000002 differ diff --git a/packs/skills/MANIFEST-000004 b/packs/skills/MANIFEST-000004 deleted file mode 100644 index 792e8a1..0000000 Binary files a/packs/skills/MANIFEST-000004 and /dev/null differ diff --git a/packs/slice/000003.log b/packs/slice/000003.log new file mode 100644 index 0000000..4a86fe9 Binary files /dev/null and b/packs/slice/000003.log differ diff --git a/packs/slice/000005.ldb b/packs/slice/000005.ldb deleted file mode 100644 index 4851c77..0000000 Binary files a/packs/slice/000005.ldb and /dev/null differ diff --git a/packs/slice/000006.log b/packs/slice/000006.log deleted file mode 100644 index c7d9b16..0000000 Binary files a/packs/slice/000006.log and /dev/null differ diff --git a/packs/slice/CURRENT b/packs/slice/CURRENT index cacca75..1a84852 100644 --- a/packs/slice/CURRENT +++ b/packs/slice/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/slice/LOG b/packs/slice/LOG index 159e9eb..f3f0f86 100644 --- a/packs/slice/LOG +++ b/packs/slice/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.422268 17416f000 Recovering log #3 -2026/08/11-08:06:46.422303 17416f000 Level-0 table #5: started -2026/08/11-08:06:46.422611 17416f000 Level-0 table #5: 16892 bytes OK -2026/08/11-08:06:46.422814 17416f000 Delete type=0 #3 -2026/08/11-08:06:46.422839 17416f000 Delete type=3 #2 +2026/08/11-08:56:03.972924 16ef6f000 Delete type=3 #1 diff --git a/packs/slice/LOG.old b/packs/slice/LOG.old deleted file mode 100644 index 13d38fd..0000000 --- a/packs/slice/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.275784 170d27000 Delete type=3 #1 diff --git a/packs/slice/MANIFEST-000002 b/packs/slice/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/slice/MANIFEST-000002 differ diff --git a/packs/slice/MANIFEST-000004 b/packs/slice/MANIFEST-000004 deleted file mode 100644 index 0552c3a..0000000 Binary files a/packs/slice/MANIFEST-000004 and /dev/null differ diff --git a/packs/tables/000003.log b/packs/tables/000003.log new file mode 100644 index 0000000..f125b7d Binary files /dev/null and b/packs/tables/000003.log differ diff --git a/packs/tables/000005.ldb b/packs/tables/000005.ldb deleted file mode 100644 index 64baa1c..0000000 Binary files a/packs/tables/000005.ldb and /dev/null differ diff --git a/packs/tables/000006.log b/packs/tables/000006.log deleted file mode 100644 index d5e06f3..0000000 Binary files a/packs/tables/000006.log and /dev/null differ diff --git a/packs/tables/CURRENT b/packs/tables/CURRENT index cacca75..1a84852 100644 --- a/packs/tables/CURRENT +++ b/packs/tables/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/tables/LOG b/packs/tables/LOG index 78d3500..31ac16d 100644 --- a/packs/tables/LOG +++ b/packs/tables/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.423379 175187000 Recovering log #3 -2026/08/11-08:06:46.423401 175187000 Level-0 table #5: started -2026/08/11-08:06:46.423489 175187000 Level-0 table #5: 3739 bytes OK -2026/08/11-08:06:46.423704 175187000 Delete type=0 #3 -2026/08/11-08:06:46.423739 175187000 Delete type=3 #2 +2026/08/11-08:56:03.971218 16e763000 Delete type=3 #1 diff --git a/packs/tables/LOG.old b/packs/tables/LOG.old deleted file mode 100644 index d87b0ac..0000000 --- a/packs/tables/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.274259 171d3f000 Delete type=3 #1 diff --git a/packs/tables/MANIFEST-000002 b/packs/tables/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/tables/MANIFEST-000002 differ diff --git a/packs/tables/MANIFEST-000004 b/packs/tables/MANIFEST-000004 deleted file mode 100644 index 004ff30..0000000 Binary files a/packs/tables/MANIFEST-000004 and /dev/null differ diff --git a/packs/talents/000003.log b/packs/talents/000003.log new file mode 100644 index 0000000..443b7ea Binary files /dev/null and b/packs/talents/000003.log differ diff --git a/packs/talents/000005.ldb b/packs/talents/000005.ldb deleted file mode 100644 index fb653de..0000000 Binary files a/packs/talents/000005.ldb and /dev/null differ diff --git a/packs/talents/000006.log b/packs/talents/000006.log deleted file mode 100644 index 29003f7..0000000 Binary files a/packs/talents/000006.log and /dev/null differ diff --git a/packs/talents/CURRENT b/packs/talents/CURRENT index cacca75..1a84852 100644 --- a/packs/talents/CURRENT +++ b/packs/talents/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/talents/LOG b/packs/talents/LOG index 3e9a074..730ff0a 100644 --- a/packs/talents/LOG +++ b/packs/talents/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.414303 17497b000 Recovering log #3 -2026/08/11-08:06:46.414338 17497b000 Level-0 table #5: started -2026/08/11-08:06:46.414485 17497b000 Level-0 table #5: 5770 bytes OK -2026/08/11-08:06:46.414718 17497b000 Delete type=0 #3 -2026/08/11-08:06:46.414751 17497b000 Delete type=3 #2 +2026/08/11-08:56:03.942298 16ff87000 Delete type=3 #1 diff --git a/packs/talents/LOG.old b/packs/talents/LOG.old deleted file mode 100644 index 06e0c9d..0000000 --- a/packs/talents/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.252690 17051b000 Delete type=3 #1 diff --git a/packs/talents/MANIFEST-000002 b/packs/talents/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/talents/MANIFEST-000002 differ diff --git a/packs/talents/MANIFEST-000004 b/packs/talents/MANIFEST-000004 deleted file mode 100644 index f8c6b47..0000000 Binary files a/packs/talents/MANIFEST-000004 and /dev/null differ diff --git a/packs/vehicles/000003.log b/packs/vehicles/000003.log new file mode 100644 index 0000000..9950ce5 Binary files /dev/null and b/packs/vehicles/000003.log differ diff --git a/packs/vehicles/000005.ldb b/packs/vehicles/000005.ldb deleted file mode 100644 index f6b2cd4..0000000 Binary files a/packs/vehicles/000005.ldb and /dev/null differ diff --git a/packs/vehicles/000006.log b/packs/vehicles/000006.log deleted file mode 100644 index 47800e2..0000000 Binary files a/packs/vehicles/000006.log and /dev/null differ diff --git a/packs/vehicles/CURRENT b/packs/vehicles/CURRENT index cacca75..1a84852 100644 --- a/packs/vehicles/CURRENT +++ b/packs/vehicles/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/vehicles/LOG b/packs/vehicles/LOG index 58e94ca..3c3d6b6 100644 --- a/packs/vehicles/LOG +++ b/packs/vehicles/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.419951 17416f000 Recovering log #3 -2026/08/11-08:06:46.419979 17416f000 Level-0 table #5: started -2026/08/11-08:06:46.420174 17416f000 Level-0 table #5: 6939 bytes OK -2026/08/11-08:06:46.420458 17416f000 Delete type=0 #3 -2026/08/11-08:06:46.420501 17416f000 Delete type=3 #2 +2026/08/11-08:56:03.951942 16e763000 Delete type=3 #1 diff --git a/packs/vehicles/LOG.old b/packs/vehicles/LOG.old deleted file mode 100644 index eb3225d..0000000 --- a/packs/vehicles/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.258984 171533000 Delete type=3 #1 diff --git a/packs/vehicles/MANIFEST-000002 b/packs/vehicles/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/vehicles/MANIFEST-000002 differ diff --git a/packs/vehicles/MANIFEST-000004 b/packs/vehicles/MANIFEST-000004 deleted file mode 100644 index d4d73b7..0000000 Binary files a/packs/vehicles/MANIFEST-000004 and /dev/null differ diff --git a/packs/weapons/000003.log b/packs/weapons/000003.log new file mode 100644 index 0000000..e1488ed Binary files /dev/null and b/packs/weapons/000003.log differ diff --git a/packs/weapons/000005.ldb b/packs/weapons/000005.ldb deleted file mode 100644 index 742beba..0000000 Binary files a/packs/weapons/000005.ldb and /dev/null differ diff --git a/packs/weapons/000006.log b/packs/weapons/000006.log deleted file mode 100644 index 4d427a6..0000000 Binary files a/packs/weapons/000006.log and /dev/null differ diff --git a/packs/weapons/CURRENT b/packs/weapons/CURRENT index cacca75..1a84852 100644 --- a/packs/weapons/CURRENT +++ b/packs/weapons/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/weapons/LOG b/packs/weapons/LOG index c53e472..7d206d0 100644 --- a/packs/weapons/LOG +++ b/packs/weapons/LOG @@ -1,5 +1 @@ -2026/08/11-08:06:46.416237 17497b000 Recovering log #3 -2026/08/11-08:06:46.416299 17497b000 Level-0 table #5: started -2026/08/11-08:06:46.416505 17497b000 Level-0 table #5: 14767 bytes OK -2026/08/11-08:06:46.416784 17497b000 Delete type=0 #3 -2026/08/11-08:06:46.416833 17497b000 Delete type=3 #2 +2026/08/11-08:56:03.945178 16ef6f000 Delete type=3 #1 diff --git a/packs/weapons/LOG.old b/packs/weapons/LOG.old deleted file mode 100644 index 161ec1b..0000000 --- a/packs/weapons/LOG.old +++ /dev/null @@ -1 +0,0 @@ -2026/08/11-08:06:24.254252 17051b000 Delete type=3 #1 diff --git a/packs/weapons/MANIFEST-000002 b/packs/weapons/MANIFEST-000002 new file mode 100644 index 0000000..bbbc585 Binary files /dev/null and b/packs/weapons/MANIFEST-000002 differ diff --git a/packs/weapons/MANIFEST-000004 b/packs/weapons/MANIFEST-000004 deleted file mode 100644 index 6a582af..0000000 Binary files a/packs/weapons/MANIFEST-000004 and /dev/null differ diff --git a/ringbrp.mjs b/ringbrp.mjs index 85ecab3..25d4e49 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -32,6 +32,11 @@ import { FALL, fallDamageFor, ASPHYXIA, breathRoundsFor, FIRE, fireBandFor, BLAST, blastBandFor, blastShareFor, BLAST_COVER, blastAfterCover, BURST, burstHitsFor, burstRoundsSpent, BURST_FUMBLE, BURST_ONE_MAJOR_WOUND, + BURST_HITS, + FIRE_MODE, fireModeFor, burstSizesFor, fireModeAttackFor, secondShotPenaltyFor, + SUSTAIN, sustainDamageFor, sustainChargeFor, + SIGHT, sightFor, sightBonusFor, sightSeesInDark, + LIGHTING, lightingModFor, SPACE_BANDS, spaceBandRank, spaceBandAfter, STATIONS, HEAT, heatEffectsFor, spaceRangeModFor, spaceHitFor, craftConditionFor, TWO_WEAPON, twoWeaponPenaltyFor, canPairWeapons, @@ -285,7 +290,14 @@ RINGBRP.locationEffects = { head: { disabled: "RINGBRP.LocEffect.head", destroyed: "RINGBRP.LocEffect.headX" } }; -RINGBRP.fireModes = ["", "single", "rapid", "slow", "beam"]; +// Not a copy — rules.mjs owns the modes; this is the keyed view the item sheet wants, +// with a blank at the front for melee. The hand-written list that used to live here +// was missing `fast`, which five catalogue weapons already carried: the select had no +// matching option, so opening one of those items and saving it blanked its fire mode. +RINGBRP.fireModes = ["", ...Object.keys(FIRE_MODE)]; +RINGBRP.sights = SIGHT; +RINGBRP.lighting = LIGHTING; +RINGBRP.sustain = SUSTAIN; RINGBRP.weaponClasses = ["melee", "thrown", "bow", "firearm", "energy", "stun", "special"]; RINGBRP.damageTags = ["kinetic", "energy", "monofilament", "disintegrator", "explosive", "blunt", "edged", "stun"]; RINGBRP.traditions = ["tradition_home", "tradition_rival_a", "tradition_rival_b", "tradition_local", "tradition_builder", "tradition_precursor"]; @@ -842,6 +854,46 @@ export function gearBonusFor(actor, skillFamilyId) { }; } +/** + * The sight actually bolted to this weapon, decided by what the agent is carrying. + * + * The point of grading sights by range band is that they apply themselves: there is + * nothing for the player to select and nothing to forget, because the band is already + * known by the time the modifier is wanted. What this has to get right is FIT — a + * telescopic sight on a service pistol is not a thing, so an optic declares what it + * fits and is matched against the weapon's own specialisation. + * + * Only carried optics count, and the best single one applies: taping three sights to + * a rifle is not three bonuses. + */ +export function sightForWeapon(actor, weapon) { + if (!actor || !weapon?.system?.isRanged) return { id: "none", source: "" }; + const spec = weapon.system.skillSpecialisationId || ""; + let best = { id: "none", source: "", worth: 0 }; + for (const it of actor.items ?? []) { + if (it.system?.carried === false) continue; + const o = it.system?.optic; + const kind = o?.kind; + if (!kind || !SIGHT[kind]) continue; + const fits = o.fits || "any"; + if (fits !== "any" && fits !== spec) continue; + // "Best" is the most it is ever worth, which keeps the choice stable across + // bands — a scope does not stop being the scope you fitted at close range. + const worth = Math.max(...Object.values(sightFor(kind).bands)); + if (worth > best.worth) best = { id: kind, source: it.name, worth }; + } + return { id: best.id, source: best.source }; +} + +/** Is this agent carrying something lit? Lamps are gear, not a checkbox. */ +export function carriedLight(actor) { + for (const it of actor?.items ?? []) { + if (it.system?.carried === false) continue; + if (it.system?.lightSource) return { on: true, source: it.name }; + } + return { on: false, source: "" }; +} + /** * Core skill roll. Everything in the system funnels through here so that the * band arithmetic exists in exactly one place. @@ -2319,9 +2371,18 @@ export async function detonate({ formula = "4d6", radius = 6, targets = [] } = { */ export async function burstAttack(actor, weapon, target, { size = "short", difficulty = "average", - situational = 0 } = {}) { + situational = 0, + sizeModApplied = false, + flavourLines = [] } = {}) { if (!actor || !weapon) return null; const b = BURST[size] ?? BURST.short; + // The mode is the gate, and it is checked HERE rather than only in the dialog, so + // that no caller — a macro, a playtest harness, the API — can have a muzzle-loader + // produce automatic fire. + if (!burstSizesFor(weapon.system?.fireMode).includes(b.id)) { + ui.notifications?.warn(game.i18n.format("RINGBRP.Notify.NoBurst", { name: weapon.name })); + return null; + } const loaded = Number(weapon.system?.ammo?.loaded) || 0; if (loaded <= 0) { ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.Empty")); @@ -2333,9 +2394,11 @@ export async function burstAttack(actor, weapon, target, { size = "short", const res = await skillRoll({ actor, label: game.i18n.format("RINGBRP.Burst.Label", { name: weapon.name, size: game.i18n.localize(b.label) }), - rating, difficulty, situational: situational + b.mod, + // When the sheet built the stack it already counted the burst's own modifier; + // adding it again here charged the player twice for the same decision. + rating, difficulty, situational: situational + (sizeModApplied ? 0 : b.mod), categoryId: "ranged", skillFamilyId: weapon.system.skillFamilyId, weapon, - flavourLines: [game.i18n.format("RINGBRP.Burst.Spends", { n: spent })] + flavourLines: [...flavourLines, game.i18n.format("RINGBRP.Burst.Spends", { n: spent })] }); if (!res) return null; // The rounds go whether or not anything is hit. That is automatic fire. @@ -2909,10 +2972,14 @@ export async function defend(actor, { type = "dodge", weapon = null, difficulty const BLOW_TTL_MS = 120000; let lastBlow = null; -export async function recordBlow({ attacker, level, weapon = null, targets = [] } = {}) { +export async function recordBlow({ attacker, level, weapon = null, targets = [], + bonus = "" } = {}) { if (!attacker || !["critical", "special", "success"].includes(level)) return null; + // `bonus` is extra damage dice the ATTACK earned rather than the weapon carrying — + // a sustained beam. It rides on the blow because the damage roll happens later and + // separately, and otherwise the choice that paid -25% to accuracy would be lost. const blow = { attackerId: attacker.id, attackerName: attacker.name, level, - weaponName: weapon?.name ?? null, targets, at: Date.now() }; + weaponName: weapon?.name ?? null, targets, bonus, at: Date.now() }; lastBlow = blow; if (game.combat) await game.combat.setFlag("ringbrp", "standingBlow", blow); return blow; @@ -4542,7 +4609,9 @@ class RingBRPCharacterSheet extends ActorSheet { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["ringbrp", "sheet", "actor", "character"], template: "systems/ringbrp/templates/character-sheet.hbs", - width: 820, + // The tab rail takes 152px the body used to have. Paying for it here keeps the + // skills list at two columns and every other tab exactly as roomy as before. + width: 972, height: 880, tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "skills" }] }); @@ -4879,7 +4948,12 @@ class RingBRPCharacterSheet extends ActorSheet { } _itemFrom(ev) { - const id = ev.currentTarget.closest("[data-item-id]")?.dataset.itemId; + // currentTarget is only set while the event is being dispatched. A handler that + // awaits anything and then calls this again gets null and throws — which is + // exactly what happened to every Coherence-costing weapon. Fall back to the + // target, which survives, so a second call after an await still resolves. + const from = ev.currentTarget ?? ev.target; + const id = from?.closest("[data-item-id]")?.dataset.itemId; return id ? this.actor.items.get(id) : null; } @@ -4971,13 +5045,16 @@ class RingBRPCharacterSheet extends ActorSheet { } async _onRollAttack(ev) { - // F5: transposed materiel charges its Coherence cost when it is actually used. - const _w = this._itemFrom(ev); - if (_w && Number(_w.system?.coherence ?? _w.system?.coherenceCost)) { - await useTransposed(this.actor, _w); - } + // Resolve the weapon ONCE, before anything is awaited. Reading it a second time + // after the Coherence charge was how the Redaction lance, the Unmaker and every + // other piece of transposed materiel became unusable: the event had gone stale + // by then and the attack died on a TypeError with nothing shown to the player. const weapon = this._itemFrom(ev); if (!weapon) return; + // F5: transposed materiel charges its Coherence cost when it is actually used. + if (Number(weapon.system?.coherence ?? weapon.system?.coherenceCost)) { + await useTransposed(this.actor, weapon); + } const sys = this.actor.system; const rating = this.actor.skillRating(weapon.system.skillFamilyId, weapon.system.skillSpecialisationId); if (rating === null) { @@ -4990,27 +5067,70 @@ class RingBRPCharacterSheet extends ActorSheet { ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.OutOfRange")); return; } + // A burst is a different resolution, not a modified one: one roll whose BAND + // decides how much of it arrived. burstAttack owns that, and takes the whole + // modifier stack this dialog built rather than rebuilding its own. + if (opts.burst) { + const target = [...(game.user.targets ?? [])][0]?.actor ?? null; + return burstAttack(this.actor, weapon, target, + { size: opts.burst, difficulty: opts.difficulty, + situational: opts.situational, sizeModApplied: true, flavourLines: opts.lines }); + } + // Ammunition (spec §4.5) if (weapon.system.ammo?.capacity > 0) { if ((weapon.system.ammo.loaded ?? 0) <= 0) { ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.Empty")); return; } - await weapon.update({ "system.ammo.loaded": weapon.system.ammo.loaded - 1 }); + const cost = opts.sustain ? sustainChargeFor(opts.sustain) : 1; + if ((weapon.system.ammo.loaded ?? 0) < cost) { + ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.NotEnoughCharge")); + return; + } + await weapon.update({ "system.ammo.loaded": weapon.system.ammo.loaded - cost }); + } + + // The second shot a fast action gives you. Both shots are worse, which is the + // whole cost, and it is once per round — which is what `secondAttackUsed` was + // reset for every round without anything ever setting it. + const shots = opts.second ? 2 : 1; + if (opts.second) { + if (this.actor.system.combat?.secondAttackUsed) { + ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.SecondUsed")); + return; + } + await this.actor.update({ "system.combat.secondAttackUsed": true }); + } + + let res = null; + for (let i = 0; i < shots; i++) { + res = await skillRoll({ + actor: this.actor, + label: shots > 1 ? `${weapon.name} (${i + 1}/${shots})` : weapon.name, + rating, + difficulty: opts.difficulty, + situational: opts.situational, + flavourLines: opts.lines, + categoryId: weapon.system?.isRanged ? "ranged" : "melee", + weapon + }); + // Leave the blow standing so whoever defends knows what quality it was. + if (res) await recordBlow({ attacker: this.actor, level: res.level, weapon, + bonus: opts.sustain ? sustainDamageFor(opts.sustain) : "", + targets: [...(game.user.targets ?? [])].map(t => t.id) }); + } + + // A slow weapon costs its reload after the shot, not before it. This is the + // other half of why it is worth +10%: you get one, and then you work for it. + if (opts.reloads && weapon.system.ammo?.capacity > 0) { + const n = Number(weapon.system.ammo.reloadRounds) || 1; + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: this.actor }), + content: `
${ + game.i18n.format("RINGBRP.Fire.Reloading", { name: weapon.name, n })}
` + }); } - const res = await skillRoll({ - actor: this.actor, - label: weapon.name, - rating, - difficulty: opts.difficulty, - situational: opts.situational, - flavourLines: opts.lines, - categoryId: weapon.system?.isRanged ? "ranged" : "melee", - weapon - }); - // Leave the blow standing so whoever defends knows what quality it was. - if (res) await recordBlow({ attacker: this.actor, level: res.level, weapon, - targets: [...(game.user.targets ?? [])].map(t => t.id) }); return res; } @@ -5027,7 +5147,8 @@ class RingBRPCharacterSheet extends ActorSheet { return ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.BlowStopped")); } return rollWeaponDamage({ actor: this.actor, weapon, - level: mine ? blow.level : "success" }); + level: mine ? blow.level : "success", + bonus: mine ? (blow.bonus ?? "") : "" }); } } @@ -5438,8 +5559,67 @@ async function promptAttackOptions({ weapon, actor }) { span: `over ${r.long * 2} m`, mod: null }); } + // What this weapon can do with a round. The mode has been on every weapon in the + // catalogue since the packs were first built and was read by nothing, so a musket + // could rip a ten-round burst and "rapid" bought a weapon precisely nothing. + const modeId = weapon.system.fireMode || ""; + const mode = fireModeFor(modeId); + const fire = { options: [], note: "" }; + if (weapon.system.isRanged) { + fire.options.push({ + value: "single", selected: true, mod: fireModeAttackFor(modeId), + label: game.i18n.localize("RINGBRP.Fire.Shot"), effect: "" + }); + for (const size of burstSizesFor(modeId)) { + const b = BURST[size]; + fire.options.push({ + value: `burst:${size}`, mod: fireModeAttackFor(modeId) + b.mod, + label: game.i18n.localize(b.label), + effect: game.i18n.format("RINGBRP.Fire.Rounds", + { n: b.rounds, hits: BURST_HITS[size].join("/") }) + }); + } + if (mode.sustain) { + for (const [id, s] of Object.entries(SUSTAIN)) { + if (id === "tap") continue; + fire.options.push({ + value: `sustain:${id}`, mod: fireModeAttackFor(modeId) + s.mod, + label: game.i18n.localize(s.label), + effect: game.i18n.format("RINGBRP.Fire.Adds", { dice: s.bonus }) + }); + } + } + if (mode.second) { + fire.options.push({ + value: "second", mod: fireModeAttackFor(modeId) + secondShotPenaltyFor(modeId), + label: game.i18n.localize("RINGBRP.Fire.Second"), + effect: game.i18n.localize("RINGBRP.Fire.SecondEffect") + }); + } + fire.note = game.i18n.localize(`RINGBRP.Fire.${mode.id}.Note`); + if (fire.options.length < 2) fire.options = []; // a single-shot weapon has no choice to offer + } + + // Kit applies itself. The sight is decided by what is carried and what it fits, + // and the lamp by whether anything carried is lit — neither is a thing the player + // selects, because both are facts about the pack rather than about the shot. + const sight = sightForWeapon(actor, weapon); + const lamp = carriedLight(actor); + const kit = []; + if (sight.id !== "none") { + const bands = sightFor(sight.id).bands; + kit.push({ name: sight.source, effect: Object.entries(bands) + .filter(([, v]) => v) + .map(([b, v]) => `${v > 0 ? "+" : ""}${v}% ${game.i18n.localize(`RINGBRP.Range.${b}`)}`) + .join(" · ") }); + } + if (lamp.on) { + kit.push({ name: lamp.source, effect: game.i18n.localize("RINGBRP.Sight.LampEffect") }); + } + const content = await rbRenderTemplate("systems/ringbrp/templates/dialog-attack.hbs", - { RINGBRP, weapon, isRanged: weapon.system.isRanged, ranges: weapon.system.ranges, bandOptions }); + { RINGBRP, weapon, isRanged: weapon.system.isRanged, ranges: weapon.system.ranges, + bandOptions, fire, kit }); return new Promise(resolve => { new (rbDialogClass())({ title: weapon.name, @@ -5454,20 +5634,64 @@ async function promptAttackOptions({ weapon, actor }) { const moved = el.querySelector("[name=moved]")?.checked ?? false; const aiming = el.querySelector("[name=aiming]")?.checked ?? false; const extra = Number(el.querySelector("[name=situational]")?.value) || 0; + const firePick = el.querySelector("[name=fire]")?.value ?? "single"; + const light = el.querySelector("[name=lighting]")?.value ?? "lit"; const style = actor.system.marksmanship?.style ?? "reflex"; const styleCfg = RINGBRP.marksmanship[style]; const lines = []; let mod = extra; + // How the weapon is being fired, before anything about the situation. + const [pick, arg] = firePick.split(":"); + const modeMod = fireModeAttackFor(modeId); + if (weapon.system.isRanged && modeMod) { + mod += modeMod; + lines.push(`${game.i18n.localize(mode.label)} +${modeMod}%`); + } + if (pick === "burst") { + mod += BURST[arg].mod; + lines.push(`${game.i18n.localize(BURST[arg].label)} ${BURST[arg].mod}%`); + } else if (pick === "sustain") { + mod += SUSTAIN[arg].mod; + lines.push(`${game.i18n.localize(SUSTAIN[arg].label)} ${SUSTAIN[arg].mod}% ` + + `(+${SUSTAIN[arg].bonus})`); + } else if (pick === "second") { + const sp = secondShotPenaltyFor(modeId); + mod += sp; + lines.push(`${game.i18n.localize("RINGBRP.Fire.Second")} ${sp}%`); + } + + let band = "short"; if (weapon.system.isRanged && weapon.system.ranges?.long) { const rb = rangeBandFor(distance, weapon.system.ranges); if (rb.mod === null) return resolve({ impossible: true }); + band = rb.band; mod += rb.mod; lines.push(`${game.i18n.localize(`RINGBRP.Range.${rb.band}`)} ${rb.mod >= 0 ? "+" : ""}${rb.mod}%`); const sb = styleRangeBonus(style, rb.band); if (sb) { mod += sb; lines.push(`${game.i18n.localize(styleCfg.label)} +${sb}%`); } } + + // The sight, at the band the shot is actually being taken at. A scope is + // worth +15% at two hundred metres and -10% across a corridor, and the + // whole reason it grades by band is so it can apply itself. + if (sight.id !== "none") { + const sb = sightBonusFor(sight.id, band); + if (sb) { + mod += sb; + lines.push(`${sight.source} ${sb >= 0 ? "+" : ""}${sb}%`); + } + } + // The dark, after the lamp and after a sight that does not need one. + const lit = lightingModFor(light, { lightSource: lamp.on, + seesInDark: sightSeesInDark(sight.id) }); + if (lit.mod) { + mod += lit.mod; + lines.push(`${game.i18n.localize(LIGHTING[lit.effective].label)} ${lit.mod}%`); + } + if (lit.revealed) lines.push(game.i18n.format("RINGBRP.Sight.Revealed", { name: lamp.source })); + const cv = RINGBRP.cover[cover]?.mod ?? 0; if (cv) { mod += cv; lines.push(`${game.i18n.localize(RINGBRP.cover[cover].label)} ${cv}%`); } if (moved && styleCfg.moveAndFire) { @@ -5479,7 +5703,14 @@ async function promptAttackOptions({ weapon, actor }) { lines.push(`${game.i18n.localize("RINGBRP.Attack.Aimed")} +${styleCfg.aim}%`); if (styleCfg.ignoresArmour) lines.push(game.i18n.format("RINGBRP.Attack.IgnoresArmour", { n: styleCfg.ignoresArmour })); } - resolve({ difficulty: "average", situational: mod, lines }); + resolve({ + difficulty: "average", situational: mod, lines, + // What the caller has to DO about the choice, rather than what it cost. + burst: pick === "burst" ? arg : null, + sustain: pick === "sustain" ? arg : null, + second: pick === "second", + reloads: !!mode.reloads + }); } }, cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(null) } @@ -5502,7 +5733,8 @@ async function promptAttackOptions({ weapon, actor }) { * when the hit is resolved, so what changes here is the weapon dice and the note * the card carries. */ -export async function rollWeaponDamage({ actor = null, weapon, level = "success", band = null } = {}) { +export async function rollWeaponDamage({ actor = null, weapon, level = "success", band = null, + bonus = "" } = {}) { if (!weapon) return null; const d = weapon.system?.damage ?? {}; let formula = d.formula || "1d6"; @@ -5511,6 +5743,8 @@ export async function rollWeaponDamage({ actor = null, weapon, level = "success" if (!b) return null; formula = d[b] || d.formula || "1d6"; } + // Dice the ATTACK earned rather than the weapon carrying — a sustained beam. + if (bonus) formula = `${formula} + ${bonus}`; const dm = actor?.system?.damageModifier; const usesDm = weapon.system?.appliesDamageModifier && dm && dm !== "0"; @@ -5527,10 +5761,25 @@ export async function rollWeaponDamage({ actor = null, weapon, level = "success" const note = level === "critical" ? game.i18n.localize("RINGBRP.Chat.DamageCritical") : level === "special" ? game.i18n.localize("RINGBRP.Chat.DamageSpecial") : ""; + // This was the one card in the system still wearing Foundry's stock dice styling, + // because it posted the Roll and let the core template dress it. It now renders + // through the module's own card like every other result, and the Roll still rides + // on the message so the dice remain inspectable and Dice So Nice still animates. + const content = await rbRenderTemplate("systems/ringbrp/templates/chat-damage.hbs", { + title: game.i18n.format("RINGBRP.Chat.DamageFlavor", { name: weapon.name }), + level, formula, bonus, + modifier: usesDm ? dm : "", + note, + total: roll.total, + // Wound types have never carried localised labels — the item sheet prints the + // raw key too — so this stays consistent rather than inventing a key namespace. + wound: weapon.system?.woundType || "", + dice: roll.dice.flatMap(d => d.results.filter(r => r.active) + .map(r => ({ result: r.result, faces: d.faces }))) + }); await roll.toMessage({ speaker: ChatMessage.getSpeaker({ actor: actor ?? undefined }), - flavor: game.i18n.format("RINGBRP.Chat.DamageFlavor", { name: weapon.name }) - + (note ? ` — ${note}` : "") + content }); return roll.total; } @@ -5805,6 +6054,7 @@ Hooks.once("init", () => { addDossierItem, useAuthority, widenScope, rehabilitate, tickDying, died, stabilise, reviveIfHealed, conditionFor, dyingLimitFor, fall, asphyxiate, burn, detonate, burstAttack, twoWeaponAttack, + sightForWeapon, carriedLight, fireModeFor, burstSizesFor, sustainDamageFor, manStation, bestStationFor, STATIONS, SPACE_BANDS, heatEffectsFor, craftConditionFor, spaceBandAfter, fallDamageFor, breathRoundsFor, fireBandFor, blastBandFor, burstHitsFor, @@ -5826,8 +6076,61 @@ Hooks.once("init", () => { }; }); -Hooks.once("ready", () => { +/** + * Bring gear that predates a field up to date with the catalogue. + * + * A sight and a lamp are supposed to apply themselves, and on a fresh import they + * do. But an agent created before those fields existed carries a Field lamp whose + * `lightSource` is the template default of false, so the lamp sat in the pack doing + * nothing and the dark stayed at its full penalty. "Automatic" that requires the + * player to re-import their kit is not automatic. + * + * The compendium is the authority and items carry `flags.ringbrp.key` (stamped at + * build), so the match is exact rather than by display name — the fuzzy-name match + * this project used before mapped a climbing kit to breaching charges. + */ +async function migrateFittedGear() { + const pack = game.packs.get("ringbrp.gear"); + if (!pack) return 0; + const canon = new Map(); + for (const doc of await pack.getDocuments()) { + const key = doc.flags?.ringbrp?.key; + if (!key) continue; + if (doc.system?.optic?.kind || doc.system?.lightSource) { + canon.set(key, { optic: doc.system.optic, lightSource: !!doc.system.lightSource }); + } + } + if (!canon.size) return 0; + let fixed = 0; + const owners = [...game.actors, ...game.scenes.map(s => s.tokens + .filter(t => !t.actorLink && t.actor).map(t => t.actor)).flat()]; + for (const actor of owners) { + const updates = []; + for (const it of actor.items) { + if (it.type !== "gear") continue; + const want = canon.get(it.flags?.ringbrp?.key); + if (!want) continue; + const stale = (it.system.optic?.kind ?? "") !== (want.optic?.kind ?? "") + || !!it.system.lightSource !== want.lightSource; + if (stale) updates.push({ _id: it.id, "system.optic": want.optic, + "system.lightSource": want.lightSource }); + } + if (updates.length) { + await actor.updateEmbeddedDocuments("Item", updates); + fixed += updates.length; + } + } + return fixed; +} + +Hooks.once("ready", async () => { console.log("RingBRP | ready"); + if (!game.user.isGM) return; + const fixed = await migrateFittedGear(); + if (fixed) { + console.log(`RingBRP | fitted ${fixed} item(s) that predated the sight and lamp fields`); + ui.notifications?.info(game.i18n.format("RINGBRP.Notify.GearFitted", { n: fixed })); + } }); /** diff --git a/rules.mjs b/rules.mjs index 7ea825f..fcfc9cd 100644 --- a/rules.mjs +++ b/rules.mjs @@ -564,6 +564,190 @@ export function burstRoundsSpent(size, loaded) { /** A fumbled burst is the interesting one: it is a jam, or it is worse. */ export const BURST_FUMBLE = ["RINGBRP.Burst.Jam", "RINGBRP.Burst.Empty", "RINGBRP.Burst.Wild"]; +/** + * WHAT A WEAPON CAN DO WITH A ROUND. + * + * Every weapon in the catalogue has carried a `fireMode` since the packs were first + * built, and until now precisely nothing read it. A musket could rip a ten-round + * burst; a weapon marked "rapid" got no benefit from being rapid. The mode is now + * the gate on the burst rules and it carries its own attack modifier, so choosing + * a slow weapon is a decision rather than a drawback. + * + * The trade runs one way along the ladder. The faster a weapon puts rounds out, the + * less each individual shot is worth; the slower it is, the more the one shot you + * get is worth taking properly. + * + * slow one shot, then work — and it is fired by somebody who knows that + * single the ordinary case, and the baseline every modifier is measured from + * fast the action will give you a second shot if you will accept both being worse + * rapid bursts, which is the automatic-fire rules above + * beam no recoil, no lead, no drop — and it can be held on the target + * + * `fast` was in the catalogue on five weapons (longbow, sling, javelin, the Webley + * and the Lee-Enfield) and was not in the system's list of modes at all, so the item + * sheet offered no matching option and re-saving one of those weapons silently blanked + * its mode. It is a real rate of fire and it is now a real mode. + */ +export const FIRE_MODE = { + slow: { id: "slow", label: "RINGBRP.Fire.slow.Label", attack: 10, burst: [], + reloads: true, second: false, sustain: false }, + single: { id: "single", label: "RINGBRP.Fire.single.Label", attack: 0, burst: [], + reloads: false, second: false, sustain: false }, + fast: { id: "fast", label: "RINGBRP.Fire.fast.Label", attack: 0, burst: [], + reloads: false, second: true, sustain: false }, + rapid: { id: "rapid", label: "RINGBRP.Fire.rapid.Label", attack: 0, burst: ["short", "long", "full"], + reloads: false, second: false, sustain: false }, + beam: { id: "beam", label: "RINGBRP.Fire.beam.Label", attack: 5, burst: [], + reloads: false, second: false, sustain: true } +}; + +/** A blank mode is a melee weapon or an unclassified one: it behaves as `single`. */ +export function fireModeFor(id) { + return FIRE_MODE[id] || FIRE_MODE.single; +} + +/** Which bursts this weapon can actually produce. Empty means it cannot. */ +export function burstSizesFor(id) { + return fireModeFor(id).burst; +} + +/** The flat modifier for firing this weapon at all, before range and everything else. */ +export function fireModeAttackFor(id) { + return fireModeFor(id).attack; +} + +/** + * The second shot a fast action will give you. BOTH shots take it, not just the + * second — the cost of hurrying is that you hurried the first one too, which is + * what stops this from being a free extra attack for anyone holding a revolver. + */ +export const SECOND_SHOT = -20; + +export function secondShotPenaltyFor(id) { + return fireModeFor(id).second ? SECOND_SHOT : null; +} + +/** + * SUSTAINED BEAMS. + * + * A beam does not fire discrete rounds, so a burst is the wrong shape for it: there + * is nothing to count hits of. What a beam can do instead is stay on the target, + * and that buys DAMAGE rather than hits — the one place in this system where a firing + * choice adds dice instead of adding rolls. + * + * It is paid for twice, in accuracy and in charge, because holding a line on a moving + * person is hard and because the alternative is that everybody holds every shot. + */ +export const SUSTAIN = { + tap: { id: "tap", label: "RINGBRP.Sustain.tap", mod: 0, bonus: "", charge: 1 }, + hold: { id: "hold", label: "RINGBRP.Sustain.hold", mod: -10, bonus: "1d6", charge: 3 }, + lock: { id: "lock", label: "RINGBRP.Sustain.lock", mod: -25, bonus: "2d6", charge: 6 } +}; + +export function sustainFor(id) { + return SUSTAIN[id] || SUSTAIN.tap; +} + +/** The extra dice a sustained beam adds to its damage formula, or "" for none. */ +export function sustainDamageFor(id) { + return sustainFor(id).bonus; +} + +/** + * What a sustained beam costs, counted in SHOTS — because `ammo.loaded` is spent one + * per shot everywhere else in the system, so a cell holding 20 holds twenty shots. + * + * This was briefly multiplied by the weapon's `energyPerShot`, which made a locked + * beam cost 60 from a 20-shot cell and therefore impossible to fire at all. That + * field is written by the pack builder and read by nothing; the magazine is the + * authority on what a weapon has left. + */ +export function sustainChargeFor(id) { + return sustainFor(id).charge; +} + +/* -------------------------------------------- */ +/* What is bolted to the weapon */ +/* -------------------------------------------- */ + +/** + * SIGHTS. + * + * A sight is not a flat bonus, and making it one is why most systems end up with + * everybody carrying the same scope. A sight is good at the distance it was ground + * for and a liability at the others, so the bonus is read off the RANGE BAND the + * attack is already using — which means it applies itself, with nothing for the + * player to remember and nothing for them to choose. + * + * A telescopic sight is a PENALTY inside ten metres. That is the whole point of it. + */ +export const SIGHT = { + none: { id: "none", label: "RINGBRP.Sight.none", + bands: { short: 0, medium: 0, long: 0, extreme: 0 }, dark: false }, + irons: { id: "irons", label: "RINGBRP.Sight.irons", + bands: { short: 5, medium: 5, long: 0, extreme: 0 }, dark: false }, + reflex: { id: "reflex", label: "RINGBRP.Sight.reflex", + bands: { short: 10, medium: 5, long: 0, extreme: -10 }, dark: false }, + telescopic: { id: "telescopic", label: "RINGBRP.Sight.telescopic", + bands: { short: -10, medium: 5, long: 15, extreme: 20 }, dark: false }, + thermal: { id: "thermal", label: "RINGBRP.Sight.thermal", + bands: { short: 5, medium: 5, long: 5, extreme: 0 }, dark: true } +}; + +export function sightFor(id) { + return SIGHT[id] || SIGHT.none; +} + +/** What this sight is worth at this band. Bands come from the range ladder. */ +export function sightBonusFor(id, band) { + const b = sightFor(id).bands; + return Number(b?.[band]) || 0; +} + +/** A thermal sight is the only one that removes the dark rather than surviving it. */ +export function sightSeesInDark(id) { + return !!sightFor(id).dark; +} + +/** + * LIGHT. + * + * The department issues lamps because most crossings do not come with the lights on, + * and until now the dark cost an attack nothing at all. It costs something now, and + * a carried lamp buys most of it back. + * + * What it does not buy back is the second half: a lamp is a position. The rule + * returns `revealed` so the card can say so, and what the other side does about it + * is the GM's, not the engine's. + */ +export const LIGHTING = { + lit: { id: "lit", label: "RINGBRP.Lighting.lit", mod: 0 }, + dim: { id: "dim", label: "RINGBRP.Lighting.dim", mod: -10 }, + dark: { id: "dark", label: "RINGBRP.Lighting.dark", mod: -30 }, + pitch: { id: "pitch", label: "RINGBRP.Lighting.pitch", mod: -50 } +}; + +/** A lamp never makes it daylight. This is the best it gets you. */ +export const LIGHT_FLOOR = "dim"; + +export function lightingFor(id) { + return LIGHTING[id] || LIGHTING.lit; +} + +/** + * The modifier the dark actually applies, after a lamp and after a thermal sight. + * A sight that sees in the dark beats a lamp, and does not tell anybody where you are. + */ +export function lightingModFor(id, { lightSource = false, seesInDark = false } = {}) { + const here = lightingFor(id); + if (seesInDark) return { mod: 0, effective: "lit", revealed: false }; + if (!lightSource) return { mod: here.mod, effective: here.id, revealed: false }; + const floor = lightingFor(LIGHT_FLOOR); + // A lamp cannot make anything worse, and cannot make it better than the floor. + const eff = here.mod >= floor.mod ? here : floor; + return { mod: eff.mod, effective: eff.id, revealed: eff.id !== here.id }; +} + /** * TWO WEAPONS. * diff --git a/styles/ringbrp.css b/styles/ringbrp.css index fd34d3d..da51b29 100644 --- a/styles/ringbrp.css +++ b/styles/ringbrp.css @@ -276,20 +276,60 @@ Tabs — solid active state, not a Material underline ============================================================ */ +/* A RAIL, not a strip. Eight tabs across the top of an 820px sheet compressed to + 11px type and were genuinely hard to read; down the side each one gets a whole + line to itself, so the type can be larger and the active tab can be a solid + block rather than a tint. */ + +.rb-main { + flex: 1 1 auto; min-height: 0; + display: flex; flex-direction: row; align-items: stretch; +} + .rb-tabs { - display: flex; padding: 0 14px; + flex: 0 0 152px; + display: flex; flex-direction: column; padding: 8px 0 0; background: var(--rb-void); - border-bottom: 1px solid var(--rb-line); + border-right: 1px solid var(--rb-line); + overflow-y: auto; } .rb-tabs .item { - font-family: var(--rb-body); font-weight: 700; font-size: 11px; letter-spacing: .06em; - text-transform: uppercase; color: var(--rb-muted); - padding: 9px 14px 8px; + display: flex; align-items: center; justify-content: space-between; gap: 6px; + font-family: var(--rb-display); font-weight: 700; font-size: 12px; letter-spacing: .05em; + text-transform: uppercase; color: var(--rb-muted); white-space: nowrap; + padding: 10px 10px 10px 13px; + border-left: 3px solid transparent; + border-bottom: 1px solid var(--rb-hairline); } -.rb-tabs .item:hover { color: var(--rb-white); } -.rb-tabs .item.active { color: var(--rb-void); background: var(--rb-yellow); } +.rb-tabs .item:hover { color: var(--rb-white); background: var(--rb-panel); } +.rb-tabs .item.active { + color: var(--rb-void); background: var(--rb-yellow); + border-left-color: var(--rb-white); +} +.rb-tab-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.rb-tab-count { + flex: 0 0 auto; font-family: var(--rb-mono); font-size: 10px; font-weight: 700; + letter-spacing: 0; padding: 1px 5px; border-radius: 2px; + background: var(--rb-panel); color: var(--rb-white); +} +.rb-tabs .item.active .rb-tab-count { background: var(--rb-void); color: var(--rb-yellow); } +.rb-tab-count.rb-tab-bad { background: var(--rb-danger); color: var(--rb-void); } +.rb-tabs .item.active .rb-tab-count.rb-tab-bad { background: var(--rb-void); color: var(--rb-danger); } -.rb-body { flex: 1 1 auto; overflow-y: auto; padding: 12px 14px 16px; } +.rb-body { flex: 1 1 auto; min-width: 0; overflow-y: auto; padding: 12px 14px 16px; } + +/* Narrow windows cannot afford 132px of rail — fold it back to a strip. */ +@media (max-width: 560px) { + .rb-main { flex-direction: column; } + .rb-tabs { + flex: 0 0 auto; flex-direction: row; overflow-x: auto; padding: 0 8px; + border-right: none; border-bottom: 1px solid var(--rb-line); + } + .rb-tabs .item { + border-left: none; border-bottom: 3px solid transparent; padding: 8px 11px; + } + .rb-tabs .item.active { border-bottom-color: var(--rb-white); } +} /* ============================================================ Section heads — the book's rule treatment @@ -325,13 +365,13 @@ .rb-body .tab[data-tab="skills"].active { display: block; - column-count: 2; + /* Driven by the space actually available rather than by window width. The rail + takes 152px the body used to have, so a width-based breakpoint measured the + wrong box and dropped to one column while there was still room for two. */ + columns: 300px 2; column-gap: 22px; column-fill: balance; } -@media (max-width: 720px) { - .rb-body .tab[data-tab="skills"].active { column-count: 1; } -} .rb-skills { list-style: none; margin: 0; padding: 0; } .rb-skill { display: grid; grid-template-columns: 13px 20px minmax(0,1fr) 44px 104px 15px 15px; @@ -476,6 +516,44 @@ .rb-wound-stamina { font-size: 11px; color: var(--rb-muted); font-style: italic; margin: 0; } .rb-dialog .notes { font-size: 12px; color: var(--rb-muted); } +/* Damage. This card used to be posted as a bare Roll and wore Foundry's stock + dice styling — grey boxes on a white panel, in the middle of a navy sheet. */ +.rb-chat-formula { font-family: var(--rb-mono); color: var(--rb-white); } +.rb-chat-mod { font-size: 11px; color: var(--rb-muted); } +.rb-damage-result { align-items: baseline; gap: 9px; } +.rb-damage-total { + font-size: 34px; font-weight: 700; line-height: 1; color: var(--rb-yellow); + font-variant-numeric: tabular-nums; +} +.rb-damage-unit { + font-family: var(--rb-display); font-size: 11px; letter-spacing: .12em; + text-transform: uppercase; color: var(--rb-muted); +} +.rb-damage-wound { + margin-left: auto; font-family: var(--rb-mono); font-size: 10px; + text-transform: uppercase; letter-spacing: .06em; color: var(--rb-muted); + border: 1px solid var(--rb-hairline); padding: 1px 6px; +} +.rb-damage-dice { gap: 6px; } +.rb-damage-dice span { + min-width: 17px; text-align: center; color: var(--rb-white); + border: 1px solid var(--rb-hairline); padding: 1px 0; +} +.rb-damage.rb-critical .rb-chat-title { color: var(--rb-yellow); } +.rb-damage.rb-critical .rb-damage-total { color: var(--rb-white); } + +/* What the pack is doing to the shot without being asked. */ +.rb-dialog-kit { + margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--rb-hairline); +} +.rb-kit-head { + font-family: var(--rb-display); font-size: 10px; letter-spacing: .1em; + text-transform: uppercase; color: var(--rb-yellow); +} +.rb-dialog-kit ul { list-style: none; margin: 4px 0 0; padding: 0; font-size: 12px; color: var(--rb-muted); } +.rb-dialog-kit li:before { content: "› "; color: var(--rb-yellow); } +.rb-fire-group .notes { margin: 3px 0 0; font-style: italic; } + /* ============================================================ ITEM SHEET — the statline, after the 1984 weapons table ============================================================ */ diff --git a/system.json b/system.json index 467cd02..67ca553 100644 --- a/system.json +++ b/system.json @@ -1,8 +1,8 @@ { "id": "ringbrp", "title": "The Custodians", - "description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there \u2014 and who come back a little less from here each time.", - "version": "0.7.0", + "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": "0.8.0", "compatibility": { "minimum": 13, "verified": "14.364" @@ -36,7 +36,7 @@ "packs": [ { "name": "pregens", - "label": "The Custodians \u00b7 Agents", + "label": "The Custodians · Agents", "path": "packs/pregens", "type": "Actor", "system": "ringbrp", @@ -47,7 +47,7 @@ }, { "name": "npcs", - "label": "The Custodians \u00b7 Natives & Threats", + "label": "The Custodians · Natives & Threats", "path": "packs/npcs", "type": "Actor", "system": "ringbrp", @@ -58,14 +58,14 @@ }, { "name": "skills", - "label": "The Custodians \u00b7 Skill List", + "label": "The Custodians · Skill List", "path": "packs/skills", "type": "Item", "system": "ringbrp" }, { "name": "talents", - "label": "The Custodians \u00b7 Talents", + "label": "The Custodians · Talents", "path": "packs/talents", "type": "Item", "system": "ringbrp", @@ -76,7 +76,7 @@ }, { "name": "powers", - "label": "The Custodians \u00b7 The Quiet Arts", + "label": "The Custodians · The Quiet Arts", "path": "packs/powers", "type": "Item", "system": "ringbrp", @@ -87,21 +87,21 @@ }, { "name": "weapons", - "label": "The Custodians \u00b7 Weapons", + "label": "The Custodians · Weapons", "path": "packs/weapons", "type": "Item", "system": "ringbrp" }, { "name": "armour", - "label": "The Custodians \u00b7 Armour", + "label": "The Custodians · Armour", "path": "packs/armour", "type": "Item", "system": "ringbrp" }, { "name": "gear", - "label": "The Custodians \u00b7 Gear & Devices", + "label": "The Custodians · Gear & Devices", "path": "packs/gear", "type": "Item", "system": "ringbrp" @@ -115,28 +115,28 @@ }, { "name": "rules", - "label": "The Custodians \u00b7 Rules", + "label": "The Custodians · Rules", "path": "packs/rules", "type": "JournalEntry", "system": "ringbrp" }, { "name": "slice", - "label": "The Custodians \u00b7 The Ilfracombe Crossing", + "label": "The Custodians · The Ilfracombe Crossing", "path": "packs/slice", "type": "JournalEntry", "system": "ringbrp" }, { "name": "tables", - "label": "The Custodians \u00b7 Site & Hazard Tables", + "label": "The Custodians · Site & Hazard Tables", "path": "packs/tables", "type": "RollTable", "system": "ringbrp" }, { "name": "expedition", - "label": "The Custodians \u00b7 Case File: ILFRACOMBE", + "label": "The Custodians · Case File: ILFRACOMBE", "path": "packs/expedition", "type": "Actor", "system": "ringbrp", diff --git a/template.json b/template.json index cfde35a..27741fc 100644 --- a/template.json +++ b/template.json @@ -251,7 +251,12 @@ "value": 0 }, "researchFacility": "", - "coherenceCost": 0 + "coherenceCost": 0, + "optic": { + "kind": "", + "fits": "any" + }, + "lightSource": false }, "device": { "templates": [ diff --git a/templates/character-sheet.hbs b/templates/character-sheet.hbs index ae241ec..afdeb4c 100644 --- a/templates/character-sheet.hbs +++ b/templates/character-sheet.hbs @@ -104,15 +104,17 @@ {{/each}} +
+
@@ -547,4 +549,6 @@
+ + diff --git a/templates/chat-damage.hbs b/templates/chat-damage.hbs new file mode 100644 index 0000000..948c5c1 --- /dev/null +++ b/templates/chat-damage.hbs @@ -0,0 +1,19 @@ +
+

{{title}}

+
+ {{formula}} + {{#if bonus}}+{{bonus}} {{localize "RINGBRP.Chat.Sustained"}}{{/if}} + {{#if modifier}}{{modifier}} {{localize "RINGBRP.Chat.DamageMod"}}{{/if}} +
+ {{#if note}}{{/if}} +
+ {{total}} + {{localize "RINGBRP.Chat.Damage"}} + {{#if wound}}{{wound}}{{/if}} +
+ {{#if dice.length}} +
+ {{#each dice as |d|}}{{d.result}}{{/each}} +
+ {{/if}} +
diff --git a/templates/dialog-attack.hbs b/templates/dialog-attack.hbs index 9bb4b35..2a8845f 100644 --- a/templates/dialog-attack.hbs +++ b/templates/dialog-attack.hbs @@ -1,4 +1,17 @@
+ + {{#if fire.options.length}} +
+ + +

{{fire.note}}

+
+ {{/if}} + {{#if bandOptions.length}}
@@ -17,6 +30,14 @@ {{/each}}
+
+ + +
@@ -27,4 +48,11 @@ + + {{#if kit.length}} +
+ {{localize "RINGBRP.Dialog.Fitted"}} + +
+ {{/if}}
diff --git a/tools/build-packs.mjs b/tools/build-packs.mjs index c2285f8..ea4c8da 100644 --- a/tools/build-packs.mjs +++ b/tools/build-packs.mjs @@ -118,6 +118,10 @@ function gearItem(g) { powerDraw: 0, skillBonus: { skillFamilyId: g.bonus?.fam ?? "", value: g.bonus?.value ?? 0 }, researchFacility: g.facility ?? "", coherenceCost: g.coherence ?? 0, + // A sight applies itself off the range band, and a lamp off the dark. Both are + // facts about what is in the pack rather than choices made at the trigger. + optic: { kind: g.optic ?? "", fits: g.fits ?? "any" }, + lightSource: !!g.lit, isAnchor: /anchor/i.test(g.name) }, effects: [], ...BASE(), diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index 15b0ad1..182f63a 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -71,10 +71,22 @@ for (const name of constants) { if (keys.length < 2) continue; for (const rel of files) { const src = await readFile(new URL(rel, root), "utf8"); - // an object literal that carries two or more of the canonical keys is a copy + // An object literal that carries the canonical keys is a copy. "Two or more" + // alone was too blunt: FIRE_MODE has `slow` and `fast`, and so does the entirely + // unrelated speedBands vocabulary, so an honest table was reported as a copy of + // a rule it has nothing to do with. A real drift bug duplicates the WHOLE table + // — every instance this guard has ever caught did — so require a majority of the + // rule's keys as well. Partial overlaps are still printed, because a half-copy + // is worth a human look even when it is not worth failing the build. for (const m of src.matchAll(/=\s*\{([^{}]*)\}/g)) { const hits = keys.filter(k => new RegExp(`\\b${k}\\s*:`).test(m[1])); - if (hits.length >= 2) fail(`${rel} re-declares ${name} keys (${hits.join(", ")}) — assign rules.${name} itself`); + if (hits.length < 2) continue; + if (hits.length * 2 >= keys.length) { + fail(`${rel} re-declares ${name} keys (${hits.join(", ")}) — assign rules.${name} itself`); + } else { + console.log(` note ${rel} shares ${hits.length}/${keys.length} key names with ` + + `${name} (${hits.join(", ")}) — read as coincidence, not a copy`); + } } } } @@ -244,6 +256,45 @@ const checks = [ ["burstHitsFor(success, short)", RULES.burstHitsFor("success","short"), 1], ["burstHitsFor(failure, long)", RULES.burstHitsFor("failure","long"), 0], ["burstRoundsSpent(long, 4 loaded)", RULES.burstRoundsSpent("long",4), 4], + // The mode is the gate. A musket may not rip a burst however the API is called. + ["burstSizesFor(slow) is empty", RULES.burstSizesFor("slow").length, 0], + ["burstSizesFor(rapid)", RULES.burstSizesFor("rapid").join(","), "short,long,full"], + ["fireModeFor(fast) is a real mode", RULES.fireModeFor("fast").id, "fast"], + ["fireModeFor(blank) falls to single", RULES.fireModeFor("").id, "single"], + ["fireModeAttackFor(slow)", RULES.fireModeAttackFor("slow"), 10], + ["fireModeAttackFor(beam)", RULES.fireModeAttackFor("beam"), 5], + ["fireModeAttackFor(rapid)", RULES.fireModeAttackFor("rapid"), 0], + // A second shot costs BOTH shots, and only a fast action offers one at all. + ["secondShotPenaltyFor(fast)", RULES.secondShotPenaltyFor("fast"), -20], + ["secondShotPenaltyFor(slow) is none", RULES.secondShotPenaltyFor("slow"), null], + // A beam buys damage, not hits — the one firing choice that adds dice. + ["sustainFor(lock).mod", RULES.sustainFor("lock").mod, -25], + ["sustainDamageFor(hold)", RULES.sustainDamageFor("hold"), "1d6"], + ["sustainDamageFor(tap) is none", RULES.sustainDamageFor("tap"), ""], + // Counted in SHOTS, so a 20-shot cell affords three locked beams — not zero. + ["sustainChargeFor(lock) in shots", RULES.sustainChargeFor("lock"), 6], + ["sustainChargeFor(hold) in shots", RULES.sustainChargeFor("hold"), 3], + ["sustainChargeFor(tap) is one shot", RULES.sustainChargeFor("tap"), 1], + // A scope is a LIABILITY up close. That is the trade that makes sights a choice. + ["sightBonusFor(telescopic, short)", RULES.sightBonusFor("telescopic","short"), -10], + ["sightBonusFor(telescopic, extreme)", RULES.sightBonusFor("telescopic","extreme"), 20], + ["sightBonusFor(reflex, short)", RULES.sightBonusFor("reflex","short"), 10], + ["sightBonusFor(reflex, extreme)", RULES.sightBonusFor("reflex","extreme"), -10], + ["sightBonusFor(none, long)", RULES.sightBonusFor("none","long"), 0], + ["sightFor(nonsense) is none", RULES.sightFor("wat").id, "none"], + ["sightSeesInDark(thermal)", RULES.sightSeesInDark("thermal"), true], + ["sightSeesInDark(telescopic)", RULES.sightSeesInDark("telescopic"), false], + // A lamp buys the dark back to dim, never to daylight, and it shows you. + ["lightingFor(pitch).mod", RULES.lightingFor("pitch").mod, -50], + ["lightingModFor(pitch, no lamp)", RULES.lightingModFor("pitch").mod, -50], + ["lightingModFor(pitch, lamp)", RULES.lightingModFor("pitch",{lightSource:true}).mod, -10], + ["lightingModFor(pitch, lamp) reveals", RULES.lightingModFor("pitch",{lightSource:true}).revealed, true], + // A lamp in a lit room is not a penalty and does not reveal anything new. + ["lightingModFor(lit, lamp)", RULES.lightingModFor("lit",{lightSource:true}).mod, 0], + ["lightingModFor(lit, lamp) quiet", RULES.lightingModFor("lit",{lightSource:true}).revealed, false], + // Thermal beats a lamp: it removes the dark AND does not announce you. + ["lightingModFor(dark, thermal)", RULES.lightingModFor("dark",{seesInDark:true}).mod, 0], + ["lightingModFor(dark, thermal) quiet", RULES.lightingModFor("dark",{seesInDark:true}).revealed, false], // two weapons ["twoWeaponPenaltyFor() off hand", RULES.twoWeaponPenaltyFor().off, -40], ["twoWeaponPenaltyFor(trained) main", RULES.twoWeaponPenaltyFor({trained:true}).main, -20], diff --git a/tools/content.mjs b/tools/content.mjs index 62c02a8..c9501bc 100644 --- a/tools/content.mjs +++ b/tools/content.mjs @@ -316,7 +316,7 @@ export const GEAR = [ { key: "anchor", name: "Personal anchor", mass: 0.1, desc: "Something that remembers you as you were. Restores 1 Coherence per period of downtime spent with it." }, { key: "key", name: "Crossing key", mass: 0.4, trad: "tradition_transposed", coherence: 1, desc: "Opens a path without a scheduled window. Costs 1 Coherence each time it is used." }, /* --- standard agency issue: what every case draws from stores --- */ - { key: "torch", name: "Field lamp", mass: 0.5, desc: "Sealed, sixty hours. Nothing on the far side is reliably lit." }, + { key: "torch", name: "Field lamp", mass: 0.5, lit: true, desc: "Sealed, sixty hours. Nothing on the far side is reliably lit. Buys the dark back to dim, and tells the dark where you are." }, { key: "rope", name: "Line and harness", mass: 3.5, bonus: { fam: "athletics", value: 20 }, desc: "+20% Athletics for any climb or descent." }, { key: "rations", name: "Ration pack (3 days)", mass: 2.4, desc: "Restores 1 Resource to the case when shared by the whole team." }, { key: "water", name: "Filter flask", mass: 0.9, desc: "Makes almost anything drinkable. Almost." }, @@ -381,6 +381,21 @@ export const GEAR = [ { key: "cordon_kit", name: "Cordon tape and signs", mass: 2.2, desc: "Two hundred metres of tape and four signs citing an Act that does not exist." }, { key: "clipboard", name: "Clipboard", mass: 0.4, desc: "Nobody stops a person carrying a clipboard. This is not a joke and is in the manual." }, + /* --- fitted to the weapon: these apply themselves off the range band --- * + * A sight is not a flat bonus. It is ground for a distance, and it is a liability + * at the others — which is why the telescopic sight costs you inside ten metres + * and why nobody sensibly carries all four. */ + { key: "sight_irons", name: "Aperture sights", mass: 0.2, optic: "irons", fits: "any", + desc: "+5% short and medium. Iron, unbreakable, and no help at all past a hundred metres." }, + { key: "sight_reflex", name: "Reflex sight", mass: 0.3, optic: "reflex", fits: "any", + desc: "+10% short, +5% medium, nothing long, -10% extreme. Both eyes open, for rooms and corridors." }, + { key: "sight_scope", name: "Telescopic sight", mass: 0.7, optic: "telescopic", fits: "rifle", + desc: "-10% short, +5% medium, +15% long, +20% extreme. Rifles only. The penalty up close is the whole point of it." }, + { key: "sight_thermal", name: "Thermal sight", mass: 1.1, optic: "thermal", fits: "rifle", era: "future", + desc: "+5% out to long range, and the dark stops mattering — without a lamp to announce you. Rifles only." }, + { key: "weapon_lamp", name: "Weapon lamp", mass: 0.2, lit: true, + desc: "Clamps under the barrel. Buys the dark back to dim, and points at whatever you are pointing at." }, + /* --- looking at things --- */ { key: "uv_lamp", name: "Ultraviolet lamp", mass: 0.6, desc: "Shows what has been cleaned up and what has been written on the walls in the meantime." }, { key: "signal_mirror", name: "Signalling mirror", mass: 0.1, desc: "For line of sight without a signal. Also shows you what is behind you, which is the more common use." }, diff --git a/tools/icons.mjs b/tools/icons.mjs index 9d388a2..9280348 100644 --- a/tools/icons.mjs +++ b/tools/icons.mjs @@ -111,6 +111,11 @@ export const ICON_MAP = { /* ---- future stores ---- */ wound_foam:["kit","gear",-16,"future"], cold_light:["bolt","gear",-20,"future"], + // Fitted to the weapon. Lenses for the three optical sights, so they read as a set; + // the thermal one is a future-era lens and the lamp is a bolt like the other lights. + sight_irons:["bar","gear",26], sight_reflex:["lens","gear",8], + sight_scope:["lens","gear",-8], sight_thermal:["lens","gear",44,"future"], + weapon_lamp:["bolt","gear",12], field_slate:["chip","gear",24,"future"], locator_tags:["target","gear",-24,"future"], density_scanner:["scanner","gear",28,"future"], seal_foam:["cannister","gear",-28,"future"], assist_sleeve:["hand","gear",32,"future"], diff --git a/tools/rules-text.mjs b/tools/rules-text.mjs index c82997d..c0dd5c2 100644 --- a/tools/rules-text.mjs +++ b/tools/rules-text.mjs @@ -22,6 +22,7 @@ import { DYING, dyingLimitFor, LAST_ENTRY, FALL, fallDamageFor, ASPHYXIA, breathRoundsFor, FIRE, BLAST, BLAST_COVER, BURST, burstHitsFor, TWO_WEAPON, twoWeaponPenaltyFor, + FIRE_MODE, SECOND_SHOT, SUSTAIN, SIGHT, LIGHTING, LIGHT_FLOOR, STATIONS, SPACE_BANDS, SPACE_RANGE_MOD, HEAT, heatEffectsFor, spaceHitFor, powerStartingRating, brawlFallbackFor, bleedStoppedBy, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, @@ -329,7 +330,52 @@ ${table(["Cover", "What gets through"], Object.entries(BLAST_COVER).map(([k, v]) => [k, v === 0 ? "nothing" : `${Math.round(v * 100)}%`]))} ` }, -{ name: "2c · Automatic fire, and two weapons", content: ` +{ name: "2c · How a weapon fires, and what is bolted to it", content: ` +

What a weapon can do with a round

+

Every weapon has a fire mode, and it is a decision rather than a +drawback. The trade runs one way: the faster a weapon puts rounds out, the less each +individual shot is worth, and the slower it is, the more the one shot you get is worth +taking properly.

+${table(["Mode", "To hit", "What it offers"], + Object.values(FIRE_MODE).map(m => [L(m.label), pc(m.attack), + m.burst.length ? "bursts" + : m.sustain ? "the beam can be held on the target" + : m.second ? `a second shot, at ${pc(SECOND_SHOT)} to both` + : m.reloads ? "one shot, then work" + : "one aimed shot a round"]))} +

A weapon that cannot fire a burst cannot be made to. A muzzle-loader +has no automatic setting however the button is pressed.

+ +

Holding a beam

+

A beam does not fire discrete rounds, so counting hits is the wrong shape for it. +What a beam can do instead is stay on the target, and that buys damage +— the one firing choice in this game that adds dice rather than adding rolls. It is +paid for twice, in accuracy and in charge.

+${table(["", "To hit", "Damage", "Charge"], + Object.values(SUSTAIN).map(s => [L(s.label), pc(s.mod), + s.bonus ? `+${s.bonus}` : "—", `×${s.charge}`]))} + +

Sights

+

A sight is not a flat bonus, which is why most games end up with everybody carrying +the same scope. A sight is ground for a distance and is a liability at the +others. You never choose to apply it: the range band is already known by the +time the modifier is wanted, so a carried sight applies itself.

+${table(["Sight", "Short", "Medium", "Long", "Extreme"], + Object.values(SIGHT).filter(s => s.id !== "none").map(s => [L(s.label), + pc(s.bands.short), pc(s.bands.medium), pc(s.bands.long), pc(s.bands.extreme)]))} +

The telescopic sight is a penalty inside ten metres. That is the +whole point of it, and it is why nobody sensibly carries all four. A sight only counts +if it fits — the scope and the thermal sight are rifle furniture and do nothing +bolted to a service pistol.

+ +

The dark

+

Most crossings do not come with the lights on.

+${table(["", "To hit"], Object.values(LIGHTING).map(l => [L(l.label), pc(l.mod)]))} +

A carried lamp buys the dark back to ${L(LIGHTING[LIGHT_FLOOR].label)} +and never better — and it is a position. The card will say so. A thermal sight removes +the dark outright and does not announce you, which is most of what you are +paying its weight for.

+

Automatic fire

One roll, not four. The success level says how much of the burst arrived, and the rounds are gone either way — that is the honest part