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:
@@ -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",
|
||||
"Larraman’s Organ",
|
||||
"Catalepsean Node",
|
||||
"Preomnor",
|
||||
"Omophagea",
|
||||
"Multi-Lung",
|
||||
"Occulobe",
|
||||
"Lyman’s Ear",
|
||||
"Sus-an Membrane",
|
||||
"Oolitic Kidney",
|
||||
"Neuroglottis",
|
||||
"Mucranoid",
|
||||
"Betcher’s 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 Horde’s 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 Chaplain’s 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 Chapter’s 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
"Larraman’s Organ",
|
||||
"Catalepsean Node",
|
||||
"Preomnor",
|
||||
"Omophagea",
|
||||
"Multi-Lung",
|
||||
"Occulobe",
|
||||
"Lyman’s Ear",
|
||||
"Sus-an Membrane",
|
||||
"Oolitic Kidney",
|
||||
"Neuroglottis",
|
||||
"Mucranoid",
|
||||
"Betcher’s 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
|
||||
+4285
-2189
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.');
|
||||
@@ -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
@@ -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 Locate the gene-code terminal …"
|
||||
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 Reinforcements arrive round 3 …"
|
||||
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 Locate the gene-code terminal …"
|
||||
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 Reinforcements arrive round 3 …"
|
||||
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">
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user