From 750cbd257d31072b57de62ba9f2cf9607840a99b Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:21:57 +0100 Subject: [PATCH] Character sheet: reload button for ranged weapons A reload icon sits beside the ammo count in the readied-weapon strip and the weapons table, for any weapon with a magazine. It tops the weapon up from its reserve (rules.reloadFor, spot-checked), posts the new count and the weapon's reload time to chat, and warns when the weapon is already full or the reserve is empty. Co-Authored-By: Claude Opus 5 --- lang/en.json | 4 ++++ ringbrp.mjs | 26 +++++++++++++++++++++++++- rules.mjs | 12 ++++++++++++ styles/ringbrp.css | 2 ++ templates/character-sheet.hbs | 4 ++-- tools/check-rules.mjs | 4 ++++ 6 files changed, 49 insertions(+), 3 deletions(-) diff --git a/lang/en.json b/lang/en.json index cf8b6f3..5b3c5a5 100644 --- a/lang/en.json +++ b/lang/en.json @@ -217,6 +217,7 @@ "RINGBRP.Sheet.Damage": "Damage", "RINGBRP.Sheet.Range": "Range", "RINGBRP.Sheet.Ammo": "Ammo", + "RINGBRP.Sheet.ReloadHint": "Reload from reserve", "RINGBRP.Sheet.Points": "pts", "RINGBRP.Sheet.Integrity": "Integrity", "RINGBRP.Sheet.ClearWound": "Clear this wound", @@ -387,6 +388,8 @@ "RINGBRP.Notify.NoSkill": "Nothing on this sheet knows how to use {name}.", "RINGBRP.Notify.OutOfRange": "Too far away to hit. Get closer or use something with more reach.", "RINGBRP.Notify.Empty": "Empty. Reload before firing.", + "RINGBRP.Notify.ReloadFull": "{name} is already fully loaded.", + "RINGBRP.Notify.NoReserve": "No reserve ammunition left for {name}.", "RINGBRP.Notify.NoLeg": "No unresolved phase remains.", "RINGBRP.Notify.BadDamage": "Damage was not a number — the hit was not applied. See the console.", "RINGBRP.Notify.BlowStopped": "That blow was stopped. There is no damage to roll.", @@ -855,6 +858,7 @@ "RINGBRP.Fire.Adds": "adds {dice} damage", "RINGBRP.Fire.Second": "Two shots", "RINGBRP.Fire.SecondEffect": "both hurried", + "RINGBRP.Fire.Reloaded": "{name} reloaded: {loaded}/{capacity}, {reserve} in reserve. Takes {n} round(s).", "RINGBRP.Fire.Reloading": "{name} — reloading, {n} round(s)", "RINGBRP.Fire.slow.Label": "Slow", "RINGBRP.Fire.single.Label": "Single", diff --git a/ringbrp.mjs b/ringbrp.mjs index 95a5910..b572ab8 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -35,7 +35,7 @@ import { RETURN_CLOCK, returnPenaltyFor, returnIsDifficult, extractionCostFor, FALL, fallDamageFor, ASPHYXIA, breathRoundsFor, FIRE, fireBandFor, BLAST, blastBandFor, blastShareFor, BLAST_COVER, blastAfterCover, - BURST, burstHitsFor, burstRoundsSpent, BURST_FUMBLE, BURST_ONE_MAJOR_WOUND, + BURST, burstHitsFor, burstRoundsSpent, BURST_FUMBLE, BURST_ONE_MAJOR_WOUND, reloadFor, BURST_HITS, LAMP, lampReachFor, lampCovers, CONTACT_OUTCOMES, contactDeltaFor, impressionDeltaFor, COVERAGE, coverageFor, armourCoversKind, locationArmourFor, @@ -5005,6 +5005,9 @@ class RingBRPCharacterSheet extends ActorSheet { el.addEventListener("click", () => this._onRollLocation())); root.querySelectorAll("[data-action='roll-attack']").forEach(el => el.addEventListener("click", ev => this._onRollAttack(ev))); + root.querySelectorAll("[data-action='reload-weapon']").forEach(el => + el.addEventListener("click", ev => { ev.preventDefault(); ev.stopPropagation(); + this._onReloadWeapon(ev); })); // Hovering a limb highlights its row and vice versa, so the picture and the // numbers are obviously the same thing. const link = (sel, on) => root.querySelectorAll(sel).forEach(el => { @@ -5287,6 +5290,27 @@ class RingBRPCharacterSheet extends ActorSheet { return res; } + /** Top the weapon up from its reserve and say how long it takes. */ + async _onReloadWeapon(ev) { + const weapon = this._itemFrom(ev); + const ammo = weapon?.system?.ammo; + if (!(ammo?.capacity > 0)) return; + const r = reloadFor(ammo.capacity, ammo.loaded, ammo.reserve); + if (r.moved <= 0) { + const key = (Number(ammo.loaded) || 0) >= ammo.capacity ? "RINGBRP.Notify.ReloadFull" + : "RINGBRP.Notify.NoReserve"; + return ui.notifications.warn(game.i18n.format(key, { name: weapon.name })); + } + await weapon.update({ "system.ammo.loaded": r.loaded, "system.ammo.reserve": r.reserve }); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: this.actor }), + content: `
${ + game.i18n.format("RINGBRP.Fire.Reloaded", { + name: weapon.name, loaded: r.loaded, capacity: ammo.capacity, + reserve: r.reserve, n: Number(ammo.reloadRounds) || 1 })}
` + }); + } + async _onRollDamage(ev) { const weapon = this._itemFrom(ev); if (!weapon) return; diff --git a/rules.mjs b/rules.mjs index 1883d53..fa5a3f3 100644 --- a/rules.mjs +++ b/rules.mjs @@ -1077,6 +1077,18 @@ export function burstRoundsSpent(size, loaded) { return Math.min(Number(loaded) || 0, b.rounds); } +/** + * A reload tops the weapon up from its reserve. Whatever the magazine has room for + * comes out of the reserve, and no more than the reserve holds. + */ +export function reloadFor(capacity, loaded, reserve) { + const cap = Math.max(0, Number(capacity) || 0); + const have = Math.min(cap, Math.max(0, Number(loaded) || 0)); + const spare = Math.max(0, Number(reserve) || 0); + const moved = Math.min(cap - have, spare); + return { loaded: have + moved, reserve: spare - moved, moved }; +} + /** 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"]; diff --git a/styles/ringbrp.css b/styles/ringbrp.css index f683431..a7a55bf 100644 --- a/styles/ringbrp.css +++ b/styles/ringbrp.css @@ -714,6 +714,8 @@ .rb-weapontable .rb-col-ready { width: 40px; text-align: center; } .rb-weapontable td { vertical-align: middle; } .rb-weapontable .rb-equip { font-size: 15px; } +.rb-reload { margin-left: 4px; font-size: 11px; color: var(--rb-muted); cursor: pointer; } +.rb-reload:hover { color: var(--rb-yellow); } .rb-weapon-icon { width: 40px; height: 40px; margin-right: 9px; border: 1px solid var(--rb-hairline); background: var(--rb-void); diff --git a/templates/character-sheet.hbs b/templates/character-sheet.hbs index dc06fa6..45152e9 100644 --- a/templates/character-sheet.hbs +++ b/templates/character-sheet.hbs @@ -115,7 +115,7 @@ {{w.name}} {{#if (rbEq w.system.damage.mode "byBand")}}{{w.system.damage.short}}/{{w.system.damage.medium}}/{{w.system.damage.long}}{{else}}{{w.system.damage.formula}}{{/if}} {{#if w.system.rangeSummary}}{{w.system.rangeSummary}}{{/if}} - {{#if w.system.ammo.capacity}}{{w.system.ammo.loaded}}/{{w.system.ammo.capacity}}{{/if}} + {{#if w.system.ammo.capacity}}{{w.system.ammo.loaded}}/{{w.system.ammo.capacity}}{{/if}} {{else}} {{localize "RINGBRP.Sheet.NothingReadied"}} @@ -239,7 +239,7 @@ {{w.name}} {{#if (rbEq w.system.damage.mode "byBand")}}{{w.system.damage.short}}/{{w.system.damage.medium}}/{{w.system.damage.long}}{{else}}{{w.system.damage.formula}}{{/if}} {{w.system.rangeSummary}} - {{#if w.system.ammo.capacity}}{{w.system.ammo.loaded}}/{{w.system.ammo.capacity}}{{/if}} + {{#if w.system.ammo.capacity}}{{w.system.ammo.loaded}}/{{w.system.ammo.capacity}} {{/if}} {{w.system.massKg}} kg diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index ede5ba3..e348251 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -320,6 +320,10 @@ const checks = [ ["burstHitsFor(critical, full) capped", RULES.burstHitsFor("critical","full"), 7], ["burstHitsFor(success, short)", RULES.burstHitsFor("success","short"), 1], ["burstHitsFor(failure, long)", RULES.burstHitsFor("failure","long"), 0], + ["reloadFor(6 cap, 2 loaded, 12)", JSON.stringify(RULES.reloadFor(6,2,12)), '{"loaded":6,"reserve":8,"moved":4}'], + ["reloadFor(6 cap, 0 loaded, 3)", JSON.stringify(RULES.reloadFor(6,0,3)), '{"loaded":3,"reserve":0,"moved":3}'], + ["reloadFor(full)", RULES.reloadFor(6,6,12).moved, 0], + ["reloadFor(no reserve)", RULES.reloadFor(6,1,0).moved, 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],