From 89ce557355aea1119deea8fffe200d60a217c0eb Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:13:21 +0100 Subject: [PATCH] Death at zero, and a case brief that answers the useful questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-223: nothing happened at 0 hit points. An agent was marked down and lay there forever; the only mention of death in the system was flavour text on a destroyed head. Zero now starts a clock whose length is the agent's own Major Wound threshold, ticked from the same hook that charges blood loss. First Aid or Medicine stabilises and stops the count; healing above zero clears it; heal() refuses the dead. On death, Coherence decides what the record says — from a proper death in service down to not being recorded as dead at all. Case generator: four phases instead of five (the arc drew two interchangeable middles that read as padding). The brief now leads with Where / When / Classification / Phases, states the opposition in full, and has a "How you get back" section. place and duration were generated and never printed. The same facts are on the case file itself. 300 seeds checked for object leaks; one found and fixed (group taboo/desire are objects with .text). Co-Authored-By: Claude Opus 5 --- docs/REVIEW_LOG.md | 61 +++++++++++++ lang/en.json | 22 +++++ packs/armour/LOG | 10 +-- packs/armour/LOG.old | 2 +- packs/expedition/LOG | 10 +-- packs/expedition/LOG.old | 2 +- packs/gear/LOG | 10 +-- packs/gear/LOG.old | 2 +- packs/npcs/000006.log | Bin 259001 -> 259409 bytes packs/npcs/LOG | 10 +-- packs/npcs/LOG.old | 2 +- packs/powers/LOG | 10 +-- packs/powers/LOG.old | 2 +- packs/pregens/000006.log | Bin 203191 -> 203463 bytes packs/pregens/LOG | 10 +-- packs/pregens/LOG.old | 2 +- packs/rules/000005.ldb | Bin 23316 -> 24552 bytes packs/rules/000006.log | Bin 44873 -> 47237 bytes packs/rules/LOG | 10 +-- packs/rules/LOG.old | 2 +- packs/rules/MANIFEST-000004 | Bin 147 -> 147 bytes packs/skills/LOG | 10 +-- packs/skills/LOG.old | 2 +- packs/slice/LOG | 10 +-- packs/slice/LOG.old | 2 +- packs/tables/LOG | 10 +-- packs/tables/LOG.old | 2 +- packs/talents/LOG | 10 +-- packs/talents/LOG.old | 2 +- packs/vehicles/LOG | 10 +-- packs/vehicles/LOG.old | 2 +- packs/weapons/LOG | 10 +-- packs/weapons/LOG.old | 2 +- ringbrp.mjs | 160 +++++++++++++++++++++++++++++++++- rules.mjs | 64 ++++++++++++++ styles/ringbrp.css | 19 ++++ template.json | 16 +++- templates/character-sheet.hbs | 17 ++++ tools/check-rules.mjs | 11 +++ tools/mission.mjs | 60 ++++++++++--- tools/rules-text.mjs | 28 ++++++ 41 files changed, 520 insertions(+), 94 deletions(-) diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 31a92a2..4d25aa6 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -3799,3 +3799,64 @@ floor the old ramp produced. **R-222 confirmed across the board.** Every agent came out of this case with ticks — Dodge, First Aid, Firearm (Pistol), Firearm (Rifle), Navigate — where before the fix a case produced none at all from phase work. + +## Zero hit points now means something + +**R-223 · nothing happened at zero.** An agent reduced to 0 hit points was marked down +and then lay there indefinitely. Bleeding deliberately stops at zero, no rule advanced +anything, and the only mention of death in the entire system was flavour text on a +destroyed head location. A game with hit locations, Major Wounds and bleeding had no +way for anyone to die. + +Hit points are not taken below zero — every damage path clamps there and unpicking +that would touch everything. Zero starts a clock instead: + + above 2 up + 2 or below DOWN — out of the fight, still conscious, still yours + 0 DYING — unconscious, no actions, no defences, the count begins + count met DEAD + +The count is the agent's own **Major Wound threshold**, so the number that already +decides how much damage they take standing up also decides how long they have on the +floor, with a floor of two rounds. It ticks from the same end-of-round hook that +charges blood loss, so nobody has to remember it — which is exactly why bleeding +worked and dying did not exist. + +**Stabilising** takes First Aid or Medicine and stops the count. It does not put +anyone back on their feet: still zero, still unconscious, until somebody heals them +above it, which clears the count automatically. A FUMBLED attempt over a dying agent +costs them a round they do not have. `heal()` now refuses the dead, which is the one +place that had to say so because every medical path routes through it. + +**The last entry.** A Custodian's death is a filing question. How well filed they were +decides what the record says: at Coherence 7+ a death in service with a form and a +pension; at 4–6 two dates of death on file and Registry asking which is correct; at +1–3 a file that will not close, with correspondence still arriving and being answered; +at 0 they are not recorded as dead at all but filed as transferred, to a section the +department cannot produce an address for. + +Four rules, ten spot-checks, on the sheet as a banner in all three states, and +documented in the rulebook. 44 rules, 148 formulas. + +## The case generator, simplified and made to say the useful things + +**Four phases, not five.** The arc drew TWO interchangeable middle phases, which read +as padding — two seeds in a row produced "The search" then "Inside" with nothing to +tell them apart. One middle phase: Approach, the thing in the way, the job, and out. + +**The brief now answers the operational questions first.** It opened on a +classification note and never stated where the site was, how long the team had, what +was likely to be in the way, or how they were getting out — all of which the generator +already knew and threw away. `place` and `duration` were generated and never printed. +The brief now leads with a table of Where / When / Classification / Phases, then the +job, then what is reported, then a full section on the opposition — numbers, tier, +motive, tactics, and what might work instead of shooting — then **How you get back**: +the extraction ground, the Transposition roll, the classification modifier, and the +warning that a bad crossing is the only way an Art is ever acquired. + +The same four facts are now on the CASE FILE ITSELF, so a GM does not have to open the +journal to answer "where are we and how long have we got". + +Checked across 300 seeds for `[object Object]`, `undefined` and `NaN` in every +generated page: clean. One real leak found and fixed on the way — the group's taboo +and desire are objects with a `.text`, and the brief was printing the object. diff --git a/lang/en.json b/lang/en.json index 90e8166..0dc3582 100644 --- a/lang/en.json +++ b/lang/en.json @@ -624,6 +624,28 @@ "RINGBRP.Wound.variant.mobility": "mobility", "RINGBRP.Heal.Bleeds": "{name} bleeds {amount}.", "RINGBRP.Heal.BledOut": "bled out", + "RINGBRP.Death.Dying": "{name} is dying.", + "RINGBRP.Death.Count": "Round {n} of {of}.", + "RINGBRP.Death.Died": "{name} is dead.", + "RINGBRP.Death.Coherence": "They died at Coherence {n}.", + "RINGBRP.Death.TheEntry": "The last entry:", + "RINGBRP.Death.StableTag": "stable — the count has stopped", + "RINGBRP.Death.CountTag": "round", + "RINGBRP.Death.Entry.recorded": "Recorded properly. A death in service, a form, a pension to a next of kin, and a name that stays where it was put.", + "RINGBRP.Death.Entry.queried": "Recorded, and queried. Two dates of death are on file and Registry has asked which is correct. Nobody has answered.", + "RINGBRP.Death.Entry.unclosed": "The file will not close. Correspondence continues to arrive for them, and it is answered.", + "RINGBRP.Death.Entry.transferred": "Not recorded as dead at all. They are filed as transferred, to a section the department cannot produce an address for. Somebody will meet them again.", + "RINGBRP.Death.Stabilising": "Stabilising {name}", + "RINGBRP.Death.Stabilised": "{name} is stable. Still at zero, still unconscious, but the count has stopped.", + "RINGBRP.Death.NotStabilised": "It does not hold. {name} is still going.", + "RINGBRP.Death.TooLate": "They are dead. There is nothing medical left to do.", + "RINGBRP.Death.NotDying": "They are not dying. Ordinary First Aid will serve.", + "RINGBRP.Death.WrongSkill": "Stabilising takes one of: {list}.", + "RINGBRP.Condition.up": "up", + "RINGBRP.Condition.down": "down", + "RINGBRP.Condition.dying": "DYING", + "RINGBRP.Condition.dead": "DEAD", + "RINGBRP.Sheet.Stabilise": "Stabilise", "RINGBRP.Heal.BleedStopped": "The bleeding is staunched. {name} is no longer losing blood.", "RINGBRP.Heal.Medicine": "Medicine", "RINGBRP.React.ReadinessSkill": "from training", diff --git a/packs/armour/LOG b/packs/armour/LOG index 81db68d..d137772 100644 --- a/packs/armour/LOG +++ b/packs/armour/LOG @@ -1,5 +1,5 @@ -2026/08/10-21:10:35.033759 175517000 Recovering log #3 -2026/08/10-21:10:35.033780 175517000 Level-0 table #5: started -2026/08/10-21:10:35.033929 175517000 Level-0 table #5: 1902 bytes OK -2026/08/10-21:10:35.034226 175517000 Delete type=0 #3 -2026/08/10-21:10:35.034283 175517000 Delete type=3 #2 +2026/08/10-22:10:05.167695 172fa3000 Recovering log #3 +2026/08/10-22:10:05.167719 172fa3000 Level-0 table #5: started +2026/08/10-22:10:05.167831 172fa3000 Level-0 table #5: 1902 bytes OK +2026/08/10-22:10:05.168131 172fa3000 Delete type=0 #3 +2026/08/10-22:10:05.168177 172fa3000 Delete type=3 #2 diff --git a/packs/armour/LOG.old b/packs/armour/LOG.old index f71abab..7a81f41 100644 --- a/packs/armour/LOG.old +++ b/packs/armour/LOG.old @@ -1 +1 @@ -2026/08/10-21:10:26.742472 172977000 Delete type=3 #1 +2026/08/10-22:09:56.857234 172fe3000 Delete type=3 #1 diff --git a/packs/expedition/LOG b/packs/expedition/LOG index a89a9bd..2ef6314 100644 --- a/packs/expedition/LOG +++ b/packs/expedition/LOG @@ -1,5 +1,5 @@ -2026/08/10-21:10:35.042390 175d23000 Recovering log #3 -2026/08/10-21:10:35.042410 175d23000 Level-0 table #5: started -2026/08/10-21:10:35.042554 175d23000 Level-0 table #5: 4129 bytes OK -2026/08/10-21:10:35.042821 175d23000 Delete type=0 #3 -2026/08/10-21:10:35.042870 175d23000 Delete type=3 #2 +2026/08/10-22:10:05.175210 172797000 Recovering log #3 +2026/08/10-22:10:05.175229 172797000 Level-0 table #5: started +2026/08/10-22:10:05.175333 172797000 Level-0 table #5: 4129 bytes OK +2026/08/10-22:10:05.175537 172797000 Delete type=0 #3 +2026/08/10-22:10:05.175564 172797000 Delete type=3 #2 diff --git a/packs/expedition/LOG.old b/packs/expedition/LOG.old index 9a8d6b7..9201cdc 100644 --- a/packs/expedition/LOG.old +++ b/packs/expedition/LOG.old @@ -1 +1 @@ -2026/08/10-21:10:26.766828 173183000 Delete type=3 #1 +2026/08/10-22:09:56.883542 172fe3000 Delete type=3 #1 diff --git a/packs/gear/LOG b/packs/gear/LOG index 4fc69e1..bf1383d 100644 --- a/packs/gear/LOG +++ b/packs/gear/LOG @@ -1,5 +1,5 @@ -2026/08/10-21:10:35.034917 175d23000 Recovering log #3 -2026/08/10-21:10:35.035011 175d23000 Level-0 table #5: started -2026/08/10-21:10:35.035308 175d23000 Level-0 table #5: 27954 bytes OK -2026/08/10-21:10:35.035591 175d23000 Delete type=0 #3 -2026/08/10-21:10:35.035641 175d23000 Delete type=3 #2 +2026/08/10-22:10:05.168741 172797000 Recovering log #3 +2026/08/10-22:10:05.168838 172797000 Level-0 table #5: started +2026/08/10-22:10:05.169097 172797000 Level-0 table #5: 27954 bytes OK +2026/08/10-22:10:05.169298 172797000 Delete type=0 #3 +2026/08/10-22:10:05.169329 172797000 Delete type=3 #2 diff --git a/packs/gear/LOG.old b/packs/gear/LOG.old index f00b55c..fdb436c 100644 --- a/packs/gear/LOG.old +++ b/packs/gear/LOG.old @@ -1 +1 @@ -2026/08/10-21:10:26.744343 17419b000 Delete type=3 #1 +2026/08/10-22:09:56.859443 1727d7000 Delete type=3 #1 diff --git a/packs/npcs/000006.log b/packs/npcs/000006.log index 398e78ae86c524685bbdbb53e2474e20b3884b3c..d76f90845e20594e998d7b28c3a0b9bea92f7fa4 100644 GIT binary patch delta 665 zcmdn_od4o0{sj#z|Ca~8-OSSPNSyJ{=I7I&u(;?brBr6-r7KxgD;4FJ=A{%XSsCak z6_+F?W#(iSr=}=br6uMRrvjCwCL%aFiNz(Zc_l@aN>)lrwVO}R`Iy8m@b5nF&wA#` z7gwy^TEMu~f$`RMHAlwH)UjgwO;^VGmh4x5^&A7*vHf8%V;bZ3rwxp;nM|wXw!6Gy z{7of$?B6qf3%Ic+muBPAW23oM(yeG z^-K%5e-LF7XWTB=$aI;X@#ywX6PWf;$u;YyGC5_k3mS|42l;y866PeP=^M^632x6~ zWWHs|_;b6fHM1)#RU&bFmjkns9s8jb5f{8lS*BmSz!bij&x(m@dP6m{;C9(Y=5NW2 SFSd(3Wu}(jF1%!Z$q4|8?Dnq! delta 254 zcmVJPc+51@bqE3#lXvjl+R7Xi4l=a=LJvk#r|Vv`URLH+%% zw+rwA^$(Yzm;(@#yRT@IKM7O^DXl?M`F{hqA^rhP0kV))2IGt%${73Yq`~d>6Hi5O!@6Po=i`u zWSxK?lZqj`p|-&RkadmHj8W73>Y0kRtz^<;+-};)^uLz<**Xr>15zy0KiV>Fn*QuR ilhk&DUrelw+j$t7<2af6<+tZ>F~6mfb0YbfB-&PlN$n z1h?;q0TFkXP`Lpel_Uad2SqS_NBMsPw^7LgH3F9)mH`pB)dB+DMgr9yx3ozDuo(om du>%7I0k_xz16~ON%O1Bl3j^&0w`mXqUJ9s~Ro?&r diff --git a/packs/pregens/LOG b/packs/pregens/LOG index 09c7b2a..1bb2764 100644 --- a/packs/pregens/LOG +++ b/packs/pregens/LOG @@ -1,5 +1,5 @@ -2026/08/10-21:10:35.021326 175517000 Recovering log #3 -2026/08/10-21:10:35.021545 175517000 Level-0 table #5: started -2026/08/10-21:10:35.022420 175517000 Level-0 table #5: 54921 bytes OK -2026/08/10-21:10:35.022844 175517000 Delete type=0 #3 -2026/08/10-21:10:35.022893 175517000 Delete type=3 #2 +2026/08/10-22:10:05.158567 173fbb000 Recovering log #3 +2026/08/10-22:10:05.158795 173fbb000 Level-0 table #5: started +2026/08/10-22:10:05.159257 173fbb000 Level-0 table #5: 54921 bytes OK +2026/08/10-22:10:05.159544 173fbb000 Delete type=0 #3 +2026/08/10-22:10:05.159580 173fbb000 Delete type=3 #2 diff --git a/packs/pregens/LOG.old b/packs/pregens/LOG.old index a8fde99..b6b487a 100644 --- a/packs/pregens/LOG.old +++ b/packs/pregens/LOG.old @@ -1 +1 @@ -2026/08/10-21:10:26.750048 17419b000 Delete type=3 #1 +2026/08/10-22:09:56.866210 171fcb000 Delete type=3 #1 diff --git a/packs/rules/000005.ldb b/packs/rules/000005.ldb index f65723cf6452c8801703ece4bbb16204cb9d74e0..d2d07c74670dc8117f28ee81aa28c7fe91d0d9d1 100644 GIT binary patch delta 2338 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 M+qiai^K`5GA5XK-`~Uy| delta 54 zcmV-60LlM_@dC--0t7l^v;46Hi#h`18?%u*jtR3{O{FrE$9WaA9DpYY2V0j(z(Ows MlVJoFvssBOFLyT;4*&oF diff --git a/packs/rules/LOG b/packs/rules/LOG index 7b84bc8..8d56ba2 100644 --- a/packs/rules/LOG +++ b/packs/rules/LOG @@ -1,5 +1,5 @@ -2026/08/10-21:10:35.037687 175d23000 Recovering log #3 -2026/08/10-21:10:35.037745 175d23000 Level-0 table #5: started -2026/08/10-21:10:35.038003 175d23000 Level-0 table #5: 23316 bytes OK -2026/08/10-21:10:35.038279 175d23000 Delete type=0 #3 -2026/08/10-21:10:35.038312 175d23000 Delete type=3 #2 +2026/08/10-22:10:05.171339 172797000 Recovering log #3 +2026/08/10-22:10:05.171403 172797000 Level-0 table #5: started +2026/08/10-22:10:05.171720 172797000 Level-0 table #5: 24552 bytes OK +2026/08/10-22:10:05.172072 172797000 Delete type=0 #3 +2026/08/10-22:10:05.172108 172797000 Delete type=3 #2 diff --git a/packs/rules/LOG.old b/packs/rules/LOG.old index a682cbf..bc4ce69 100644 --- a/packs/rules/LOG.old +++ b/packs/rules/LOG.old @@ -1 +1 @@ -2026/08/10-21:10:26.765165 173183000 Delete type=3 #1 +2026/08/10-22:09:56.881569 172fe3000 Delete type=3 #1 diff --git a/packs/rules/MANIFEST-000004 b/packs/rules/MANIFEST-000004 index e1e6f6a5daf4a1df3ab04f4db38f0e59f354bbd7..23212a130d7e1edb56dbd91c97129e7c9a56eec5 100644 GIT binary patch delta 30 mcmbQtIGJ&RvdA8TZ=RV9j7)5t49x5-T l.disabled).length; sys.isDown = (sys.hp.value <= 2) || !!sys.wounded?.defenceOnly || !!sys.wounded?.unconscious; + // Up / down / dying / dead, from the one rule, so nothing re-derives it. + const dy = sys.dying ?? { rounds: 0, stabilised: false }; + sys.condition = (sys.dying?.dead) ? "dead" : conditionFor({ + hp: sys.hp.value, dyingRounds: dy.rounds, majorWound: sys.majorWound?.value, + stabilised: dy.stabilised, unconscious: sys.wounded?.unconscious + }); + sys.dyingLimit = dyingLimitFor(sys.majorWound?.value); } _prepareExpedition() { @@ -1870,6 +1878,120 @@ async function promptAuthorityUse(authority) { } + +/* -------------------------------------------- */ +/* Dying, and death */ +/* -------------------------------------------- */ + +/** + * Advance the count on an agent at zero. Called from the same end-of-round hook that + * charges blood loss, so nobody has to remember it — which is the whole reason + * bleeding works and dying did not exist. + */ +export async function tickDying(actor) { + if (!actor) return null; + const sys = actor.system; + if (sys.dying?.dead) return null; + if ((Number(sys.hp?.value) || 0) > 0) return null; // not at zero + if (sys.dying?.stabilised) return null; // held + + const rounds = (Number(sys.dying?.rounds) || 0) + DYING.perRound; + const limit = dyingLimitFor(sys.majorWound?.value); + await actor.update({ "system.dying.rounds": rounds }); + + if (rounds < limit) { + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
${ + game.i18n.format("RINGBRP.Death.Dying", { name: actor.name })} ${ + game.i18n.format("RINGBRP.Death.Count", { n: rounds, of: limit })}
` + }); + return { state: "dying", rounds, limit }; + } + return died(actor); +} + +/** + * The last entry. + * + * A Custodian's death is a filing question, because everything here is. How well + * filed they were decides what the record ends up saying — and the badly filed are + * not recorded as dead at all, which becomes somebody's problem later. + */ +export async function died(actor) { + if (!actor || actor.system.dying?.dead) return null; + const coherence = Number(actor.system?.coherence?.value) || 0; + const entry = lastEntryFor(coherence); + await actor.update({ + "system.dying.dead": true, + "system.dying.lastEntry": entry.id, + "system.hp.value": 0 + }); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
` + + `

${game.i18n.format("RINGBRP.Death.Died", { name: actor.name })}

` + + `

${game.i18n.format("RINGBRP.Death.Coherence", { n: coherence })}

` + + `

${game.i18n.localize("RINGBRP.Death.TheEntry")} ${game.i18n.localize(entry.label)}

` + + `
` + }); + return { state: "dead", entry: entry.id }; +} + +/** + * Stabilise somebody at zero. Stops the count; it does not put them back on their + * feet. They are still at zero and still unconscious until somebody heals them. + */ +export async function stabilise(medic, patient, { familyId = "first_aid", + difficulty = "average" } = {}) { + if (!medic || !patient) return null; + const sys = patient.system; + if (sys.dying?.dead) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Death.TooLate")); + return null; + } + if ((Number(sys.hp?.value) || 0) > 0) { + ui.notifications?.info(game.i18n.localize("RINGBRP.Death.NotDying")); + return null; + } + if (!DYING.stabiliseWith.includes(familyId)) { + ui.notifications?.warn(game.i18n.format("RINGBRP.Death.WrongSkill", + { list: DYING.stabiliseWith.join(", ") })); + return null; + } + const res = await skillRoll({ + actor: medic, label: game.i18n.format("RINGBRP.Death.Stabilising", { name: patient.name }), + rating: medic.skillRating(familyId) ?? 0, difficulty, + categoryId: "mental", skillFamilyId: familyId, printedBase: 0 + }); + if (!res) return null; + const ok = ["critical", "special", "success"].includes(res.level); + if (ok) await patient.update({ "system.dying.stabilised": true }); + // A fumble over a dying person costs them a round of the count they do not have. + else if (res.level === "fumble") { + const rounds = (Number(sys.dying?.rounds) || 0) + 1; + await patient.update({ "system.dying.rounds": rounds }); + if (rounds >= dyingLimitFor(sys.majorWound?.value)) await died(patient); + } + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: medic }), + content: `
${ + game.i18n.format(ok ? "RINGBRP.Death.Stabilised" : "RINGBRP.Death.NotStabilised", + { name: patient.name })}
` + }); + return { ...res, stabilised: ok }; +} + +/** Healing above zero clears the count. Nobody should have to clear it by hand. */ +export async function reviveIfHealed(actor) { + if (!actor) return null; + if ((Number(actor.system?.hp?.value) || 0) <= 0) return null; + if (!(Number(actor.system?.dying?.rounds) || 0) && !actor.system?.dying?.stabilised) return null; + await actor.update({ "system.dying.rounds": 0, "system.dying.stabilised": false }); + return true; +} + + /* -------------------------------------------- */ /* Borrowed Authority */ /* -------------------------------------------- */ @@ -2135,6 +2257,12 @@ RINGBRP.healing = { */ export async function heal(actor, amount, { source = "natural", locationId = "" } = {}) { if (!actor || !(amount > 0)) return null; + // The dead do not heal. Everything else in the medical chain routes through here, + // so this is the one place that has to say so. + if (actor.system?.dying?.dead) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Death.TooLate")); + return null; + } const max = Number(actor.system.hp?.max) || 0; const before = Number(actor.system.hp?.value) || 0; const after = Math.min(max, before + amount); @@ -2145,6 +2273,10 @@ export async function heal(actor, amount, { source = "natural", locationId = "" // the stored value untouched — a location healed to zero silently kept its // damage, while a partial heal appeared to work. Fully-mended locations must be // removed with the "-=" deletion form. + if (after > 0) { + upd["system.dying.rounds"] = 0; + upd["system.dying.stabilised"] = false; + } const dmg = foundry.utils.deepClone(actor.system.locationDamage ?? {}); let pool = after - before; const order = locationId ? [locationId, ...Object.keys(dmg).filter(k => k !== locationId)] @@ -2571,7 +2703,17 @@ export async function generateMissionCase({ seed = null, notify = true, ...opts forcedEncounterId: p.revealsGap ? p.reveal : null })) }, - notes: `

${m.codename} — ${m.classification.name}. ${m.place}.

` + // The case file itself should answer where, when, and what the job is without + // anyone opening the journal. It used to carry one line of codename and place. + notes: `

${m.codename} — ${m.classification.name}.

` + + `

Where: ${m.place}
` + + `When: ${m.duration}
` + + `Job: ${m.resolution}

` + + `

Expect: about ${m.adversary.count} ${m.adversary.name} (${m.adversary.tier}). ` + + `${m.adversary.leverage}

` + + `

Way back: ${m.phases[m.phases.length - 1].terrain}. ` + + `Transposition per agent` + + `${m.crossingModifier ? `, ${m.crossingModifier}% for the classification` : ""}.

` + `

${m.anomaly.brief}

`, // A case with nobody assigned cannot resolve a phase: resolveLegDialog needs // a roster to pick a lead from and bails silently without one. But the old @@ -4009,6 +4151,15 @@ class RingBRPCharacterSheet extends ActorSheet { return Object.assign(Object.create(Object.getPrototypeOf(it)), it, { functions: fns }); }); ctx.wounds = sys.wounds ?? []; + ctx.condition = sys.condition ?? "up"; + ctx.conditionLabel = game.i18n.localize(`RINGBRP.Condition.${ctx.condition}`); + ctx.dyingNow = ctx.condition === "dying"; + ctx.deadNow = ctx.condition === "dead"; + ctx.dyingRounds = Number(sys.dying?.rounds) || 0; + ctx.dyingLimit = sys.dyingLimit ?? 0; + ctx.stabilised = !!sys.dying?.stabilised; + ctx.lastEntry = sys.dying?.lastEntry + ? game.i18n.localize(`RINGBRP.Death.Entry.${sys.dying.lastEntry}`) : ""; ctx.styleLabel = game.i18n.localize(RINGBRP.marksmanship[sys.marksmanship?.style]?.label ?? ""); // The dropdown offered a choice with no stated consequence — two words and no // hint of what either buys you. Built from RINGBRP.marksmanship so the sheet @@ -5174,6 +5325,7 @@ Hooks.once("init", () => { resolveContact, newEncounter, claimRewards, activeRewardEffects, defenceOutcomeFor, levelRank, recordBlow, standingBlow, amendBlow, addDossierItem, useAuthority, widenScope, rehabilitate, + tickDying, died, stabilise, reviveIfHealed, conditionFor, dyingLimitFor, scopeStanceFor, dossierTrustFor, mergeStageFor, SCOPE_TIERS, heal, firstAid, medicalCare, applyBleeding, stopBleeding, bleedRateFor, BLEED, defend, defencePenaltyFor, @@ -5285,7 +5437,11 @@ Hooks.on("updateCombat", async (combat, changed) => { // elapsed yet, and starting combat used to cost a bleeding agent a free tick. if ("round" in changed && changed.round > 1) { for (const c of combat.combatants) { - if (c.actor && c.actor.type !== "expedition") await applyBleeding(c.actor); + if (!c.actor || c.actor.type === "expedition") continue; + await applyBleeding(c.actor); + // Bleeding stops AT zero by design; the count that starts there is what + // carries on. Same hook, so neither has to be remembered. + await tickDying(c.actor); } } diff --git a/rules.mjs b/rules.mjs index b58766c..6b3d0ee 100644 --- a/rules.mjs +++ b/rules.mjs @@ -368,6 +368,70 @@ export function defencePenaltyFor(defencesUsed = 0) { return DEFENCE_STEP * Math.max(0, Number(defencesUsed) || 0); } +/* -------------------------------------------- */ +/* Dying, and death */ +/* -------------------------------------------- */ + +/** + * WHAT HAPPENS AT ZERO. + * + * Nothing did. An agent reduced to 0 hit points was marked down and then lay there + * indefinitely: bleeding deliberately stops at 0, no rule advanced anything, and the + * only mention of death in the whole system was flavour text on a destroyed head. + * A game with hit locations, Major Wounds and bleeding had no way for anyone to die. + * + * Hit points are NOT taken below zero — every damage path clamps there, and unpicking + * that would touch everything. Instead, zero starts a clock: + * + * above 2 you are up + * 2 or below DOWN — out of the fight, still conscious, still yours + * 0 DYING — unconscious, no actions, no defences, and a count begins + * count met DEAD + * + * The count is the agent's own Major Wound threshold, so a solid Custodian has longer + * on the floor than a slight one, which is the same number already deciding how much + * damage they can take standing up. First Aid or Medicine STABILISES: the count stops + * and they stay at 0, unconscious, until somebody heals them above it. + */ +export const DYING = { perRound: 1, minCount: 2, stabiliseWith: ["first_aid", "medicine"] }; + +/** How many rounds this agent has at zero before the count is met. */ +export function dyingLimitFor(majorWound) { + return Math.max(DYING.minCount, Number(majorWound) || 0); +} + +/** The four states, from one place, so nothing has to re-derive them. */ +export function conditionFor({ hp = 0, dyingRounds = 0, majorWound = 0, + stabilised = false, unconscious = false } = {}) { + const h = Number(hp) || 0; + if (h <= 0) { + if (!stabilised && (Number(dyingRounds) || 0) >= dyingLimitFor(majorWound)) return "dead"; + return "dying"; + } + if (h <= 2 || unconscious) return "down"; + return "up"; +} + +/** + * THE LAST ENTRY. + * + * A Custodian's death is a filing question, because everything here is. How well + * filed they were when they died decides what the record ends up saying, and the + * badly filed do not get recorded as dead at all — which is the department's + * problem, and then somebody else's. + */ +export const LAST_ENTRY = [ + { atLeast: 7, id: "recorded", label: "RINGBRP.Death.Entry.recorded" }, + { atLeast: 4, id: "queried", label: "RINGBRP.Death.Entry.queried" }, + { atLeast: 1, id: "unclosed", label: "RINGBRP.Death.Entry.unclosed" }, + { atLeast: 0, id: "transferred", label: "RINGBRP.Death.Entry.transferred" } +]; + +export function lastEntryFor(coherence) { + const c = Number(coherence) || 0; + return LAST_ENTRY.find(e => c >= e.atLeast) ?? LAST_ENTRY[LAST_ENTRY.length - 1]; +} + /** * HOW HARD A CASE PHASE IS. * diff --git a/styles/ringbrp.css b/styles/ringbrp.css index 9028efe..fd34d3d 100644 --- a/styles/ringbrp.css +++ b/styles/ringbrp.css @@ -1242,3 +1242,22 @@ /* The case generator sits with the others but is the one that makes a whole case. */ .rb-genbtn-case { border-color: var(--rb-yellow); color: var(--rb-yellow); } + +/* --- Dying and dead ------------------------------------------------------- */ +.rb-dyingbar, .rb-deadbar { + display: flex; gap: 10px; align-items: baseline; + padding: 6px 10px; margin: 0 0 8px; + font-family: var(--rb-body); font-size: 11px; + border-left: 3px solid var(--rb-red, #ff7a5c); background: var(--rb-deep); +} +.rb-dyingbar.rb-stable { border-left-color: var(--rb-yellow); } +.rb-deadbar { border-left-color: var(--rb-white); } +.rb-dyingbar-title { + font-weight: 700; letter-spacing: .12em; text-transform: uppercase; + color: var(--rb-red, #ff7a5c); +} +.rb-dyingbar.rb-stable .rb-dyingbar-title { color: var(--rb-yellow); } +.rb-deadbar .rb-dyingbar-title { color: var(--rb-white); } +.rb-dying { border-left: 3px solid var(--rb-red, #ff7a5c); padding-left: 8px; } +.rb-stable { border-left: 3px solid var(--rb-yellow); padding-left: 8px; } +.rb-dead { border-left: 3px solid var(--rb-white); padding-left: 8px; } diff --git a/template.json b/template.json index 4354cb5..9af98f0 100644 --- a/template.json +++ b/template.json @@ -82,7 +82,13 @@ }, "pursuits": [], "biography": "", - "rank": "" + "rank": "", + "dying": { + "rounds": 0, + "stabilised": false, + "dead": false, + "lastEntry": "" + } }, "npc": { "templates": [ @@ -96,7 +102,13 @@ "naturalArmour": { "value": 0 }, - "tactics": "" + "tactics": "", + "dying": { + "rounds": 0, + "stabilised": false, + "dead": false, + "lastEntry": "" + } }, "expedition": { "supply": { diff --git a/templates/character-sheet.hbs b/templates/character-sheet.hbs index 011e94c..ae241ec 100644 --- a/templates/character-sheet.hbs +++ b/templates/character-sheet.hbs @@ -118,6 +118,23 @@
+ {{#if dyingNow}} +
+ {{localize "RINGBRP.Condition.dying"}} + {{#if stabilised}} + {{localize "RINGBRP.Death.StableTag"}} + {{else}} + {{localize "RINGBRP.Death.CountTag"}} {{dyingRounds}} / {{dyingLimit}} + {{/if}} +
+ {{/if}} + {{#if deadNow}} +
+ {{localize "RINGBRP.Condition.dead"}} + {{lastEntry}} +
+ {{/if}} + {{#if impaired.active}}
diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index 3356f55..3cad218 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -197,6 +197,17 @@ const checks = [ ["defenceTypeAllowed('','parry')", RULES.defenceTypeAllowed("","parry"), "parry"], // defences are graded: a defence stops its own quality or worse, and only // shaves a better blow by as many steps as it was outclassed by + // zero is a clock, not a state: dying, then dead, and the record decides the rest + ["dyingLimitFor(6)", RULES.dyingLimitFor(6), 6], + ["dyingLimitFor(1) has a floor", RULES.dyingLimitFor(1), 2], + ["conditionFor(hp 8)", RULES.conditionFor({hp:8}), "up"], + ["conditionFor(hp 2) is down", RULES.conditionFor({hp:2}), "down"], + ["conditionFor(hp 0) is dying", RULES.conditionFor({hp:0,majorWound:6}), "dying"], + ["conditionFor(count met) is dead", RULES.conditionFor({hp:0,dyingRounds:6,majorWound:6}), "dead"], + ["stabilised never reaches dead", RULES.conditionFor({hp:0,dyingRounds:9,majorWound:6,stabilised:true}), "dying"], + ["lastEntryFor(10)", RULES.lastEntryFor(10).id, "recorded"], + ["lastEntryFor(3)", RULES.lastEntryFor(3).id, "unclosed"], + ["lastEntryFor(0)", RULES.lastEntryFor(0).id, "transferred"], // the case ramp: attrition accumulates and stops; only Containment is Difficult ["phaseRampFor(0) is the way in", RULES.phaseRampFor(0,"approach").difficulty, "average"], ["phaseRampFor(0) modifier", RULES.phaseRampFor(0,"approach").modifier, 0], diff --git a/tools/mission.mjs b/tools/mission.mjs index e88f0e6..1ed1eed 100644 --- a/tools/mission.mjs +++ b/tools/mission.mjs @@ -94,7 +94,10 @@ function buildPhases(r, { anomaly, gap }) { // extraction, and "getting out with it" presumed the it had been obtained // offscreen: the generator produced an approach, not a session. The phase where // they do the thing they were sent to do is the one the case is about. - const middle = some(PHASE_KINDS.filter(k => !["approach", "extraction", "containment"].includes(k.id)), 2, r); + // ONE middle phase, not two. A five-phase arc with two interchangeable middles + // read as padding — the case is Approach, the thing in the way, the job, and out. + // Four phases is a session; five was a session and a half with a repeat in it. + const middle = some(PHASE_KINDS.filter(k => !["approach", "extraction", "containment"].includes(k.id)), 1, r); const kinds = [ PHASE_KINDS.find(k => k.id === "approach"), ...middle, @@ -244,22 +247,55 @@ const ul = xs => `
    ${xs.map(x => `
  • ${esc(x)}
  • `).join("")}
`; /** The player-facing brief. Everything here is true as far as the agency knows. */ export function briefPage(m) { + // The brief used to open on a classification note and never say where the site + // was, how long the team had, what was likely to be in the way, or how they were + // getting out — all of which the generator already knew. An operational brief + // answers those four first and explains itself afterwards. + const ex = m.phases[m.phases.length - 1]; return ` -

Classification ${esc(m.classification.name)}. ${esc(m.classification.note)}

-

The brief

-

${esc(m.anomaly.brief)}

-

Containment. ${esc(m.anomaly.containment)}

+ + + + + +
Where${esc(m.place)}
When${esc(m.duration)} — that is the window, not an estimate.
Classification${esc(m.classification.name)} — ${esc(m.classification.note)}
Phases${m.phases.length}: ${m.phases.map(p => esc(p.name)).join(" → ")}
+

What you are being sent to do

-

${esc(m.resolution)}

-

Known complication

-

${esc(m.complication)}

-

Reported signs

+

${esc(m.resolution)}

+ +

What is reported

+

${esc(m.anomaly.brief)}

+

Why it cannot be left. ${esc(m.anomaly.containment)}

+

Signs you have the right site:

${ul(m.anomaly.signs)} -

Starting state

+ +

What is likely to be in your way

+

${esc(m.adversary.name)} — expect around ${m.adversary.count}, tier +${esc(m.adversary.tier)}.

+

Why they care. ${esc(m.adversary.motive)}

+

How they will go about it. ${esc(m.adversary.tactics)}

+

What might work instead of shooting. ${esc(m.adversary.leverage)}

+

Also on the ground: ${esc(m.group.name)} — ${esc(m.group.description)}

+

Do not: ${esc(m.group.taboo?.text ?? m.group.taboo)} +${m.group.taboo?.penaltyIfViolated ? `(${m.group.taboo.penaltyIfViolated}% if you do)` : ""}

+

They want: ${esc(m.group.desire?.text ?? m.group.desire)}

+

Known complication. ${esc(m.complication)}

+ +

How you get back

+
    +
  • Extraction is ${esc(ex.terrain)}.
  • +
  • Return is a Transposition roll per agent. Anyone may aim for +somebody else instead of themselves.
  • +
  • Classification modifier on the way home: ${m.crossingModifier ? `${m.crossingModifier}%` : "none"}.
  • +
  • A rough return costs Coherence. A crossing that goes badly wrong is the only way +an Art is ever acquired.
  • +
+ +

What you start with

  • Containment ${m.tracks.containment} · Resources ${m.tracks.resources} · Clearance ${m.tracks.clearance}
  • -
  • Destination knowledge: rumoured — every phase this journey is Difficult.
  • -
  • Return modifier for the classification: ${m.crossingModifier ? `${m.crossingModifier}%` : "none"}
  • +
  • Phases cost ${m.phaseCost} Resources in total if nothing goes wrong. It will.
  • +
  • Ground is rumoured, not known.
`; } diff --git a/tools/rules-text.mjs b/tools/rules-text.mjs index 2efe21f..82f53c4 100644 --- a/tools/rules-text.mjs +++ b/tools/rules-text.mjs @@ -19,6 +19,7 @@ import { SCOPE_TIERS, scopeStanceFor, scrutinyFor, authorityOutcomeFor, dossierTrustFor, widenScopeCostFor, DOSSIER, MERGE_STAGES, skillBaseFrom, encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, + DYING, dyingLimitFor, LAST_ENTRY, powerStartingRating, brawlFallbackFor, bleedStoppedBy, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, @@ -309,6 +310,33 @@ ${table(["1D10", "Wound", "What it does to you"], woundRows)}

These stack, with each other and with a shattered limb, and they are applied to every roll you make. The Skills tab shows a red bar when you are carrying any.

+

Zero hit points

+

Hit points do not go below zero. Zero starts a clock instead.

+${table(["Hit points", "You are"], [ + ["above 2", "up — no restriction from injury alone"], + ["2 or below", "down — out of the fight, still conscious, still yours"], + ["0", "dying — unconscious, no actions, no defences, and the count begins"] +])} +

Each round at zero adds ${DYING.perRound} to the count. When the count reaches your +own Major Wound threshold you are dead — so the same +number that decides how much damage you can take standing up also decides how long you +have on the floor. A minimum of ${DYING.minCount} rounds applies however slight you are.

+

Stabilising takes ${DYING.stabiliseWith.join(" or ")} and stops the +count. It does not put you back on your feet: you are still at zero and still +unconscious until somebody heals you above it. Healing above zero clears the count. +A fumbled attempt over a dying agent costs them a round they do not have.

+ +

The last entry

+

A Custodian's death is a filing question, because everything here is. What the +record ends up saying depends on how well filed they were when it happened.

+${table(["Coherence at death", "What the record says"], + LAST_ENTRY.map((e, i) => { + const upper = i === 0 ? "10" : String(LAST_ENTRY[i - 1].atLeast - 1); + return [e.atLeast === 0 ? "0" : `${e.atLeast}–${upper}`, L(e.label)]; + }))} +

The badly filed are not recorded as dead at all. That is the department's problem +first, and then somebody else's.

+

Bleeding

${table(["Wound", "Per round", "Stopped by"], Object.entries(BLEED).map(([k, v]) => [L(`RINGBRP.Wound.${k}.title`), `${v.perRound} hp`,