diff --git a/README.md b/README.md index 531adae..86cdb39 100644 --- a/README.md +++ b/README.md @@ -180,9 +180,9 @@ The build runs all three guards before it writes anything: ``` -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 ``` diff --git a/art/portraits/pc_agyeman.webp b/art/portraits/pc_agyeman.webp new file mode 100644 index 0000000..e27af9f Binary files /dev/null and b/art/portraits/pc_agyeman.webp differ diff --git a/art/portraits/pc_bhattacharya.webp b/art/portraits/pc_bhattacharya.webp new file mode 100644 index 0000000..f5dcfb4 Binary files /dev/null and b/art/portraits/pc_bhattacharya.webp differ diff --git a/art/portraits/pc_finch.webp b/art/portraits/pc_finch.webp new file mode 100644 index 0000000..81b14d9 Binary files /dev/null and b/art/portraits/pc_finch.webp differ diff --git a/art/portraits/pc_holloway.webp b/art/portraits/pc_holloway.webp new file mode 100644 index 0000000..a06ed7a Binary files /dev/null and b/art/portraits/pc_holloway.webp differ diff --git a/art/portraits/pc_kowalczyk.webp b/art/portraits/pc_kowalczyk.webp new file mode 100644 index 0000000..3b22332 Binary files /dev/null and b/art/portraits/pc_kowalczyk.webp differ diff --git a/art/portraits/pc_lindqvist.webp b/art/portraits/pc_lindqvist.webp new file mode 100644 index 0000000..3e15f33 Binary files /dev/null and b/art/portraits/pc_lindqvist.webp differ diff --git a/art/portraits/pc_nkemdirim.webp b/art/portraits/pc_nkemdirim.webp new file mode 100644 index 0000000..4b0cded Binary files /dev/null and b/art/portraits/pc_nkemdirim.webp differ diff --git a/art/portraits/pc_okonkwo.webp b/art/portraits/pc_okonkwo.webp new file mode 100644 index 0000000..9ee32f7 Binary files /dev/null and b/art/portraits/pc_okonkwo.webp differ diff --git a/art/portraits/pc_rahimi.webp b/art/portraits/pc_rahimi.webp new file mode 100644 index 0000000..256e580 Binary files /dev/null and b/art/portraits/pc_rahimi.webp differ diff --git a/art/portraits/pc_sandoval.webp b/art/portraits/pc_sandoval.webp new file mode 100644 index 0000000..cb11b5d Binary files /dev/null and b/art/portraits/pc_sandoval.webp differ diff --git a/packs/armour/LOG b/packs/armour/LOG index 64122b1..f203490 100644 --- a/packs/armour/LOG +++ b/packs/armour/LOG @@ -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 diff --git a/packs/armour/LOG.old b/packs/armour/LOG.old index 359bf9b..a5fa76f 100644 --- a/packs/armour/LOG.old +++ b/packs/armour/LOG.old @@ -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 diff --git a/packs/expedition/LOG b/packs/expedition/LOG index d6d3173..10fe401 100644 --- a/packs/expedition/LOG +++ b/packs/expedition/LOG @@ -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 diff --git a/packs/expedition/LOG.old b/packs/expedition/LOG.old index c189aa3..545259a 100644 --- a/packs/expedition/LOG.old +++ b/packs/expedition/LOG.old @@ -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 diff --git a/packs/gear/LOG b/packs/gear/LOG index 90124f5..1b2dbec 100644 --- a/packs/gear/LOG +++ b/packs/gear/LOG @@ -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 diff --git a/packs/gear/LOG.old b/packs/gear/LOG.old index c88d7d9..b74605f 100644 --- a/packs/gear/LOG.old +++ b/packs/gear/LOG.old @@ -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 diff --git a/packs/npcs/LOG b/packs/npcs/LOG index 2b7df33..af7da0a 100644 --- a/packs/npcs/LOG +++ b/packs/npcs/LOG @@ -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 diff --git a/packs/npcs/LOG.old b/packs/npcs/LOG.old index 1b98b67..c281e28 100644 --- a/packs/npcs/LOG.old +++ b/packs/npcs/LOG.old @@ -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 diff --git a/packs/powers/LOG b/packs/powers/LOG index 9cbef14..53ebaa9 100644 --- a/packs/powers/LOG +++ b/packs/powers/LOG @@ -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 diff --git a/packs/powers/LOG.old b/packs/powers/LOG.old index ced2e69..ad0408a 100644 --- a/packs/powers/LOG.old +++ b/packs/powers/LOG.old @@ -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 diff --git a/packs/pregens/000005.ldb b/packs/pregens/000005.ldb index ef0a53e..1d86f82 100644 Binary files a/packs/pregens/000005.ldb and b/packs/pregens/000005.ldb differ diff --git a/packs/pregens/000006.log b/packs/pregens/000006.log index ddbae86..89ec2d2 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 42ee2a9..f053163 100644 --- a/packs/pregens/LOG +++ b/packs/pregens/LOG @@ -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 diff --git a/packs/pregens/LOG.old b/packs/pregens/LOG.old index 2c2de79..958cf4f 100644 --- a/packs/pregens/LOG.old +++ b/packs/pregens/LOG.old @@ -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 diff --git a/packs/pregens/MANIFEST-000004 b/packs/pregens/MANIFEST-000004 index fb2adca..f4df0f9 100644 Binary files a/packs/pregens/MANIFEST-000004 and b/packs/pregens/MANIFEST-000004 differ diff --git a/packs/primer/LOG b/packs/primer/LOG index a50ac4a..ded65ec 100644 --- a/packs/primer/LOG +++ b/packs/primer/LOG @@ -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 diff --git a/packs/primer/LOG.old b/packs/primer/LOG.old index 6de6949..0f57b52 100644 --- a/packs/primer/LOG.old +++ b/packs/primer/LOG.old @@ -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 diff --git a/packs/rules/LOG b/packs/rules/LOG index e4d9c12..00fb933 100644 --- a/packs/rules/LOG +++ b/packs/rules/LOG @@ -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 diff --git a/packs/rules/LOG.old b/packs/rules/LOG.old index 9568411..2353f09 100644 --- a/packs/rules/LOG.old +++ b/packs/rules/LOG.old @@ -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 diff --git a/packs/skills/LOG b/packs/skills/LOG index d732c09..d74332d 100644 --- a/packs/skills/LOG +++ b/packs/skills/LOG @@ -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 diff --git a/packs/skills/LOG.old b/packs/skills/LOG.old index a1432a0..93fde0f 100644 --- a/packs/skills/LOG.old +++ b/packs/skills/LOG.old @@ -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 diff --git a/packs/slice/LOG b/packs/slice/LOG index 671c6e4..4c5a81c 100644 --- a/packs/slice/LOG +++ b/packs/slice/LOG @@ -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 diff --git a/packs/slice/LOG.old b/packs/slice/LOG.old index e24df3f..28a7871 100644 --- a/packs/slice/LOG.old +++ b/packs/slice/LOG.old @@ -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 diff --git a/packs/starter/LOG b/packs/starter/LOG index 23a3421..45b61ed 100644 --- a/packs/starter/LOG +++ b/packs/starter/LOG @@ -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 diff --git a/packs/starter/LOG.old b/packs/starter/LOG.old index b8e48b1..dc38bf9 100644 --- a/packs/starter/LOG.old +++ b/packs/starter/LOG.old @@ -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 diff --git a/packs/tables/LOG b/packs/tables/LOG index c2e3d86..6298faa 100644 --- a/packs/tables/LOG +++ b/packs/tables/LOG @@ -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 diff --git a/packs/tables/LOG.old b/packs/tables/LOG.old index 869365b..6a9f6e8 100644 --- a/packs/tables/LOG.old +++ b/packs/tables/LOG.old @@ -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 diff --git a/packs/talents/LOG b/packs/talents/LOG index 1889c72..0706396 100644 --- a/packs/talents/LOG +++ b/packs/talents/LOG @@ -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 diff --git a/packs/talents/LOG.old b/packs/talents/LOG.old index 7ba195c..5d26396 100644 --- a/packs/talents/LOG.old +++ b/packs/talents/LOG.old @@ -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 diff --git a/packs/vehicles/LOG b/packs/vehicles/LOG index 247f394..f63009c 100644 --- a/packs/vehicles/LOG +++ b/packs/vehicles/LOG @@ -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 diff --git a/packs/vehicles/LOG.old b/packs/vehicles/LOG.old index 8fbd016..91746f6 100644 --- a/packs/vehicles/LOG.old +++ b/packs/vehicles/LOG.old @@ -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 diff --git a/packs/weapons/LOG b/packs/weapons/LOG index 7b5de13..613b092 100644 --- a/packs/weapons/LOG +++ b/packs/weapons/LOG @@ -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 diff --git a/packs/weapons/LOG.old b/packs/weapons/LOG.old index 2d24c97..bfe591f 100644 --- a/packs/weapons/LOG.old +++ b/packs/weapons/LOG.old @@ -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 diff --git a/styles/ringbrp.css b/styles/ringbrp.css index 9b3e015..da70305 100644 --- a/styles/ringbrp.css +++ b/styles/ringbrp.css @@ -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; } diff --git a/tools/build-packs.mjs b/tools/build-packs.mjs index 8b757e4..8b26b7f 100644 --- a/tools/build-packs.mjs +++ b/tools/build-packs.mjs @@ -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: `

${e.label}, ${spec.rank}. ${e.blurb}

${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 = `

${R.label}, ${spec.rank}. ${R.blurb}

` - + `

Your job in this case. ${spec.spotlight}

` - + (spec.complication ? `

Your complication. ${spec.complication}

` : "") - + `

${spec.note}

`; - return { ...spec, skills, weapons, armour, gear, bio }; + const e = expandFromRegister(spec, { where: "starter" }); + return { ...e, + bio: `

${e.label}, ${spec.rank}. ${e.blurb}

` + + `

Your job in this case. ${spec.spotlight}

` + + (spec.complication ? `

Your complication. ${spec.complication}

` : "") + + `

${spec.note}

` }; }; const actorDocs = [], itemDocsFor = new Map(); diff --git a/tools/check-behaviour.mjs b/tools/check-behaviour.mjs index c6f6204..7c85c69 100644 --- a/tools/check-behaviour.mjs +++ b/tools/check-behaviour.mjs @@ -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(); diff --git a/tools/roster.mjs b/tools/roster.mjs new file mode 100644 index 0000000..9e41038 --- /dev/null +++ b/tools/roster.mjs @@ -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: "

Nineteen years, four of them on secondment to an office that no longer " + + "appears in the directory. Signs for everything and reads it first.

" + + "

Anchor: a wedding ring she does not wear, kept in a coin envelope " + + "in her jacket.

" }, + + { 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: "

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.

" + + "

Anchor: his sister's voice note, saved and never deleted.

" }, + + { 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: "

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.

" + + "

Anchor: a paperback with somebody else's name inside the cover.

" }, + + { 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: "

Thirty years in the registry. Has read files that were withdrawn " + + "before he finished them and remembers what was in them anyway.

" + + "

Anchor: a brass slide rule that belonged to his predecessor.

" }, + + { 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: "

Writes the arrangement that everyone can live with, then holds both sides " + + "to it. Has never raised her voice on an operation.

" + + "

Anchor: her mother's tea glass, chipped, carried in a sock.

" }, + + /* ---------------- 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: "

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.

" + + "

Anchor: a pager that has not worked since 2019.

" }, + + { 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: "

Chartered, unimpressed, and the only person who asks what a thing is made " + + "of before asking what it wants.

" + + "

Anchor: the first tool he ever bought, resharpened forty times.

" }, + + { 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: "

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.

" + + "

Anchor: a door key to a flat that was demolished.

" }, + + { 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: "

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.

" + + "

Anchor: his old warrant number, written inside his watch strap.

" }, + + { 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: "

A chair in comparative folklore and a reading list nobody else has " + + "finished. Was consulted once and never quite sent home.

" + + "

Anchor: a photograph of a lecture theatre, taken from the back.

" } +];