Death at zero, and a case brief that answers the useful questions

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 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-08-10 22:13:21 +01:00
co-authored by Claude Opus 5
parent a360d77b83
commit 89ce557355
41 changed files with 520 additions and 94 deletions
+61
View File
@@ -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.
+22
View File
@@ -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",
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/10-21:10:35.025087 175517000 Recovering log #3
2026/08/10-21:10:35.025512 175517000 Level-0 table #5: started
2026/08/10-21:10:35.026438 175517000 Level-0 table #5: 60660 bytes OK
2026/08/10-21:10:35.026776 175517000 Delete type=0 #3
2026/08/10-21:10:35.026817 175517000 Delete type=3 #2
2026/08/10-22:10:05.160943 173fbb000 Recovering log #3
2026/08/10-22:10:05.161166 173fbb000 Level-0 table #5: started
2026/08/10-22:10:05.161661 173fbb000 Level-0 table #5: 60660 bytes OK
2026/08/10-22:10:05.161946 173fbb000 Delete type=0 #3
2026/08/10-22:10:05.162005 173fbb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/10-21:10:26.754647 172977000 Delete type=3 #1
2026/08/10-22:09:56.870862 172fe3000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/10-21:10:35.031149 1744ff000 Recovering log #3
2026/08/10-21:10:35.031171 1744ff000 Level-0 table #5: started
2026/08/10-21:10:35.031288 1744ff000 Level-0 table #5: 3295 bytes OK
2026/08/10-21:10:35.031521 1744ff000 Delete type=0 #3
2026/08/10-21:10:35.031553 1744ff000 Delete type=3 #2
2026/08/10-22:10:05.165461 172fa3000 Recovering log #3
2026/08/10-22:10:05.165486 172fa3000 Level-0 table #5: started
2026/08/10-22:10:05.165592 172fa3000 Level-0 table #5: 3295 bytes OK
2026/08/10-22:10:05.165831 172fa3000 Delete type=0 #3
2026/08/10-22:10:05.165879 172fa3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/10-21:10:26.760755 17419b000 Delete type=3 #1
2026/08/10-22:09:56.877345 172fe3000 Delete type=3 #1
Binary file not shown.
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/10-21:10:35.028347 175517000 Recovering log #3
2026/08/10-21:10:35.028398 175517000 Level-0 table #5: started
2026/08/10-21:10:35.028587 175517000 Level-0 table #5: 6679 bytes OK
2026/08/10-21:10:35.028935 175517000 Delete type=0 #3
2026/08/10-21:10:35.029009 175517000 Delete type=3 #2
2026/08/10-22:10:05.163199 173fbb000 Recovering log #3
2026/08/10-22:10:05.163244 173fbb000 Level-0 table #5: started
2026/08/10-22:10:05.163412 173fbb000 Level-0 table #5: 6679 bytes OK
2026/08/10-22:10:05.163630 173fbb000 Delete type=0 #3
2026/08/10-22:10:05.163662 173fbb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/10-21:10:26.736502 172977000 Delete type=3 #1
2026/08/10-22:09:56.849645 171fcb000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/10-21:10:35.039746 1744ff000 Recovering log #3
2026/08/10-21:10:35.039788 1744ff000 Level-0 table #5: started
2026/08/10-21:10:35.040037 1744ff000 Level-0 table #5: 16892 bytes OK
2026/08/10-21:10:35.040363 1744ff000 Delete type=0 #3
2026/08/10-21:10:35.040421 1744ff000 Delete type=3 #2
2026/08/10-22:10:05.172842 173fbb000 Recovering log #3
2026/08/10-22:10:05.172885 173fbb000 Level-0 table #5: started
2026/08/10-22:10:05.173125 173fbb000 Level-0 table #5: 16892 bytes OK
2026/08/10-22:10:05.173406 173fbb000 Delete type=0 #3
2026/08/10-22:10:05.173455 173fbb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/10-21:10:26.759088 173183000 Delete type=3 #1
2026/08/10-22:09:56.875668 1727d7000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/10-21:10:35.041184 175517000 Recovering log #3
2026/08/10-21:10:35.041207 175517000 Level-0 table #5: started
2026/08/10-21:10:35.041346 175517000 Level-0 table #5: 3739 bytes OK
2026/08/10-21:10:35.041618 175517000 Delete type=0 #3
2026/08/10-21:10:35.041664 175517000 Delete type=3 #2
2026/08/10-22:10:05.174148 172fa3000 Recovering log #3
2026/08/10-22:10:05.174171 172fa3000 Level-0 table #5: started
2026/08/10-22:10:05.174281 172fa3000 Level-0 table #5: 3739 bytes OK
2026/08/10-22:10:05.174533 172fa3000 Delete type=0 #3
2026/08/10-22:10:05.174562 172fa3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/10-21:10:26.757057 173183000 Delete type=3 #1
2026/08/10-22:09:56.873729 171fcb000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/10-21:10:35.029938 174d0b000 Recovering log #3
2026/08/10-21:10:35.029970 174d0b000 Level-0 table #5: started
2026/08/10-21:10:35.030128 174d0b000 Level-0 table #5: 5105 bytes OK
2026/08/10-21:10:35.030410 174d0b000 Delete type=0 #3
2026/08/10-21:10:35.030446 174d0b000 Delete type=3 #2
2026/08/10-22:10:05.164350 1737af000 Recovering log #3
2026/08/10-22:10:05.164377 1737af000 Level-0 table #5: started
2026/08/10-22:10:05.164513 1737af000 Level-0 table #5: 5105 bytes OK
2026/08/10-22:10:05.164819 1737af000 Delete type=0 #3
2026/08/10-22:10:05.164862 1737af000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/10-21:10:26.739179 173183000 Delete type=3 #1
2026/08/10-22:09:56.853599 172fe3000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/10-21:10:35.036441 175517000 Recovering log #3
2026/08/10-21:10:35.036467 175517000 Level-0 table #5: started
2026/08/10-21:10:35.036615 175517000 Level-0 table #5: 5012 bytes OK
2026/08/10-21:10:35.036909 175517000 Delete type=0 #3
2026/08/10-21:10:35.036961 175517000 Delete type=3 #2
2026/08/10-22:10:05.170089 172fa3000 Recovering log #3
2026/08/10-22:10:05.170122 172fa3000 Level-0 table #5: started
2026/08/10-22:10:05.170277 172fa3000 Level-0 table #5: 5012 bytes OK
2026/08/10-22:10:05.170539 172fa3000 Delete type=0 #3
2026/08/10-22:10:05.170582 172fa3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/10-21:10:26.746192 173183000 Delete type=3 #1
2026/08/10-22:09:56.861512 1737ef000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/10-21:10:35.032367 175d23000 Recovering log #3
2026/08/10-21:10:35.032410 175d23000 Level-0 table #5: started
2026/08/10-21:10:35.032599 175d23000 Level-0 table #5: 7240 bytes OK
2026/08/10-21:10:35.032954 175d23000 Delete type=0 #3
2026/08/10-21:10:35.033016 175d23000 Delete type=3 #2
2026/08/10-22:10:05.166515 172797000 Recovering log #3
2026/08/10-22:10:05.166560 172797000 Level-0 table #5: started
2026/08/10-22:10:05.166726 172797000 Level-0 table #5: 7240 bytes OK
2026/08/10-22:10:05.166989 172797000 Delete type=0 #3
2026/08/10-22:10:05.167039 172797000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/10-21:10:26.740769 17419b000 Delete type=3 #1
2026/08/10-22:09:56.855434 1727d7000 Delete type=3 #1
+158 -2
View File
@@ -27,6 +27,7 @@ import {
encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor,
defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP,
defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom,
DYING, dyingLimitFor, conditionFor, lastEntryFor, LAST_ENTRY,
brawlFallbackFor, BRAWL_FALLBACK,
SCOPE_TIERS, scopeRank, scopeStanceFor, scrutinyFor, RED_FLAG_STEP,
authorityOutcomeFor, trustAfter, TRUST_MAX, DOSSIER, dossierTrustFor,
@@ -698,6 +699,13 @@ class RingBRPActor extends Actor {
sys.disabledLocations = sys.locations.filter(l => 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: `<div class="ringbrp-chat rb-dying"><strong>${
game.i18n.format("RINGBRP.Death.Dying", { name: actor.name })}</strong> ${
game.i18n.format("RINGBRP.Death.Count", { n: rounds, of: limit })}</div>`
});
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: `<div class="ringbrp-chat rb-dead">`
+ `<h3 class="rb-chat-title">${game.i18n.format("RINGBRP.Death.Died", { name: actor.name })}</h3>`
+ `<p class="rb-chat-flavour">${game.i18n.format("RINGBRP.Death.Coherence", { n: coherence })}</p>`
+ `<p><strong>${game.i18n.localize("RINGBRP.Death.TheEntry")}</strong> ${game.i18n.localize(entry.label)}</p>`
+ `</div>`
});
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: `<div class="ringbrp-chat ${ok ? "rb-stable" : "rb-dying"}">${
game.i18n.format(ok ? "RINGBRP.Death.Stabilised" : "RINGBRP.Death.NotStabilised",
{ name: patient.name })}</div>`
});
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: `<p><strong>${m.codename}</strong> — ${m.classification.name}. ${m.place}.</p>`
// 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: `<p><strong>${m.codename}</strong> — ${m.classification.name}.</p>`
+ `<p><strong>Where:</strong> ${m.place}<br/>`
+ `<strong>When:</strong> ${m.duration}<br/>`
+ `<strong>Job:</strong> ${m.resolution}</p>`
+ `<p><strong>Expect:</strong> about ${m.adversary.count} ${m.adversary.name} (${m.adversary.tier}). `
+ `${m.adversary.leverage}</p>`
+ `<p><strong>Way back:</strong> ${m.phases[m.phases.length - 1].terrain}. `
+ `Transposition per agent`
+ `${m.crossingModifier ? `, ${m.crossingModifier}% for the classification` : ""}.</p>`
+ `<p>${m.anomaly.brief}</p>`,
// 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);
}
}
+64
View File
@@ -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.
*
+19
View File
@@ -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; }
+14 -2
View File
@@ -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": {
+17
View File
@@ -118,6 +118,23 @@
<section class="sheet-body rb-body">
<div class="tab" data-group="primary" data-tab="skills">
{{#if dyingNow}}
<div class="rb-dyingbar{{#if stabilised}} rb-stable{{/if}}">
<span class="rb-dyingbar-title">{{localize "RINGBRP.Condition.dying"}}</span>
{{#if stabilised}}
<span>{{localize "RINGBRP.Death.StableTag"}}</span>
{{else}}
<span>{{localize "RINGBRP.Death.CountTag"}} {{dyingRounds}} / {{dyingLimit}}</span>
{{/if}}
</div>
{{/if}}
{{#if deadNow}}
<div class="rb-deadbar">
<span class="rb-dyingbar-title">{{localize "RINGBRP.Condition.dead"}}</span>
<span>{{lastEntry}}</span>
</div>
{{/if}}
{{#if impaired.active}}
<div class="rb-woundbar" data-tooltip="{{localize 'RINGBRP.Sheet.WoundBarHint'}}">
<span class="rb-woundbar-title"><i class="fas fa-triangle-exclamation"></i>
+11
View File
@@ -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],
+48 -12
View File
@@ -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 => `<ul>${xs.map(x => `<li>${esc(x)}</li>`).join("")}</ul>`;
/** 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 `
<p><strong>Classification ${esc(m.classification.name)}.</strong> <em>${esc(m.classification.note)}</em></p>
<h2>The brief</h2>
<p>${esc(m.anomaly.brief)}</p>
<p><strong>Containment.</strong> ${esc(m.anomaly.containment)}</p>
<table class="rb-brief-head"><tbody>
<tr><th>Where</th><td><strong>${esc(m.place)}</strong></td></tr>
<tr><th>When</th><td><strong>${esc(m.duration)}</strong> — that is the window, not an estimate.</td></tr>
<tr><th>Classification</th><td><strong>${esc(m.classification.name)}</strong> — ${esc(m.classification.note)}</td></tr>
<tr><th>Phases</th><td>${m.phases.length}: ${m.phases.map(p => esc(p.name)).join(" &rarr; ")}</td></tr>
</tbody></table>
<h2>What you are being sent to do</h2>
<p>${esc(m.resolution)}</p>
<h2>Known complication</h2>
<p>${esc(m.complication)}</p>
<h2>Reported signs</h2>
<p><strong>${esc(m.resolution)}</strong></p>
<h2>What is reported</h2>
<p>${esc(m.anomaly.brief)}</p>
<p><strong>Why it cannot be left.</strong> ${esc(m.anomaly.containment)}</p>
<p><strong>Signs you have the right site:</strong></p>
${ul(m.anomaly.signs)}
<h2>Starting state</h2>
<h2>What is likely to be in your way</h2>
<p><strong>${esc(m.adversary.name)}</strong> — expect around ${m.adversary.count}, tier
<strong>${esc(m.adversary.tier)}</strong>.</p>
<p><strong>Why they care.</strong> ${esc(m.adversary.motive)}</p>
<p><strong>How they will go about it.</strong> ${esc(m.adversary.tactics)}</p>
<p><strong>What might work instead of shooting.</strong> ${esc(m.adversary.leverage)}</p>
<p><strong>Also on the ground:</strong> ${esc(m.group.name)} — ${esc(m.group.description)}</p>
<p><strong>Do not:</strong> ${esc(m.group.taboo?.text ?? m.group.taboo)}
${m.group.taboo?.penaltyIfViolated ? `(${m.group.taboo.penaltyIfViolated}% if you do)` : ""}</p>
<p><strong>They want:</strong> ${esc(m.group.desire?.text ?? m.group.desire)}</p>
<p><strong>Known complication.</strong> ${esc(m.complication)}</p>
<h2>How you get back</h2>
<ul>
<li>Extraction is <strong>${esc(ex.terrain)}</strong>.</li>
<li>Return is a <strong>Transposition</strong> roll per agent. Anyone may aim for
somebody else instead of themselves.</li>
<li>Classification modifier on the way home: <strong>${m.crossingModifier ? `${m.crossingModifier}%` : "none"}</strong>.</li>
<li>A rough return costs Coherence. A crossing that goes badly wrong is the only way
an Art is ever acquired.</li>
</ul>
<h2>What you start with</h2>
<ul>
<li><strong>Containment ${m.tracks.containment} · Resources ${m.tracks.resources} · Clearance ${m.tracks.clearance}</strong></li>
<li>Destination knowledge: <strong>rumoured</strong> — every phase this journey is Difficult.</li>
<li>Return modifier for the classification: <strong>${m.crossingModifier ? `${m.crossingModifier}%` : "none"}</strong></li>
<li>Phases cost <strong>${m.phaseCost} Resources</strong> in total if nothing goes wrong. It will.</li>
<li>Ground is <strong>rumoured</strong>, not known.</li>
</ul>`;
}
+28
View File
@@ -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)}
<p>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.</p>
<h2>Zero hit points</h2>
<p>Hit points do not go below zero. Zero starts a clock instead.</p>
${table(["Hit points", "You are"], [
["above 2", "<strong>up</strong> — no restriction from injury alone"],
["2 or below", "<strong>down</strong> — out of the fight, still conscious, still yours"],
["0", "<strong>dying</strong> — unconscious, no actions, no defences, and the count begins"]
])}
<p>Each round at zero adds ${DYING.perRound} to the count. When the count reaches your
own <strong>Major Wound</strong> threshold you are <strong>dead</strong> — 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.</p>
<p><strong>Stabilising</strong> 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 <em>fumbled</em> attempt over a dying agent costs them a round they do not have.</p>
<h3>The last entry</h3>
<p>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.</p>
${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)];
}))}
<p>The badly filed are not recorded as dead at all. That is the department's problem
first, and then somebody else's.</p>
<h2>Bleeding</h2>
${table(["Wound", "Per round", "Stopped by"], Object.entries(BLEED).map(([k, v]) =>
[L(`RINGBRP.Wound.${k}.title`), `${v.perRound} hp`,