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 398e78a..d76f908 100644 Binary files a/packs/npcs/000006.log and b/packs/npcs/000006.log differ diff --git a/packs/npcs/LOG b/packs/npcs/LOG index 837a43b..fd4b2a5 100644 --- a/packs/npcs/LOG +++ b/packs/npcs/LOG @@ -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 diff --git a/packs/npcs/LOG.old b/packs/npcs/LOG.old index 9942779..342c4ce 100644 --- a/packs/npcs/LOG.old +++ b/packs/npcs/LOG.old @@ -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 diff --git a/packs/powers/LOG b/packs/powers/LOG index 2045096..f720acc 100644 --- a/packs/powers/LOG +++ b/packs/powers/LOG @@ -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 diff --git a/packs/powers/LOG.old b/packs/powers/LOG.old index 080d557..856d7e6 100644 --- a/packs/powers/LOG.old +++ b/packs/powers/LOG.old @@ -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 diff --git a/packs/pregens/000006.log b/packs/pregens/000006.log index eceac51..f25632f 100644 Binary files a/packs/pregens/000006.log and b/packs/pregens/000006.log differ 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 f65723c..d2d07c7 100644 Binary files a/packs/rules/000005.ldb and b/packs/rules/000005.ldb differ diff --git a/packs/rules/000006.log b/packs/rules/000006.log index c70cb3c..e969202 100644 Binary files a/packs/rules/000006.log and b/packs/rules/000006.log differ 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 e1e6f6a..23212a1 100644 Binary files a/packs/rules/MANIFEST-000004 and b/packs/rules/MANIFEST-000004 differ diff --git a/packs/skills/LOG b/packs/skills/LOG index aab4d6d..04b4ef1 100644 --- a/packs/skills/LOG +++ b/packs/skills/LOG @@ -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 diff --git a/packs/skills/LOG.old b/packs/skills/LOG.old index 265661d..ce8588c 100644 --- a/packs/skills/LOG.old +++ b/packs/skills/LOG.old @@ -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 diff --git a/packs/slice/LOG b/packs/slice/LOG index b7366cc..beab734 100644 --- a/packs/slice/LOG +++ b/packs/slice/LOG @@ -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 diff --git a/packs/slice/LOG.old b/packs/slice/LOG.old index 207dcd4..b38546c 100644 --- a/packs/slice/LOG.old +++ b/packs/slice/LOG.old @@ -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 diff --git a/packs/tables/LOG b/packs/tables/LOG index 4e04f5c..dc76f70 100644 --- a/packs/tables/LOG +++ b/packs/tables/LOG @@ -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 diff --git a/packs/tables/LOG.old b/packs/tables/LOG.old index 92285dd..42d556d 100644 --- a/packs/tables/LOG.old +++ b/packs/tables/LOG.old @@ -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 diff --git a/packs/talents/LOG b/packs/talents/LOG index 8906fbc..8901b19 100644 --- a/packs/talents/LOG +++ b/packs/talents/LOG @@ -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 diff --git a/packs/talents/LOG.old b/packs/talents/LOG.old index 54be61d..6b33420 100644 --- a/packs/talents/LOG.old +++ b/packs/talents/LOG.old @@ -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 diff --git a/packs/vehicles/LOG b/packs/vehicles/LOG index 953ee0e..82ab12e 100644 --- a/packs/vehicles/LOG +++ b/packs/vehicles/LOG @@ -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 diff --git a/packs/vehicles/LOG.old b/packs/vehicles/LOG.old index 07a3f70..1c74ec1 100644 --- a/packs/vehicles/LOG.old +++ b/packs/vehicles/LOG.old @@ -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 diff --git a/packs/weapons/LOG b/packs/weapons/LOG index fd48b42..54e5a6a 100644 --- a/packs/weapons/LOG +++ b/packs/weapons/LOG @@ -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 diff --git a/packs/weapons/LOG.old b/packs/weapons/LOG.old index 0829054..2a59f88 100644 --- a/packs/weapons/LOG.old +++ b/packs/weapons/LOG.old @@ -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 diff --git a/ringbrp.mjs b/ringbrp.mjs index 64dc8fb..1e74b2f 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -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: `
${ + 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`,