Add the duty roster: ten ready-to-play agents with generated portraits
Five postings and five trades, so a group choosing from this sheet meets both halves of character creation without anyone having to explain them. Skills and kit are not written out per agent. They are expanded from the register at build time by the same helper the starter scenario uses, which is hoisted out of the BLACK PLATFORM block for the purpose — two copies of that expansion is two places for the induction rule to drift. Ratings sit at the middle of each band rather than being rolled, because a pregen that varies between installs is not a pregen. Portraits are generated art, resized to 512 and stored as webp (12MB of PNG became 210KB). buildActor now takes a portrait and gives the same face to the prototype token: a pregen you can recognise on the map is worth more at a convention table than a correct one you cannot tell apart. It throws on a portrait that is not on disk, because a broken image on a pregen sheet is the sort of thing that is first noticed on a projector. Guard: check-behaviour gains a test that every roster agent names a real posting or trade and has its portrait on disk, verified to bite. It caught "archive", which is not a posting — Bhattacharya is Registry. Verified in a running Foundry: 14 pregens in the pack, sheets render with the portraits, coherence and hit points fill from the template, no system errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -180,9 +180,9 @@ The build runs all three guards before it writes anything:
|
||||
|
||||
<!-- guards:start -->
|
||||
```
|
||||
check-rules: OK — 87 rules, 17 files scanned, 3 aliases + 41 constant sets checked, 275 formulas verified
|
||||
check-rules: OK — 87 rules, 18 files scanned, 3 aliases + 41 constant sets checked, 275 formulas verified
|
||||
check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
|
||||
check-lang: OK — en.json, 927 keys, no leaf/branch collisions
|
||||
check-behaviour: OK — 38 behavioural tests
|
||||
check-behaviour: OK — 41 behavioural tests
|
||||
```
|
||||
<!-- guards:end -->
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.925114 17200b000 Recovering log #3
|
||||
2026/08/12-10:15:09.925148 17200b000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.925283 17200b000 Level-0 table #5: 4591 bytes OK
|
||||
2026/08/12-10:15:09.925570 17200b000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.925615 17200b000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.335240 17275b000 Recovering log #3
|
||||
2026/08/12-11:03:53.335622 17275b000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.335876 17275b000 Level-0 table #5: 4591 bytes OK
|
||||
2026/08/12-11:03:53.336211 17275b000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.336262 17275b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.782384 173eef000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.559352 16fe23000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.941304 173023000 Recovering log #3
|
||||
2026/08/12-10:15:09.941329 173023000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.941465 173023000 Level-0 table #5: 4129 bytes OK
|
||||
2026/08/12-10:15:09.941748 173023000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.941781 173023000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.359869 171f4f000 Recovering log #3
|
||||
2026/08/12-11:03:53.360351 171f4f000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.360577 171f4f000 Level-0 table #5: 4129 bytes OK
|
||||
2026/08/12-11:03:53.360909 171f4f000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.360948 171f4f000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.827491 1726cb000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.616347 16f617000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.926306 173023000 Recovering log #3
|
||||
2026/08/12-10:15:09.926421 173023000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.926814 173023000 Level-0 table #5: 31614 bytes OK
|
||||
2026/08/12-10:15:09.927163 173023000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.927207 173023000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.337242 171f4f000 Recovering log #3
|
||||
2026/08/12-11:03:53.337820 171f4f000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.338200 171f4f000 Level-0 table #5: 31614 bytes OK
|
||||
2026/08/12-11:03:53.338535 171f4f000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.338582 171f4f000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.784415 173eef000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.561859 16ee0b000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.914246 173023000 Recovering log #3
|
||||
2026/08/12-10:15:09.915233 173023000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.916771 173023000 Level-0 table #5: 225945 bytes OK
|
||||
2026/08/12-10:15:09.917162 173023000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.917219 173023000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.319350 171f4f000 Recovering log #3
|
||||
2026/08/12-11:03:53.321070 171f4f000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.322779 171f4f000 Level-0 table #5: 225945 bytes OK
|
||||
2026/08/12-11:03:53.323152 171f4f000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.323422 171f4f000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.800859 1726cb000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.588411 16f617000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.922245 17200b000 Recovering log #3
|
||||
2026/08/12-10:15:09.922265 17200b000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.922421 17200b000 Level-0 table #5: 3295 bytes OK
|
||||
2026/08/12-10:15:09.922681 17200b000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.922713 17200b000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.331118 171f4f000 Recovering log #3
|
||||
2026/08/12-11:03:53.331736 171f4f000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.331902 171f4f000 Level-0 table #5: 3295 bytes OK
|
||||
2026/08/12-11:03:53.332213 171f4f000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.332254 171f4f000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.821081 1726cb000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.609842 16ee0b000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.911544 173023000 Recovering log #3
|
||||
2026/08/12-10:15:09.911759 173023000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.912426 173023000 Level-0 table #5: 58792 bytes OK
|
||||
2026/08/12-10:15:09.912801 173023000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.912846 173023000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.311385 17275b000 Recovering log #3
|
||||
2026/08/12-11:03:53.313147 17275b000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.314996 17275b000 Level-0 table #5: 234094 bytes OK
|
||||
2026/08/12-11:03:53.315506 17275b000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.315692 17275b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.791044 173eef000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.574790 16ee0b000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.929600 17382f000 Recovering log #3
|
||||
2026/08/12-10:15:09.929643 17382f000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.929903 17382f000 Level-0 table #5: 21613 bytes OK
|
||||
2026/08/12-10:15:09.930174 17382f000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.930216 17382f000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.341396 171f4f000 Recovering log #3
|
||||
2026/08/12-11:03:53.341499 171f4f000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.341782 171f4f000 Level-0 table #5: 21613 bytes OK
|
||||
2026/08/12-11:03:53.342135 171f4f000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.342170 171f4f000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.817678 1726cb000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.606226 17062f000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.930884 17200b000 Recovering log #3
|
||||
2026/08/12-10:15:09.930957 17200b000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.931274 17200b000 Level-0 table #5: 33484 bytes OK
|
||||
2026/08/12-10:15:09.931485 17200b000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.931513 17200b000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.343398 17275b000 Recovering log #3
|
||||
2026/08/12-11:03:53.344128 17275b000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.344828 17275b000 Level-0 table #5: 33484 bytes OK
|
||||
2026/08/12-11:03:53.345178 17275b000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.345409 17275b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.825718 1726cb000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.613896 16ee0b000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.920048 17200b000 Recovering log #3
|
||||
2026/08/12-10:15:09.920101 17200b000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.920292 17200b000 Level-0 table #5: 7590 bytes OK
|
||||
2026/08/12-10:15:09.920533 17200b000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.920569 17200b000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.326666 172f67000 Recovering log #3
|
||||
2026/08/12-11:03:53.326865 172f67000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.327127 172f67000 Level-0 table #5: 7590 bytes OK
|
||||
2026/08/12-11:03:53.327438 172f67000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.327686 172f67000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.775951 172ed7000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.551765 16ee0b000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.938481 172817000 Recovering log #3
|
||||
2026/08/12-10:15:09.938536 172817000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.938839 172817000 Level-0 table #5: 16902 bytes OK
|
||||
2026/08/12-10:15:09.939182 172817000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.939235 172817000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.354941 172f67000 Recovering log #3
|
||||
2026/08/12-11:03:53.355204 172f67000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.355515 172f67000 Level-0 table #5: 16902 bytes OK
|
||||
2026/08/12-11:03:53.355809 172f67000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.355952 172f67000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.819552 1736e3000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.608270 16f617000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.933901 172817000 Recovering log #3
|
||||
2026/08/12-10:15:09.934300 172817000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.935025 172817000 Level-0 table #5: 82299 bytes OK
|
||||
2026/08/12-10:15:09.935279 172817000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.935312 172817000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.349870 172f67000 Recovering log #3
|
||||
2026/08/12-11:03:53.350807 172f67000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.351355 172f67000 Level-0 table #5: 82299 bytes OK
|
||||
2026/08/12-11:03:53.351750 172f67000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.352023 172f67000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.811578 1736e3000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.600665 16f617000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.940141 17200b000 Recovering log #3
|
||||
2026/08/12-10:15:09.940161 17200b000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.940281 17200b000 Level-0 table #5: 3739 bytes OK
|
||||
2026/08/12-10:15:09.940565 17200b000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.940598 17200b000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.357157 17275b000 Recovering log #3
|
||||
2026/08/12-11:03:53.357573 17275b000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.358045 17275b000 Level-0 table #5: 3739 bytes OK
|
||||
2026/08/12-11:03:53.358519 17275b000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.358556 17275b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.805210 1726cb000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.593778 16fe23000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.921227 173023000 Recovering log #3
|
||||
2026/08/12-10:15:09.921256 173023000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.921405 173023000 Level-0 table #5: 5770 bytes OK
|
||||
2026/08/12-10:15:09.921672 173023000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.921715 173023000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.329230 173773000 Recovering log #3
|
||||
2026/08/12-11:03:53.329412 173773000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.329602 173773000 Level-0 table #5: 5770 bytes OK
|
||||
2026/08/12-11:03:53.329896 173773000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.329940 173773000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.779038 173eef000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.555278 17062f000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.928207 172817000 Recovering log #3
|
||||
2026/08/12-10:15:09.928248 172817000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.928429 172817000 Level-0 table #5: 6939 bytes OK
|
||||
2026/08/12-10:15:09.928761 172817000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.928810 172817000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.339804 17275b000 Recovering log #3
|
||||
2026/08/12-11:03:53.339950 17275b000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.340109 17275b000 Level-0 table #5: 6939 bytes OK
|
||||
2026/08/12-11:03:53.340345 17275b000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.340376 17275b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.786318 173eef000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.564184 17062f000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-10:15:09.923574 17382f000 Recovering log #3
|
||||
2026/08/12-10:15:09.923739 17382f000 Level-0 table #5: started
|
||||
2026/08/12-10:15:09.924049 17382f000 Level-0 table #5: 14767 bytes OK
|
||||
2026/08/12-10:15:09.924348 17382f000 Delete type=0 #3
|
||||
2026/08/12-10:15:09.924394 17382f000 Delete type=3 #2
|
||||
2026/08/12-11:03:53.333070 173773000 Recovering log #3
|
||||
2026/08/12-11:03:53.333335 173773000 Level-0 table #5: started
|
||||
2026/08/12-11:03:53.333713 173773000 Level-0 table #5: 14767 bytes OK
|
||||
2026/08/12-11:03:53.333997 173773000 Delete type=0 #3
|
||||
2026/08/12-11:03:53.334140 173773000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-10:14:43.780736 1726cb000 Delete type=3 #1
|
||||
2026/08/12-11:01:46.557371 16fe23000 Delete type=3 #1
|
||||
|
||||
@@ -34,10 +34,22 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.ringbrp-sheet,
|
||||
.ringbrp-chat,
|
||||
.ringbrp-journal,
|
||||
.ringbrp-window {
|
||||
/* ============================================================
|
||||
DESIGN TOKENS — declared once, on :root
|
||||
============================================================
|
||||
These used to be declared on the component classes themselves
|
||||
(.ringbrp-sheet, .ringbrp-chat, .ringbrp-window). That works right up until somebody
|
||||
adds a NEW component: journals got their own class, inherited no tokens, and every
|
||||
var() in forty rules silently resolved to nothing — which CSS treats as "unset"
|
||||
rather than as an error, so the numeric properties applied and every colour did not.
|
||||
R-250, and it cost four wrong diagnoses.
|
||||
|
||||
On :root they are available to anything this system renders, anywhere in the
|
||||
document, including UI Foundry mounts outside a sheet: tooltips, context menus,
|
||||
notifications, dialogs, and whatever gets added next. Prefixed --rb-, so nothing
|
||||
here can collide with Foundry's own custom properties or another module's. */
|
||||
|
||||
:root {
|
||||
/* sampled from the 1984 Explorer Book cover */
|
||||
--rb-void: #0c125b;
|
||||
--rb-deep: #101a6e;
|
||||
@@ -1256,11 +1268,11 @@
|
||||
.rb-chat-damage { display: flex; align-items: center; gap: 8px; margin: 6px 0 2px; flex-wrap: wrap; }
|
||||
.rb-dmg-btn {
|
||||
flex: 0 1 auto; padding: 3px 10px; cursor: pointer;
|
||||
background: var(--rb-panel, #1b2a5e); color: var(--rb-accent, #ffd75e);
|
||||
border: 1px solid var(--rb-accent, #ffd75e); border-radius: 3px;
|
||||
background: var(--rb-panel); color: var(--rb-yellow);
|
||||
border: 1px solid var(--rb-yellow); border-radius: 3px;
|
||||
font-family: inherit; font-size: 0.85rem; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
}
|
||||
.rb-dmg-btn:hover:not(:disabled) { background: var(--rb-accent, #ffd75e); color: var(--rb-panel, #1b2a5e); }
|
||||
.rb-dmg-btn:hover:not(:disabled) { background: var(--rb-yellow); color: var(--rb-panel); }
|
||||
.rb-dmg-btn:disabled { opacity: 0.45; cursor: default; }
|
||||
.rb-dmg-note { font-size: 0.75rem; font-style: italic; color: var(--rb-muted, #9fb0d9); }
|
||||
|
||||
@@ -1274,15 +1286,15 @@
|
||||
|
||||
/* Reaction — the working, so the printed number is not a magic figure */
|
||||
.rb-react-work { margin: 4px 0 2px; font-size: 0.78rem; }
|
||||
.rb-react-work summary { cursor: pointer; color: var(--rb-accent, #ffd75e); letter-spacing: 0.03em; }
|
||||
.rb-react-work summary { cursor: pointer; color: var(--rb-yellow); letter-spacing: 0.03em; }
|
||||
.rb-worktable { width: 100%; border-collapse: collapse; margin-top: 4px; }
|
||||
.rb-worktable th { text-align: left; font-weight: 600; padding: 1px 6px 1px 0; white-space: nowrap; }
|
||||
.rb-worktable td { padding: 1px 0; color: var(--rb-muted, #9fb0d9); }
|
||||
.rb-worknum { text-align: right; font-weight: 700; color: var(--rb-text, #e8eeff); width: 3em; }
|
||||
.rb-worknum { text-align: right; font-weight: 700; color: var(--rb-white); width: 3em; }
|
||||
.rb-worknote td { font-style: italic; font-size: 0.72rem; padding-bottom: 3px; }
|
||||
.rb-workload .rb-worknum { color: var(--rb-danger, #d9534f); }
|
||||
.rb-worktotal th, .rb-worktotal .rb-worknum { border-top: 1px solid var(--rb-line, #3b4a80); padding-top: 3px; }
|
||||
.rb-worktotal .rb-worknum { color: var(--rb-accent, #ffd75e); font-size: 1rem; }
|
||||
.rb-worktotal .rb-worknum { color: var(--rb-yellow); font-size: 1rem; }
|
||||
|
||||
/* ============================================================
|
||||
The figure, and the hit-location table beside it
|
||||
@@ -1305,14 +1317,14 @@
|
||||
/* Damage state. Read at a glance: pale = whole, red = gone. */
|
||||
.rb-part {
|
||||
stroke: var(--rb-line, #3b4a80); stroke-width: 1.5;
|
||||
fill: var(--rb-panel-2, #24356b); transition: fill 120ms ease;
|
||||
fill: var(--rb-blue); transition: fill 120ms ease;
|
||||
}
|
||||
.rb-part-whole { fill: var(--rb-panel-2, #24356b); }
|
||||
.rb-part-whole { fill: var(--rb-blue); }
|
||||
.rb-part-grazed { fill: #6d7a3f; }
|
||||
.rb-part-hurt { fill: #a8722c; }
|
||||
.rb-part-disabled { fill: #b2402f; stroke: #e08b7c; }
|
||||
.rb-part-destroyed { fill: #5d1b16; stroke: #e08b7c; stroke-dasharray: 3 2; }
|
||||
.rb-part:hover { stroke: var(--rb-accent, #ffd75e); stroke-width: 2.2; }
|
||||
.rb-part:hover { stroke: var(--rb-yellow); stroke-width: 2.2; }
|
||||
|
||||
/* The table was ragged because every column sized itself to its content.
|
||||
Fixed layout plus tabular numerals lines the digits up. */
|
||||
@@ -1349,7 +1361,7 @@
|
||||
|
||||
.rb-sub-head {
|
||||
margin: 8px 0 3px; font-size: 0.72rem; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; color: var(--rb-accent, #ffd75e);
|
||||
text-transform: uppercase; color: var(--rb-yellow);
|
||||
border-bottom: 1px solid var(--rb-line, #3b4a80); padding-bottom: 2px;
|
||||
}
|
||||
.rb-wounds-compact li { font-size: 0.78rem; padding: 2px 0; }
|
||||
@@ -1366,7 +1378,7 @@
|
||||
}
|
||||
.rb-woundchip {
|
||||
font-size: 0.72rem; padding: 1px 7px; border-radius: 9px;
|
||||
background: rgba(0, 0, 0, 0.28); color: var(--rb-text, #e8eeff);
|
||||
background: rgba(0, 0, 0, 0.28); color: var(--rb-white);
|
||||
}
|
||||
.rb-woundbar-pen { font-size: 0.72rem; color: var(--rb-muted, #9fb0d9); margin-left: auto; }
|
||||
|
||||
@@ -1551,7 +1563,7 @@
|
||||
/* --- Defence outcome ------------------------------------------------------ */
|
||||
.rb-defence { border-left: 3px solid var(--rb-line); padding-left: 8px; }
|
||||
.rb-defence.rb-aside { border-left-color: var(--rb-yellow); }
|
||||
.rb-defence.rb-through { border-left-color: var(--rb-red, #ff7a5c); }
|
||||
.rb-defence.rb-through { border-left-color: var(--rb-danger); }
|
||||
|
||||
/* --- Role card in the creation dialog ------------------------------------ */
|
||||
.rb-rolecard {
|
||||
@@ -1583,7 +1595,7 @@
|
||||
border-left: 2px solid var(--rb-line);
|
||||
}
|
||||
.rb-authorities li.rb-auth-flagged { border-left-color: var(--rb-yellow); }
|
||||
.rb-authorities li.rb-auth-dead { border-left-color: var(--rb-red, #ff7a5c); opacity: .55; }
|
||||
.rb-authorities li.rb-auth-dead { border-left-color: var(--rb-danger); opacity: .55; }
|
||||
.rb-auth-head { display: flex; gap: 8px; align-items: center; }
|
||||
.rb-auth-head .rb-strong { flex: 0 0 auto; }
|
||||
.rb-auth-trust {
|
||||
@@ -1615,16 +1627,16 @@
|
||||
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);
|
||||
border-left: 3px solid var(--rb-danger); 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);
|
||||
color: var(--rb-danger);
|
||||
}
|
||||
.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-dying { border-left: 3px solid var(--rb-danger); 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; }
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ClassicLevel } from "classic-level";
|
||||
import { createHash } from "node:crypto";
|
||||
import { ICON_MAP, SKILL_ICON } from "./icons.mjs";
|
||||
import { rm, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
@@ -172,6 +173,65 @@ function deviceItem(d) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- register expansion ---------------- */
|
||||
|
||||
/**
|
||||
* A posting or trade expanded into the shape buildActor wants.
|
||||
*
|
||||
* Shared by the starter scenario and the duty roster, because two copies of this is
|
||||
* two places for the induction rule to drift. Ratings are FIXED at the middle of each
|
||||
* band rather than rolled: a pregen that varies between installs is not a pregen.
|
||||
*/
|
||||
const REG = await import("../postings.mjs");
|
||||
const midBand = b => Math.round((b[0] + b[1]) / 2);
|
||||
|
||||
function expandFromRegister(spec, { where = "roster" } = {}) {
|
||||
const { ROLES, TRADES, INDUCTION, TRADE_BANDS, STANDING_ISSUE, TIER_ISSUE, TIERS } = REG;
|
||||
const isTrade = !!spec.trade;
|
||||
const R = isTrade ? TRADES[spec.trade] : ROLES[spec.role];
|
||||
if (!R) throw new Error(`${where}: no ${isTrade ? "trade" : "role"} "${spec.trade ?? spec.role}"`);
|
||||
const tierKey = { "Probationary": "probationary", "Field Officer": "officer",
|
||||
"Senior Field Officer": "senior", "Case Officer": "veteran" }[spec.rank]
|
||||
?? "officer";
|
||||
const T = TIERS[tierKey];
|
||||
const coreV = isTrade ? midBand(TRADE_BANDS.core) : midBand(T.core);
|
||||
const supportV = isTrade ? midBand(TRADE_BANDS.support) : midBand(T.support);
|
||||
const inducV = midBand(INDUCTION.bandByTier[tierKey] ?? INDUCTION.band);
|
||||
|
||||
const skills = [];
|
||||
const seen = new Set();
|
||||
const addSkill = (id, val) => {
|
||||
if (seen.has(id)) return; // induction never demotes a trade
|
||||
seen.add(id);
|
||||
const [fam, sp = ""] = id.split(":");
|
||||
skills.push({ fam, spec: sp, val });
|
||||
};
|
||||
for (const k of R.core) addSkill(k, coreV);
|
||||
for (const k of R.support) addSkill(k, supportV);
|
||||
if (isTrade) for (const k of INDUCTION.skills) addSkill(k, inducV);
|
||||
addSkill("language:trade_cant", 55);
|
||||
// Floors only ever RAISE a rating, so a posting already better stays better.
|
||||
for (const [id, floor] of Object.entries(spec.floors ?? {})) {
|
||||
const [fam, sp = ""] = id.split(":");
|
||||
const found = skills.find(x => x.fam === fam && x.spec === sp);
|
||||
if (found) found.val = Math.max(found.val, floor);
|
||||
else { skills.push({ fam, spec: sp, val: floor }); seen.add(id); }
|
||||
}
|
||||
|
||||
const kitKeys = [...STANDING_ISSUE, ...R.kit,
|
||||
...(isTrade ? INDUCTION.kit : []),
|
||||
...(spec.extraWeapons ?? []), ...(spec.extraGear ?? []),
|
||||
...(TIER_ISSUE[tierKey] ?? [])];
|
||||
const weapons = [], armour = [], gear = [];
|
||||
for (const k of new Set(kitKeys)) {
|
||||
if (WEAPON_LOOKUP.has(k)) weapons.push(k);
|
||||
else if (ARMOUR_LOOKUP.has(k)) armour.push(k);
|
||||
else if (GEAR_LOOKUP.has(k)) gear.push(k);
|
||||
else throw new Error(`${where}: ${spec.name} issued unknown item "${k}"`);
|
||||
}
|
||||
return { ...spec, label: R.label, blurb: R.blurb, skills, weapons, armour, gear };
|
||||
}
|
||||
|
||||
/* ---------------- actor factory ---------------- */
|
||||
|
||||
const SKILL_LOOKUP = new Map(
|
||||
@@ -284,15 +344,27 @@ function buildActor(spec, type) {
|
||||
system.tactics = spec.tactics ?? "";
|
||||
}
|
||||
|
||||
// A generated portrait if the spec names one, the generic silhouette otherwise.
|
||||
// The token gets the same face: a pregen you can recognise on the map is worth
|
||||
// more at a convention table than a correct one you cannot tell apart.
|
||||
let face = ICON.actor;
|
||||
if (spec.portrait) {
|
||||
const rel = `art/portraits/${spec.portrait}.webp`;
|
||||
// A missing portrait is a broken image on a pregen sheet that nobody notices
|
||||
// until it is on a projector at a table. Fail the build instead.
|
||||
if (!existsSync(path.join(ROOT, rel))) throw new Error(`${spec.name}: no portrait at ${rel}`);
|
||||
face = `systems/ringbrp/${rel}`;
|
||||
}
|
||||
|
||||
const actorDoc = {
|
||||
_id: actorId,
|
||||
name: spec.name, type, img: ICON.actor,
|
||||
name: spec.name, type, img: face,
|
||||
system,
|
||||
items: items.map(i => i._id), // Foundry LevelDB packs store an ID LIST here
|
||||
effects: [],
|
||||
prototypeToken: {
|
||||
name: spec.name, displayName: 20, actorLink: type === "character",
|
||||
width: 1, height: 1, texture: { src: ICON.actor },
|
||||
width: 1, height: 1, texture: { src: face },
|
||||
sight: { enabled: true }, disposition: type === "character" ? 1 : -1,
|
||||
bar1: { attribute: "hp" }, bar2: { attribute: null },
|
||||
flags: {}
|
||||
@@ -439,10 +511,15 @@ await writePack("armour", ARMOURS.map(a => {
|
||||
await writePack("vehicles", entries);
|
||||
}
|
||||
|
||||
// pregens
|
||||
// pregens — the six hand-written originals plus the ten-agent duty roster
|
||||
{
|
||||
const { ROSTER } = await import("./roster.mjs");
|
||||
const roster = ROSTER.map(spec => {
|
||||
const e = expandFromRegister(spec, { where: "roster" });
|
||||
return { ...e, bio: `<p><strong>${e.label}, ${spec.rank}.</strong> ${e.blurb}</p>${spec.bio}` };
|
||||
});
|
||||
const entries = [];
|
||||
for (const p of PREGENS) {
|
||||
for (const p of [...PREGENS, ...roster]) {
|
||||
const { actorDoc, itemDocs } = buildActor(p, "character");
|
||||
entries.push([`!actors!${actorDoc._id}`, actorDoc]);
|
||||
for (const it of itemDocs) entries.push([`!actors.items!${actorDoc._id}.${it._id}`, it]);
|
||||
@@ -476,60 +553,15 @@ await writePack("armour", ARMOURS.map(a => {
|
||||
// imports in one click rather than as nine loose documents a GM has to assemble.
|
||||
{
|
||||
const S = await import("./scenario-starter.mjs");
|
||||
const { ROLES, TRADES, INDUCTION, TRADE_BANDS, STANDING_ISSUE, TIER_ISSUE, TIERS }
|
||||
= await import("../postings.mjs");
|
||||
|
||||
// A posting or trade expanded into the shape buildActor wants. Ratings are FIXED at
|
||||
// the middle of the band rather than rolled: every table that runs the starter should
|
||||
// get the same six agents, and a pregen that varies between installs is not a pregen.
|
||||
const mid = b => Math.round((b[0] + b[1]) / 2);
|
||||
// Expanded by the shared helper; the starter adds its own biography on top.
|
||||
const expand = spec => {
|
||||
const isTrade = !!spec.trade;
|
||||
const R = isTrade ? TRADES[spec.trade] : ROLES[spec.role];
|
||||
if (!R) throw new Error(`starter: no ${isTrade ? "trade" : "role"} "${spec.trade ?? spec.role}"`);
|
||||
const T = TIERS[spec.rank === "Probationary" ? "probationary" : "officer"];
|
||||
const coreV = isTrade ? mid(TRADE_BANDS.core) : mid(T.core);
|
||||
const supportV = isTrade ? mid(TRADE_BANDS.support) : mid(T.support);
|
||||
const inducV = mid(INDUCTION.bandByTier[spec.rank === "Probationary" ? "probationary" : "officer"]);
|
||||
|
||||
const skills = [];
|
||||
const seen = new Set();
|
||||
const addSkill = (id, val) => {
|
||||
if (seen.has(id)) return; // induction never demotes a trade
|
||||
seen.add(id);
|
||||
const [fam, sp = ""] = id.split(":");
|
||||
skills.push({ fam, spec: sp, val });
|
||||
};
|
||||
for (const k of R.core) addSkill(k, coreV);
|
||||
for (const k of R.support) addSkill(k, supportV);
|
||||
if (isTrade) for (const k of INDUCTION.skills) addSkill(k, inducV);
|
||||
addSkill("language:trade_cant", 55);
|
||||
// Scenario floors. These only ever RAISE a rating — a posting that is already
|
||||
// better at something stays better — so that nobody is a spectator in a fight.
|
||||
for (const [id, floor] of Object.entries(spec.floors ?? {})) {
|
||||
const [fam, sp = ""] = id.split(":");
|
||||
const found = skills.find(x => x.fam === fam && x.spec === sp);
|
||||
if (found) found.val = Math.max(found.val, floor);
|
||||
else { skills.push({ fam, spec: sp, val: floor }); seen.add(id); }
|
||||
}
|
||||
|
||||
// Kit: standing issue, the posting or trade's own, and induction for a trade.
|
||||
const kitKeys = [...STANDING_ISSUE, ...R.kit,
|
||||
...(isTrade ? INDUCTION.kit : []),
|
||||
...(spec.extraWeapons ?? []), ...(spec.extraGear ?? []),
|
||||
...(TIER_ISSUE[spec.rank === "Probationary" ? "probationary" : "officer"] ?? [])];
|
||||
const weapons = [], armour = [], gear = [];
|
||||
for (const k of new Set(kitKeys)) {
|
||||
if (WEAPON_LOOKUP.has(k)) weapons.push(k);
|
||||
else if (ARMOUR_LOOKUP.has(k)) armour.push(k);
|
||||
else if (GEAR_LOOKUP.has(k)) gear.push(k);
|
||||
else throw new Error(`starter: ${spec.name} issued unknown item "${k}"`);
|
||||
}
|
||||
const bio = `<p><strong>${R.label}, ${spec.rank}.</strong> ${R.blurb}</p>`
|
||||
+ `<p><strong>Your job in this case.</strong> ${spec.spotlight}</p>`
|
||||
+ (spec.complication ? `<p><strong>Your complication.</strong> ${spec.complication}</p>` : "")
|
||||
+ `<p class="rb-gm-note"><em>${spec.note}</em></p>`;
|
||||
return { ...spec, skills, weapons, armour, gear, bio };
|
||||
const e = expandFromRegister(spec, { where: "starter" });
|
||||
return { ...e,
|
||||
bio: `<p><strong>${e.label}, ${spec.rank}.</strong> ${e.blurb}</p>`
|
||||
+ `<p><strong>Your job in this case.</strong> ${spec.spotlight}</p>`
|
||||
+ (spec.complication ? `<p><strong>Your complication.</strong> ${spec.complication}</p>` : "")
|
||||
+ `<p class="rb-gm-note"><em>${spec.note}</em></p>` };
|
||||
};
|
||||
|
||||
const actorDocs = [], itemDocsFor = new Map();
|
||||
|
||||
@@ -264,6 +264,54 @@ test("every starter group is usable by the contact engine", () => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- *
|
||||
* R-250 — every design token used must be declared, and on :root
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
// An undefined custom property is not a CSS error. `var(--rb-nothing)` resolves to
|
||||
// "unset", so a rule half-applies: paddings land, colours vanish, and nothing anywhere
|
||||
// says so. That is exactly how the journals shipped unstyled. Read the stylesheet.
|
||||
test("every --rb- token used in the stylesheet is declared", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const css = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8");
|
||||
const declared = new Set([...css.matchAll(/^\s*(--rb-[\w-]+)\s*:/gm)].map(m => m[1]));
|
||||
const used = new Set([...css.matchAll(/var\(\s*(--rb-[\w-]+)/g)].map(m => m[1]));
|
||||
const missing = [...used].filter(t => !declared.has(t));
|
||||
assert.equal(missing.length, 0,
|
||||
`used but never declared: ${missing.join(", ")}`);
|
||||
});
|
||||
test("R-250 the tokens are declared on :root, not on a component class", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const css = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8");
|
||||
// The block that declares the palette must be selected by :root, so any new
|
||||
// component inherits it without having to be added to a list.
|
||||
const block = css.match(/([^{}]*)\{[^{}]*--rb-void\s*:/);
|
||||
assert.ok(block, "could not find the block declaring --rb-void");
|
||||
assert.ok(/(^|\s|,):root\s*$/.test(block[1].trim()) || block[1].trim() === ":root",
|
||||
`the palette is declared on "${block[1].trim().slice(0, 60)}" rather than :root`);
|
||||
});
|
||||
|
||||
// The duty roster is ten pregens handed to players who did not build them, so a
|
||||
// wrong posting key or a missing face is not a cosmetic problem — it is a sheet
|
||||
// somebody is asked to play from. The build throws on both; this says which one
|
||||
// and why before the build gets that far.
|
||||
test("every roster agent names a real posting or trade and has a portrait on disk", async () => {
|
||||
const { ROSTER } = await import("./roster.mjs");
|
||||
const { ROLES, TRADES } = await import("../postings.mjs");
|
||||
const { existsSync } = await import("node:fs");
|
||||
assert.equal(ROSTER.length, 10, "the roster is meant to be ten agents");
|
||||
for (const a of ROSTER) {
|
||||
const register = a.trade ? TRADES : ROLES;
|
||||
const key = a.trade ?? a.role;
|
||||
assert.ok(register[key], `${a.name}: no ${a.trade ? "trade" : "posting"} "${key}"`);
|
||||
assert.ok(existsSync(new URL(`../art/portraits/${a.portrait}.webp`, import.meta.url)),
|
||||
`${a.name}: no portrait at art/portraits/${a.portrait}.webp`);
|
||||
}
|
||||
// Five postings and five trades, so a group choosing from this sheet sees both
|
||||
// halves of character creation without anyone having to explain them.
|
||||
assert.equal(ROSTER.filter(a => a.trade).length, 5, "the roster should be half trades");
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
await runAll();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* THE DUTY ROSTER — ten ready-to-play agents for the compendium.
|
||||
*
|
||||
* Written compactly: a posting or a trade, a rank, eight characteristics and a face.
|
||||
* Skills and kit are expanded from the register at build time by the same helper the
|
||||
* starter scenario uses, so a roster agent cannot drift from the generator. Change
|
||||
* TRADE_BANDS and every doctor on this list changes with it.
|
||||
*
|
||||
* Five postings and five trades on purpose. A group picking from this sheet sees both
|
||||
* halves of character creation without anybody having to explain them.
|
||||
*
|
||||
* Portraits are generated art, one per agent, in art/portraits.
|
||||
*/
|
||||
|
||||
export const ROSTER = [
|
||||
|
||||
/* ---------------- postings: career department ---------------- */
|
||||
|
||||
{ key: "pc_holloway", name: "Marta Holloway", role: "lead", rank: "Senior Field Officer",
|
||||
portrait: "pc_holloway", chosenStyle: "reflex", species: "baseline",
|
||||
age: { chronological: 44, physiological: 44 },
|
||||
ch: { str: 11, siz: 12, con: 13, int: 15, pow: 14, dex: 14, cha: 14, edu: 13 },
|
||||
talents: ["first_through", "read_room", "clean_scene"],
|
||||
bio: "<p>Nineteen years, four of them on secondment to an office that no longer "
|
||||
+ "appears in the directory. Signs for everything and reads it first.</p>"
|
||||
+ "<p><em>Anchor:</em> a wedding ring she does not wear, kept in a coin envelope "
|
||||
+ "in her jacket.</p>" },
|
||||
|
||||
{ key: "pc_okonkwo", name: "Ben Okonkwo", role: "heavy", rank: "Field Officer",
|
||||
portrait: "pc_okonkwo", chosenStyle: "reflex", species: "baseline",
|
||||
age: { chronological: 33, physiological: 33 },
|
||||
ch: { str: 17, siz: 16, con: 16, int: 11, pow: 11, dex: 13, cha: 11, edu: 10 },
|
||||
talents: ["hard_landing", "first_through"],
|
||||
bio: "<p>Goes through the door and holds the corridor while everyone else does the "
|
||||
+ "clever part. Has never once complained about being the one who is hit.</p>"
|
||||
+ "<p><em>Anchor:</em> his sister's voice note, saved and never deleted.</p>" },
|
||||
|
||||
{ key: "pc_sandoval", name: "Rey Sandoval", role: "overwatch", rank: "Field Officer",
|
||||
portrait: "pc_sandoval", chosenStyle: "deliberate", species: "baseline",
|
||||
age: { chronological: 37, physiological: 37 },
|
||||
ch: { str: 12, siz: 12, con: 13, int: 14, pow: 13, dex: 17, cha: 10, edu: 12 },
|
||||
talents: ["first_through", "unblinking"],
|
||||
bio: "<p>Takes the long lane and the long view. Keeps a private list of the shots "
|
||||
+ "she chose not to take, and will not discuss it.</p>"
|
||||
+ "<p><em>Anchor:</em> a paperback with somebody else's name inside the cover.</p>" },
|
||||
|
||||
{ key: "pc_bhattacharya", name: "Arun Bhattacharya", role: "registry",
|
||||
rank: "Senior Field Officer", portrait: "pc_bhattacharya",
|
||||
chosenStyle: "deliberate", species: "baseline",
|
||||
age: { chronological: 56, physiological: 56 },
|
||||
ch: { str: 10, siz: 11, con: 11, int: 17, pow: 15, dex: 11, cha: 12, edu: 17 },
|
||||
talents: ["chain_custody", "debriefer", "requisition"],
|
||||
bio: "<p>Thirty years in the registry. Has read files that were withdrawn "
|
||||
+ "before he finished them and remembers what was in them anyway.</p>"
|
||||
+ "<p><em>Anchor:</em> a brass slide rule that belonged to his predecessor.</p>" },
|
||||
|
||||
{ key: "pc_rahimi", name: "Soraya Rahimi", role: "settlements", rank: "Field Officer",
|
||||
portrait: "pc_rahimi", chosenStyle: "deliberate", species: "baseline",
|
||||
age: { chronological: 41, physiological: 41 },
|
||||
ch: { str: 10, siz: 11, con: 12, int: 15, pow: 15, dex: 11, cha: 17, edu: 15 },
|
||||
talents: ["read_room", "debriefer"],
|
||||
bio: "<p>Writes the arrangement that everyone can live with, then holds both sides "
|
||||
+ "to it. Has never raised her voice on an operation.</p>"
|
||||
+ "<p><em>Anchor:</em> her mother's tea glass, chipped, carried in a sock.</p>" },
|
||||
|
||||
/* ---------------- trades: recruited from an ordinary job ---------------- */
|
||||
|
||||
{ key: "pc_nkemdirim", name: "Dr Chidi Nkemdirim", trade: "doctor", rank: "Probationary",
|
||||
portrait: "pc_nkemdirim", chosenStyle: "deliberate", species: "baseline",
|
||||
age: { chronological: 32, physiological: 32 },
|
||||
ch: { str: 10, siz: 11, con: 12, int: 16, pow: 13, dex: 13, cha: 13, edu: 18 },
|
||||
talents: ["triage", "unblinking"],
|
||||
bio: "<p>Six years of emergency medicine and eleven months of this. Still the "
|
||||
+ "calmest person in the room, and still startled by what the room contains.</p>"
|
||||
+ "<p><em>Anchor:</em> a pager that has not worked since 2019.</p>" },
|
||||
|
||||
{ key: "pc_kowalczyk", name: "Marek Kowalczyk", trade: "engineer", rank: "Probationary",
|
||||
portrait: "pc_kowalczyk", chosenStyle: "reflex", species: "baseline",
|
||||
age: { chronological: 30, physiological: 30 },
|
||||
ch: { str: 13, siz: 13, con: 13, int: 17, pow: 11, dex: 14, cha: 11, edu: 15 },
|
||||
talents: ["hard_landing", "requisition"],
|
||||
bio: "<p>Chartered, unimpressed, and the only person who asks what a thing is made "
|
||||
+ "of before asking what it wants.</p>"
|
||||
+ "<p><em>Anchor:</em> the first tool he ever bought, resharpened forty times.</p>" },
|
||||
|
||||
{ key: "pc_finch", name: "Wren Finch", trade: "criminal", rank: "Probationary",
|
||||
portrait: "pc_finch", chosenStyle: "reflex", species: "baseline",
|
||||
age: { chronological: 26, physiological: 26 },
|
||||
ch: { str: 10, siz: 10, con: 12, int: 14, pow: 12, dex: 18, cha: 14, edu: 9 },
|
||||
talents: ["cover_work", "clean_scene"],
|
||||
bio: "<p>Opens things without leaving a mark, which is the part she is proud of. "
|
||||
+ "Signed the Act in an interview room, which is the part she is not.</p>"
|
||||
+ "<p><em>Anchor:</em> a door key to a flat that was demolished.</p>" },
|
||||
|
||||
{ key: "pc_agyeman", name: "Joseph Agyeman", trade: "police", rank: "Field Officer",
|
||||
portrait: "pc_agyeman", chosenStyle: "reflex", species: "baseline",
|
||||
age: { chronological: 52, physiological: 52 },
|
||||
ch: { str: 14, siz: 14, con: 14, int: 13, pow: 13, dex: 12, cha: 14, edu: 12 },
|
||||
talents: ["read_room", "chain_custody", "first_through"],
|
||||
bio: "<p>Twenty-six years on division before somebody handed him a file with no "
|
||||
+ "crime number. Knows how a statement is taken and how a scene is lost.</p>"
|
||||
+ "<p><em>Anchor:</em> his old warrant number, written inside his watch strap.</p>" },
|
||||
|
||||
{ key: "pc_lindqvist", name: "Professor Åke Lindqvist", trade: "professor",
|
||||
rank: "Field Officer", portrait: "pc_lindqvist",
|
||||
chosenStyle: "deliberate", species: "baseline",
|
||||
age: { chronological: 63, physiological: 63 },
|
||||
ch: { str: 9, siz: 11, con: 10, int: 17, pow: 16, dex: 10, cha: 14, edu: 19 },
|
||||
talents: ["debriefer", "wrong_memory", "read_room"],
|
||||
bio: "<p>A chair in comparative folklore and a reading list nobody else has "
|
||||
+ "finished. Was consulted once and never quite sent home.</p>"
|
||||
+ "<p><em>Anchor:</em> a photograph of a lecture theatre, taken from the back.</p>" }
|
||||
];
|
||||