feat: rules midgame priority system, mission enhancements, and requisition shop improvements

- Add midgame priority ranking for rules (midgamePriority field)
- Enhance rules search to support midgame filtering and sorting
- Improve mission simulation and midgame tracking
- Fix RequisitionShop player name handling
- Add rules midplay simulation tests
- Update MissionTab with midgame scene tracking
- Add backend logging for rules updates
This commit is contained in:
2026-06-24 12:09:25 +02:00
parent 7d038dd942
commit 7873f450da
18 changed files with 7418 additions and 2453 deletions
+285
View File
@@ -0,0 +1,285 @@
{
"character": {
"name": "Brother Sepheran",
"name_uncertain": true,
"name_notes": "Handwritten name appears to read 'Sepheran' or close variant.",
"player_name": "Anders",
"chapter": "Blood Angels",
"specialty": "Deathwatch Devastator Marine",
"system": "Warhammer 40,000 Roleplay: Deathwatch",
"rank": 1,
"chapter_demeanour": "The Red Thirst",
"personal_demeanour": "Calculating",
"past_event": null,
"power_armour_history": null,
"description": null
},
"characteristics": {
"weapon_skill": 47,
"ballistic_skill": 48,
"strength": 41,
"toughness": 43,
"agility": 42,
"intelligence": 50,
"perception": 43,
"willpower": 48,
"fellowship": 38
},
"derived": {
"wounds": {
"total": 23,
"current": null
},
"fatigue": null,
"fate_points": {
"total": 2,
"current": 2
},
"insanity": {
"current_points": null,
"battle_fatigue": null,
"primarchs_curse": null
},
"corruption": {
"current": null
},
"movement": {
"half": 5,
"full": 10,
"charge": 15,
"run": 30,
"unit": "meters"
},
"renown": {
"current": 1
},
"experience": {
"xp_to_spend": 500,
"total_xp_spent": null
}
},
"skills": [
{
"name": "Awareness",
"characteristic": "Per",
"trained": true
},
{
"name": "Common Lore",
"characteristic": "Int",
"specialisations": [
"Adeptus Astartes",
"Imperium",
"War"
],
"trained": true
},
{
"name": "Dodge",
"characteristic": "Ag",
"trained": true
},
{
"name": "Intimidate",
"characteristic": "Str",
"trained": true
},
{
"name": "Logic",
"characteristic": "Int",
"trained": true
},
{
"name": "Scholastic Lore",
"characteristic": "Int",
"specialisations": [
"Codex Astartes"
],
"trained": true
}
],
"talents_and_traits": [
"Ambidextrous",
"Astartes Weapon Training",
"Bulging Biceps",
"Heightened Senses (Hearing, Sight)",
"Killing Strike",
"Nerves of Steel",
"Quick Draw",
"Resistance (Psychic Powers)",
"True Grit",
"Unarmed Master",
"Unnatural Strength (x2)",
"Unnatural Toughness (x2)",
"Unrelenting Devastation",
"Deathwatch Training"
],
"chapter_abilities": [
{
"name": "Blood Frenzy",
"summary": "Once per combat, may enter Blood Frenzy. While active, may re-roll Damage dice with melee weapons. If the weapon inflicts multiple dice of damage, all dice or one die may be re-rolled. Righteous Fury is confirmed on a 9 or 10 instead of only 10. Lasts for one round."
},
{
"name": "The Red Thirst",
"summary": "A deep-seated Blood Angels flaw: battle fury and blood hunger that must be held in check."
}
],
"space_marine_abilities": [
"Secondary Heart/Ossmodula/Biscopea",
"Haemastamen",
"Larramans Organ",
"Catalepsean Node",
"Preomnor",
"Omophagea",
"Multi-Lung",
"Occulobe",
"Lymans Ear",
"Sus-an Membrane",
"Oolitic Kidney",
"Neuroglottis",
"Mucranoid",
"Betchers Gland",
"Progenoids",
"Black Carapace"
],
"armour": {
"name": "Mark VII Power Armour",
"pattern": "Mark VII",
"armour_points": {
"head": 8,
"body": 8,
"left_arm": 8,
"right_arm": 8,
"left_leg": 8,
"right_leg": 8
},
"power_armour_abilities": [
"Servo-Augmented Musculature: +20 Strength",
"Auto-senses: Dark Sight, immune to Photon Flash and Stun Grenades; called shots are Half Actions; +10 to sight and hearing Awareness Tests",
"Built-in Vox Link",
"Built-in Magboots",
"Nutrient Recycling",
"Recoil Suppression: may fire Basic weapons one-handed without penalty",
"Size: Hulking (Black Carapace means no bonus for enemies to attack)",
"Poor Manual Dexterity: -10 penalty to delicate manual tasks unless using equipment designed for Space Marines",
"Osmotic Gill Life Sustainer: with helmet on, the armour is environmentally sealed"
],
"armour_additions": []
},
"gear": [
"Mark VII power armour",
"Heavy bolter",
"Bolt pistol",
"3 frag grenades",
"3 krak grenades",
"Combat knife"
],
"weapons": [
{
"name": "Heavy Bolter",
"class": "Heavy",
"damage": "2d10+10",
"damage_type": "X",
"penetration": 6,
"range": "150m",
"rof": "-/-/10",
"clip": 60,
"reload": "Full",
"special_rules": [
"Tearing"
],
"uncertain": false,
"handwritten_notes": [
"Sheet appears to include extra handwritten damage notes likely from Unrelenting Devastation or horde-related calculations; base profile retained here."
]
},
{
"name": "Combat Knife",
"class": "Melee",
"damage": "1d10+2",
"damage_type": "Rending",
"penetration": 2,
"range": null,
"rof": null,
"clip": null,
"reload": null,
"special_rules": [],
"uncertain": true,
"uncertainty_notes": [
"Handwritten damage type text is faint but appears to be standard combat knife profile."
]
},
{
"name": "Bolt Pistol",
"class": "Pistol",
"damage": "2d10+5",
"damage_type": "X",
"penetration": 5,
"range": "30m",
"rof": "S/3/-",
"clip": 14,
"reload": "Full",
"special_rules": [
"Tearing"
],
"uncertain": false
},
{
"name": "Grenades",
"variants": [
{
"name": "Krak Grenade",
"class": "Thrown",
"damage": "3d10+4",
"damage_type": "X",
"penetration": 6,
"range": "SB x 3",
"special_rules": [],
"clip": 3,
"uncertain": true
},
{
"name": "Frag Grenade",
"class": "Thrown",
"damage": "2d10+0",
"damage_type": "X",
"penetration": 0,
"range": "SB x 3",
"special_rules": [
"Blast (5)"
],
"clip": 3,
"uncertain": true
}
],
"uncertainty_notes": [
"The handwritten entry combines Krak/Frag on one line and is cramped. Values above are the most plausible read and align with standard-style notation on the sheet."
]
}
],
"special_rules": [
{
"name": "Unrelenting Devastation",
"summary": "When firing a Heavy weapon against a Horde, the Devastator Marine inflicts 1 extra point of damage to a Hordes Magnitude for every hit."
},
{
"name": "Deathwatch Training",
"summary": "The Space Marine automatically confirms Righteous Fury against aliens."
}
],
"notes": [
"This appears to be a Rank 1 Devastator Marine pregen or near-pregen from Deathwatch.",
"Heavy bolter sheet includes handwritten math that likely mixes base weapon damage with situational Horde damage; this should not be treated as the raw weapon line.",
"Name spelling should be confirmed from the player."
],
"source_confidence": {
"identity_and_core_stats": "high",
"skills": "high",
"talents_traits": "high",
"wargear_list": "high",
"weapon_profiles": "medium",
"handwritten_modifiers": "medium_to_low"
}
}
+252
View File
@@ -0,0 +1,252 @@
{
"character": {
"name": null,
"placeholder_name": "Andreas_character_pending_name",
"player_name": "Andreas",
"chapter": "Ultramarines",
"specialty": "Deathwatch Apothecary",
"system": "Warhammer 40,000 Roleplay: Deathwatch",
"rank": 1,
"chapter_demeanour": "Honour the Codex",
"personal_demeanour": "Studious",
"past_event": null,
"power_armour_history": null,
"description": null
},
"characteristics": {
"weapon_skill": 46,
"ballistic_skill": 40,
"strength": 41,
"toughness": 45,
"agility": 43,
"intelligence": 40,
"perception": 41,
"willpower": 42,
"fellowship": 43
},
"derived": {
"wounds": {
"total": 19,
"current": null
},
"fatigue": null,
"fate_points": {
"total": 2,
"current": null
},
"insanity": {
"current_points": null,
"battle_fatigue": null,
"primarchs_curse": null
},
"corruption": {
"current": null
},
"movement": {
"half": 5,
"full": 10,
"charge": 15,
"run": 30,
"unit": "meters"
},
"renown": null,
"experience": {
"xp_to_spend": null,
"total_xp_spent": null
}
},
"skills": [
{
"name": "Awareness",
"characteristic": "Per",
"trained": true,
"source": "sheet"
},
{
"name": "Common Lore",
"characteristic": "Int",
"specialisations": [
"Adeptus Astartes",
"Imperium",
"War"
],
"trained": true,
"source": "sheet"
},
{
"name": "Dodge",
"characteristic": "Ag",
"trained": true,
"source": "sheet"
},
{
"name": "Intimidate",
"characteristic": "Str",
"trained": true,
"source": "sheet"
},
{
"name": "Medicae",
"characteristic": "Int",
"trained": true,
"source": "sheet"
},
{
"name": "Scholastic Lore",
"characteristic": "Int",
"specialisations": [
"Codex Astartes"
],
"trained": true,
"source": "sheet"
}
],
"gear": [
{
"name": "Mark VII power armour",
"source": "sheet"
},
{
"name": "Chainsword",
"source": "sheet"
},
{
"name": "Bolt pistol",
"source": "sheet"
},
{
"name": "3 frag grenades",
"source": "sheet"
},
{
"name": "3 krak grenades",
"source": "sheet"
},
{
"name": "Combat knife",
"source": "sheet"
}
],
"talents_and_special_rules": [
{
"name": "Enhance Healing",
"summary": "When making a successful Medicae test, the Apothecary may restore 1d5 additional Wounds.",
"source": "sheet"
},
{
"name": "Deathwatch Training",
"summary": "The Space Marine automatically confirms Righteous Fury against aliens.",
"source": "sheet"
}
],
"chapter_abilities": [
{
"name": "Favoured Son",
"summary": "The Ultramarines Chapter may re-roll any Fellowship Tests when dealing with either Space Marines or members of the Imperial armed forces.",
"source": "sheet"
},
{
"name": "Honour the Codex",
"summary": "Ultramarines are thorough, analytical, and attentive to detail, relying on doctrine and battle drills.",
"source": "sheet"
}
],
"armour": {
"name": "Mark VII Power Armour",
"pattern": "Mark VII",
"armour_points": 8,
"armour_points_by_location": {
"head": 8,
"body": 8,
"left_arm": 8,
"right_arm": 8,
"left_leg": 8,
"right_leg": 8
},
"source": "sheet_and_standard_inference"
},
"inferred_fill_ins": {
"space_marine_baseline_traits": [
"Astartes physiology package present",
"Power armour user",
"Black Carapace integration assumed",
"Unnatural Strength/Toughness style marine baseline likely applies depending on how you want the digital schema normalized"
],
"role_summary": "Frontline support specialist focused on battlefield medicine, recovery, and sustained squad effectiveness.",
"combat_role": [
"support melee",
"field medic",
"stabilizer",
"midline combatant"
],
"likely_primary_weapon_profile_placeholders": [
{
"name": "Chainsword",
"status": "not_visible_on_weapon_detail_sheet",
"note": "Only listed in gear, no backside weapon profile provided."
},
{
"name": "Bolt Pistol",
"status": "not_visible_on_weapon_detail_sheet",
"note": "Only listed in gear, no backside weapon profile provided."
}
]
},
"missing_fields_still_missing": [
{
"field": "character.name",
"reason": "Top identity line is cropped out of the image."
},
{
"field": "current_wounds",
"reason": "Not filled on visible sheet."
},
{
"field": "current_fate_points",
"reason": "Not filled on visible sheet."
},
{
"field": "fatigue",
"reason": "Not filled on visible sheet."
},
{
"field": "renown",
"reason": "Not visible on this image."
},
{
"field": "xp_to_spend",
"reason": "Not visible on this image."
},
{
"field": "total_xp_spent",
"reason": "Not visible on this image."
},
{
"field": "weapon_profiles",
"reason": "No filled weapon-side sheet provided."
}
],
"gm_quick_read": {
"chapter": "Ultramarines",
"specialty": "Apothecary",
"best_stats": [
"Weapon Skill 46",
"Toughness 45",
"Agility 43",
"Fellowship 43"
],
"key_support_feature": "Enhance Healing",
"playstyle": "Reliable support marine who can stay in the fight and keep others functioning."
},
"source_confidence": {
"identity": "medium_low",
"core_stats": "high",
"skills": "high",
"gear": "high",
"special_rules": "high",
"weapon_profiles": "low",
"inferred_fill_ins": "medium"
}
}
+440
View File
@@ -0,0 +1,440 @@
{
"conversion_type": "homebrew_rebuild_from_existing_sheet",
"system": "Warhammer 40,000 Roleplay: Deathwatch",
"conversion_status": "playable_homebrew",
"source_character": {
"name": "Brother Octavius",
"player_name": "Chris",
"chapter": "Ultramarines",
"original_specialty": "Deathwatch Tactical Marine",
"rank": 1,
"chapter_demeanour": "Favoured Son",
"personal_demeanour": "Pious"
},
"converted_character": {
"name": "Reclusiarch-Brother Octavius",
"preferred_short_name": "Brother Octavius",
"player_name": "Chris",
"chapter": "Ultramarines",
"specialty": "Deathwatch Chaplain",
"rank": 1,
"status": "homebrew_conversion",
"archetype": "leadership_support_melee_presence",
"chapter_demeanour": "Favoured Son",
"personal_demeanour": "Pious",
"role_summary": "A battlefield spiritual leader focused on morale, command presence, hatred of the xenos, and close-quarters shock leadership."
},
"identity_and_theme": {
"core_fantasy": "A stern, doctrinally grounded Ultramarines Chaplain who inspires allies, condemns the alien, and leads from the front.",
"battlefield_role": [
"moral anchor",
"leader",
"anti-fear presence",
"melee support",
"discipline enforcer"
],
"personality_notes": [
"Measured and doctrinal rather than wild or ecstatic",
"Pious fits extremely well for a Chaplain conversion",
"Favoured Son also still fits because an Ultramarines Chaplain can embody Codex orthodoxy and Imperial idealism"
]
},
"characteristics": {
"weapon_skill": 45,
"ballistic_skill": 45,
"strength": 49,
"toughness": 49,
"agility": 32,
"intelligence": 45,
"perception": 46,
"willpower": 45,
"fellowship": 48
},
"derived": {
"wounds": {
"total": 20,
"current": null
},
"fatigue": null,
"fate_points": {
"total": 4,
"current": null
},
"insanity": {
"current_points": null,
"battle_fatigue": null,
"primarchs_curse": null
},
"corruption": {
"current": null
},
"movement": {
"half": 4,
"full": 8,
"charge": 12,
"run": 24,
"unit": "meters"
}
},
"skills": [
{
"name": "Awareness",
"characteristic": "Per",
"trained": true,
"source": "original_sheet"
},
{
"name": "Charm",
"characteristic": "Fel",
"trained": true,
"source": "original_sheet"
},
{
"name": "Command",
"characteristic": "Fel",
"trained": true,
"source": "converted_for_chaplain"
},
{
"name": "Common Lore",
"characteristic": "Int",
"specialisations": [
"Adeptus Astartes",
"Imperium",
"War"
],
"trained": true,
"source": "original_sheet"
},
{
"name": "Dodge",
"characteristic": "Ag",
"trained": true,
"source": "original_sheet"
},
{
"name": "Intimidate",
"characteristic": "Str",
"trained": true,
"source": "original_sheet"
},
{
"name": "Scholastic Lore",
"characteristic": "Int",
"specialisations": [
"Codex Astartes",
"Imperial Creed"
],
"trained": true,
"source": [
"original_sheet",
"converted_for_chaplain"
]
},
{
"name": "Inquiry",
"characteristic": "Fel",
"trained": true,
"source": "converted_for_chaplain"
}
],
"skills_priority_for_ai": {
"social_leadership": [
"Command",
"Charm",
"Intimidate",
"Inquiry"
],
"awareness_and_control": [
"Awareness",
"Dodge"
],
"knowledge": [
"Common Lore (Adeptus Astartes)",
"Common Lore (Imperium)",
"Common Lore (War)",
"Scholastic Lore (Codex Astartes)",
"Scholastic Lore (Imperial Creed)"
]
},
"talents_and_traits": [
{
"name": "Astartes Weapon Training",
"source": "assumed_space_marine_core"
},
{
"name": "Bulging Biceps",
"source": "assumed_space_marine_core"
},
{
"name": "Heightened Senses (Hearing, Sight)",
"source": "assumed_space_marine_core"
},
{
"name": "Killing Strike",
"source": "assumed_space_marine_core"
},
{
"name": "Nerves of Steel",
"source": "assumed_space_marine_core"
},
{
"name": "Quick Draw",
"source": "assumed_space_marine_core"
},
{
"name": "Resistance (Psychic Powers)",
"source": "assumed_space_marine_core"
},
{
"name": "True Grit",
"source": "assumed_space_marine_core"
},
{
"name": "Unarmed Master",
"source": "assumed_space_marine_core"
},
{
"name": "Unnatural Strength (x2)",
"source": "assumed_space_marine_core"
},
{
"name": "Unnatural Toughness (x2)",
"source": "assumed_space_marine_core"
},
{
"name": "Deathwatch Training",
"source": "original_sheet"
},
{
"name": "Air of Authority",
"source": "converted_for_chaplain",
"note": "Chosen to reflect command presence and spiritual authority."
},
{
"name": "Iron Discipline",
"source": "converted_for_chaplain",
"note": "Chosen to reflect morale and battlefield steadiness."
},
{
"name": "Hatred (Xenos)",
"source": "converted_for_chaplain",
"note": "Very fitting for Deathwatch identity and Chaplain rhetoric."
}
],
"removed_or_replaced_from_original": [
{
"name": "Bolter Mastery",
"reason": "Too Tactical-Marine-specific for a true Chaplain rebuild."
}
],
"chapter_abilities": [
{
"name": "Favoured Son",
"summary": "May re-roll Fellowship Tests when dealing with Space Marines or members of the Imperial armed forces.",
"source": "original_sheet"
},
{
"name": "Honour the Codex",
"summary": "An Ultramarines Chaplain expresses doctrine, discipline, and battlefield order through liturgy, ritual, and example.",
"source": "adapted_from_original_flavour"
}
],
"chaplain_features": [
{
"name": "Litanies of Hate",
"type": "homebrew_chaplain_rule",
"summary": "As a Half Action, the Chaplain may invoke a litany. Until the start of his next turn, allies in support range who can hear him gain a morale bonus to resist Fear, Pinning, and intimidation-based effects.",
"ai_use": "Use before charges, during morale checks, or when xenos terror effects are expected."
},
{
"name": "Voice of the Reclusiam",
"type": "homebrew_chaplain_rule",
"summary": "When issuing Command or Intimidate in a combat context, the Chaplain gains a situational bonus if his authority, office, or religious rhetoric is relevant.",
"ai_use": "Apply during rally attempts, battlefield orders, prisoner intimidation, and morale restoration."
},
{
"name": "Hatred of the Alien",
"type": "homebrew_chaplain_rule",
"summary": "Against xenos, the Chaplain is exceptionally forceful in rhetoric and combat presence; GMs may grant situational bonuses in scenes involving denunciation, resolve, or purging zeal.",
"ai_use": "Best used in Deathwatch-typical anti-xenos scenes."
}
],
"armour": {
"name": "Mark VII Power Armour",
"pattern": "Mark VII",
"armour_points": {
"head": 8,
"body": 8,
"left_arm": 8,
"right_arm": 8,
"left_leg": 8,
"right_leg": 8
},
"power_armour_abilities": [
"Servo-Augmented Musculature: +20 Strength",
"Auto-senses: Dark Sight, immune to Photon Flash and Stun Grenades; called shots are Half Actions; +10 to sight and hearing Awareness Tests",
"Built-in Vox Link",
"Built-in Magboots",
"Nutrient Recycling",
"Recoil Suppression: may fire Basic weapons one-handed without penalty",
"Size: Hulking (Black Carapace means no bonus for enemies to attack)",
"Poor Manual Dexterity: -10 penalty to delicate manual tasks unless using equipment designed for Space Marines",
"Osmotic Gill Life Sustainer: with helmet on, the armour is environmentally sealed"
],
"armour_additions": [
"Skull helm or Chaplain-styled helm (narrative cosmetic conversion)",
"Rosarius mount or sanctified warding field emitter (homebrew narrative equipment)"
]
},
"gear": [
{
"name": "Mark VII power armour",
"source": "original_sheet"
},
{
"name": "Crozius Arcanum",
"source": "converted_for_chaplain",
"role": "primary_melee_weapon"
},
{
"name": "Rosarius",
"source": "converted_for_chaplain",
"role": "chaplain_iconic_wargear"
},
{
"name": "Bolt pistol",
"source": "original_sheet"
},
{
"name": "3 frag grenades",
"source": "original_sheet"
},
{
"name": "3 krak grenades",
"source": "original_sheet"
},
{
"name": "Combat knife",
"source": "original_sheet"
},
{
"name": "Reclusiam seals, reliquary tokens, and liturgical parchments",
"source": "converted_for_chaplain",
"role": "narrative_identity"
}
],
"weapons": [
{
"name": "Crozius Arcanum",
"class": "Melee",
"role": "primary",
"profile_status": "homebrew_placeholder",
"notes": [
"Treat as the Chaplains signature power maul/holy weapon equivalent.",
"Exact official profile not asserted here because this conversion is being built from the existing sheet rather than quoted from a verified source."
],
"ai_behavior": "Preferred when leading charges, dueling priority melee threats, or making ideological statements through combat."
},
{
"name": "Bolt Pistol",
"class": "Pistol",
"role": "secondary",
"profile_status": "standard_assumed",
"notes": [
"Retained from original gear loadout."
],
"ai_behavior": "Use at close range or when transitioning into melee."
},
{
"name": "Combat Knife",
"class": "Melee",
"role": "backup",
"profile_status": "standard_assumed"
},
{
"name": "Frag Grenades",
"class": "Thrown",
"count": 3,
"role": "crowd_control"
},
{
"name": "Krak Grenades",
"class": "Thrown",
"count": 3,
"role": "anti_armour_or_elite_target"
}
],
"combat_doctrine": {
"preferred_range": "close_to_mid",
"preferred_style": [
"lead from front",
"stabilize morale",
"threaten enemies verbally",
"close into decisive melee when appropriate"
],
"opening_actions": [
"Assess morale threat",
"Issue litany or command effect",
"Position centrally where allies can hear and see him",
"Advance behind or beside assault elements rather than acting as a backline shooter"
],
"avoid_patterns": [
"Do not play him like a pure Tactical Marine bolter platform",
"Do not waste his role standing too far from the squad",
"Do not reduce him to only flavour text; his mechanical identity should affect scene control and morale"
]
},
"social_and_roleplay_ai_guidance": {
"speech_style": [
"formal",
"stern",
"doctrinal",
"judgmental but not theatrical unless needed"
],
"keywords": [
"duty",
"faith",
"oath",
"purity",
"honour",
"the Emperor",
"the Codex",
"xenos corruption"
],
"leadership_style": "He does not inspire through warmth alone, but through certainty, authority, ritual, and the weight of sacred office."
},
"gm_use_summary": {
"fast_read": "Ultramarines Chaplain conversion of Brother Octavius. Strong social authority, morale support, anti-xenos zeal, and melee-led leadership presence.",
"best_scene_types": [
"rallying allies",
"terror resistance scenes",
"interrogations",
"battle sermons",
"boarding actions",
"purge-and-cleanse missions"
],
"less_suited_scene_types": [
"stealth specialist work",
"heavy weapons dominance",
"technical hacking or machine interface tasks"
]
},
"conversion_notes": [
"This build is intentionally honest about being a homebrew conversion from a Tactical Marine rather than pretending to be a direct official Chaplain sheet.",
"Core characteristics were preserved to maintain continuity of character identity.",
"Bolter Mastery was removed because it anchors the character too hard to Tactical Marine behavior.",
"Command presence, morale control, anti-xenos hatred, and symbolic gear were added because otherwise this would only be a cosmetic rename.",
"The conversion favors role integrity over strict preservation of the original combat niche."
],
"confidence": {
"identity": "high",
"core_stats": "high",
"chaplain_theme_fit": "high",
"official_rules_exactness": "medium_low",
"playable_homebrew_quality": "high"
}
}
+307
View File
@@ -0,0 +1,307 @@
{
"character": {
"name": "Brother Elyas",
"player_name": "Claes",
"chapter": "Dark Angels",
"specialty": "Deathwatch Assault Marine",
"system": "Warhammer 40,000 Roleplay: Deathwatch",
"rank": 1,
"chapter_demeanour": "Son of the Lion",
"personal_demeanour": "Hot-Blooded",
"past_event": null,
"power_armour_history": null,
"description": null
},
"characteristics": {
"weapon_skill": 44,
"ballistic_skill": 46,
"strength": 47,
"toughness": 44,
"agility": 48,
"intelligence": 30,
"perception": 41,
"willpower": 41,
"fellowship": 37
},
"derived": {
"wounds": {
"total": 21,
"current": null
},
"fatigue": null,
"fate_points": {
"total": 3,
"current": null
},
"insanity": {
"current_points": null,
"battle_fatigue": null,
"primarchs_curse": null
},
"corruption": {
"current": null
},
"movement": {
"half": 5,
"full": 10,
"charge": 15,
"run": 30,
"jump_pack_half": 12,
"jump_pack_full": 24,
"jump_pack_charge": 36,
"jump_pack_run": 72,
"unit": "meters"
},
"renown": {
"current": null
},
"experience": {
"xp_to_spend": null,
"total_xp_spent": 500,
"uncertain": true,
"uncertainty_notes": [
"The handwritten 500 appears near the experience area, likely Total XP Spent, but the placement is not fully crisp."
]
}
},
"skills": [
{
"name": "Awareness",
"characteristic": "Per",
"level": "basic"
},
{
"name": "Common Lore",
"characteristic": "Int",
"specialisations": [
"Adeptus Astartes",
"Deathwatch",
"Imperium"
],
"level": "trained"
},
{
"name": "Dodge",
"characteristic": "Ag",
"level": "trained"
},
{
"name": "Intimidate",
"characteristic": "Str",
"level": "trained"
},
{
"name": "Psyniscience",
"characteristic": "Per",
"level": "trained"
},
{
"name": "Scholastic Lore",
"characteristic": "Int",
"specialisations": [
"Codex Astartes"
],
"level": "basic"
},
{
"name": "Shadowing",
"characteristic": "Ag",
"level": "trained"
},
{
"name": "Speak Language",
"characteristic": "Int",
"specialisations": [
"High Gothic",
"Low Gothic"
],
"level": "basic"
},
{
"name": "Survival",
"characteristic": "Int",
"level": "basic"
},
{
"name": "Tracking",
"characteristic": "Int",
"level": "basic"
}
],
"skills_uncertain_or_partial": [
{
"name": "Acrobatics",
"note": "Appears to have a mark in the Basic column, but image angle and shading make it less reliable than the core marked skills."
},
{
"name": "Climb",
"note": "Looks marked at Basic, but kept out of the core list due to image ambiguity."
},
{
"name": "Concealment",
"note": "Looks marked at Basic, but confidence is lower."
},
{
"name": "Disguise",
"note": "There may be a mark present; not promoted to confirmed."
},
{
"name": "Lip Reading",
"note": "Possible mark in Basic; not fully certain."
},
{
"name": "Navigation (Surface)",
"note": "Possible mark in Basic; not fully certain."
},
{
"name": "Tech-Use",
"note": "Possible Basic mark; not fully certain."
}
],
"talents_and_traits": [
"Ambidextrous",
"Astartes Weapon Training",
"Bulging Biceps",
"Heightened Senses (Hearing, Sight)",
"Killing Strike",
"Nerves of Steel",
"Quick Draw",
"Resistance (Psychic Powers)",
"True Grit",
"Unarmed Master",
"Unnatural Strength (x2)",
"Unnatural Toughness (x2)",
"Wings of Angels",
"Deathwatch Training"
],
"chapter_abilities": [
{
"name": "Stoic Defence",
"summary": "Once per combat, a Dark Angels Battle-Brother may enact Stoic Defence, gaining 6 Temporary Wounds and reducing maximum Movement Rate to half Agility Bonus, rounded up. These bonus Wounds are lost first and are also lost if the Battle-Brother leaves his chosen location."
},
{
"name": "Son of the Lion",
"summary": "Dark Angels are secretive, distrustful of outsiders, and drilled from early on to guard the Chapters hidden truths."
}
],
"special_rules": [
{
"name": "Wings of Angels",
"summary": "When making a Charge whilst using his Jump Pack, the Assault Marine adds an additional 1d5 Damage to any melee attacks."
},
{
"name": "Deathwatch Training",
"summary": "The Space Marine automatically confirms Righteous Fury against aliens."
}
],
"armour": {
"name": "Mark VII Power Armour",
"pattern": "Mark VII",
"armour_points": {
"head": 8,
"body": 8,
"left_arm": 8,
"right_arm": 8,
"left_leg": 8,
"right_leg": 8
},
"power_armour_abilities": [
"Servo-Augmented Musculature: +20 Strength",
"Auto-senses: Dark Sight, immune to Photon Flash and Stun Grenades; called shots are Half Actions; +10 to sight and hearing Awareness Tests",
"Built-in Vox Link",
"Built-in Magboots",
"Nutrient Recycling",
"Recoil Suppression: may fire Basic weapons one-handed without penalty",
"Size: Hulking (Black Carapace means no bonus for enemies to attack)",
"Poor Manual Dexterity: delicate tasks suffer a -10 penalty unless using equipment designed for Space Marines",
"Osmotic Gill Life Sustainer: with helmet on, armour is environmentally sealed"
],
"armour_additions": []
},
"gear": [
"Mark VII power armour",
"Power fist",
"Jump pack",
"Bolt pistol",
"3 frag grenades",
"3 krak grenades",
"Combat knife"
],
"weapons": [
{
"name": "Power Fist",
"class": "Melee",
"damage": "2d10+12",
"damage_type": null,
"penetration": 9,
"range": null,
"rof": null,
"clip": null,
"reload": null,
"special_rules": [
"Power Field"
],
"uncertain": true,
"uncertainty_notes": [
"Damage and penetration are readable, but damage type is not clearly written."
]
},
{
"name": "Bolt Pistol",
"class": "Pistol",
"damage": "2d10+5",
"damage_type": "X",
"penetration": 5,
"range": "30m",
"rof": "S/3/-",
"clip": 14,
"reload": "Full",
"special_rules": [
"Tearing"
],
"uncertain": false
},
{
"name": "Frag Grenades",
"count": 3,
"profile_status": "gear_only",
"uncertain": true
},
{
"name": "Krak Grenades",
"count": 3,
"profile_status": "gear_only",
"uncertain": true
},
{
"name": "Combat Knife",
"profile_status": "gear_only",
"uncertain": true
}
],
"gm_quick_read": {
"role": "Fast melee assault specialist",
"best_features": [
"High Agility",
"Jump Pack mobility",
"Power Fist burst damage",
"Charge bonus from Wings of Angels"
],
"playstyle": "Aggressive assault marine who wants to close quickly, hit hard on the charge, and dominate short decisive engagements."
},
"notes": [
"This sheet is fairly complete across front, skills page, and partial weapon page.",
"The weapon page only has Power Fist and Bolt Pistol filled in; other listed gear items do not have full profiles written out.",
"Some skill-box markings are ambiguous because of angle and page shadowing, so only the clearest were promoted to confirmed entries."
],
"source_confidence": {
"identity_and_core_stats": "high",
"skills": "medium_high",
"talents_traits": "high",
"wargear_list": "high",
"weapon_profiles": "medium",
"handwritten_modifiers": "medium"
}
}
+270
View File
@@ -0,0 +1,270 @@
{
"character": {
"name": "Brother Skold",
"player_name": "Philip",
"chapter": "Space Wolves",
"specialty": "Tactical Marine",
"system": "Warhammer 40,000 Roleplay: Deathwatch",
"rank": 1,
"chapter_demeanour": "Fury of the Wolf",
"personal_demeanour": "Gregarious",
"past_event": null,
"power_armour_history": null,
"description": null
},
"characteristics": {
"weapon_skill": 42,
"ballistic_skill": 45,
"strength": 41,
"toughness": 42,
"agility": 37,
"intelligence": 41,
"perception": 53,
"willpower": 38,
"fellowship": 53
},
"derived": {
"wounds": {
"total": 20,
"current": null
},
"fatigue": null,
"fate_points": {
"total": 4,
"current": null
},
"insanity": {
"current_points": null,
"battle_fatigue": null,
"primarchs_curse": null
},
"corruption": {
"current": null
},
"movement": {
"half": 4,
"full": 8,
"charge": 12,
"run": 24,
"unit": "meters"
},
"renown": {
"current": 2
},
"experience": {
"xp_to_spend": 500,
"total_xp_spent": null
}
},
"skills": [
{
"name": "Awareness",
"characteristic": "Per",
"trained": true
},
{
"name": "Charm",
"characteristic": "Fel",
"trained": true
},
{
"name": "Common Lore",
"characteristic": "Int",
"specialisations": [
"Adeptus Astartes",
"Imperium",
"War"
],
"trained": true
},
{
"name": "Dodge",
"characteristic": "Ag",
"trained": true
},
{
"name": "Intimidate",
"characteristic": "Str",
"trained": true
},
{
"name": "Search",
"characteristic": "Per",
"trained": true
}
],
"talents_and_traits": [
"Ambidextrous",
"Astartes Weapon Training",
"Bulging Biceps",
"Heightened Senses (Hearing, Sight)",
"Killing Strike",
"Nerves of Steel",
"Quick Draw",
"Resistance (Psychic Powers)",
"True Grit",
"Unarmed Master",
"Unnatural Strength (x2)",
"Unnatural Toughness (x2)",
"Bolter Mastery",
"Deathwatch Training"
],
"chapter_abilities": [
{
"name": "Wolf Senses",
"summary": "May re-roll any failed Perception-based Tests and counts as being able to see normally in total darkness. These bonuses do not apply if wearing a helmet for a sealed set of armour."
},
{
"name": "Fury of the Wolf",
"summary": "Space Wolves are fierce, aggressive warriors with strong loyalty and a sense of duty and honour."
}
],
"space_marine_abilities": [
"Secondary Heart/Ossmodula/Biscopea",
"Haemastamen",
"Larramans Organ",
"Catalepsean Node",
"Preomnor",
"Omophagea",
"Multi-Lung",
"Occulobe",
"Lymans Ear",
"Sus-an Membrane",
"Oolitic Kidney",
"Neuroglottis",
"Mucranoid",
"Betchers Gland",
"Progenoids",
"Black Carapace"
],
"armour": {
"name": "Mark VII Power Armour",
"pattern": "Mark VII",
"armour_points": {
"head": 8,
"body": 8,
"left_arm": 8,
"right_arm": 8,
"left_leg": 8,
"right_leg": 8
},
"power_armour_abilities": [
"Servo-Augmented Musculature: +20 Strength",
"Auto-senses: Dark Sight, immune to Photon Flash and Stun Grenades; called shots are Half Actions; +10 to sight and hearing Awareness Tests",
"Built-in Vox Link",
"Built-in Magboots",
"Nutrient Recycling",
"Recoil Suppression: may fire Basic weapons one-handed without penalty",
"Size: Hulking (Black Carapace means no bonus for enemies to attack)",
"Poor Manual Dexterity: -10 penalty to delicate manual tasks unless using equipment designed for Space Marines",
"Osmotic Gill Life Sustainer: with helmet on, the armour is environmentally sealed"
],
"armour_additions": []
},
"gear": [
"Mark VII power armour",
"Bolter with Kraken rounds",
"Bolt pistol",
"3 frag grenades",
"3 krak grenades",
"Combat knife"
],
"weapons": [
{
"name": "Combat Knife",
"class": "Melee",
"damage": "1d10+2",
"damage_type": "Rending",
"penetration": 2,
"range": null,
"rof": null,
"clip": null,
"reload": null,
"special_rules": [],
"uncertain": false
},
{
"name": "Boltergun",
"pattern": "Possibly Godwyn-pattern",
"class": "Basic",
"damage": "2d10+5+2",
"damage_type": "X",
"penetration": "5 (8 with Kraken rounds?)",
"range": "100",
"rof": "S/4/-",
"clip": 28,
"reload": "Full",
"special_rules": [
"Tearing"
],
"uncertain": true,
"uncertainty_notes": [
"Pattern text is hard to read",
"Penetration appears to reference Kraken rounds in parentheses",
"Damage line includes handwritten +2, likely from Bolter Mastery"
]
},
{
"name": "Bolt Pistol",
"class": "Pistol",
"damage": "2d10+5",
"damage_type": "X",
"penetration": "5 (8 with Kraken rounds?)",
"range": "30",
"rof": "S/3/-",
"clip": 14,
"reload": "Full",
"special_rules": [
"Tearing"
],
"uncertain": true,
"uncertainty_notes": [
"A few handwritten numbers are faint"
]
},
{
"name": "Grenades",
"variants": [
{
"name": "Frag Grenade",
"class": "Thrown",
"damage": "2d10+4",
"damage_type": "X",
"penetration": 0,
"range": "SB x 3",
"special_rules": [
"Blast"
],
"uncertain": true
},
{
"name": "Krak Grenade",
"class": "Thrown",
"damage": "3d10+4",
"damage_type": "X",
"penetration": 6,
"range": "SB x 3",
"special_rules": [
"Concussive/knock-out text unclear"
],
"uncertain": true
}
]
}
],
"notes": [
"Starter sheet and full sheet agree on the core statline and base role.",
"Weapon details are partly handwritten and should be verified against the original weapon profiles if this is going into a rules engine.",
"The sheet appears to be a pregen or lightly customized Rank 1 Deathwatch Tactical Marine."
],
"source_confidence": {
"identity_and_core_stats": "high",
"skills": "high",
"talents_traits": "high",
"wargear_list": "high",
"weapon_profiles": "medium",
"handwritten_modifiers": "medium_to_low"
}
}
+165
View File
@@ -12956,3 +12956,168 @@ Connected to MongoDB
[2026-06-23T13:17:14.755Z] MariaDB: Error getting all weapons {"message":"Table 'deathwatch.weapons' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM weapons ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.weapons' doesn't exist"}
[2026-06-23T13:17:14.768Z] MariaDB: Error getting all bestiary {"message":"Table 'deathwatch.bestiary' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM bestiary ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.bestiary' doesn't exist"}
[2026-06-23T13:33:33.058Z] MariaDB: Tables created successfully
[2026-06-23T21:19:35.818Z] SESSION: Session validation successful gm
[2026-06-23T21:19:35.872Z] API: Fetch player gm success
[2026-06-23T21:19:45.935Z] MariaDB: Updated mission progress 9
[2026-06-23T21:19:52.803Z] MariaDB: Updated mission progress 9
[2026-06-23T21:23:05.809Z] Session deleted: session_gm_1782248674070_qhcsas
[2026-06-23T21:23:05.811Z] SESSION: Logout session_gm_1782248674070_qhcsas
[2026-06-23T21:23:09.669Z] Session created: session_anders_1782249789618_j7t7ao
[2026-06-23T21:23:09.671Z] API: Player login anders success
[2026-06-23T21:23:09.694Z] API: Fetch player anders success
[2026-06-23T21:23:09.699Z] SESSION: Session validation successful anders
[2026-06-23T21:23:09.718Z] API: Fetch player anders success
[2026-06-23T21:31:19.816Z] SESSION: Session validation successful anders
[2026-06-23T21:31:19.842Z] API: Fetch player anders success
[2026-06-23T21:42:44.161Z] SESSION: Session validation successful anders
[2026-06-23T21:42:44.190Z] API: Fetch player anders success
[2026-06-23T21:43:01.779Z] MariaDB: Updated mission progress 9
[2026-06-23T21:43:02.800Z] MariaDB: Updated mission progress 9
[2026-06-23T21:43:26.760Z] MariaDB: Updated mission progress 9
[2026-06-23T21:43:28.139Z] MariaDB: Updated mission progress 9
[2026-06-23T21:51:15.547Z] MariaDB: Error creating tables {"code":"ER_ACCESS_DENIED_ERROR","errno":1045,"sqlState":"28000","sqlMessage":"Access denied for user 'deathwatch'@'unifi' (using password: YES)"}
[2026-06-23T21:51:24.282Z] API: Fetch all players (public) 5
[2026-06-23T21:51:29.207Z] API: Fetch player anders success
[2026-06-23T21:51:34.975Z] API: Fetch player anders success
[2026-06-23T21:51:35.043Z] API: Fetch player christoffer success
[2026-06-23T21:51:35.092Z] API: Fetch player claes success
[2026-06-23T21:51:35.143Z] API: Fetch player phillip success
[2026-06-23T22:13:55.577Z] API: Fetch all players (public) 5
[2026-06-23T22:28:05.676Z] API: Fetch all players (public) 5
[2026-06-23T22:28:05.682Z] SESSION: Session validation successful anders
[2026-06-23T22:28:05.707Z] API: Fetch player anders success
[2026-06-23T22:28:24.067Z] API: Fetch all players (public) 5
[2026-06-23T22:28:24.073Z] SESSION: Session validation successful anders
[2026-06-23T22:28:24.096Z] API: Fetch player anders success
[2026-06-23T22:28:26.641Z] Session deleted: session_anders_1782249789618_j7t7ao
[2026-06-23T22:28:26.643Z] SESSION: Logout session_anders_1782249789618_j7t7ao
[2026-06-23T22:28:50.408Z] Session created: session_gm_1782253730353_zuylf6
[2026-06-23T22:28:50.410Z] API: Player login gm success
[2026-06-23T22:28:50.438Z] API: Fetch player gm success
[2026-06-23T22:28:50.445Z] SESSION: Session validation successful gm
[2026-06-23T22:28:50.472Z] API: Fetch player gm success
[2026-06-23T22:29:21.031Z] MariaDB: Updated mission progress 9
[2026-06-23T22:29:29.169Z] MariaDB: Error getting all bestiary {"message":"Table 'deathwatch.bestiary' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM bestiary ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.bestiary' doesn't exist"}
[2026-06-23T22:29:29.171Z] MariaDB: Error getting all weapons {"message":"Table 'deathwatch.weapons' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM weapons ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.weapons' doesn't exist"}
[2026-06-23T22:30:35.715Z] API: Fetch all players (public) 5
[2026-06-23T22:30:40.608Z] API: Fetch player anders success
[2026-06-23T22:30:48.559Z] API: Fetch all players (public) 5
[2026-06-23T22:30:52.186Z] API: Fetch player anders success
[2026-06-23T22:31:04.535Z] API: Fetch player anders success
[2026-06-23T22:31:04.599Z] API: Fetch player christoffer success
[2026-06-23T22:31:04.689Z] API: Fetch player claes success
[2026-06-23T22:31:04.754Z] API: Fetch player phillip success
[2026-06-23T22:37:40.170Z] MariaDB: Tables created successfully
[2026-06-23T22:37:44.798Z] API: Fetch player anders success
[2026-06-23T22:37:50.139Z] API: Fetch player christoffer success
[2026-06-23T22:40:12.754Z] API: Fetch all players (public) 5
[2026-06-23T22:40:17.142Z] API: Fetch player anders success
[2026-06-23T22:42:05.342Z] API: Fetch player anders success
[2026-06-23T22:42:05.417Z] API: Fetch player christoffer success
[2026-06-23T22:42:05.507Z] API: Fetch player claes success
[2026-06-23T22:42:05.587Z] API: Fetch player phillip success
[2026-06-23T22:51:32.054Z] API: Fetch player anders success
[2026-06-23T22:51:41.076Z] API: Fetch player anders success
[2026-06-23T22:51:41.132Z] API: Fetch player christoffer success
[2026-06-23T22:51:41.186Z] API: Fetch player claes success
[2026-06-23T22:51:41.247Z] API: Fetch player phillip success
[2026-06-24T08:13:37.788Z] MariaDB: Error creating tables {"code":"ER_ACCESS_DENIED_ERROR","errno":1045,"sqlState":"28000","sqlMessage":"Access denied for user 'deathwatch'@'unifi' (using password: YES)"}
[2026-06-24T08:22:57.268Z] API: Fetch all players (public) 5
[2026-06-24T08:23:16.201Z] MariaDB: Updated mission progress 9
[2026-06-24T08:23:16.425Z] MariaDB: Updated mission progress 9
[2026-06-24T08:25:06.391Z] MariaDB: Tables created successfully
[2026-06-24T08:25:34.126Z] MariaDB: Tables created successfully
[2026-06-24T08:26:39.167Z] MariaDB: Tables created successfully
[2026-06-24T08:27:43.791Z] MariaDB: Tables created successfully
[2026-06-24T08:30:55.243Z] MariaDB: Tables created successfully
[2026-06-24T08:31:05.243Z] Simulation #1 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T08:31:12.631Z] Simulation #2 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T08:31:12.901Z] Simulation #3 completed: The Hunt for Fabius Bile — PARTIAL_FAILURE
[2026-06-24T08:31:49.070Z] API: Fetch all players (public) 5
[2026-06-24T08:31:49.074Z] SESSION: Session validation successful gm
[2026-06-24T08:31:49.208Z] API: Fetch player gm success
[2026-06-24T08:31:50.273Z] API: Fetch all players (public) 5
[2026-06-24T08:32:40.599Z] API: Fetch all players (public) 5
[2026-06-24T08:33:05.674Z] API: Fetch player anders success
[2026-06-24T08:33:25.244Z] API: Fetch all players (public) 5
[2026-06-24T08:33:27.960Z] MariaDB: Created player andreas
[2026-06-24T08:33:27.961Z] API: Created player andreas success
[2026-06-24T08:33:32.571Z] API: Fetch all players (public) 6
[2026-06-24T08:33:56.731Z] API: Fetch player andreas success
[2026-06-24T08:34:05.777Z] MariaDB: Updated player andreas
[2026-06-24T08:34:05.779Z] API: Updated player andreas success
[2026-06-24T08:34:12.700Z] API: Fetch player andreas success
[2026-06-24T08:35:01.308Z] API: Fetch all players (public) 6
[2026-06-24T08:35:10.183Z] Simulation #4 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T08:35:10.211Z] API: Fetch all players (public) 6
[2026-06-24T08:35:21.222Z] Simulation #5 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T08:35:21.254Z] API: Fetch all players (public) 6
[2026-06-24T08:38:11.460Z] MariaDB: Updated player andreas
[2026-06-24T08:38:11.464Z] API: Updated player andreas success
[2026-06-24T08:38:20.757Z] API: Fetch player andreas success
[2026-06-24T08:47:21.904Z] MariaDB: Updated player anders
[2026-06-24T08:47:21.910Z] API: Updated player anders success
[2026-06-24T08:47:35.419Z] MariaDB: Updated player andreas
[2026-06-24T08:47:35.421Z] API: Updated player andreas success
[2026-06-24T08:47:53.448Z] MariaDB: Updated player christoffer
[2026-06-24T08:47:53.450Z] API: Updated player christoffer success
[2026-06-24T08:48:09.440Z] MariaDB: Updated player claes
[2026-06-24T08:48:09.442Z] API: Updated player claes success
[2026-06-24T08:48:29.596Z] MariaDB: Updated player phillip
[2026-06-24T08:48:29.598Z] API: Updated player phillip success
[2026-06-24T08:48:36.934Z] API: Fetch player anders success
[2026-06-24T08:48:36.980Z] API: Fetch player andreas success
[2026-06-24T08:48:37.026Z] API: Fetch player christoffer success
[2026-06-24T08:48:37.083Z] API: Fetch player claes success
[2026-06-24T08:48:37.144Z] API: Fetch player phillip success
[2026-06-24T08:50:32.259Z] MariaDB: Updated player andreas
[2026-06-24T08:50:32.263Z] API: Updated player andreas success
[2026-06-24T08:50:41.668Z] API: Fetch player andreas success
[2026-06-24T08:51:03.555Z] MariaDB: Updated player andreas
[2026-06-24T08:51:03.556Z] API: Updated player andreas success
[2026-06-24T08:51:09.123Z] API: Fetch player andreas success
[2026-06-24T08:52:00.746Z] Simulation #6 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T08:52:10.863Z] Simulation #7 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T08:55:01.129Z] API: Fetch all players (public) 6
[2026-06-24T08:55:01.154Z] SESSION: Session validation successful gm
[2026-06-24T08:55:01.179Z] API: Fetch player gm success
[2026-06-24T08:56:18.671Z] API: Fetch all players (public) 6
[2026-06-24T08:56:22.973Z] Simulation #8 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T08:56:23.004Z] API: Fetch all players (public) 6
[2026-06-24T08:56:45.435Z] Simulation #9 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T08:56:45.467Z] API: Fetch all players (public) 6
[2026-06-24T08:56:52.641Z] Simulation #10 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T08:56:52.675Z] API: Fetch all players (public) 6
[2026-06-24T09:00:12.185Z] API: Fetch all players (public) 6
[2026-06-24T09:00:22.010Z] MariaDB: Updated mission progress 9
[2026-06-24T09:02:33.620Z] MariaDB: Tables created successfully
[2026-06-24T09:03:16.974Z] Simulation #11 completed: The Hunt for Fabius Bile — PARTIAL_FAILURE
[2026-06-24T09:03:53.171Z] MariaDB: Tables created successfully
[2026-06-24T09:03:59.717Z] Simulation #12 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T09:04:00.015Z] Simulation #13 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T09:04:00.514Z] Simulation #14 completed: The Hunt for Fabius Bile — PARTIAL_FAILURE
[2026-06-24T09:04:00.826Z] Simulation #15 completed: The Hunt for Fabius Bile — PARTIAL_FAILURE
[2026-06-24T09:04:01.082Z] Simulation #16 completed: The Hunt for Fabius Bile — PARTIAL_FAILURE
[2026-06-24T09:04:33.097Z] API: Fetch all players (public) 6
[2026-06-24T09:04:33.132Z] SESSION: Session validation successful gm
[2026-06-24T09:04:33.159Z] API: Fetch player gm success
[2026-06-24T09:04:34.550Z] API: Fetch all players (public) 6
[2026-06-24T09:04:43.038Z] Simulation #17 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T09:04:43.072Z] API: Fetch all players (public) 6
[2026-06-24T09:04:48.507Z] Simulation #18 completed: The Hunt for Fabius Bile — VICTORY
[2026-06-24T09:04:48.538Z] API: Fetch all players (public) 6
[2026-06-24T09:09:43.564Z] API: Fetch all players (public) 6
[2026-06-24T09:09:51.120Z] SESSION: Session validation successful gm
[2026-06-24T09:09:51.130Z] API: Fetch all players (public) 6
[2026-06-24T09:09:51.144Z] API: Fetch player gm success
[2026-06-24T09:09:51.795Z] API: Fetch all players (public) 6
[2026-06-24T09:09:51.798Z] SESSION: Session validation successful gm
[2026-06-24T09:09:51.821Z] API: Fetch player gm success
[2026-06-24T09:09:51.976Z] API: Fetch all players (public) 6
[2026-06-24T09:09:51.987Z] SESSION: Session validation successful gm
[2026-06-24T09:09:52.010Z] API: Fetch player gm success
[2026-06-24T09:16:50.410Z] API: Fetch all players (public) 6
[2026-06-24T09:16:50.423Z] SESSION: Session validation successful gm
[2026-06-24T09:16:50.443Z] API: Fetch player gm success
[2026-06-24T09:17:13.311Z] MariaDB: Updated mission progress 9
[2026-06-24T10:07:25.442Z] API: Fetch all players (public) 6
+130 -34
View File
@@ -6,6 +6,19 @@ const router = express.Router();
console.log('Rules routes registered (MariaDB)');
const rulesJsonPath = path.join(__dirname, '..', 'rules', 'rules-database.json');
function parseMaybeJson(value, fallback = []) {
if (Array.isArray(value)) return value;
if (!value) return fallback;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : fallback;
} catch (e) {
return fallback;
}
}
// Helpers to clean up OCR/extracted text
function cleanText(s) {
if (!s) return s;
@@ -32,45 +45,75 @@ function cleanTitle(t) {
return s;
}
// Fetch all rules from MariaDB
function normalizeRule(r) {
const ruleId = r.rule_id || r.ruleId || r.id;
return {
id: ruleId || r.id,
numericId: Number.isInteger(r.id) ? r.id : undefined,
rule_id: ruleId,
title: cleanTitle(r.title),
summary: cleanText(r.summary || ''),
content: cleanText(r.content),
page: r.page,
pageEnd: r.page_end || r.pageEnd || r.page,
source: r.source,
sourceAbbr: r.source_abbr || r.sourceAbbr,
category: r.category,
tags: parseMaybeJson(r.tags),
aliases: parseMaybeJson(r.aliases),
relatedRules: parseMaybeJson(r.related_rules || r.relatedRules),
sourceMethod: r.source_method || r.sourceMethod || 'unknown',
confidence: r.confidence == null ? null : Number(r.confidence),
midgamePriority: r.midgame_priority || r.midgamePriority || 0
};
}
function getRulesFromJson() {
try {
if (!fs.existsSync(rulesJsonPath)) return [];
const data = JSON.parse(fs.readFileSync(rulesJsonPath, 'utf8'));
return (data.rules || []).map(normalizeRule);
} catch (e) {
console.error('Failed to read rules JSON fallback:', e);
logToFile('Error reading rules JSON fallback:', e);
return [];
}
}
function mergeRules(dbRules, jsonRules) {
const byId = new Map();
for (const rule of dbRules || []) {
byId.set(String(rule.rule_id || rule.id), rule);
}
for (const rule of jsonRules || []) {
byId.set(String(rule.rule_id || rule.id), rule);
}
return Array.from(byId.values());
}
// Fetch all rules from MariaDB and merge in the generated JSON midgame database.
async function getAllRules() {
try {
const rows = await rulesHelpers.getAll();
return rows.map(r => ({
id: r.id,
rule_id: r.rule_id,
title: cleanTitle(r.title),
content: cleanText(r.content),
page: r.page,
source: r.source,
sourceAbbr: r.source_abbr,
category: r.category
}));
return mergeRules(rows && rows.length ? rows.map(normalizeRule) : [], getRulesFromJson());
} catch (e) {
console.error('Failed to read rules from MariaDB:', e);
logToFile('Error getting all rules:', e);
return [];
return getRulesFromJson();
}
}
async function getRuleById(ruleId) {
try {
const rows = await rulesHelpers.getAll();
const row = rows.find(r => r.rule_id === ruleId || r.id === ruleId);
const sourceRows = mergeRules(rows && rows.length ? rows.map(normalizeRule) : [], getRulesFromJson());
const row = sourceRows.find(r => String(r.rule_id) === String(ruleId) || String(r.id) === String(ruleId) || String(r.numericId) === String(ruleId));
if (!row) return null;
return {
id: row.rule_id || row.id,
title: cleanTitle(row.title),
content: cleanText(row.content),
page: row.page,
source: row.source,
sourceAbbr: row.source_abbr,
category: row.category
};
return row;
} catch (e) {
console.error('Failed to read rule by id:', e);
logToFile('Error getting rule by id:', ruleId, e);
return null;
return getRulesFromJson().find(r => String(r.rule_id) === String(ruleId) || String(r.id) === String(ruleId)) || null;
}
}
@@ -79,7 +122,9 @@ router.get('/categories', async (req, res) => {
try {
const rows = await getAllRules();
const categories = [...new Set(rows.map(r => r.category).filter(Boolean))];
const categoryList = categories.map(cat => ({ id: cat, name: cleanTitle(cat) }));
const categoryList = categories
.sort((a, b) => a.localeCompare(b))
.map(cat => ({ id: cat, name: cleanTitle(cat.replace(/_/g, ' ')) }));
res.json([{ id: 'all', name: 'All Rules' }, ...categoryList]);
} catch (error) {
console.error('Categories error:', error);
@@ -90,41 +135,82 @@ router.get('/categories', async (req, res) => {
// Search rules (MariaDB)
router.get('/search', async (req, res) => {
try {
const { q: query, category, limit = 20 } = req.query;
const { q: query, category, tag, source, midgame, limit = 20 } = req.query;
if (!query || !query.trim()) return res.json([]);
const limitInt = Math.max(1, parseInt(limit) || 20);
const allRules = await getAllRules();
const term = query.toLowerCase();
const term = query.trim().toLowerCase();
const tokens = term.split(/\s+/).filter(Boolean);
const includesTerm = (value, t = term) => String(value || '').toLowerCase().includes(t);
const arrayIncludes = (arr, t = term) => (arr || []).some(v => includesTerm(v, t));
const tokenMatch = (rule) => {
const haystacks = [
rule.title,
rule.summary,
rule.content,
rule.category,
...(rule.tags || []),
...(rule.aliases || [])
].map(v => String(v || '').toLowerCase());
return tokens.every(token => haystacks.some(v => v.includes(token)));
};
const score = (rule, t) => {
const title = (rule.title || '').toLowerCase();
const summary = (rule.summary || '').toLowerCase();
const content = (rule.content || '').toLowerCase();
const tags = (rule.tags || []).map(v => String(v).toLowerCase());
const aliases = (rule.aliases || []).map(v => String(v).toLowerCase());
const exactTitle = title === t;
const titleStarts = title.startsWith(t);
const titleContains = title.includes(t);
const exactAlias = aliases.includes(t);
const aliasContains = aliases.some(a => a.includes(t));
const tagContains = tags.some(a => a.includes(t));
const summaryContains = summary.includes(t);
const isCanonical = rule.source === 'Core Rulebook';
const tokenHits = tokens.reduce((sum, token) => {
return sum +
(title.includes(token) ? 8 : 0) +
(aliases.some(a => a.includes(token)) ? 6 : 0) +
(tags.some(a => a.includes(token)) ? 4 : 0) +
(summary.includes(token) ? 3 : 0) +
(content.includes(token) ? 1 : 0);
}, 0);
// Higher = shown first
return (exactTitle ? 100 : 0) +
(exactAlias ? 90 : 0) +
(titleStarts ? 50 : 0) +
(titleContains ? 20 : 0) +
(aliasContains ? 18 : 0) +
(tagContains ? 15 : 0) +
(summaryContains ? 8 : 0) +
(isCanonical ? 10 : 0) +
(Number(rule.midgamePriority) || 0) +
tokenHits +
(content.includes(t) ? 1 : 0);
};
let filtered = allRules.filter(rule => {
const titleMatch = rule.title && rule.title.toLowerCase().includes(term);
const contentMatch = rule.content && rule.content.toLowerCase().includes(term);
const titleMatch = includesTerm(rule.title);
const summaryMatch = includesTerm(rule.summary);
const contentMatch = includesTerm(rule.content);
const tagMatch = arrayIncludes(rule.tags);
const aliasMatch = arrayIncludes(rule.aliases);
const categoryMatch = !category || category === 'all' || rule.category === category;
return (titleMatch || contentMatch) && categoryMatch;
const sourceMatch = !source || source === 'all' || rule.sourceAbbr === source || rule.source === source;
const midgameMatch = !midgame || midgame === 'false' || Number(rule.midgamePriority || 0) >= 70;
const explicitTagMatch = !tag || tag === 'all' || (rule.tags || []).some(t => String(t).toLowerCase() === String(tag).toLowerCase());
return (titleMatch || summaryMatch || contentMatch || tagMatch || aliasMatch || tokenMatch(rule)) && categoryMatch && sourceMatch && midgameMatch && explicitTagMatch;
});
// If category filtering yielded no results, try without category filter
if (filtered.length === 0 && category && category !== 'all') {
filtered = allRules.filter(rule => {
const titleMatch = rule.title && rule.title.toLowerCase().includes(term);
const contentMatch = rule.content && rule.content.toLowerCase().includes(term);
return titleMatch || contentMatch;
const sourceMatch = !source || source === 'all' || rule.sourceAbbr === source || rule.source === source;
return sourceMatch && (includesTerm(rule.title) || includesTerm(rule.summary) || includesTerm(rule.content) || arrayIncludes(rule.tags) || arrayIncludes(rule.aliases) || tokenMatch(rule));
});
}
@@ -157,7 +243,7 @@ router.get('/rule/:id', async (req, res) => {
// Get random rules for discovery
router.get('/random', async (req, res) => {
try {
const { count = 5, category } = req.query;
const { count = 5, category, midgame } = req.query;
const max = Math.max(1, parseInt(count) || 5);
const allRules = await getAllRules();
@@ -166,6 +252,9 @@ router.get('/random', async (req, res) => {
if (category && category !== 'all') {
filtered = allRules.filter(rule => rule.category === category);
}
if (midgame && midgame !== 'false') {
filtered = filtered.filter(rule => Number(rule.midgamePriority || 0) >= 70);
}
// Shuffle and pick random rules
const shuffled = filtered.sort(() => 0.5 - Math.random());
@@ -186,6 +275,8 @@ router.get('/stats', async (req, res) => {
const categoryCount = {};
const sourceCount = {};
const methodCount = {};
let midgameRules = 0;
allRules.forEach(rule => {
if (rule.category) {
@@ -194,12 +285,17 @@ router.get('/stats', async (req, res) => {
if (rule.source) {
sourceCount[rule.source] = (sourceCount[rule.source] || 0) + 1;
}
if (rule.sourceMethod) {
methodCount[rule.sourceMethod] = (methodCount[rule.sourceMethod] || 0) + 1;
}
if (Number(rule.midgamePriority || 0) >= 70) midgameRules += 1;
});
const categories = Object.entries(categoryCount).map(([category, c]) => ({ category, c }));
const sources = Object.entries(sourceCount).map(([source, c]) => ({ source, c }));
const sourceMethods = Object.entries(methodCount).map(([sourceMethod, c]) => ({ sourceMethod, c }));
res.json({ totalRules, categories, sources, searchTerms: 0 });
res.json({ totalRules, midgameRules, categories, sources, sourceMethods, searchTerms: 0 });
} catch (error) {
console.error('Stats error:', error);
res.status(500).json({ error: 'Failed to get stats' });
File diff suppressed because one or more lines are too long
+4
View File
@@ -35,6 +35,10 @@
"extract:armoury": "node scripts/extract-armoury.js \"$PWD/Deathwatch-Core-Rulebook.pdf\" public/deathwatch-armoury.json",
"server": "npm test && cd database && node server.js",
"server:notest": "cd database && node server.js",
"rules:build": "python3 scripts/build-midgame-rules.py",
"rules:import": "node scripts/import-midgame-rules-to-db.js",
"rules:simulate": "node scripts/simulate-midplay-rules.js",
"rules:verify-scenarios": "node scripts/verify-rules-ingame-scenarios.js",
"db:sqlite": "cd database && ./switch-db.sh sqlite",
"pm2:start": "cd database && pm2 start pm2.config.js --env production --update-env",
"pm2:stop": "pm2 stop deathwatch-server || true",
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env python3
"""
Build a midgame-oriented Deathwatch rules database.
This intentionally does not try to blindly turn every PDF heading into a rule.
It builds high-value session references from verified local sources, keeps
source/page provenance, and marks OCR/text fallbacks explicitly.
"""
import csv
import json
import re
from datetime import datetime, timezone
from pathlib import Path
import fitz
REPO = Path(__file__).resolve().parent.parent
RULES_DIR = REPO / "database" / "rules"
DATA_DIR = REPO / "data"
BACKUPS_DIR = REPO / "database" / "backups"
OUT = RULES_DIR / "rules-database.json"
BOOKS = {
"CR": {"name": "Core Rulebook", "path": RULES_DIR / "CR.pdf"},
"FF": {"name": "First Founding", "path": RULES_DIR / "FF.pdf"},
"RoB": {"name": "Rites of Battle", "path": RULES_DIR / "RoB.pdf"},
"MoX": {"name": "Mark of the Xenos", "path": RULES_DIR / "MoX.pdf"},
"HtC": {"name": "Honour the Chapter", "path": RULES_DIR / "HtC.pdf"},
"ERR": {"name": "Errata", "path": RULES_DIR / "Errata.pdf"},
}
MIDGAME_CATEGORIES = [
"combat",
"actions",
"damage",
"conditions",
"hordes",
"psychic",
"skills",
"talents",
"traits",
"squad_mode",
"cohesion",
"requisition",
"renown",
"equipment",
"mission",
"gm_tables",
"errata",
"supplements",
]
REQUIRED_RULES = [
"Dodge",
"Parry",
"Full Auto Burst",
"Semi-Auto Burst",
"Suppressing Fire",
"Overwatch",
"Pinning",
"Righteous Fury",
"Critical Damage",
"Hordes",
"Cohesion Damage",
"Maintaining Squad Mode",
"Requisition",
"Renown",
"Fear",
]
PLAY_AID_CONTENT = {
"Requisition": """Quick Use
- The Mission has a Requisition rating set by the GM or mission authority.
- Each Battle-Brother gets that many Requisition Points for this Mission.
- Total item costs may not exceed the Battle-Brother's available Requisition.
- Unspent Requisition does not carry over.
- Requisitioned equipment is returned at the end of the Mission.
Pooling
- The squad may pool Requisition for communal gear or to equip one Battle-Brother with a costly item.
- The GM should approve who carries and controls pooled equipment.
Included Ammunition and Consumables
- Ranged weapons include enough basic ammunition for the Mission unless the GM rules otherwise.
- Grenades, missiles, and similar consumables last for the Mission unless tracked separately.
- Special Issue Ammunition costs are per single clip.
Renown Gate
- Paying the Requisition cost is not enough if the item has a Renown requirement.
- A Battle-Brother must meet or exceed the item's Renown Rank before he may Requisition it.
Availability to Requisition
Ubiquitous: 1
Abundant: 2
Plentiful: 3
Common: 4-5
Average: 6-8
Scarce: 9-14
Rare: 15-20
Very Rare: 21-30
Extremely Rare: 31-50
Near Unique: 51-70
Unique: 71+
Mid-play Ruling
- If an item is from another 40k RPG line, use Availability to estimate cost.
- Add a Renown requirement for rare, sacred, xenos, relic, or Astartes-only gear.
- If players challenge the Mission's Requisition rating, let the Kill-team Leader make the argument once; repeated haggling should have social consequences.""",
"Renown": """Quick Use
- Renown is the Battle-Brother's status within the Deathwatch.
- Renown gates access to prestigious, rare, or dangerous armoury items.
- An item with a Renown requirement cannot be Requisitioned until the Battle-Brother meets that rank.
- When a squad pools Requisition, the intended bearer still needs the required Renown.
- Renown increases through service, sacrifice, victory, and Mission rewards.
Renown Ranks
0-19: Initiated
20-39: Respected
40-59: Distinguished
60-79: Famed
80+: Hero
Mid-play Ruling
- Use Renown to say "not yet" even when the squad has enough Requisition.
- Let pooled Requisition buy an item only if the intended bearer meets its Renown requirement.
- For gear imported from another source, assign a Renown gate when the item is iconic, relic-grade, restricted, or politically sensitive.""",
}
def clean_text(value):
value = str(value or "").replace("\r\n", "\n").replace("\r", "\n")
value = value.replace("\u0008", "")
value = re.sub(r"[ \t]+", " ", value)
value = re.sub(r" *\n *", "\n", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def slug(value):
value = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return value[:90] or "rule"
def pdf_pages(abbr, start, end=None):
book = BOOKS[abbr]
path = book["path"]
if not path.exists():
return ""
end = end or start
doc = fitz.open(path)
chunks = []
for page_num in range(start, min(end, len(doc)) + 1):
chunks.append(doc[page_num - 1].get_text("text"))
doc.close()
return clean_text("\n\n".join(chunks))
def source_method_for(abbr, fallback=False):
if fallback:
return "text"
return "pdf" if BOOKS.get(abbr, {}).get("path", Path()).exists() else "text"
def make_rule(
title,
summary,
category,
source_abbr,
page=None,
page_end=None,
content="",
aliases=None,
tags=None,
related=None,
priority=50,
method=None,
confidence=0.9,
):
book = BOOKS.get(source_abbr, {"name": source_abbr})
rule_id = f"{source_abbr.lower()}-{slug(category)}-{slug(title)}"
return {
"id": rule_id,
"rule_id": rule_id,
"title": title,
"summary": summary,
"content": clean_text(content),
"category": category,
"tags": sorted(set(tags or [])),
"aliases": sorted(set(aliases or [])),
"relatedRules": sorted(set(related or [])),
"page": page,
"pageEnd": page_end or page,
"source": book["name"],
"sourceAbbr": source_abbr,
"sourceMethod": method or source_method_for(source_abbr),
"confidence": confidence,
"midgamePriority": priority,
}
def load_skills():
path = REPO / "database" / "deathwatch_skills_p94_107.csv"
if not path.exists():
return []
rules = []
with path.open(newline="", encoding="utf-8") as handle:
for row in csv.DictReader(handle):
name = row["name"].strip()
characteristic = row.get("characteristic", "").strip()
skill_type = row.get("type", "").strip()
descriptors = row.get("descriptors", "").strip()
parts = []
if row.get("skill_text"):
parts.append(row["skill_text"].strip())
if row.get("skill_description"):
parts.append(row["skill_description"].strip())
if row.get("skill_use"):
parts.append(f"Skill Use: {row['skill_use'].strip()}")
summary_bits = [skill_type, characteristic]
if descriptors:
summary_bits.append(descriptors)
summary = " / ".join([b for b in summary_bits if b])
rules.append(
make_rule(
title=name,
summary=summary,
category="skills",
source_abbr="CR",
page=94,
page_end=107,
content="\n\n".join(parts),
aliases=[f"{name} Skill", characteristic],
tags=["skill", skill_type.lower(), descriptors.lower()],
related=["Test Difficulty"],
priority=75 if name in {"Awareness", "Dodge", "Command", "Medicae", "Intimidate"} else 55,
method="csv+pdf",
confidence=0.95,
)
)
return rules
CURATED_SECTIONS = [
# Core adjudication
("Test Difficulty", "Difficulty modifiers and common test bands.", "gm_tables", "CR", 204, 205, ["difficulty", "modifier", "ordinary", "challenging"], ["Skill Tests"], 90),
("Fate Points", "Spend or burn Fate Points during play.", "gm_tables", "CR", 205, 205, ["fate", "reroll", "heal", "initiative"], ["Critical Damage"], 70),
("Movement", "Narrative and structured movement, jumping, swimming, climbing, carrying, throwing, and gravity.", "gm_tables", "CR", 206, 211, ["movement", "jump", "swim", "carry", "throw", "gravity"], ["Actions"], 70),
# Cohesion and modes
("Cohesion", "Kill-team Cohesion, Cohesion damage, recovery, and Cohesion Challenges.", "cohesion", "CR", 212, 213, ["cohesion", "cohesion challenge"], ["Squad Mode"], 95),
("Cohesion Damage", "When incoming damage reduces the Kill-team's Cohesion and how the leader can resist it.", "cohesion", "CR", 213, 213, ["cohesion damage", "command test"], ["Cohesion", "Squad Mode"], 100),
("Squad Mode", "Entering, maintaining, leaving, and tracking Squad Mode.", "squad_mode", "CR", 214, 215, ["squad mode", "support range", "changing modes"], ["Cohesion", "Solo Mode"], 95),
("Maintaining Squad Mode", "Conditions that cause a Battle-Brother to drop out of Squad Mode.", "squad_mode", "CR", 215, 215, ["maintain squad mode", "support range"], ["Cohesion Damage"], 100),
("Solo Mode Abilities", "Chapter-linked Solo Mode abilities and improvements.", "squad_mode", "CR", 216, 219, ["solo mode", "chapter ability"], ["Squad Mode"], 70),
("Squad Mode Abilities", "Activating, sustaining, joining, and using Squad Mode abilities.", "squad_mode", "CR", 220, 226, ["bolter assault", "fire support", "regroup", "tactical spacing"], ["Cohesion"], 90),
# Mission/session structure
("Missions", "Mission preparation, objectives, complications, rewards, experience, renown, and requisition.", "mission", "CR", 227, 232, ["mission", "objective", "oath", "complication", "reward"], ["Requisition", "Renown"], 75),
("Mission Complications", "Complications that change an operation mid-session.", "mission", "CR", 232, 232, ["complication", "bad beginning", "cut off"], ["Missions"], 80),
# Combat
("Combat Overview", "Structured time, initiative, rounds, turns, and surprise.", "combat", "CR", 235, 237, ["combat", "initiative", "surprise", "round", "turn"], ["Actions"], 90),
("Actions", "Full, half, free, reaction, extended, and subtype rules.", "actions", "CR", 237, 238, ["action", "half action", "full action", "reaction"], ["Combat Overview"], 95),
("Combat Actions", "Action descriptions including Aim, Charge, Dodge, Full Auto Burst, Overwatch, Semi-Auto Burst, and Suppressing Fire.", "actions", "CR", 238, 244, ["aim", "charge", "dodge", "full auto", "overwatch", "semi-auto", "suppressing fire", "parry"], ["The Attack"], 100),
("Dodge", "Reaction to negate a hit when allowed.", "actions", "CR", 239, 240, ["dodge reaction"], ["Parry", "The Attack"], 100),
("Parry", "Reaction using Weapon Skill to negate a melee hit.", "actions", "CR", 243, 245, ["parry reaction"], ["Dodge", "The Attack"], 100),
("Full Auto Burst", "Full Action ranged attack with additional hits per degree of success.", "actions", "CR", 240, 240, ["full auto", "automatic fire"], ["Semi-Auto Burst", "Weapon Jams"], 100),
("Semi-Auto Burst", "Ranged attack mode with additional hits every two degrees of success.", "actions", "CR", 242, 243, ["semi auto", "semi-auto"], ["Full Auto Burst", "Weapon Jams"], 100),
("Suppressing Fire", "Full Auto fire to force Pinning and threaten a kill zone.", "actions", "CR", 244, 244, ["suppression", "suppressing fire"], ["Pinning", "Full Auto Burst"], 100),
("Overwatch", "Establish a kill zone and fire when targets enter it.", "actions", "CR", 241, 242, ["overwatch", "kill zone"], ["Suppressing Fire", "Pinning"], 100),
("The Attack", "Attack tests, hit locations, damage rolls, Righteous Fury, and defenses.", "combat", "CR", 245, 246, ["attack", "hit location", "damage roll", "righteous fury"], ["Dodge", "Parry", "Damage"], 100),
("Righteous Fury", "Exploding damage on natural 10s after a successful attack confirmation.", "damage", "CR", 246, 246, ["righteous fury", "damage"], ["The Attack"], 100),
("Combat Circumstances", "Cover, darkness, difficult terrain, melee, range, size, shooting into melee, and prone modifiers.", "combat", "CR", 247, 250, ["cover", "darkness", "range", "size", "prone", "melee"], ["Test Difficulty"], 100),
("Pinning", "Effects of being Pinned and how characters recover.", "conditions", "CR", 249, 249, ["pinned", "pinning test"], ["Suppressing Fire", "Overwatch"], 100),
("Weapon Jams", "Jam thresholds and clearing jammed weapons.", "combat", "CR", 249, 249, ["jam", "weapon jam"], ["Full Auto Burst", "Semi-Auto Burst"], 90),
("Damage", "Wounds, damage types, armour/Toughness reduction, and damage recovery.", "damage", "CR", 251, 251, ["wounds", "damage", "armour", "toughness"], ["Critical Damage"], 100),
("Critical Damage", "Critical damage process and reference tables.", "damage", "CR", 251, 260, ["critical damage", "critical effects", "energy", "explosive", "impact", "rending"], ["Damage"], 100),
("Conditions and Special Damage", "Fatigue, blood loss, fire, falling, suffocation, and special damage states.", "conditions", "CR", 261, 263, ["fatigue", "blood loss", "fire", "falling", "suffocation"], ["Damage"], 95),
("Fear", "Fear tests, degrees of Fear, and mental trauma during play.", "conditions", "CR", 277, 279, ["fear", "shock", "insanity"], ["Test Difficulty"], 100),
("Hordes", "Creating, attacking, damaging, breaking, and running Hordes.", "hordes", "CR", 360, 361, ["horde", "magnitude", "blast weapons", "breaking a horde"], ["Full Auto Burst", "Suppressing Fire"], 100),
# Armoury and character-facing rules
("Requisition", "Mission equipment purchasing and Requisition Point ranges.", "requisition", "CR", 139, 140, ["requisition", "req", "availability"], ["Renown", "Missions"], 100),
("Renown", "Renown ranks and their impact on equipment access.", "renown", "CR", 140, 141, ["renown", "requisition", "pooling", "respected", "distinguished", "famed", "hero"], ["Requisition"], 100),
("Craftsmanship", "Poor, common, good, and best craftsmanship effects and costs.", "equipment", "CR", 141, 141, ["craftsmanship", "poor", "good", "best"], ["Requisition"], 80),
("Weapon Profiles", "Weapon class, range, rate of fire, damage, penetration, clip, reload, and special qualities.", "equipment", "CR", 141, 142, ["weapon profile", "rof", "damage", "penetration", "clip", "reload"], ["Weapon Special Qualities"], 90),
("Weapon Special Qualities", "Rules for Accurate, Balanced, Blast, Flame, Tearing, Toxic, Storm, and other qualities.", "equipment", "CR", 143, 145, ["accurate", "balanced", "blast", "flame", "tearing", "toxic", "storm"], ["Weapon Profiles"], 100),
("Astartes Power Armour", "Power armour protection, subsystems, histories, and critical effects.", "equipment", "CR", 160, 164, ["power armour", "armour history", "subsystems"], ["Damage"], 85),
("Tools and Wargear", "Common mission gear, tools, consumables, cybernetics, and relic support rules.", "equipment", "CR", 171, 178, ["wargear", "tools", "drugs", "cybernetics"], ["Requisition"], 75),
# Talents, traits, psychic
("Talent Descriptions", "Core talent rules and talent descriptions.", "talents", "CR", 113, 130, ["talent", "prerequisites"], ["Traits"], 80),
("Trait Descriptions", "Creature and character trait rules.", "traits", "CR", 131, 137, ["trait", "unnatural", "fear", "toxic", "warp weapon"], ["Talent Descriptions"], 80),
("Psychic Powers", "Focus Power tests, Psy Rating, fettered/unfettered/push rules, and power use.", "psychic", "CR", 185, 190, ["psychic", "focus power", "psy rating"], ["Psychic Phenomena", "Perils of the Warp"], 90),
("Psychic Phenomena", "Psychic Phenomena results and when they trigger.", "psychic", "CR", 187, 188, ["psychic phenomena", "warp"], ["Perils of the Warp"], 95),
("Perils of the Warp", "Perils table and severe psychic backlash.", "psychic", "CR", 189, 189, ["perils", "warp"], ["Psychic Phenomena"], 95),
]
def load_curated_sections():
rules = []
for title, summary, category, abbr, start, end, tags, related, priority in CURATED_SECTIONS:
content = PLAY_AID_CONTENT.get(title) or pdf_pages(abbr, start, end)
rules.append(
make_rule(
title=title,
summary=summary,
category=category,
source_abbr=abbr,
page=start,
page_end=end,
content=content,
aliases=tags,
tags=[category] + tags,
related=related,
priority=priority,
method="pdf+curated" if title in PLAY_AID_CONTENT else None,
confidence=0.9 if end - start > 3 else 0.95,
)
)
return rules
def load_gmk_text_rules():
path = RULES_DIR / "GMK.txt"
content = clean_text(path.read_text(encoding="utf-8")) if path.exists() else ""
tables = DATA_DIR / "gamemasters_kit" / "gamemasterkit_tables.txt"
table_content = clean_text(tables.read_text(encoding="utf-8")) if tables.exists() else ""
rules = []
if content:
rules.append(
make_rule(
"Game Master's Kit Reference",
"GM Kit mission, antagonist, and table reference text.",
"gm_tables",
"GMK",
page=1,
page_end=32,
content=content[:50000],
aliases=["gm kit", "gamemaster kit", "tables"],
tags=["gm", "tables", "reference"],
related=["Combat Actions", "Test Difficulty"],
priority=70,
method="text",
confidence=0.75,
)
)
if table_content:
rules.append(
make_rule(
"GM Kit Quick Tables",
"OCR/text fallback for GM Kit quick-reference tables.",
"gm_tables",
"GMK",
page=None,
content=table_content,
aliases=["quick tables", "combat tables", "weapon qualities"],
tags=["gm", "tables", "ocr"],
related=["Combat Actions", "Weapon Special Qualities"],
priority=85,
method="text+ocr",
confidence=0.7,
)
)
return rules
def load_supplement_rules():
specs = [
("FF", "First Founding Session Reference", "Chapter rules, additional character options, gear, and chapter-specific material.", ["chapter", "successor", "first founding"]),
("RoB", "Rites of Battle Session Reference", "Expanded squad, mission, vehicle, armoury, and Deathwatch campaign rules.", ["rites of battle", "vehicle", "squad", "mission"]),
("MoX", "Mark of the Xenos Session Reference", "Xenos adversary rules, traits, and GM-facing encounter material.", ["xenos", "adversary", "horde", "trait"]),
("HtC", "Honour the Chapter Session Reference", "Additional Chapter options, deeds, powers, and equipment.", ["honour the chapter", "chapter", "deed"]),
("ERR", "Errata Reference", "Errata and corrections to check when a rule result conflicts.", ["errata", "correction", "faq"]),
]
rules = []
for abbr, title, summary, tags in specs:
path = BOOKS[abbr]["path"]
if not path.exists():
continue
doc = fitz.open(path)
max_pages = min(len(doc), 8)
content = []
for idx in range(max_pages):
content.append(doc[idx].get_text("text"))
doc.close()
rules.append(
make_rule(
title=title,
summary=summary,
category="supplements" if abbr != "ERR" else "errata",
source_abbr=abbr,
page=1,
page_end=max_pages,
content="\n\n".join(content),
aliases=tags,
tags=["supplement"] + tags,
related=["Requisition", "Talent Descriptions", "Trait Descriptions"],
priority=45 if abbr != "ERR" else 90,
confidence=0.65,
)
)
return rules
def dedupe(rules):
best = {}
for rule in rules:
key = rule["rule_id"]
if key not in best or rule["midgamePriority"] > best[key]["midgamePriority"]:
best[key] = rule
return sorted(best.values(), key=lambda r: (-r["midgamePriority"], r["category"], r["title"].lower()))
def build_search_index(rules):
index = {}
for idx, rule in enumerate(rules):
fields = [
rule.get("title", ""),
rule.get("summary", ""),
" ".join(rule.get("tags", [])),
" ".join(rule.get("aliases", [])),
rule.get("category", ""),
]
words = re.findall(r"[a-z0-9]{3,}", " ".join(fields).lower())
for word in set(words):
index.setdefault(word, []).append(idx)
return index
def build_report(rules):
by_category = {}
by_source = {}
missing = []
title_blob = "\n".join((r["title"] + "\n" + " ".join(r.get("aliases", []))).lower() for r in rules)
for category in MIDGAME_CATEGORIES:
by_category[category] = sum(1 for r in rules if r["category"] == category)
for rule in rules:
by_source[rule["sourceAbbr"]] = by_source.get(rule["sourceAbbr"], 0) + 1
for required in REQUIRED_RULES:
if required.lower() not in title_blob:
missing.append(required)
low_confidence = [
{"id": r["id"], "title": r["title"], "confidence": r["confidence"], "sourceMethod": r["sourceMethod"]}
for r in rules
if r["confidence"] < 0.75
]
return {
"generatedAt": datetime.now(timezone.utc).isoformat(),
"totalRules": len(rules),
"byCategory": by_category,
"bySource": by_source,
"missingRequiredRules": missing,
"lowConfidence": low_confidence,
}
def main():
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
rules = dedupe(load_curated_sections() + load_skills() + load_gmk_text_rules() + load_supplement_rules())
report = build_report(rules)
data = {
"rules": rules,
"searchIndex": build_search_index(rules),
"metadata": {
"totalRules": len(rules),
"sources": [
{"name": info["name"], "abbr": abbr, "available": info["path"].exists()}
for abbr, info in BOOKS.items()
],
"categories": sorted({r["category"] for r in rules}),
"lastUpdated": report["generatedAt"],
"builder": "scripts/build-midgame-rules.py",
"sourcePolicy": "PDF primary; OCR/text fallback allowed when marked with sourceMethod and confidence.",
},
}
OUT.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
report_path = BACKUPS_DIR / f"rules-midgame-build-report-{stamp}.json"
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(f"Wrote {OUT} ({len(rules)} rules)")
print(f"Wrote {report_path}")
if report["missingRequiredRules"]:
print("Missing required rules:", ", ".join(report["missingRequiredRules"]))
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const mysql = require('mysql2/promise');
const repoRoot = path.resolve(__dirname, '..');
const rulesPath = path.join(repoRoot, 'database', 'rules', 'rules-database.json');
const dbConfig = {
host: process.env.DB_HOST || '192.168.1.113',
user: process.env.DB_USER || 'deathwatch',
password: process.env.DB_PASSWORD || 'defaultpassword',
database: process.env.DB_NAME || 'deathwatch',
port: Number(process.env.DB_PORT || 3307)
};
const addColumnIfMissing = async (connection, table, definition) => {
try {
await connection.execute(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
} catch (error) {
if (error.code !== 'ER_DUP_FIELDNAME') throw error;
}
};
const main = async () => {
const data = JSON.parse(fs.readFileSync(rulesPath, 'utf8'));
const rules = data.rules || [];
if (!rules.length) throw new Error(`No rules found in ${rulesPath}`);
const connection = await mysql.createConnection(dbConfig);
await addColumnIfMissing(connection, 'rules', 'summary TEXT');
await addColumnIfMissing(connection, 'rules', 'page_end INT');
await addColumnIfMissing(connection, 'rules', 'tags TEXT');
await addColumnIfMissing(connection, 'rules', 'aliases TEXT');
await addColumnIfMissing(connection, 'rules', 'related_rules TEXT');
await addColumnIfMissing(connection, 'rules', 'source_method VARCHAR(50)');
await addColumnIfMissing(connection, 'rules', 'confidence DECIMAL(4,2)');
await addColumnIfMissing(connection, 'rules', 'midgame_priority INT DEFAULT 0');
const [before] = await connection.execute('SELECT COUNT(*) AS cnt FROM rules');
let upserted = 0;
for (const rule of rules) {
await connection.execute(
`INSERT INTO rules
(rule_id, title, content, summary, page, page_end, source, source_abbr, category,
tags, aliases, related_rules, source_method, confidence, midgame_priority)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
title=VALUES(title),
content=VALUES(content),
summary=VALUES(summary),
page=VALUES(page),
page_end=VALUES(page_end),
source=VALUES(source),
source_abbr=VALUES(source_abbr),
category=VALUES(category),
tags=VALUES(tags),
aliases=VALUES(aliases),
related_rules=VALUES(related_rules),
source_method=VALUES(source_method),
confidence=VALUES(confidence),
midgame_priority=VALUES(midgame_priority)`,
[
rule.rule_id || rule.id,
rule.title || '',
rule.content || '',
rule.summary || '',
rule.page || null,
rule.pageEnd || null,
rule.source || null,
rule.sourceAbbr || null,
rule.category || null,
JSON.stringify(rule.tags || []),
JSON.stringify(rule.aliases || []),
JSON.stringify(rule.relatedRules || []),
rule.sourceMethod || null,
rule.confidence == null ? null : Number(rule.confidence),
rule.midgamePriority || 0
]
);
upserted += 1;
}
const [after] = await connection.execute('SELECT COUNT(*) AS cnt FROM rules');
await connection.end();
console.log(JSON.stringify({
rulesFile: path.relative(repoRoot, rulesPath),
upserted,
before: before[0].cnt,
after: after[0].cnt
}, null, 2));
};
main().catch(error => {
console.error(error.message);
process.exit(1);
});
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const rulesPath = path.join(repoRoot, 'database', 'rules', 'rules-database.json');
const data = JSON.parse(fs.readFileSync(rulesPath, 'utf8'));
const rules = data.rules || [];
const scenarios = [
{
actor: 'Player',
moment: 'Tyranid Warrior hits Brother Lucian; player checks whether a Reaction can cancel it.',
query: 'dodge',
expected: ['Dodge']
},
{
actor: 'GM',
moment: 'Heavy bolter lays down fire across a corridor.',
query: 'suppressing fire pinning',
expected: ['Suppressing Fire', 'Pinning']
},
{
actor: 'Player',
moment: 'Devastator fires full auto and needs additional hit rules.',
query: 'full auto burst',
expected: ['Full Auto Burst']
},
{
actor: 'GM',
moment: 'A mob of gaunts becomes one mass enemy.',
query: 'horde magnitude damage',
expected: ['Hordes']
},
{
actor: 'Player',
moment: 'Librarian rolls psychic backlash.',
query: 'perils of the warp',
expected: ['Perils of the Warp']
},
{
actor: 'GM',
moment: 'Kill-team takes blast damage in Squad Mode.',
query: 'cohesion damage',
expected: ['Cohesion Damage']
},
{
actor: 'Player',
moment: 'Mission prep pauses to buy gear.',
query: 'requisition renown',
expected: ['Requisition', 'Renown']
},
{
actor: 'GM',
moment: 'Enemy causes Fear and the table needs the modifier.',
query: 'fear test',
expected: ['Fear']
}
];
function includes(value, term) {
return String(value || '').toLowerCase().includes(term);
}
function score(rule, query) {
const term = query.toLowerCase();
const tokens = term.split(/\s+/).filter(Boolean);
const title = String(rule.title || '').toLowerCase();
const summary = String(rule.summary || '').toLowerCase();
const content = String(rule.content || '').toLowerCase();
const tags = (rule.tags || []).map(v => String(v).toLowerCase());
const aliases = (rule.aliases || []).map(v => String(v).toLowerCase());
const tokenHits = tokens.reduce((sum, token) => {
return sum +
(title.includes(token) ? 8 : 0) +
(aliases.some(a => a.includes(token)) ? 6 : 0) +
(tags.some(a => a.includes(token)) ? 4 : 0) +
(summary.includes(token) ? 3 : 0) +
(content.includes(token) ? 1 : 0);
}, 0);
return (title === term ? 100 : 0) +
(aliases.includes(term) ? 90 : 0) +
(title.startsWith(term) ? 50 : 0) +
(includes(rule.title, term) ? 20 : 0) +
(aliases.some(a => a.includes(term)) ? 18 : 0) +
(tags.some(t => t.includes(term)) ? 15 : 0) +
(includes(rule.summary, term) ? 8 : 0) +
Number(rule.midgamePriority || 0) +
tokenHits +
(includes(rule.content, term) ? 1 : 0);
}
function search(query, limit = 5) {
const term = query.toLowerCase();
const tokens = term.split(/\s+/).filter(Boolean);
return rules
.filter(rule => {
const haystacks = [
rule.title,
rule.summary,
rule.content,
rule.category,
...(rule.tags || []),
...(rule.aliases || [])
].map(v => String(v || '').toLowerCase());
return tokens.every(token => haystacks.some(v => v.includes(token))) ||
haystacks.some(v => v.includes(term));
})
.sort((a, b) => score(b, query) - score(a, query))
.slice(0, limit);
}
let failures = 0;
console.log('Mid-play rules lookup simulation\n');
for (const scenario of scenarios) {
const results = search(scenario.query);
const titles = results.map(r => r.title);
const ok = scenario.expected.some(expected => titles.slice(0, 3).includes(expected));
if (!ok) failures += 1;
console.log(`${scenario.actor}: ${scenario.moment}`);
console.log(` Query: ${scenario.query}`);
console.log(` Top results: ${titles.slice(0, 3).join(' | ') || '(none)'}`);
if (results[0]) {
const page = results[0].page ? ` p.${results[0].page}${results[0].pageEnd && results[0].pageEnd !== results[0].page ? `-${results[0].pageEnd}` : ''}` : '';
console.log(` Opens: ${results[0].sourceAbbr || results[0].source}${page} [${results[0].sourceMethod}, ${Math.round(Number(results[0].confidence || 0) * 100)}%]`);
}
console.log(` ${ok ? 'PASS' : 'FAIL'}\n`);
}
if (failures) {
console.error(`${failures} mid-play lookup scenario(s) failed.`);
process.exit(1);
}
console.log('All mid-play lookup scenarios passed.');
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const rulesPath = path.join(repoRoot, 'database', 'rules', 'rules-database.json');
const db = JSON.parse(fs.readFileSync(rulesPath, 'utf8'));
const rules = db.rules || [];
const scenarios = [
{
name: 'Brother Kael Dodges a Warrior',
tableMoment: 'A Tyranid Warrior hits Brother Kael in melee. The player asks whether he can spend his Reaction to avoid the hit.',
query: 'dodge reaction',
mustFind: ['Dodge'],
formatChecks: [
{ title: 'Dodge', category: 'actions', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'Dodge appears before broad combat pages and has a CR page citation.'
},
{
name: 'Devastator Uses Full Auto',
tableMoment: 'Brother Aeldan fires a heavy bolter on full auto and needs the additional-hit rule.',
query: 'full auto burst additional hits',
mustFind: ['Full Auto Burst'],
formatChecks: [
{ title: 'Full Auto Burst', category: 'actions', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'The card distinguishes Full Auto from Semi-Auto and links to Weapon Jams.'
},
{
name: 'GM Runs a Horde',
tableMoment: 'The GM turns thirty Hormagaunts into one Horde and needs magnitude/damage handling.',
query: 'horde magnitude damage',
mustFind: ['Hordes'],
formatChecks: [
{ title: 'Hordes', category: 'hordes', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'Hordes is found as its own card instead of buried in adversary text.'
},
{
name: 'Kill-team Takes Blast Damage in Squad Mode',
tableMoment: 'A blast weapon hits a Battle-Brother in Squad Mode. The GM checks if the team loses Cohesion.',
query: 'cohesion damage blast command test',
mustFind: ['Cohesion Damage'],
formatChecks: [
{ title: 'Cohesion Damage', category: 'cohesion', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'The result points to Cohesion Damage, not only the general Cohesion card.'
},
{
name: 'Librarian Triggers Perils',
tableMoment: 'The Librarian pushes a psychic power and rolls badly. The player asks for the Perils table.',
query: 'perils of the warp psychic backlash',
mustFind: ['Perils of the Warp'],
formatChecks: [
{ title: 'Perils of the Warp', category: 'psychic', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'The Perils table is directly discoverable from common player language.'
},
{
name: 'Fear Test in the Genatorium',
tableMoment: 'A daemonhost manifests in a ruined genatorium. The GM needs the Fear test modifier.',
query: 'fear test modifier',
mustFind: ['Fear'],
formatChecks: [
{ title: 'Fear', category: 'conditions', source: 'CR', minConfidence: 0.9 }
],
worksIf: 'Fear outranks generic test difficulty and trait references.'
},
{
name: 'Mission Prep Requisition',
tableMoment: 'Before extraction, players pool Requisition for a lascannon and ask how Renown gates it.',
query: 'requisition renown pooling',
mustFind: ['Requisition', 'Renown'],
formatChecks: [
{
title: 'Requisition',
category: 'requisition',
source: 'CR',
minConfidence: 0.9,
sourceMethod: 'pdf+curated',
requiredSections: ['Quick Use', 'Pooling', 'Renown Gate', 'Availability to Requisition', 'Mid-play Ruling']
},
{
title: 'Renown',
category: 'renown',
source: 'CR',
minConfidence: 0.9,
sourceMethod: 'pdf+curated',
requiredSections: ['Quick Use', 'Renown Ranks', 'Mid-play Ruling']
}
],
worksIf: 'The answer is formatted as a play aid rather than raw PDF prose.'
}
];
function fields(rule) {
return [
rule.title,
rule.summary,
rule.content,
rule.category,
...(rule.tags || []),
...(rule.aliases || [])
].map(v => String(v || '').toLowerCase());
}
function score(rule, query) {
const term = query.toLowerCase();
const tokens = term.split(/\s+/).filter(Boolean);
const title = String(rule.title || '').toLowerCase();
const haystacks = fields(rule);
return (title === term ? 100 : 0) +
(title.includes(term) ? 45 : 0) +
Number(rule.midgamePriority || 0) +
tokens.reduce((sum, token) => sum + haystacks.reduce((hits, value) => hits + (value.includes(token) ? 1 : 0), 0), 0);
}
function search(query, limit = 5) {
const term = query.toLowerCase();
const tokens = term.split(/\s+/).filter(Boolean);
return rules
.filter(rule => {
const haystacks = fields(rule);
return haystacks.some(value => value.includes(term)) ||
tokens.every(token => haystacks.some(value => value.includes(token)));
})
.sort((a, b) => score(b, query) - score(a, query))
.slice(0, limit);
}
function assertRuleFormat(check) {
const rule = rules.find(r => r.title === check.title);
const errors = [];
if (!rule) return [`Missing rule card: ${check.title}`];
if (rule.category !== check.category) errors.push(`${check.title}: category ${rule.category} !== ${check.category}`);
if (rule.sourceAbbr !== check.source) errors.push(`${check.title}: source ${rule.sourceAbbr} !== ${check.source}`);
if (!rule.page && rule.page !== 0) errors.push(`${check.title}: missing page`);
if (!rule.sourceMethod) errors.push(`${check.title}: missing sourceMethod`);
if (check.sourceMethod && rule.sourceMethod !== check.sourceMethod) errors.push(`${check.title}: sourceMethod ${rule.sourceMethod} !== ${check.sourceMethod}`);
if (Number(rule.confidence || 0) < check.minConfidence) errors.push(`${check.title}: confidence ${rule.confidence} < ${check.minConfidence}`);
if (!rule.summary) errors.push(`${check.title}: missing summary`);
if (!rule.content || rule.content.length < 80) errors.push(`${check.title}: content too short`);
if (!Array.isArray(rule.relatedRules)) errors.push(`${check.title}: relatedRules is not an array`);
for (const section of check.requiredSections || []) {
if (!rule.content.includes(section)) errors.push(`${check.title}: missing section "${section}"`);
}
return errors;
}
let failures = 0;
console.log('In-game rules format and browse verification\n');
for (const scenario of scenarios) {
const results = search(scenario.query);
const titles = results.map(r => r.title);
const lookupOk = scenario.mustFind.every(title => titles.slice(0, 4).includes(title));
const formatErrors = scenario.formatChecks.flatMap(assertRuleFormat);
const ok = lookupOk && formatErrors.length === 0;
if (!ok) failures += 1;
console.log(`${ok ? 'PASS' : 'FAIL'} ${scenario.name}`);
console.log(` Moment: ${scenario.tableMoment}`);
console.log(` Query: ${scenario.query}`);
console.log(` Top results: ${titles.slice(0, 4).join(' | ') || '(none)'}`);
console.log(` Works if: ${scenario.worksIf}`);
for (const error of formatErrors) console.log(` Format error: ${error}`);
if (!lookupOk) console.log(` Lookup error: expected ${scenario.mustFind.join(', ')} in top 4 results`);
console.log('');
}
if (failures) {
console.error(`${failures} scenario(s) failed.`);
process.exit(1);
}
console.log('All invented in-game scenarios passed lookup and format checks.');
+226 -196
View File
@@ -127,7 +127,7 @@ function RollFeedRow({ roll, showDelete, onDelete }) {
<>Attack <strong>{payload.attackRoll}</strong> vs {payload.target} · Hits {payload.hits || 0}{payload.where ? ` · ${payload.where}` : ''}</>
)}
{typeof payload.total !== 'undefined' && roll.roll_type === 'initiative' && (
<span>Initiative <strong>{payload.total}</strong> (d10:{payload.dieRoll} + Ab:{payload.agBonus})</span>
<span>{`Initiative ${payload.total} (d10:${payload.dieRoll} + Ab:${payload.agBonus})`}</span>
)}
</div>
</div>
@@ -995,12 +995,39 @@ export default function MissionTab({ authedPlayer }) {
);
})()}
{/* Player: awaiting */}
{!isGM && (!playPrompt || playPrompt.type !== 'check') && (
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<div className="text-2xl">📋</div>
<p className="text-sm text-slate-400">No check open yet.<br />Awaiting GM orders.</p>
</div>
{/* Player: visible scene checks for mid-play reference */}
{!isGM && (
sceneChecks.length > 0 ? (
<div className="grid gap-3">
{sceneChecks.map((check, index) => {
const target = clampTarget((check.target || 45) + (check.modifier || 0));
return (
<div key={`${check.name}-${index}`} className="rounded border border-slate-700 bg-slate-900 p-3">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="font-semibold text-slate-100">{check.name}</div>
<div className="text-xs text-slate-400">
{check.skill || 'Skill'} ({check.characteristic || 'Characteristic'}) target {target}
{check.modifier ? ` (${check.modifier > 0 ? '+' : ''}${check.modifier})` : ''}
</div>
</div>
<button
onClick={openSceneInRoller}
className="rounded bg-emerald-700 px-3 py-2 text-sm font-medium hover:bg-emerald-600"
>
Open in Dice Roller
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
<div className="text-2xl">📋</div>
<p className="text-sm text-slate-400">No check open yet.<br />Awaiting GM orders.</p>
</div>
)
)}
{/* GM: full check list */}
@@ -1598,199 +1625,202 @@ export default function MissionTab({ authedPlayer }) {
</div>
</div>
{/* Scene Editor */}
{editingScene && sceneEdit && (
<div className="rounded-lg border border-amber-600/40 bg-amber-950/10 p-5 space-y-5">
<div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-semibold text-amber-200">Edit Scene</h3>
<div className="flex gap-2">
<button
onClick={saveSceneEdit}
disabled={saving}
className="rounded bg-amber-700 px-3 py-1.5 text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
<button
onClick={() => { setEditingScene(false); setSceneEdit(null); }}
className="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
>
Cancel
</button>
</div>
</div>
{/* Title + Type */}
<div className="grid grid-cols-[1fr_auto] gap-3">
<div>
<label className="mb-1 block text-xs text-slate-400">Scene Title</label>
<input
type="text"
value={sceneEdit.title}
onChange={e => seField('title', e.target.value)}
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400">Type</label>
<select
value={sceneEdit.type}
onChange={e => seField('type', e.target.value)}
className="rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
>
{['intro','investigation','combat','social','puzzle','boss'].map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
</div>
{/* Description */}
<div>
<label className="mb-1 block text-xs text-slate-400">Scene Description / Briefing</label>
<textarea
value={sceneEdit.description}
onChange={e => seField('description', e.target.value)}
rows={4}
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/>
</div>
{/* Checks */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Checks</label>
<button onClick={addCheck} className="rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600">+ Add Check</button>
</div>
<div className="space-y-3">
{sceneEdit.checks.map((check, i) => (
<div key={i} className="rounded border border-slate-700 bg-slate-900/60 p-3 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={check.name}
onChange={e => seCheck(i, 'name', e.target.value)}
placeholder="Check name"
className="min-w-0 flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
/>
<button onClick={() => removeCheck(i)} className="shrink-0 rounded bg-rose-900/50 px-2 py-1 text-xs text-rose-300 hover:bg-rose-800/60">×</button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div>
<label className="mb-0.5 block text-xs text-slate-500">Skill</label>
<input type="text" value={check.skill || ''} onChange={e => seCheck(i, 'skill', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Char</label>
<input type="text" value={check.characteristic || ''} onChange={e => seCheck(i, 'characteristic', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Target</label>
<input type="number" value={check.target || 45} onChange={e => seCheck(i, 'target', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Modifier</label>
<input type="number" value={check.modifier || 0} onChange={e => seCheck(i, 'modifier', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Reward (shown after roll)</label>
<input type="text" value={check.reward || ''} onChange={e => seCheck(i, 'reward', e.target.value)} placeholder="What succeeding unlocks…" className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
</div>
))}
{sceneEdit.checks.length === 0 && (
<p className="text-xs text-slate-500">No checks defaults will be generated from scene type.</p>
)}
</div>
</div>
{/* Enemies */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Enemies</label>
<button onClick={addEnemy} className="rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600">+ Add Enemy</button>
</div>
<div className="space-y-3">
{sceneEdit.enemies.map((enemy, i) => (
<div key={i} className="rounded border border-slate-700 bg-slate-900/60 p-3 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={enemy.name || ''}
onChange={e => seEnemy(i, 'name', e.target.value)}
placeholder="Enemy name"
className="min-w-0 flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
/>
<button onClick={() => removeEnemy(i)} className="shrink-0 rounded bg-rose-900/50 px-2 py-1 text-xs text-rose-300 hover:bg-rose-800/60">×</button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div>
<label className="mb-0.5 block text-xs text-slate-500">Wounds</label>
<input type="number" value={enemy.wounds || 10} onChange={e => seEnemy(i, 'wounds', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">BS</label>
<input type="number" value={enemy.bs || 45} onChange={e => seEnemy(i, 'bs', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">WS</label>
<input type="number" value={enemy.ws || 45} onChange={e => seEnemy(i, 'ws', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Damage</label>
<input type="text" value={enemy.damage || '1d10+4'} onChange={e => seEnemy(i, 'damage', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">AP (pen)</label>
<input type="number" value={enemy.ap || 0} onChange={e => seEnemy(i, 'ap', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
</div>
</div>
))}
{sceneEdit.enemies.length === 0 && (
<p className="text-xs text-slate-500">No enemies defined defaults generated from scene index.</p>
)}
</div>
</div>
{/* Objectives */}
<div>
<label className="mb-1 block text-xs text-slate-400">Objectives (one per line)</label>
<textarea
value={sceneEdit.objectives}
onChange={e => seField('objectives', e.target.value)}
rows={3}
placeholder="Secure the perimeter&#10;Locate the gene-code terminal&#10;…"
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-600"
/>
</div>
{/* Complications */}
<div>
<label className="mb-1 block text-xs text-slate-400">Complications / Pressure (one per line)</label>
<textarea
value={sceneEdit.complications}
onChange={e => seField('complications', e.target.value)}
rows={3}
placeholder="Spore mines trigger on failed stealth&#10;Reinforcements arrive round 3&#10;…"
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-600"
/>
</div>
<button
onClick={saveSceneEdit}
disabled={saving}
className="w-full rounded bg-amber-700 px-3 py-2 text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
</div>
)}
</div>
)}
</div>
{/* Scene Editor — full width, below the two-column grid */}
{isGM && editingScene && sceneEdit && (
<div className="w-full min-w-0 rounded-lg border border-amber-600/40 bg-amber-950/10 p-4 sm:p-5 space-y-5 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-semibold text-amber-200">Edit Scene {sceneEdit.title || 'Untitled'}</h3>
<div className="flex gap-2">
<button
onClick={saveSceneEdit}
disabled={saving}
className="rounded bg-amber-700 px-3 py-1.5 text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
<button
onClick={() => { setEditingScene(false); setSceneEdit(null); }}
className="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
>
Cancel
</button>
</div>
</div>
{/* Title + Type + Description — 3-col on wide screens */}
<div className="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,2fr)]">
<div className="min-w-0">
<label className="mb-1 block text-xs text-slate-400">Scene Title</label>
<input
type="text"
value={sceneEdit.title}
onChange={e => seField('title', e.target.value)}
className="w-full min-w-0 rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/>
</div>
<div className="min-w-0">
<label className="mb-1 block text-xs text-slate-400">Type</label>
<select
value={sceneEdit.type}
onChange={e => seField('type', e.target.value)}
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
>
{['intro','investigation','combat','social','puzzle','boss'].map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div className="min-w-0">
<label className="mb-1 block text-xs text-slate-400">Scene Description / Briefing</label>
<textarea
value={sceneEdit.description}
onChange={e => seField('description', e.target.value)}
rows={3}
className="w-full min-w-0 rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100"
/>
</div>
</div>
{/* Checks + Enemies — side by side on wide screens */}
<div className="grid min-w-0 gap-4 xl:grid-cols-2">
{/* Checks */}
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Checks</label>
<button onClick={addCheck} className="rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600">+ Add</button>
</div>
<div className="space-y-3">
{sceneEdit.checks.map((check, i) => (
<div key={i} className="min-w-0 rounded border border-slate-700 bg-slate-900/60 p-3 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={check.name}
onChange={e => seCheck(i, 'name', e.target.value)}
placeholder="Check name"
className="min-w-0 flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
/>
<button onClick={() => removeCheck(i)} className="shrink-0 rounded bg-rose-900/50 px-2 py-1 text-xs text-rose-300 hover:bg-rose-800/60">×</button>
</div>
<div className="grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-4">
<div>
<label className="mb-0.5 block text-xs text-slate-500">Skill</label>
<input type="text" value={check.skill || ''} onChange={e => seCheck(i, 'skill', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Char</label>
<input type="text" value={check.characteristic || ''} onChange={e => seCheck(i, 'characteristic', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Target</label>
<input type="number" value={check.target || 45} onChange={e => seCheck(i, 'target', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Mod</label>
<input type="number" value={check.modifier || 0} onChange={e => seCheck(i, 'modifier', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Reward (shown after roll)</label>
<input type="text" value={check.reward || ''} onChange={e => seCheck(i, 'reward', e.target.value)} placeholder="What succeeding unlocks…" className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
</div>
))}
{sceneEdit.checks.length === 0 && (
<p className="text-xs text-slate-500">No checks defaults generated from scene type.</p>
)}
</div>
</div>
{/* Enemies */}
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Enemies</label>
<button onClick={addEnemy} className="rounded bg-slate-700 px-2 py-1 text-xs hover:bg-slate-600">+ Add</button>
</div>
<div className="space-y-3">
{sceneEdit.enemies.map((enemy, i) => (
<div key={i} className="min-w-0 rounded border border-slate-700 bg-slate-900/60 p-3 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={enemy.name || ''}
onChange={e => seEnemy(i, 'name', e.target.value)}
placeholder="Enemy name"
className="min-w-0 flex-1 rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
/>
<button onClick={() => removeEnemy(i)} className="shrink-0 rounded bg-rose-900/50 px-2 py-1 text-xs text-rose-300 hover:bg-rose-800/60">×</button>
</div>
<div className="grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-5">
<div>
<label className="mb-0.5 block text-xs text-slate-500">Wounds</label>
<input type="number" value={enemy.wounds || 10} onChange={e => seEnemy(i, 'wounds', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">BS</label>
<input type="number" value={enemy.bs || 45} onChange={e => seEnemy(i, 'bs', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">WS</label>
<input type="number" value={enemy.ws || 45} onChange={e => seEnemy(i, 'ws', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">Damage</label>
<input type="text" value={enemy.damage || '1d10+4'} onChange={e => seEnemy(i, 'damage', e.target.value)} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
<div>
<label className="mb-0.5 block text-xs text-slate-500">AP</label>
<input type="number" value={enemy.ap || 0} onChange={e => seEnemy(i, 'ap', Number(e.target.value))} className="w-full rounded border border-slate-600 bg-slate-900 px-2 py-1 text-xs text-slate-100" />
</div>
</div>
</div>
))}
{sceneEdit.enemies.length === 0 && (
<p className="text-xs text-slate-500">No enemies defaults generated from scene index.</p>
)}
</div>
</div>
</div>
{/* Objectives + Complications — side by side */}
<div className="grid min-w-0 gap-4 lg:grid-cols-2">
<div className="min-w-0">
<label className="mb-1 block text-xs text-slate-400">Objectives (one per line)</label>
<textarea
value={sceneEdit.objectives}
onChange={e => seField('objectives', e.target.value)}
rows={3}
placeholder="Secure the perimeter&#10;Locate the gene-code terminal&#10;…"
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-600"
/>
</div>
<div className="min-w-0">
<label className="mb-1 block text-xs text-slate-400">Complications / Pressure (one per line)</label>
<textarea
value={sceneEdit.complications}
onChange={e => seField('complications', e.target.value)}
rows={3}
placeholder="Spore mines trigger on failed stealth&#10;Reinforcements arrive round 3&#10;…"
className="w-full rounded border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-600"
/>
</div>
</div>
<button
onClick={saveSceneEdit}
disabled={saving}
className="w-full rounded bg-amber-700 px-3 py-2 text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
</div>
)}
{/* #1 — GM Roll Feed (full, with delete) */}
{isGM && (
<div className="rounded-lg border border-slate-700 bg-slate-900 p-5">
+1 -1
View File
@@ -193,7 +193,7 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
return (
<div className="rounded-2xl bg-slate-800 border border-slate-700 p-6">
<h2 className="text-2xl font-bold mb-3">Requisition Shop</h2>
<p className="text-amber-300">Log in to access the shop.</p>
<p className="text-amber-300">Please log in to access the shop.</p>
</div>
)
}
+75 -22
View File
@@ -41,21 +41,37 @@ function RulesTab({ authedPlayer, sessionId }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Popular/Quick access rules
// Popular/Quick access rules tuned for live play
const quickRules = [
'Attack Roll',
'Damage',
'Dodge',
'Parry',
'Full Auto',
'Semi Auto',
'Single Shot',
'Power Armor',
'Full Auto Burst',
'Semi-Auto Burst',
'Suppressing Fire',
'Overwatch',
'Pinning',
'Righteous Fury',
'Critical Damage',
'Hordes',
'Fear',
'Cohesion Damage',
'Maintaining Squad Mode',
'Requisition',
'Renown',
'Squad Mode',
'Cohesion',
'Hordes'
'Renown'
];
const midgameCategories = [
{ id: 'actions', name: 'Actions' },
{ id: 'combat', name: 'Combat' },
{ id: 'damage', name: 'Damage' },
{ id: 'conditions', name: 'Conditions' },
{ id: 'hordes', name: 'Hordes' },
{ id: 'psychic', name: 'Psychic' },
{ id: 'skills', name: 'Skills' },
{ id: 'talents', name: 'Talents' },
{ id: 'squad_mode', name: 'Squad/Cohesion' },
{ id: 'requisition', name: 'Req/Renown' },
{ id: 'gm_tables', name: 'GM Tables' }
];
useEffect(() => {
@@ -90,7 +106,8 @@ function RulesTab({ authedPlayer, sessionId }) {
const params = new URLSearchParams({
q: query.trim(),
category: category !== 'all' ? category : '',
limit: '20'
midgame: 'true',
limit: '30'
});
const response = await axios.get(`/api/rules/search?${params}`, {
@@ -158,7 +175,7 @@ function RulesTab({ authedPlayer, sessionId }) {
const fetchRandom = async (category, count = browseCount) => {
setLoading(true);
try {
const params = new URLSearchParams({ count: String(count), category: category !== 'all' ? category : '' });
const params = new URLSearchParams({ count: String(count), category: category !== 'all' ? category : '', midgame: 'true' });
const resp = await axios.get(`/api/rules/random?${params}`, { headers: buildHeaders() });
if (resp && Array.isArray(resp.data)) setSearchResults(resp.data);
} catch (e) {
@@ -221,10 +238,10 @@ function RulesTab({ authedPlayer, sessionId }) {
<button
type="button"
onClick={async () => {
setLoading(true);
try {
const params = new URLSearchParams({ count: '20', category: selectedCategory !== 'all' ? selectedCategory : '' });
const resp = await axios.get(`/api/rules/random?${params}`, { headers: buildHeaders() });
setLoading(true);
try {
const params = new URLSearchParams({ count: '20', category: selectedCategory !== 'all' ? selectedCategory : '', midgame: 'true' });
const resp = await axios.get(`/api/rules/random?${params}`, { headers: buildHeaders() });
if (resp && Array.isArray(resp.data)) setSearchResults(resp.data);
} catch (e) {
warn('Browse failed', e && e.message);
@@ -242,7 +259,7 @@ function RulesTab({ authedPlayer, sessionId }) {
{/* Quick Rules */}
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3">Quick Reference</h3>
<h3 className="text-lg font-semibold mb-3">Midgame Reference</h3>
<div className="flex flex-wrap gap-2">
{quickRules.map(rule => (
<button
@@ -256,6 +273,24 @@ function RulesTab({ authedPlayer, sessionId }) {
</div>
</div>
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3">Table Situations</h3>
<div className="flex flex-wrap gap-2">
{midgameCategories.map(cat => (
<button
key={cat.id}
onClick={async () => {
setSelectedCategory(cat.id);
await fetchRandom(cat.id, 12);
}}
className="px-3 py-1 text-sm bg-slate-700 hover:bg-slate-600 rounded-md border border-slate-500 transition-colors"
>
{cat.name}
</button>
))}
</div>
</div>
{/* Category Buttons - quick browse */}
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3">Browse by Category</h3>
@@ -319,15 +354,24 @@ function RulesTab({ authedPlayer, sessionId }) {
<span className="px-2 py-1 bg-green-700 rounded">p.{rule.page}</span>
)}
{rule.source && (
<span className="px-2 py-1 bg-purple-700 rounded">{rule.source}</span>
<span className="px-2 py-1 bg-purple-700 rounded">{rule.sourceAbbr || rule.source}</span>
)}
{rule.sourceMethod && (
<span className="px-2 py-1 bg-slate-700 rounded">{rule.sourceMethod}</span>
)}
{rule.confidence != null && (
<span className="px-2 py-1 bg-slate-700 rounded">{Math.round(Number(rule.confidence) * 100)}%</span>
)}
</div>
</div>
{rule.summary && (
<p className="text-slate-200 text-sm mb-2">{rule.summary}</p>
)}
<div
className="text-slate-300 text-sm"
dangerouslySetInnerHTML={{
__html: highlightText(rule.content?.substring(0, 200) + (rule.content?.length > 200 ? '...' : ''), searchQuery)
__html: highlightText(rule.content?.substring(0, 220) + (rule.content?.length > 220 ? '...' : ''), searchQuery)
}}
/>
</div>
@@ -369,12 +413,21 @@ function RulesTab({ authedPlayer, sessionId }) {
<span className="px-2 py-1 bg-blue-700 rounded">{selectedRule.category}</span>
)}
{selectedRule.page && (
<span className="px-2 py-1 bg-green-700 rounded">Page {selectedRule.page}</span>
<span className="px-2 py-1 bg-green-700 rounded">Page {selectedRule.page}{selectedRule.pageEnd && selectedRule.pageEnd !== selectedRule.page ? `-${selectedRule.pageEnd}` : ''}</span>
)}
{selectedRule.source && (
<span className="px-2 py-1 bg-purple-700 rounded">{selectedRule.source}</span>
<span className="px-2 py-1 bg-purple-700 rounded">{selectedRule.sourceAbbr || selectedRule.source}</span>
)}
{selectedRule.sourceMethod && (
<span className="px-2 py-1 bg-slate-700 rounded">{selectedRule.sourceMethod}</span>
)}
{selectedRule.confidence != null && (
<span className="px-2 py-1 bg-slate-700 rounded">Confidence {Math.round(Number(selectedRule.confidence) * 100)}%</span>
)}
</div>
{selectedRule.summary && (
<p className="mb-4 text-slate-100">{selectedRule.summary}</p>
)}
<div
className="text-slate-200 whitespace-pre-wrap"
+12 -11
View File
@@ -210,7 +210,7 @@ describe('Issue #1 — Players see each other rolls in roll feed', () => {
render(<MissionTab authedPlayer="christoffer" />);
// Scene Roll Feed shows scene_index 0 rolls for scene 0
await waitFor(() => {
expect(screen.getByText(/Analyse the briefing slate/)).toBeInTheDocument();
expect(screen.getAllByText(/Analyse the briefing slate/).length).toBeGreaterThan(0);
});
// anders' roll visible to christoffer
expect(screen.getByText(/anders/)).toBeInTheDocument();
@@ -253,14 +253,14 @@ describe('Issue #2 — Scene revealed gate', () => {
test('players see scene text when scene.revealed is true', async () => {
mockAxiosForPlayer(scene1);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText(scene1.description)).toBeInTheDocument());
await waitFor(() => expect(screen.getAllByText(scene1.description).length).toBeGreaterThan(0));
expect(screen.queryByText(/Awaiting GM scene briefing/i)).not.toBeInTheDocument();
});
test('GM always sees scene text regardless of revealed flag', async () => {
mockAxiosForGM([scene1Hidden]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText(scene1.description)).toBeInTheDocument());
await waitFor(() => expect(screen.getAllByText(scene1.description).length).toBeGreaterThan(0));
});
test('GM can see Scene Secrets reveal toggle', async () => {
@@ -304,8 +304,8 @@ describe('Issue #3 — GM can add NPC to initiative', () => {
mockAxiosForGM([sceneWithNpc]);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
expect(screen.getByText('NPC')).toBeInTheDocument();
expect(screen.getByText('Gaunt Alpha')).toBeInTheDocument();
expect(screen.getAllByText('NPC').length).toBeGreaterThan(0);
expect(screen.getAllByText('Gaunt Alpha').length).toBeGreaterThan(0);
});
test('players also see NPC entries in the initiative list', async () => {
@@ -323,7 +323,7 @@ describe('Issue #3 — GM can add NPC to initiative', () => {
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Initiative Order')).toBeInTheDocument());
expect(screen.getByText('Corrupted Magos')).toBeInTheDocument();
expect(screen.getByText('NPC')).toBeInTheDocument();
expect(screen.getAllByText('NPC').length).toBeGreaterThan(0);
});
});
@@ -370,7 +370,7 @@ describe('Issue #5 — Check reward hidden before rolling', () => {
test('reward text is not visible when check is open but unrolled', async () => {
mockAxiosForPlayer(scene2WithCheck);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Spot the tunnel breach')).toBeInTheDocument());
await waitFor(() => expect(screen.getAllByText('Spot the tunnel breach').length).toBeGreaterThan(0));
// Reward should be hidden before rolling
expect(screen.queryByText('Act before the first wave.')).not.toBeInTheDocument();
});
@@ -390,7 +390,7 @@ describe('Issue #6 — Check assignment', () => {
mockAxiosForPlayer(scene2AssignedCheck);
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText(/Assigned to/i)).toBeInTheDocument());
expect(screen.getByText(/anders/i)).toBeInTheDocument();
expect(screen.getAllByText(/anders/i).length).toBeGreaterThan(0);
expect(screen.getByText('Roll Check')).toBeInTheDocument();
});
@@ -526,7 +526,7 @@ describe('Issue #9 — Fate point re-roll', () => {
render(<MissionTab authedPlayer="anders" />);
await waitFor(() => expect(screen.getByText('Declare')).toBeInTheDocument());
const textarea = screen.getByPlaceholderText(/State your Battle-Brother/i);
fe.type(textarea, 'We spare him for study.');
fe.change(textarea, { target: { value: 'We spare him for study.' } });
await waitFor(() => expect(screen.getByText('Declare')).not.toBeDisabled());
fe.click(screen.getByText('Declare'));
// No fate button for decisions
@@ -647,14 +647,15 @@ describe('Finding #1 — Scene activation initialises revealed:false', () => {
];
mockAxiosForGM(unrevealedScenes);
render(<MissionTab authedPlayer="gm" />);
await waitFor(() => expect(screen.getByText('Set Active Mission')).toBeInTheDocument());
const setActiveButton = await screen.findByText('Set Active Mission');
await waitFor(() => expect(setActiveButton).not.toBeDisabled());
axios.post.mockResolvedValueOnce({
data: { mission: { ...activeMission, scenes: unrevealedScenes } },
});
axios.put.mockResolvedValue({ data: { mission: { ...activeMission, scenes: unrevealedScenes } } });
fe.click(screen.getByText('Set Active Mission'));
fe.click(setActiveButton);
await waitFor(() => {
// saveProgress should have been called to initialise revealed:false on all scenes
+60
View File
@@ -0,0 +1,60 @@
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const rulesPath = path.join(repoRoot, 'database', 'rules', 'rules-database.json');
describe('mid-play rules database', () => {
const db = JSON.parse(fs.readFileSync(rulesPath, 'utf8'));
const rules = db.rules || [];
test('contains required live-play entries with provenance', () => {
const required = [
'Dodge',
'Parry',
'Full Auto Burst',
'Semi-Auto Burst',
'Suppressing Fire',
'Overwatch',
'Pinning',
'Righteous Fury',
'Critical Damage',
'Hordes',
'Cohesion Damage',
'Maintaining Squad Mode',
'Requisition',
'Renown',
'Fear'
];
for (const title of required) {
const rule = rules.find(r => r.title === title);
expect(rule).toBeTruthy();
expect(rule.sourceAbbr).toBeTruthy();
expect(rule.sourceMethod).toBeTruthy();
expect(Number(rule.confidence)).toBeGreaterThanOrEqual(0.7);
expect(Number(rule.midgamePriority)).toBeGreaterThanOrEqual(70);
}
});
test('uses the enriched schema needed by the UI', () => {
expect(db.metadata && db.metadata.builder).toBe('scripts/build-midgame-rules.py');
for (const rule of rules) {
expect(rule.rule_id || rule.id).toBeTruthy();
expect(rule.title).toBeTruthy();
expect(rule.summary).toBeDefined();
expect(Array.isArray(rule.tags)).toBe(true);
expect(Array.isArray(rule.aliases)).toBe(true);
expect(Array.isArray(rule.relatedRules)).toBe(true);
}
});
test('simulates GM and player lookups during play', () => {
const output = execFileSync('node', ['scripts/simulate-midplay-rules.js'], {
cwd: repoRoot,
encoding: 'utf8'
});
expect(output).toContain('All mid-play lookup scenarios passed.');
});
});