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,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.');
|
||||
Reference in New Issue
Block a user