Initial commit: WH40K Points Comparator
- React + MUI DataGrid app with faction filter, search, change filter - Biggest movers cards (drops/rises) scoped to current filter view - Historical points graph modal (5 MFM versions: 1.14 → current) - URL state sync (faction, dir, q params — shareable URLs) - Grimdark favicon + OG embed image (Google Imagen) - Multi-stage Dockerfile (node build → nginx serve) - docker-compose.yml with Traefik + Cloudflare TLS - Data pipeline: build_deduped_data.py merges PDF + live scrape - Ynnari merged into Aeldari (shared codex) - Mobile responsive: flex columns, no fixed pixel widths - Color semantics: green=cheaper, red=costlier (consistent everywhere) - 1,449 units across 31 factions
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.env
|
||||
.DS_Store
|
||||
*.log
|
||||
# Don't commit the large live/pdf data directories — they're source data
|
||||
live/
|
||||
pdf/
|
||||
pdf23/
|
||||
pdf32/
|
||||
pdf114/
|
||||
# Keep the built data.json though (it's the merged output)
|
||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
||||
# ── Build stage: React app ──
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY react-app/package.json react-app/package-lock.json* ./
|
||||
RUN npm ci --silent || npm install --silent
|
||||
|
||||
COPY react-app/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime stage: nginx ──
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from build stage
|
||||
COPY --from=build /app/dist/ /usr/share/nginx/html/
|
||||
|
||||
# SPA-friendly nginx config with CORS headers for module scripts
|
||||
RUN printf 'server {\n\
|
||||
listen 80;\n\
|
||||
server_name _;\n\
|
||||
root /usr/share/nginx/html;\n\
|
||||
index index.html;\n\
|
||||
\n\
|
||||
gzip on;\n\
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;\n\
|
||||
gzip_min_length 256;\n\
|
||||
\n\
|
||||
# CORS + cache for static assets\n\
|
||||
location ~* \.(js|css|json|png|jpg|webp|svg|ico|woff2?)$ {\n\
|
||||
expires 1h;\n\
|
||||
add_header Cache-Control "public, max-age=3600";\n\
|
||||
add_header Access-Control-Allow-Origin "*" always;\n\
|
||||
}\n\
|
||||
\n\
|
||||
location / {\n\
|
||||
add_header Access-Control-Allow-Origin "*" always;\n\
|
||||
try_files $uri $uri/ /index.html;\n\
|
||||
}\n\
|
||||
}\n' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
97
README.md
Normal file
97
README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# WH40K Points Comparator
|
||||
|
||||
Compare Warhammer 40,000 unit points across Munitorum Field Manual versions.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-version comparison** — Track points changes across 5 MFM versions (1.14 → current)
|
||||
- **Faction filtering** — Browse by faction or view all 1,449 units
|
||||
- **Biggest movers** — Top 5 price drops and rises for the current view
|
||||
- **Historical graph** — Click any unit to see a points history chart across all MFM versions
|
||||
- **Shareable URLs** — Filter state is stored in URL query params (`?faction=...&dir=...&q=...`)
|
||||
- **Mobile responsive** — Flex-based layout that scales to any device
|
||||
- **Social embeds** — OG/Twitter meta tags with grimdark favicon and preview image
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React + MUI (Material UI DataGrid)
|
||||
- Vite build
|
||||
- nginx (Alpine) in Docker
|
||||
- Traefik reverse proxy with Cloudflare TLS
|
||||
|
||||
## Data Sources
|
||||
|
||||
- **Current MFM** — Scraped from warhammer-community.com (live data)
|
||||
- **MFM 4.3** (Jun 2026), **3.2** (Aug 2025), **2.3** (Mar 2025), **1.14** (Dec 2024) — Parsed from PDFs
|
||||
|
||||
## Deployment
|
||||
|
||||
### Using Docker Compose (recommended)
|
||||
|
||||
1. Edit `docker-compose.yml` — change the `Host()` rule to your domain
|
||||
2. Build and deploy:
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Without Traefik
|
||||
|
||||
Uncomment the `ports` section in `docker-compose.yml`:
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:80"
|
||||
```
|
||||
|
||||
Then access at `http://localhost:8080`.
|
||||
|
||||
### Building from scratch
|
||||
|
||||
The Dockerfile is multi-stage:
|
||||
1. **Build stage** — `node:20-alpine` installs deps and runs `npm run build`
|
||||
2. **Runtime stage** — `nginx:alpine` serves the static files
|
||||
|
||||
No pre-built artifacts needed — just `docker compose build`.
|
||||
|
||||
## Data Pipeline
|
||||
|
||||
```
|
||||
PDFs + live scrape → per-faction JSON files
|
||||
↓
|
||||
build_deduped_data.py
|
||||
↓
|
||||
react-app/public/data.json
|
||||
↓
|
||||
React app (fetched at runtime)
|
||||
```
|
||||
|
||||
Run `python3 build_deduped_data.py` to rebuild `data.json` from source data.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
wh40k-factions/
|
||||
├── Dockerfile # Multi-stage: node build → nginx serve
|
||||
├── docker-compose.yml # Traefik + Cloudflare TLS config
|
||||
├── build_deduped_data.py # Merges all MFM versions into data.json
|
||||
├── parse_pdf_per_faction.py # PDF → per-faction JSON (MFM 4.3)
|
||||
├── react-app/
|
||||
│ ├── index.html # OG meta tags, favicon
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.js
|
||||
│ ├── public/
|
||||
│ │ ├── data.json # Merged dataset (5 MFM versions)
|
||||
│ │ ├── favicon.png # Grimdark Aquila icon
|
||||
│ │ └── og-image.png # Social embed banner
|
||||
│ └── src/
|
||||
│ ├── main.jsx
|
||||
│ └── App.jsx # Main app: filters, movers, DataGrid, graph modal
|
||||
├── live/ # Current MFM scrape (per-faction JSON)
|
||||
├── pdf/ # MFM 4.3 PDF parse (per-faction JSON)
|
||||
├── pdf32/ # MFM 3.2 PDF parse
|
||||
├── pdf23/ # MFM 2.3 PDF parse
|
||||
└── pdf114/ # MFM 1.14 PDF parse
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
298
build_deduped_data.py
Normal file
298
build_deduped_data.py
Normal file
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Rebuild react-app/public/data.json with:
|
||||
1. Filter out weapon-upgrade rows (size starts with "per " or "+ ").
|
||||
2. Collapse each (faction, name) into ONE row, with a `sizes` array of
|
||||
{size, original, new, change_pct, change_pts, tier, history} variants.
|
||||
3. Only keep sizes that the MFM (new/live) actually listed.
|
||||
4. Fill missing originals by scaling proportionally to model count.
|
||||
5. Build a `history` array per size with {date, version, pts} from all 3 sources:
|
||||
- v3.2 PDF (Aug 20, 2025)
|
||||
- v4.3 PDF (Jun 5, 2026)
|
||||
- Live MFM (Jun 17, 2026)
|
||||
|
||||
The history data does NOT appear in the table — it's only used when the user
|
||||
clicks a unit name to open the graph modal.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
ROOT = Path("/root/wh40k-factions")
|
||||
PDF32_DIR = ROOT / "pdf32" # v3.2
|
||||
PDF_DIR = ROOT / "pdf" # v4.3
|
||||
LIVE_DIR = ROOT / "live" # current MFM
|
||||
OUT = ROOT / "react-app" / "public" / "data.json"
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
DP_RE = re.compile(r"\b\d+DP\b", re.IGNORECASE)
|
||||
DETACHMENT_TIERS = {"ENHANCEMENTS", "DETACHMENT"}
|
||||
UPGRADE_PREFIXES = ("per ", "+ ")
|
||||
|
||||
# Version metadata (oldest → newest)
|
||||
VERSIONS = [
|
||||
{"version": "1.14", "date": "2024-12-01", "label": "MFM 1.14", "dir": ROOT / "pdf114"},
|
||||
{"version": "2.3", "date": "2025-03-01", "label": "MFM 2.3", "dir": ROOT / "pdf23"},
|
||||
{"version": "3.2", "date": "2025-08-20", "label": "MFM 3.2", "dir": PDF32_DIR},
|
||||
{"version": "4.3", "date": "2026-06-05", "label": "MFM 4.3", "dir": PDF_DIR},
|
||||
{"version": "current", "date": "2026-06-17", "label": "MFM (current)", "dir": LIVE_DIR},
|
||||
]
|
||||
|
||||
|
||||
def norm_name(s: str) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
s = s.lower()
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
s = re.sub(r"[^a-z0-9 ]", "", s)
|
||||
return s
|
||||
|
||||
|
||||
def norm_size(s: str) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
s = s.lower().strip()
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
m = re.search(r"(\d+)\s*model", s)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
return f"{n} model{'s' if n != 1 else ''}"
|
||||
return s
|
||||
|
||||
|
||||
def is_upgrade_size(size: str) -> bool:
|
||||
s = (size or "").lower().strip()
|
||||
return s.startswith(UPGRADE_PREFIXES)
|
||||
|
||||
|
||||
def is_detachment_name(name: str) -> bool:
|
||||
return bool(DP_RE.search(name or ""))
|
||||
|
||||
|
||||
def is_detachment_tier(tier) -> bool:
|
||||
if not tier:
|
||||
return False
|
||||
return str(tier).upper() in DETACHMENT_TIERS
|
||||
|
||||
|
||||
def model_count(s):
|
||||
m = re.search(r"(\d+)", s)
|
||||
return int(m.group(1)) if m else 1
|
||||
|
||||
|
||||
def load_version(ver_info):
|
||||
"""Load all rows from a version's directory. Returns dict keyed by (slug, norm_name, norm_size) -> pts."""
|
||||
rows = {}
|
||||
slug_dir = ver_info["dir"]
|
||||
if not slug_dir.exists():
|
||||
return rows
|
||||
for path in sorted(slug_dir.glob("*.json")):
|
||||
if path.stem.startswith("_"):
|
||||
continue
|
||||
data = json.load(open(path))
|
||||
slug = data.get("slug", path.stem)
|
||||
# Ynnari shares the Aeldari codex — merge into aeldari
|
||||
if slug == "ynnari":
|
||||
slug = "aeldari"
|
||||
for unit, entries in data.get("units", {}).items():
|
||||
if is_detachment_name(unit):
|
||||
continue
|
||||
for e in entries:
|
||||
if ver_info["version"] == "current" and is_detachment_tier(e.get("tier")):
|
||||
continue
|
||||
size_disp = e.get("size", "")
|
||||
if is_upgrade_size(size_disp):
|
||||
continue
|
||||
size = norm_size(size_disp)
|
||||
if not size:
|
||||
continue
|
||||
k = (slug, norm_name(unit), size)
|
||||
pts = e.get("pts")
|
||||
if pts is not None:
|
||||
# Keep lowest pts if duplicates
|
||||
if k not in rows or pts < rows[k]:
|
||||
rows[k] = pts
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
# Load each version
|
||||
version_data = {}
|
||||
for ver in VERSIONS:
|
||||
rows = load_version(ver)
|
||||
version_data[ver["version"]] = rows
|
||||
print(f"{ver['label']}: {len(rows)} size-rows loaded")
|
||||
|
||||
# Use "current" (live) as the primary set of units/sizes
|
||||
# and "4.3" as the source of "original" (old codex) values
|
||||
live_rows = version_data.get("current", {})
|
||||
pdf43_rows = version_data.get("4.3", {})
|
||||
pdf32_rows = version_data.get("3.2", {})
|
||||
|
||||
# Also load faction names from live data
|
||||
faction_names = {}
|
||||
for path in sorted(LIVE_DIR.glob("*.json")):
|
||||
if path.stem.startswith("_"):
|
||||
continue
|
||||
data = json.load(open(path))
|
||||
slug = data.get("slug", path.stem)
|
||||
faction_names[slug] = data.get("name", slug)
|
||||
|
||||
# Group live rows by (slug, norm_name)
|
||||
groups = defaultdict(list)
|
||||
for (slug, name_norm, size), pts in live_rows.items():
|
||||
groups[(slug, name_norm)].append({"size": size, "new": pts})
|
||||
|
||||
# Also include PDF-only units (removed from MFM)
|
||||
for (slug, name_norm, size), pts in pdf43_rows.items():
|
||||
if (slug, name_norm, size) not in live_rows:
|
||||
groups[(slug, name_norm)].append({"size": size, "new": None, "original": pts})
|
||||
|
||||
out_units = []
|
||||
for (slug, name_norm), grp in groups.items():
|
||||
# Sort by numeric size
|
||||
grp.sort(key=lambda r: model_count(r["size"]))
|
||||
|
||||
# Deduplicate sizes (keep first occurrence)
|
||||
seen_sizes = set()
|
||||
unique = []
|
||||
for r in grp:
|
||||
if r["size"] not in seen_sizes:
|
||||
seen_sizes.add(r["size"])
|
||||
unique.append(r)
|
||||
grp = unique
|
||||
|
||||
# Only keep sizes that the MFM (new/live) actually listed
|
||||
mfm_sizes = [r for r in grp if r["new"] is not None]
|
||||
if not mfm_sizes:
|
||||
mfm_sizes = [grp[0]] # removed unit, keep one PDF entry
|
||||
|
||||
# Find base original (smallest size with a non-None original in 4.3 PDF)
|
||||
base_orig = None
|
||||
base_count = None
|
||||
for (s, n, sz), pts in pdf43_rows.items():
|
||||
if s == slug and n == name_norm:
|
||||
if base_orig is None or model_count(sz) < base_count:
|
||||
base_orig = pts
|
||||
base_count = model_count(sz)
|
||||
|
||||
# Fill missing originals on MFM sizes by scaling from base original
|
||||
for r in mfm_sizes:
|
||||
if r.get("original") is None:
|
||||
# Try exact match in 4.3 PDF first
|
||||
key = (slug, name_norm, r["size"])
|
||||
if key in pdf43_rows:
|
||||
r["original"] = pdf43_rows[key]
|
||||
elif base_orig is not None and base_count is not None:
|
||||
cnt = model_count(r["size"])
|
||||
if base_count > 0 and cnt > 0:
|
||||
r["original"] = round(base_orig * cnt / base_count)
|
||||
else:
|
||||
r["original"] = None
|
||||
|
||||
# Build sizes[] array with history
|
||||
sizes = []
|
||||
for r in mfm_sizes:
|
||||
o, n = r.get("original"), r["new"]
|
||||
change_pct = round((n - o) / o * 100, 2) if (o is not None and n is not None and o > 0) else None
|
||||
change_pts = (n - o) if (o is not None and n is not None) else None
|
||||
|
||||
# Build history for this size
|
||||
history = []
|
||||
for ver in VERSIONS:
|
||||
key = (slug, name_norm, r["size"])
|
||||
pts_map = version_data[ver["version"]]
|
||||
if key in pts_map:
|
||||
history.append({
|
||||
"date": ver["date"],
|
||||
"version": ver["label"],
|
||||
"pts": pts_map[key],
|
||||
})
|
||||
|
||||
sizes.append({
|
||||
"size": r["size"],
|
||||
"original": o,
|
||||
"new": n,
|
||||
"tier": None,
|
||||
"change_pct": change_pct,
|
||||
"change_pts": change_pts,
|
||||
"history": history,
|
||||
})
|
||||
|
||||
# default_size = smallest
|
||||
default = sizes[0]
|
||||
default_size = default["size"]
|
||||
|
||||
# Display name: try to find Title Case from PDF data
|
||||
display_name = name_norm.title()
|
||||
for path in sorted(PDF_DIR.glob("*.json")):
|
||||
if path.stem.startswith("_"):
|
||||
continue
|
||||
data = json.load(open(path))
|
||||
if data.get("slug") == slug:
|
||||
for unit in data.get("units", {}):
|
||||
if norm_name(unit) == name_norm:
|
||||
display_name = unit
|
||||
break
|
||||
break
|
||||
|
||||
faction_name = faction_names.get(slug, slug)
|
||||
|
||||
out_units.append({
|
||||
"faction": slug,
|
||||
"faction_name": faction_name,
|
||||
"name": display_name,
|
||||
"size": default["size"],
|
||||
"original": default["original"],
|
||||
"new": default["new"],
|
||||
"tier": default.get("tier"),
|
||||
"change_pct": default["change_pct"],
|
||||
"change_pts": default["change_pts"],
|
||||
"sizes": sizes,
|
||||
"default_size": default_size,
|
||||
})
|
||||
|
||||
# Stats
|
||||
has_both = sum(1 for u in out_units if u["original"] is not None and u["new"] is not None)
|
||||
only_pdf = sum(1 for u in out_units if u["original"] is not None and u["new"] is None)
|
||||
only_live = sum(1 for u in out_units if u["original"] is None and u["new"] is not None)
|
||||
pct_changes = [u["change_pct"] for u in out_units if u["change_pct"] is not None]
|
||||
pct_changes_sorted = sorted(pct_changes, key=lambda x: x)
|
||||
units_with_history = sum(1 for u in out_units if any(len(s.get("history", [])) > 1 for s in u["sizes"]))
|
||||
|
||||
payload = {
|
||||
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"versions": [{"date": v["date"], "label": v["label"]} for v in VERSIONS],
|
||||
"factions": sorted({u["faction"] for u in out_units}),
|
||||
"faction_names": {u["faction"]: u["faction_name"] for u in out_units},
|
||||
"stats": {
|
||||
"total_rows": len(out_units),
|
||||
"rows_with_both": has_both,
|
||||
"rows_pdf_only": only_pdf,
|
||||
"rows_live_only": only_live,
|
||||
"biggest_drop_pct": pct_changes_sorted[0] if pct_changes_sorted else None,
|
||||
"biggest_rise_pct": pct_changes_sorted[-1] if pct_changes_sorted else None,
|
||||
"multi_size": sum(1 for u in out_units if len(u["sizes"]) > 1),
|
||||
"units_with_history": units_with_history,
|
||||
},
|
||||
"units": out_units,
|
||||
}
|
||||
|
||||
OUT.write_text(json.dumps(payload, ensure_ascii=False))
|
||||
print(f"\nWrote {OUT}")
|
||||
print(f" total rows: {len(out_units)}")
|
||||
print(f" with both: {has_both}")
|
||||
print(f" PDF only: {only_pdf}")
|
||||
print(f" LIVE only: {only_live}")
|
||||
print(f" multi-size: {sum(1 for u in out_units if len(u['sizes']) > 1)}")
|
||||
print(f" with history: {units_with_history}")
|
||||
if pct_changes:
|
||||
print(f" biggest drop: {pct_changes_sorted[0]:.2f}%")
|
||||
print(f" biggest rise: {pct_changes_sorted[-1]:.2f}%")
|
||||
print(f" size: {OUT.stat().st_size / 1024:.1f} KB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
188
build_pdfs.py
Normal file
188
build_pdfs.py
Normal file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Visit each WH40K faction page slowly and print to PDF.
|
||||
|
||||
Uses the system chromium binary via Playwright so we don't need to download
|
||||
the bundled headless shell. Each page gets a polite delay, waits for
|
||||
content, then prints to PDF with background graphics enabled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
FACTIONS = [
|
||||
("adepta-sororitas", "Adepta Sororitas", "https://mfm.warhammer-community.com/en/adepta-sororitas"),
|
||||
("adeptus-custodes", "Adeptus Custodes", "https://mfm.warhammer-community.com/en/adeptus-custodes"),
|
||||
("adeptus-mechanicus", "Adeptus Mechanicus", "https://mfm.warhammer-community.com/en/adeptus-mechanicus"),
|
||||
("aeldari", "Aeldari", "https://mfm.warhammer-community.com/en/aeldari"),
|
||||
("astra-militarum", "Astra Militarum", "https://mfm.warhammer-community.com/en/astra-militarum"),
|
||||
("black-templars", "Black Templars", "https://mfm.warhammer-community.com/en/black-templars"),
|
||||
("blood-angels", "Blood Angels", "https://mfm.warhammer-community.com/en/blood-angels"),
|
||||
("chaos-daemons", "Chaos Daemons", "https://mfm.warhammer-community.com/en/chaos-daemons"),
|
||||
("chaos-knights", "Chaos Knights", "https://mfm.warhammer-community.com/en/chaos-knights"),
|
||||
("chaos-space-marines", "Chaos Space Marines", "https://mfm.warhammer-community.com/en/chaos-space-marines"),
|
||||
("chaos-titan-legions", "Chaos Titan Legions", "https://mfm.warhammer-community.com/en/chaos-titan-legions"),
|
||||
("dark-angels", "Dark Angels", "https://mfm.warhammer-community.com/en/dark-angels"),
|
||||
("death-guard", "Death Guard", "https://mfm.warhammer-community.com/en/death-guard"),
|
||||
("deathwatch", "Deathwatch", "https://mfm.warhammer-community.com/en/deathwatch"),
|
||||
("drukhari", "Drukhari", "https://mfm.warhammer-community.com/en/drukhari"),
|
||||
("emperors-children", "Emperor's Children", "https://mfm.warhammer-community.com/en/emperors-children"),
|
||||
("genestealer-cults", "Genestealer Cults", "https://mfm.warhammer-community.com/en/genestealer-cults"),
|
||||
("grey-knights", "Grey Knights", "https://mfm.warhammer-community.com/en/grey-knights"),
|
||||
("imperial-agents", "Imperial Agents", "https://mfm.warhammer-community.com/en/imperial-agents"),
|
||||
("imperial-knights", "Imperial Knights", "https://mfm.warhammer-community.com/en/imperial-knights"),
|
||||
("leagues-of-votann", "Leagues of Votann", "https://mfm.warhammer-community.com/en/leagues-of-votann"),
|
||||
("necrons", "Necrons", "https://mfm.warhammer-community.com/en/necrons"),
|
||||
("orks", "Orks", "https://mfm.warhammer-community.com/en/orks"),
|
||||
("space-marines", "Space Marines", "https://mfm.warhammer-community.com/en/space-marines"),
|
||||
("space-wolves", "Space Wolves", "https://mfm.warhammer-community.com/en/space-wolves"),
|
||||
("tau-empire", "T'au Empire", "https://mfm.warhammer-community.com/en/tau-empire"),
|
||||
("thousand-sons", "Thousand Sons", "https://mfm.warhammer-community.com/en/thousand-sons"),
|
||||
("titan-legions", "Titan Legions", "https://mfm.warhammer-community.com/en/titan-legions"),
|
||||
("tyranids", "Tyranids", "https://mfm.warhammer-community.com/en/tyranids"),
|
||||
("world-eaters", "World Eaters", "https://mfm.warhammer-community.com/en/world-eaters"),
|
||||
]
|
||||
|
||||
OUT_DIR = Path("/root/wh40k-factions/pdfs")
|
||||
LOG_DIR = Path("/root/wh40k-factions/logs")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CHROMIUM_BIN = "/usr/bin/chromium"
|
||||
|
||||
# Polite crawl pacing
|
||||
PAGE_DELAY_S = 3.0 # settle time after navigation
|
||||
NETWORK_IDLE_TIMEOUT_MS = 20000
|
||||
SLOW_LOAD_BUFFER_MS = 4000
|
||||
|
||||
|
||||
def slugify(s: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
|
||||
|
||||
|
||||
def fetch_one(p, slug: str, name: str, url: str, idx: int, total: int) -> dict:
|
||||
"""Visit a single URL, render it, save a PDF. Return a status dict."""
|
||||
pdf_path = OUT_DIR / f"{slug}.pdf"
|
||||
log_path = LOG_DIR / f"{slug}.log"
|
||||
status = {
|
||||
"slug": slug,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"pdf": str(pdf_path),
|
||||
"ok": False,
|
||||
"size_bytes": 0,
|
||||
"error": None,
|
||||
"elapsed_s": 0.0,
|
||||
}
|
||||
print(f"[{idx:>2}/{total}] {name} -> {url}", flush=True)
|
||||
start = time.time()
|
||||
browser = None
|
||||
try:
|
||||
context = p.chromium.launch_persistent_context(
|
||||
user_data_dir=f"/tmp/wh40k-chrome-{slug}",
|
||||
executable_path=CHROMIUM_BIN,
|
||||
headless=True,
|
||||
viewport={"width": 1280, "height": 1800},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
|
||||
),
|
||||
accept_downloads=False,
|
||||
ignore_https_errors=True,
|
||||
)
|
||||
page = context.new_page()
|
||||
# Quiet the page console
|
||||
page.on("pageerror", lambda exc: print(f" pageerror: {exc}", flush=True))
|
||||
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||
# Give the Next.js client-side hydration time to run, then wait for
|
||||
# network to settle (images, fonts, etc.).
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=NETWORK_IDLE_TIMEOUT_MS)
|
||||
except Exception as e:
|
||||
print(f" networkidle timeout (continuing): {e}", flush=True)
|
||||
|
||||
# Scroll to bottom to trigger lazy-loaded images / sections, then
|
||||
# back to top so the PDF starts at the header.
|
||||
page.evaluate(
|
||||
"""
|
||||
async () => {
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const total = document.body.scrollHeight;
|
||||
for (let y = 0; y <= total; y += 800) {
|
||||
window.scrollTo(0, y);
|
||||
await sleep(120);
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
await sleep(SLEEP);
|
||||
}
|
||||
""".replace("SLEEP", str(SLOW_LOAD_BUFFER_MS))
|
||||
)
|
||||
|
||||
# Final settle
|
||||
page.wait_for_timeout(int(SLOW_LOAD_BUFFER_MS))
|
||||
|
||||
page.pdf(
|
||||
path=str(pdf_path),
|
||||
format="A4",
|
||||
print_background=True,
|
||||
margin={"top": "10mm", "bottom": "10mm", "left": "10mm", "right": "10mm"},
|
||||
prefer_css_page_size=False,
|
||||
)
|
||||
|
||||
context.close()
|
||||
if pdf_path.exists() and pdf_path.stat().st_size > 1024:
|
||||
status["ok"] = True
|
||||
status["size_bytes"] = pdf_path.stat().st_size
|
||||
print(f" OK {pdf_path.name} ({status['size_bytes']/1024:.1f} KiB)", flush=True)
|
||||
else:
|
||||
status["error"] = "pdf missing or too small"
|
||||
print(f" FAIL {status['error']}", flush=True)
|
||||
except Exception as e:
|
||||
status["error"] = repr(e)
|
||||
print(f" FAIL {status['error']}", flush=True)
|
||||
finally:
|
||||
if browser:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
status["elapsed_s"] = round(time.time() - start, 2)
|
||||
log_path.write_text(json.dumps(status, indent=2))
|
||||
return status
|
||||
|
||||
|
||||
def main() -> int:
|
||||
results = []
|
||||
with sync_playwright() as p:
|
||||
for i, (slug, name, url) in enumerate(FACTIONS, 1):
|
||||
r = fetch_one(p, slug, name, url, i, len(FACTIONS))
|
||||
results.append(r)
|
||||
# Inter-page politeness delay (skip after last)
|
||||
if i < len(FACTIONS):
|
||||
time.sleep(PAGE_DELAY_S)
|
||||
|
||||
# Summary
|
||||
ok = sum(1 for r in results if r["ok"])
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"Done. {ok}/{len(results)} factions converted.")
|
||||
print("=" * 60)
|
||||
for r in results:
|
||||
flag = "OK" if r["ok"] else "FAIL"
|
||||
size = f"{r['size_bytes']/1024:.1f} KiB" if r["ok"] else r["error"]
|
||||
print(f" [{flag:>4}] {r['name']:<28} {size}")
|
||||
|
||||
(LOG_DIR / "_summary.json").write_text(json.dumps(results, indent=2))
|
||||
return 0 if ok == len(results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
203
build_site_data.py
Normal file
203
build_site_data.py
Normal file
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Merge per-faction PDF (original) + LIVE (new) data into a single
|
||||
client-loadable manifest. Skip detachment entries (names with DP suffix
|
||||
or ENHANCEMENTS tier). Compute % change. Write to:
|
||||
/root/wh40k-factions/site/data.json
|
||||
|
||||
Schema:
|
||||
{
|
||||
"generated_at": "...",
|
||||
"factions": ["adepta-sororitas", ...], # ordered list
|
||||
"units": [
|
||||
{
|
||||
"faction": "astra-militarum",
|
||||
"faction_name": "Astra Militarum",
|
||||
"name": "Valkyrie",
|
||||
"size": "1 model",
|
||||
"original": 190, # may be null if unit not in PDF
|
||||
"new": 170, # may be null if unit not in LIVE
|
||||
"tier": "YOUR 1ST TO 2ND UNITS COST", # may be null
|
||||
"change_pct": -10.53, # may be null
|
||||
"change_pts": -20
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path("/root/wh40k-factions")
|
||||
PDF_DIR = ROOT / "pdf"
|
||||
LIVE_DIR = ROOT / "live"
|
||||
OUT = ROOT / "site" / "data.json"
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Detachments: skip any unit name with DP suffix, and skip ENHANCEMENTS-tier rows.
|
||||
DP_RE = re.compile(r"\b\d+DP\b", re.IGNORECASE)
|
||||
DETACHMENT_TIERS = {"ENHANCEMENTS", "DETACHMENT"}
|
||||
|
||||
|
||||
def norm_name(s: str) -> str:
|
||||
"""Normalize unit name for cross-source matching.
|
||||
PDF has Title Case, LIVE has UPPERCASE — fold them together."""
|
||||
if not s:
|
||||
return ""
|
||||
s = s.lower()
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
s = re.sub(r"[^a-z0-9 ]", "", s)
|
||||
return s
|
||||
|
||||
|
||||
def norm_size(s: str) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
s = s.lower().strip()
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
m = re.search(r"(\d+)\s*model", s)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
return f"{n} model{'s' if n != 1 else ''}"
|
||||
return s
|
||||
|
||||
|
||||
def is_detachment_name(name: str) -> bool:
|
||||
return bool(DP_RE.search(name or ""))
|
||||
|
||||
|
||||
def is_detachment_tier(tier) -> bool:
|
||||
if not tier:
|
||||
return False
|
||||
return str(tier).upper() in DETACHMENT_TIERS
|
||||
|
||||
|
||||
def main():
|
||||
# Discover factions present in both dirs
|
||||
pdf_slugs = {p.stem for p in PDF_DIR.glob("*.json")
|
||||
if not p.stem.startswith("_")}
|
||||
live_slugs = {p.stem for p in LIVE_DIR.glob("*.json")
|
||||
if not p.stem.startswith("_")}
|
||||
slugs = sorted(pdf_slugs | live_slugs)
|
||||
print(f"PDF factions: {len(pdf_slugs)}")
|
||||
print(f"LIVE factions: {len(live_slugs)}")
|
||||
print(f"Total slugs: {len(slugs)}")
|
||||
|
||||
# Build a { (slug, name_norm, size_norm): {pdf_pts, live_pts, tier, name_display} }
|
||||
rows = {}
|
||||
|
||||
# PDF
|
||||
for slug in slugs:
|
||||
path = PDF_DIR / f"{slug}.json"
|
||||
if not path.exists():
|
||||
continue
|
||||
data = json.load(open(path))
|
||||
faction_name = data.get("name", slug)
|
||||
for unit, entries in data.get("units", {}).items():
|
||||
if is_detachment_name(unit):
|
||||
continue
|
||||
for e in entries:
|
||||
size = norm_size(e.get("size"))
|
||||
if not size:
|
||||
continue
|
||||
k = (slug, norm_name(unit), size)
|
||||
rec = rows.setdefault(k, {
|
||||
"faction": slug,
|
||||
"faction_name": faction_name,
|
||||
"name": unit,
|
||||
"size": e.get("size", size), # keep display form
|
||||
"original": None,
|
||||
"new": None,
|
||||
"tier": None,
|
||||
})
|
||||
# PDF may have multiple rows per (unit, size) — take the lowest as "base"
|
||||
pts = e.get("pts")
|
||||
if pts is not None:
|
||||
if rec["original"] is None or pts < rec["original"]:
|
||||
rec["original"] = pts
|
||||
|
||||
# LIVE
|
||||
for slug in slugs:
|
||||
path = LIVE_DIR / f"{slug}.json"
|
||||
if not path.exists():
|
||||
continue
|
||||
data = json.load(open(path))
|
||||
faction_name = data.get("name", slug)
|
||||
for unit, entries in data.get("units", {}).items():
|
||||
if is_detachment_name(unit):
|
||||
continue
|
||||
for e in entries:
|
||||
if is_detachment_tier(e.get("tier")):
|
||||
continue
|
||||
size = norm_size(e.get("size"))
|
||||
if not size:
|
||||
continue
|
||||
k = (slug, norm_name(unit), size)
|
||||
rec = rows.setdefault(k, {
|
||||
"faction": slug,
|
||||
"faction_name": faction_name,
|
||||
"name": unit,
|
||||
"size": e.get("size", size),
|
||||
"original": None,
|
||||
"new": None,
|
||||
"tier": None,
|
||||
})
|
||||
pts = e.get("pts")
|
||||
if pts is not None:
|
||||
if rec["new"] is None or pts < rec["new"]:
|
||||
rec["new"] = pts
|
||||
# Use the cheapest tier as the "primary" tier label
|
||||
tier = e.get("tier")
|
||||
if tier and not rec["tier"]:
|
||||
rec["tier"] = tier
|
||||
|
||||
# Compute change
|
||||
out_units = []
|
||||
for k, r in rows.items():
|
||||
o, n = r["original"], r["new"]
|
||||
if o is not None and n is not None and o > 0:
|
||||
r["change_pct"] = round((n - o) / o * 100, 2)
|
||||
r["change_pts"] = n - o
|
||||
else:
|
||||
r["change_pct"] = None
|
||||
r["change_pts"] = None
|
||||
out_units.append(r)
|
||||
|
||||
# Stats
|
||||
has_both = sum(1 for u in out_units if u["original"] is not None and u["new"] is not None)
|
||||
only_pdf = sum(1 for u in out_units if u["original"] is not None and u["new"] is None)
|
||||
only_live = sum(1 for u in out_units if u["original"] is None and u["new"] is not None)
|
||||
pct_changes = [u["change_pct"] for u in out_units if u["change_pct"] is not None]
|
||||
pct_changes_sorted = sorted(pct_changes, key=lambda x: x)
|
||||
|
||||
payload = {
|
||||
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"factions": sorted({u["faction"] for u in out_units}),
|
||||
"faction_names": {u["faction"]: u["faction_name"] for u in out_units},
|
||||
"stats": {
|
||||
"total_rows": len(out_units),
|
||||
"rows_with_both": has_both,
|
||||
"rows_pdf_only": only_pdf,
|
||||
"rows_live_only": only_live,
|
||||
"biggest_drop_pct": pct_changes_sorted[0] if pct_changes_sorted else None,
|
||||
"biggest_rise_pct": pct_changes_sorted[-1] if pct_changes_sorted else None,
|
||||
},
|
||||
"units": out_units,
|
||||
}
|
||||
|
||||
OUT.write_text(json.dumps(payload, ensure_ascii=False))
|
||||
print(f"\nWrote {OUT}")
|
||||
print(f" total rows: {len(out_units)}")
|
||||
print(f" with both: {has_both}")
|
||||
print(f" PDF only: {only_pdf}")
|
||||
print(f" LIVE only: {only_live}")
|
||||
if pct_changes:
|
||||
print(f" biggest drop: {pct_changes_sorted[0]:.2f}%")
|
||||
print(f" biggest rise: {pct_changes_sorted[-1]:.2f}%")
|
||||
print(f" size: {OUT.stat().st_size / 1024:.1f} KB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1821
csv/_all_factions.csv
Normal file
1821
csv/_all_factions.csv
Normal file
File diff suppressed because it is too large
Load Diff
41
csv/adepta-sororitas.csv
Normal file
41
csv/adepta-sororitas.csv
Normal file
@@ -0,0 +1,41 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Adepta Sororitas,1 Mortifiers,70,70,"0,00%",,,
|
||||
Adepta Sororitas,1 Penitent Engines,75,70,"-6,67%",,,
|
||||
Adepta Sororitas,10 Arco-Flagellants,,140,,,,
|
||||
Adepta Sororitas,10 Celestian Sacresants,,150,,165,,
|
||||
Adepta Sororitas,10 Repentia Squad,,160,,,,
|
||||
Adepta Sororitas,10 Seraphim Squad,,160,,,170,
|
||||
Adepta Sororitas,10 Zephyrim Squad,,160,,,,
|
||||
Adepta Sororitas,2 Mortifiers,,130,,,,
|
||||
Adepta Sororitas,2 Penitent Engines,,140,,,,
|
||||
Adepta Sororitas,3 Arco-Flagellants,45,50,"11,11%",,,
|
||||
Adepta Sororitas,5 Celestian Sacresants,70,75,"7,14%",90,,
|
||||
Adepta Sororitas,5 Repentia Squad,75,75,"0,00%",,,
|
||||
Adepta Sororitas,5 Seraphim Squad,80,85,"6,25%",,95,
|
||||
Adepta Sororitas,5 Zephyrim Squad,80,80,"0,00%",,,
|
||||
Adepta Sororitas,AESTRED THURGA AND AGATHAE DOLAN,70,80,"14,29%",,,
|
||||
Adepta Sororitas,BATTLE SISTERS SQUAD,105,100,"-4,76%",,,
|
||||
Adepta Sororitas,CANONESS,60,60,"0,00%",,,
|
||||
Adepta Sororitas,CANONESS WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Adepta Sororitas,CASTIGATOR,160,165,"3,12%",,175,
|
||||
Adepta Sororitas,CELESTIAN INSIDIANTS,120,120,"0,00%",,,
|
||||
Adepta Sororitas,DAEMONIFUGE,85,85,"0,00%",,,
|
||||
Adepta Sororitas,DIALOGUS,40,40,"0,00%",,,
|
||||
Adepta Sororitas,DOGMATA,45,45,"0,00%",,,
|
||||
Adepta Sororitas,DOMINION SQUAD,120,100,"-16,67%",,110,
|
||||
Adepta Sororitas,EXORCIST,210,180,"-14,29%",220,,
|
||||
Adepta Sororitas,HOSPITALLER,60,75,"25,00%",85,,
|
||||
Adepta Sororitas,IMAGIFIER,65,65,"0,00%",,,
|
||||
Adepta Sororitas,IMMOLATOR,115,110,"-4,35%",,,120
|
||||
Adepta Sororitas,INTRANZIA FRAYE,150,150,"0,00%",,,
|
||||
Adepta Sororitas,JUNITH ERUITA,80,115,"43,75%",,,
|
||||
Adepta Sororitas,MINISTORUM PRIEST,50,50,"0,00%",,,
|
||||
Adepta Sororitas,MORVENN VAHL,185,185,"0,00%",,,
|
||||
Adepta Sororitas,PALATINE,50,50,"0,00%",,,
|
||||
Adepta Sororitas,PARAGON WARSUITS,210,180,"-14,29%",,190,
|
||||
Adepta Sororitas,RETRIBUTOR SQUAD,120,105,"-12,50%",,115,
|
||||
Adepta Sororitas,SAINT CELESTINE,150,150,"0,00%",,,
|
||||
Adepta Sororitas,SANCTIFIERS,110,110,"0,00%",,,
|
||||
Adepta Sororitas,SISTERS NOVITIATE SQUAD,100,100,"0,00%",,,
|
||||
Adepta Sororitas,SORORITAS RHINO,75,75,"0,00%",,,
|
||||
Adepta Sororitas,TRIUMPH OF SAINT KATHERINE,235,245,"4,26%",,,
|
||||
|
50
csv/adeptus-custodes.csv
Normal file
50
csv/adeptus-custodes.csv
Normal file
@@ -0,0 +1,50 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Adeptus Custodes,10 Prosecutors,,85,,,,
|
||||
Adeptus Custodes,10 Vigilators,,100,,,,
|
||||
Adeptus Custodes,10 Witchseekers,,100,,,,
|
||||
Adeptus Custodes,2 Allarus Custodians,110,110,"0,00%",,,
|
||||
Adeptus Custodes,2 Vertus Praetors,150,145,"-3,33%",,,
|
||||
Adeptus Custodes,3 Agamatus Custodians,225,225,"0,00%",,,
|
||||
Adeptus Custodes,3 Allarus Custodians,,165,,,,
|
||||
Adeptus Custodes,3 Aquilon Custodians,195,195,"0,00%",,,
|
||||
Adeptus Custodes,3 Venatari Custodians,165,160,"-3,03%",,,
|
||||
Adeptus Custodes,3 Vertus Praetors,,215,,,,
|
||||
Adeptus Custodes,4 Custodian Guard,160,170,"6,25%",,,180
|
||||
Adeptus Custodes,4 Custodian Wardens,210,210,"0,00%",230,,
|
||||
Adeptus Custodes,4 Prosecutors,40,45,"12,50%",,,
|
||||
Adeptus Custodes,4 Vigilators,45,50,"11,11%",,,
|
||||
Adeptus Custodes,4 Witchseekers,45,50,"11,11%",,,
|
||||
Adeptus Custodes,5 Allarus Custodians,,275,,,,
|
||||
Adeptus Custodes,5 Custodian Guard,,215,,,,225
|
||||
Adeptus Custodes,5 Custodian Wardens,,260,,280,,
|
||||
Adeptus Custodes,5 Prosecutors,,50,,,,
|
||||
Adeptus Custodes,5 Vigilators,,55,,,,
|
||||
Adeptus Custodes,5 Witchseekers,,55,,,,
|
||||
Adeptus Custodes,6 Agamatus Custodians,,450,,,,
|
||||
Adeptus Custodes,6 Allarus Custodians,,330,,,,
|
||||
Adeptus Custodes,6 Aquilon Custodians,,390,,,,
|
||||
Adeptus Custodes,6 Venatari Custodians,,320,,,,
|
||||
Adeptus Custodes,9 Prosecutors,,75,,,,
|
||||
Adeptus Custodes,9 Vigilators,,90,,,,
|
||||
Adeptus Custodes,9 Witchseekers,,90,,,,
|
||||
Adeptus Custodes,ALEYA,65,65,"0,00%",,,
|
||||
Adeptus Custodes,ANATHEMA PSYKANA RHINO,75,75,"0,00%",,,
|
||||
Adeptus Custodes,ARES GUNSHIP,580,580,"0,00%",610,,
|
||||
Adeptus Custodes,BLADE CHAMPION,120,110,"-8,33%",125,,
|
||||
Adeptus Custodes,CALADIUS GRAV-TANK,215,210,"-2,33%",,225,
|
||||
Adeptus Custodes,CONTEMPTOR-ACHILLUS DREADNOUGHT,155,155,"0,00%",,170,
|
||||
Adeptus Custodes,CONTEMPTOR-GALATUS DREADNOUGHT,165,165,"0,00%",,180,
|
||||
Adeptus Custodes,CORONUS GRAV-CARRIER,200,180,"-10,00%",,200,
|
||||
Adeptus Custodes,CUSTODIAN GUARD WITH ADRASITE AND PYRITHITE SPEARS,,250,,,,260
|
||||
Adeptus Custodes,KNIGHT-CENTURA,55,55,"0,00%",,,
|
||||
Adeptus Custodes,ORION ASSAULT DROPSHIP,690,690,"0,00%",740,,
|
||||
Adeptus Custodes,PALLAS GRAV-ATTACK,105,100,"-4,76%",,,
|
||||
Adeptus Custodes,SAGITTARUM CUSTODIANS,225,225,"0,00%",,,
|
||||
Adeptus Custodes,SHIELD-CAPTAIN,120,110,"-8,33%",,,
|
||||
Adeptus Custodes,SHIELD-CAPTAIN IN ALLARUS TERMINATOR ARMOUR,,130,,,,
|
||||
Adeptus Custodes,SHIELD-CAPTAIN ON DAWNEAGLE JETBIKE,150,140,"-6,67%",,,
|
||||
Adeptus Custodes,TELEMON HEAVY DREADNOUGHT,225,225,"0,00%",245,,
|
||||
Adeptus Custodes,TRAJANN VALORIS,140,135,"-3,57%",,,
|
||||
Adeptus Custodes,VALERIAN,110,110,"0,00%",,,
|
||||
Adeptus Custodes,VENERABLE CONTEMPTOR DREADNOUGHT,170,170,"0,00%",,185,
|
||||
Adeptus Custodes,VENERABLE LAND RAIDER,220,220,"0,00%",240,,
|
||||
|
52
csv/adeptus-mechanicus.csv
Normal file
52
csv/adeptus-mechanicus.csv
Normal file
@@ -0,0 +1,52 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Adeptus Mechanicus,1 Ironstrider Ballistarii,85,80,"-5,88%",,95,
|
||||
Adeptus Mechanicus,1 Sydonian Dragoons With Radium Jezzails,55,55,"0,00%",,,
|
||||
Adeptus Mechanicus,1 Sydonian Dragoons With Taser Lances,65,60,"-7,69%",,,
|
||||
Adeptus Mechanicus,10 Corpuscarii Electro-Priests,,130,,,,
|
||||
Adeptus Mechanicus,10 Fulgurite Electro-Priests,,140,,,,
|
||||
Adeptus Mechanicus,10 Pteraxii Skystalkers,,150,,,160,
|
||||
Adeptus Mechanicus,10 Pteraxii Sterylizors,,160,,,170,
|
||||
Adeptus Mechanicus,10 Sicarian Infiltrators,,155,,,165,
|
||||
Adeptus Mechanicus,10 Sicarian Ruststalkers,,160,,,170,
|
||||
Adeptus Mechanicus,2 Ironstrider Ballistarii,,160,,,175,
|
||||
Adeptus Mechanicus,2 Kastelan Robots,180,160,"-11,11%",180,,
|
||||
Adeptus Mechanicus,2 Sydonian Dragoons With Radium Jezzails,,100,,,,
|
||||
Adeptus Mechanicus,2 Sydonian Dragoons With Taser Lances,,120,,,,
|
||||
Adeptus Mechanicus,3 Ironstrider Ballistarii,,240,,,255,
|
||||
Adeptus Mechanicus,3 Kataphron Breachers,160,150,"-6,25%",,,
|
||||
Adeptus Mechanicus,3 Kataphron Destroyers,105,100,"-4,76%",,,
|
||||
Adeptus Mechanicus,3 Serberys Raiders,60,60,"0,00%",,,
|
||||
Adeptus Mechanicus,3 Serberys Sulphurhounds,55,55,"0,00%",,,
|
||||
Adeptus Mechanicus,3 Sydonian Dragoons With Radium Jezzails,,150,,,,
|
||||
Adeptus Mechanicus,3 Sydonian Dragoons With Taser Lances,,170,,,,
|
||||
Adeptus Mechanicus,4 Kastelan Robots,,320,,340,,
|
||||
Adeptus Mechanicus,5 Corpuscarii Electro-Priests,65,65,"0,00%",,,
|
||||
Adeptus Mechanicus,5 Fulgurite Electro-Priests,70,70,"0,00%",,,
|
||||
Adeptus Mechanicus,5 Pteraxii Skystalkers,75,80,"6,67%",,90,
|
||||
Adeptus Mechanicus,5 Pteraxii Sterylizors,80,80,"0,00%",,90,
|
||||
Adeptus Mechanicus,5 Sicarian Infiltrators,75,75,"0,00%",,85,
|
||||
Adeptus Mechanicus,5 Sicarian Ruststalkers,80,75,"-6,25%",,85,
|
||||
Adeptus Mechanicus,6 Kataphron Breachers,,320,,,,
|
||||
Adeptus Mechanicus,6 Kataphron Destroyers,,210,,,,
|
||||
Adeptus Mechanicus,6 Serberys Raiders,,120,,,,
|
||||
Adeptus Mechanicus,6 Serberys Sulphurhounds,,110,,,,
|
||||
Adeptus Mechanicus,ARCHAEOPTER FUSILAVE,160,160,"0,00%",,,
|
||||
Adeptus Mechanicus,ARCHAEOPTER STRATORAPTOR,185,185,"0,00%",,,
|
||||
Adeptus Mechanicus,ARCHAEOPTER TRANSVECTOR,150,145,"-3,33%",,,
|
||||
Adeptus Mechanicus,BELISARIUS CAWL,210,220,"4,76%",,,
|
||||
Adeptus Mechanicus,CYBERNETICA DATASMITH,35,25,"-28,57%",,,
|
||||
Adeptus Mechanicus,HASTARII EXTERMINATORS,135,115,"-14,81%",,130,
|
||||
Adeptus Mechanicus,HASTARII FUSILIERS,145,115,"-20,69%",,130,
|
||||
Adeptus Mechanicus,ONAGER DUNECRAWLER,155,155,"0,00%",,,
|
||||
Adeptus Mechanicus,SERVITOR BATTLECLADE,60,65,"8,33%",,,
|
||||
Adeptus Mechanicus,SKITARII MARSHAL,35,35,"0,00%",,,
|
||||
Adeptus Mechanicus,SKITARII RANGERS,85,85,"0,00%",,,
|
||||
Adeptus Mechanicus,SKITARII VANGUARD,95,90,"-5,26%",,,
|
||||
Adeptus Mechanicus,SKORPIUS DISINTEGRATOR,165,160,"-3,03%",,,
|
||||
Adeptus Mechanicus,SKORPIUS DUNERIDER,85,85,"0,00%",,,
|
||||
Adeptus Mechanicus,SYDONIAN SKATROS,50,50,"0,00%",,,
|
||||
Adeptus Mechanicus,TECH-PRIEST DOMINUS,65,65,"0,00%",,,
|
||||
Adeptus Mechanicus,TECH-PRIEST ENGINSEER,55,55,"0,00%",,,
|
||||
Adeptus Mechanicus,TECH-PRIEST MANIPULUS,60,60,"0,00%",,,
|
||||
Adeptus Mechanicus,TECHNOARCHEOLOGIST,45,45,"0,00%",,,
|
||||
Adeptus Mechanicus,THULIA GHULD,210,180,"-14,29%",,,
|
||||
|
98
csv/aeldari.csv
Normal file
98
csv/aeldari.csv
Normal file
@@ -0,0 +1,98 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Aeldari,1 Starfangs,,70,,,,
|
||||
Aeldari,1 Vyper,75,75,"0,00%",,,
|
||||
Aeldari,1 War Walkers,,85,,,,
|
||||
Aeldari,1 Warlock Skyrunners,45,45,"0,00%",,,
|
||||
Aeldari,10 Corsair Skyreavers,,150,,,160,
|
||||
Aeldari,10 Corsair Voidreavers,,110,,,,
|
||||
Aeldari,10 Corsair Voidscarred,,160,,,,
|
||||
Aeldari,10 Dark Reapers,,210,,,,
|
||||
Aeldari,10 Dire Avengers,,150,,,,
|
||||
Aeldari,10 Fire Dragons,,240,,,250,
|
||||
Aeldari,10 Howling Banshees,,165,,,,
|
||||
Aeldari,10 Rangers,,110,,,,
|
||||
Aeldari,10 Striking Scorpions,,145,,,,
|
||||
Aeldari,10 Swooping Hawks,,190,,,205,
|
||||
Aeldari,10 Warp Spiders,,200,,,215,
|
||||
Aeldari,10 Ynnari Incubi,,160,,,170,
|
||||
Aeldari,11 Troupe,,190,,,,
|
||||
Aeldari,12 Troupe,,205,,,,
|
||||
Aeldari,2 Skyweavers,95,105,"10,53%",,,
|
||||
Aeldari,2 Starfangs,,140,,,,
|
||||
Aeldari,2 Vyper,,140,,,,
|
||||
Aeldari,2 War Walkers,,170,,,,
|
||||
Aeldari,2 Warlock Conclave,55,55,"0,00%",,,
|
||||
Aeldari,2 Warlock Skyrunners,,90,,,,
|
||||
Aeldari,3 Shining Spears,110,105,"-4,55%",,,
|
||||
Aeldari,3 Shroud Runners,80,95,"18,75%",,,
|
||||
Aeldari,3 Windriders,80,80,"0,00%",,,
|
||||
Aeldari,3 Ynnari Reavers,,65,,,,
|
||||
Aeldari,4 Skyweavers,,210,,,,
|
||||
Aeldari,4 Warlock Conclave,,120,,,,
|
||||
Aeldari,5 Corsair Skyreavers,75,80,"6,67%",,90,
|
||||
Aeldari,5 Corsair Voidreavers,65,65,"0,00%",,,
|
||||
Aeldari,5 Corsair Voidscarred,80,80,"0,00%",,,
|
||||
Aeldari,5 Dark Reapers,90,100,"11,11%",,,
|
||||
Aeldari,5 Dire Avengers,75,75,"0,00%",,,
|
||||
Aeldari,5 Fire Dragons,120,120,"0,00%",,130,
|
||||
Aeldari,5 Howling Banshees,95,85,"-10,53%",,,
|
||||
Aeldari,5 Rangers,55,60,"9,09%",,,
|
||||
Aeldari,5 Striking Scorpions,85,75,"-11,76%",,,
|
||||
Aeldari,5 Swooping Hawks,95,100,"5,26%",,115,
|
||||
Aeldari,5 Troupe,85,85,"0,00%",,,
|
||||
Aeldari,5 Warp Spiders,105,115,"9,52%",,130,
|
||||
Aeldari,5 Ynnari Incubi,,80,,,90,
|
||||
Aeldari,6 Shining Spears,,210,,,,
|
||||
Aeldari,6 Shroud Runners,,175,,,,
|
||||
Aeldari,6 Troupe,,100,,,,
|
||||
Aeldari,6 Windriders,,160,,,,
|
||||
Aeldari,6 Ynnari Reavers,,120,,,,
|
||||
Aeldari,ASURMEN,135,135,"0,00%",,,
|
||||
Aeldari,AUTARCH,85,85,"0,00%",,,
|
||||
Aeldari,AUTARCH WAYLEAPER,80,80,"0,00%",,,
|
||||
Aeldari,AVATAR OF KHAINE,280,265,"-5,36%",,,
|
||||
Aeldari,BAHARROTH,115,125,"8,70%",,,
|
||||
Aeldari,CRIMSON HUNTER,160,160,"0,00%",,,
|
||||
Aeldari,D-CANNON PLATFORM,125,110,"-12,00%",125,,
|
||||
Aeldari,DEATH JESTER,90,80,"-11,11%",,,
|
||||
Aeldari,ELDRAD ULTHRAN,120,130,"8,33%",,,
|
||||
Aeldari,FALCON,130,130,"0,00%",,,
|
||||
Aeldari,FARSEER,70,70,"0,00%",,,
|
||||
Aeldari,FARSEER SKYRUNNER,80,70,"-12,50%",,,
|
||||
Aeldari,FIRE PRISM,150,150,"0,00%",,,
|
||||
Aeldari,FUEGAN,120,130,"8,33%",,,
|
||||
Aeldari,GUARDIAN DEFENDERS,100,90,"-10,00%",,,
|
||||
Aeldari,HEMLOCK WRAITHFIGHTER,155,155,"0,00%",,,
|
||||
Aeldari,JAIN ZAR,120,105,"-12,50%",,,
|
||||
Aeldari,KHARSETH,95,85,"-10,53%",,,
|
||||
Aeldari,LHYKHIS,135,135,"0,00%",,,
|
||||
Aeldari,MAUGAN RA,100,100,"0,00%",,,
|
||||
Aeldari,NIGHT SPINNER,190,170,"-10,53%",190,,
|
||||
Aeldari,PHANTOM TITAN,,2100,,,,
|
||||
Aeldari,PRINCE YRIEL,95,95,"0,00%",,,
|
||||
Aeldari,REVENANT TITAN,,1100,,,,
|
||||
Aeldari,SHADOW WEAVER PLATFORM,75,60,"-20,00%",,,
|
||||
Aeldari,SHADOWSEER,60,60,"0,00%",,,
|
||||
Aeldari,SOLITAIRE,115,115,"0,00%",,,
|
||||
Aeldari,SPIRITSEER,65,55,"-15,38%",,,
|
||||
Aeldari,STARWEAVER,80,80,"0,00%",,,
|
||||
Aeldari,STORM GUARDIANS,110,110,"0,00%",,,
|
||||
Aeldari,THE VISARCH,,90,,,,
|
||||
Aeldari,THE YNCARNE,,245,,,,
|
||||
Aeldari,TROUPE MASTER,75,75,"0,00%",,,
|
||||
Aeldari,VIBRO CANNON PLATFORM,60,60,"0,00%",,,
|
||||
Aeldari,VOIDWEAVER,125,115,"-8,00%",,,
|
||||
Aeldari,WARLOCK,45,45,"0,00%",,,
|
||||
Aeldari,WAVE SERPENT,125,125,"0,00%",,,
|
||||
Aeldari,WRAITHBLADES,150,140,"-6,67%",,,
|
||||
Aeldari,WRAITHGUARD,160,145,"-9,38%",,,
|
||||
Aeldari,WRAITHKNIGHT,435,415,"-4,60%",435,,
|
||||
Aeldari,WRAITHKNIGHT WITH GHOSTGLAIVE,420,405,"-3,57%",425,,
|
||||
Aeldari,WRAITHLORD,130,125,"-3,85%",,,
|
||||
Aeldari,YNNARI ARCHON,,85,,,,
|
||||
Aeldari,YNNARI KABALITE WARRIORS,,110,,,,
|
||||
Aeldari,YNNARI RAIDER,,80,,,,
|
||||
Aeldari,YNNARI SUCCUBUS,,45,,,,
|
||||
Aeldari,YNNARI VENOM,,70,,,,
|
||||
Aeldari,YNNARI WYCHES,,90,,,,
|
||||
Aeldari,YVRAINE,,100,,,,
|
||||
|
86
csv/astra-militarum.csv
Normal file
86
csv/astra-militarum.csv
Normal file
@@ -0,0 +1,86 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Astra Militarum,1 Armoured Sentinels,65,65,"0,00%",,,
|
||||
Astra Militarum,1 Hippogriff Afv,70,70,"0,00%",,,
|
||||
Astra Militarum,1 Scout Sentinels,55,55,"0,00%",,,
|
||||
Astra Militarum,10 Attilan Rough Riders,,120,,,125,
|
||||
Astra Militarum,10 Cadian Shock Troops,65,75,"15,38%",,,
|
||||
Astra Militarum,10 Catachan Jungle Fighters,65,75,"15,38%",,,
|
||||
Astra Militarum,10 Death Korps Of Krieg,65,75,"15,38%",,,
|
||||
Astra Militarum,10 Death Riders,,110,,,,
|
||||
Astra Militarum,10 Krieg Combat Engineers,,95,,,105,
|
||||
Astra Militarum,10 Ratlings,,100,,,,
|
||||
Astra Militarum,10 Tempestus Scions,,155,,,165,
|
||||
Astra Militarum,2 Armoured Sentinels,,120,,,,
|
||||
Astra Militarum,2 Hippogriff Afv,,140,,,,
|
||||
Astra Militarum,2 Scout Sentinels,,100,,,,
|
||||
Astra Militarum,20 Cadian Shock Troops,,145,,,,
|
||||
Astra Militarum,20 Catachan Jungle Fighters,,145,,,,
|
||||
Astra Militarum,20 Death Korps Of Krieg,,145,,,,
|
||||
Astra Militarum,3 Bullgryn Squad,100,90,"-10,00%",105,,
|
||||
Astra Militarum,3 Ogryn Squad,60,60,"0,00%",,,
|
||||
Astra Militarum,5 Attilan Rough Riders,60,60,"0,00%",,65,
|
||||
Astra Militarum,5 Death Riders,60,60,"0,00%",,,
|
||||
Astra Militarum,5 Krieg Combat Engineers,60,65,"8,33%",,75,
|
||||
Astra Militarum,5 Ratlings,60,60,"0,00%",,,
|
||||
Astra Militarum,5 Tempestus Scions,70,75,"7,14%",,85,
|
||||
Astra Militarum,6 Bullgryn Squad,,200,,215,,
|
||||
Astra Militarum,6 Ogryn Squad,,120,,,,
|
||||
Astra Militarum,AEGIS DEFENCE LINE,145,145,"0,00%",,,
|
||||
Astra Militarum,ARTILLERY TEAM,95,95,"0,00%",,,
|
||||
Astra Militarum,AVENGER STRIKE FIGHTER,,130,,,,
|
||||
Astra Militarum,BANEBLADE,450,450,"0,00%",470,,
|
||||
Astra Militarum,BANEHAMMER,420,420,"0,00%",440,,
|
||||
Astra Militarum,BANESWORD,450,450,"0,00%",470,,
|
||||
Astra Militarum,BASILISK,140,115,"-17,86%",135,,
|
||||
Astra Militarum,CADIAN CASTELLAN,55,55,"0,00%",,,
|
||||
Astra Militarum,CADIAN COMMAND SQUAD,65,65,"0,00%",,,
|
||||
Astra Militarum,CADIAN HEAVY WEAPONS SQUAD,65,65,"0,00%",,,
|
||||
Astra Militarum,CADIAN RECON SQUAD,80,80,"0,00%",,,
|
||||
Astra Militarum,CATACHAN COMMAND SQUAD,65,60,"-7,69%",,,
|
||||
Astra Militarum,CATACHAN HEAVY WEAPONS SQUAD,65,65,"0,00%",,,
|
||||
Astra Militarum,CENTAUR RSV,85,75,"-11,76%",,,
|
||||
Astra Militarum,CHIMERA,85,85,"0,00%",,,
|
||||
Astra Militarum,COMMISSAR,30,30,"0,00%",,,
|
||||
Astra Militarum,COMMISSAR GRAVES,110,125,"13,64%",,,
|
||||
Astra Militarum,COMMISSAR GRAVES ON FOOT,65,65,"0,00%",,,
|
||||
Astra Militarum,COMMISSAR YARRICK,150,130,"-13,33%",,,
|
||||
Astra Militarum,CYCLOPS DEMOLITION VEHICLE,,40,,,45,
|
||||
Astra Militarum,DEATHSTRIKE,145,125,"-13,79%",135,,
|
||||
Astra Militarum,DOOMHAMMER,415,415,"0,00%",435,,
|
||||
Astra Militarum,FIELD ORDNANCE BATTERY,110,90,"-18,18%",,,
|
||||
Astra Militarum,GAUNT’S GHOSTS,100,95,"-5,00%",,,
|
||||
Astra Militarum,HELLHAMMER,420,420,"0,00%",440,,
|
||||
Astra Militarum,HELLHOUND,125,125,"0,00%",,135,
|
||||
Astra Militarum,HYDRA,95,90,"-5,26%",,,
|
||||
Astra Militarum,KASRKIN,110,115,"4,55%",,125,
|
||||
Astra Militarum,KRIEG COMMAND SQUAD,65,65,"0,00%",,,
|
||||
Astra Militarum,KRIEG HEAVY WEAPONS SQUAD,75,60,"-20,00%",,,
|
||||
Astra Militarum,LEMAN RUSS BATTLE TANK,185,185,"0,00%",,195,
|
||||
Astra Militarum,LEMAN RUSS COMMANDER,235,235,"0,00%",,250,
|
||||
Astra Militarum,LEMAN RUSS DEMOLISHER,190,190,"0,00%",,200,
|
||||
Astra Militarum,LEMAN RUSS ERADICATOR,170,170,"0,00%",,180,
|
||||
Astra Militarum,LEMAN RUSS EXECUTIONER,170,170,"0,00%",,180,
|
||||
Astra Militarum,LEMAN RUSS EXTERMINATOR,180,180,"0,00%",,190,
|
||||
Astra Militarum,LEMAN RUSS PUNISHER,150,150,"0,00%",,160,
|
||||
Astra Militarum,LEMAN RUSS VANQUISHER,145,150,"3,45%",,160,
|
||||
Astra Militarum,LORD MARSHAL DREIR,100,75,"-25,00%",,,
|
||||
Astra Militarum,LORD SOLAR LEONTUS,130,130,"0,00%",,,
|
||||
Astra Militarum,MANTICORE,165,150,"-9,09%",170,,
|
||||
Astra Militarum,MILITARUM TEMPESTUS COMMAND SQUAD,85,85,"0,00%",95,,
|
||||
Astra Militarum,MINISTORUM PRIEST,35,35,"0,00%",,,
|
||||
Astra Militarum,NORK DEDDOG,60,60,"0,00%",,,
|
||||
Astra Militarum,OGRYN BODYGUARD,40,40,"0,00%",,,
|
||||
Astra Militarum,PRIMARIS PSYKER,60,60,"0,00%",,,
|
||||
Astra Militarum,ROGAL DORN BATTLE TANK,260,260,"0,00%",275,,
|
||||
Astra Militarum,ROGAL DORN COMMANDER,290,290,"0,00%",305,,
|
||||
Astra Militarum,SHADOWSWORD,410,410,"0,00%",430,,
|
||||
Astra Militarum,SLY MARBO,55,55,"0,00%",,,
|
||||
Astra Militarum,STORMLORD,430,430,"0,00%",450,,
|
||||
Astra Militarum,STORMSWORD,465,465,"0,00%",485,,
|
||||
Astra Militarum,TAUROX,75,75,"0,00%",,,
|
||||
Astra Militarum,TAUROX PRIME,90,85,"-5,56%",,,
|
||||
Astra Militarum,TECH-PRIEST ENGINSEER,45,45,"0,00%",,,
|
||||
Astra Militarum,TEMPESTUS AQUILONS,100,100,"0,00%",,,
|
||||
Astra Militarum,URSULA CREED,85,85,"0,00%",,,
|
||||
Astra Militarum,VALKYRIE,,170,,,180,
|
||||
Astra Militarum,WYVERN,,95,,115,,
|
||||
|
116
csv/black-templars.csv
Normal file
116
csv/black-templars.csv
Normal file
@@ -0,0 +1,116 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Black Templars,1 Firestrike Servo-Turrets,75,75,"0,00%",,,
|
||||
Black Templars,10 Assault Intercessor Squad,,150,,,,
|
||||
Black Templars,10 Assault Intercessors With Jump Packs,,160,,,170,
|
||||
Black Templars,10 Devastator Squad,,200,,,,
|
||||
Black Templars,10 Heavy Intercessor Squad,,200,,,,
|
||||
Black Templars,10 Hellblaster Squad,,220,,,,
|
||||
Black Templars,10 Incursor Squad,,150,,,160,
|
||||
Black Templars,10 Infernus Squad,,170,,,,
|
||||
Black Templars,10 Infiltrator Squad,,180,,,190,
|
||||
Black Templars,10 Intercessor Squad,,150,,,,
|
||||
Black Templars,10 Reiver Squad,,150,,,,
|
||||
Black Templars,10 Scout Squad,,120,,,130,
|
||||
Black Templars,10 Sternguard Veteran Squad,,160,,,,
|
||||
Black Templars,10 Sword Brethren Squad,,260,,,275,
|
||||
Black Templars,10 Terminator Assault Squad,,310,,,,
|
||||
Black Templars,10 Terminator Squad,,320,,,,
|
||||
Black Templars,10 Vanguard Veteran Squad With Jump Packs,,200,,,210,
|
||||
Black Templars,2 Firestrike Servo-Turrets,,150,,,,
|
||||
Black Templars,3 Aggressor Squad,95,90,"-5,26%",,100,
|
||||
Black Templars,3 Bladeguard Veteran Squad,80,80,"0,00%",,90,
|
||||
Black Templars,3 Centurion Assault Squad,150,150,"0,00%",,,
|
||||
Black Templars,3 Centurion Devastator Squad,175,175,"0,00%",,,
|
||||
Black Templars,3 Eradicator Squad,90,90,"0,00%",,100,
|
||||
Black Templars,3 Inceptor Squad,120,120,"0,00%",,135,
|
||||
Black Templars,3 Outrider Squad,80,70,"-12,50%",,,
|
||||
Black Templars,4 Sword Brethren Squad,,105,,,120,
|
||||
Black Templars,5 Assault Intercessor Squad,75,75,"0,00%",,,
|
||||
Black Templars,5 Assault Intercessors With Jump Packs,90,85,"-5,56%",,95,
|
||||
Black Templars,5 Devastator Squad,120,120,"0,00%",,,
|
||||
Black Templars,5 Heavy Intercessor Squad,100,100,"0,00%",,,
|
||||
Black Templars,5 Hellblaster Squad,110,110,"0,00%",,,
|
||||
Black Templars,5 Incursor Squad,80,85,"6,25%",,95,
|
||||
Black Templars,5 Infernus Squad,90,85,"-5,56%",,,
|
||||
Black Templars,5 Infiltrator Squad,100,110,"10,00%",,120,
|
||||
Black Templars,5 Intercessor Squad,80,80,"0,00%",,,
|
||||
Black Templars,5 Reiver Squad,80,75,"-6,25%",,,
|
||||
Black Templars,5 Scout Squad,70,65,"-7,14%",,75,
|
||||
Black Templars,5 Sternguard Veteran Squad,85,85,"0,00%",,,
|
||||
Black Templars,5 Sword Brethren Squad,,130,,,145,
|
||||
Black Templars,5 Terminator Assault Squad,180,155,"-13,89%",,,
|
||||
Black Templars,5 Terminator Squad,175,160,"-8,57%",,,
|
||||
Black Templars,5 Vanguard Veteran Squad With Jump Packs,100,100,"0,00%",,110,
|
||||
Black Templars,6 Aggressor Squad,,180,,,190,
|
||||
Black Templars,6 Bladeguard Veteran Squad,,160,,,170,
|
||||
Black Templars,6 Centurion Assault Squad,,300,,,,
|
||||
Black Templars,6 Centurion Devastator Squad,,350,,,,
|
||||
Black Templars,6 Eradicator Squad,,180,,,190,
|
||||
Black Templars,6 Inceptor Squad,,240,,,255,
|
||||
Black Templars,6 Outrider Squad,,140,,,,
|
||||
Black Templars,9 Sword Brethren Squad,,235,,,250,
|
||||
Black Templars,ANCIENT,50,40,"-20,00%",,,
|
||||
Black Templars,ANCIENT IN TERMINATOR ARMOUR,75,65,"-13,33%",,,
|
||||
Black Templars,APOTHECARY,50,40,"-20,00%",,,
|
||||
Black Templars,APOTHECARY BIOLOGIS,70,70,"0,00%",,,
|
||||
Black Templars,ASTRAEUS,525,525,"0,00%",575,,
|
||||
Black Templars,BALLISTUS DREADNOUGHT,150,150,"0,00%",,160,
|
||||
Black Templars,BLADEGUARD ANCIENT,45,40,"-11,11%",,,
|
||||
Black Templars,BRUTALIS DREADNOUGHT,160,150,"-6,25%",,160,
|
||||
Black Templars,CAPTAIN,80,80,"0,00%",,,
|
||||
Black Templars,CAPTAIN IN GRAVIS ARMOUR,80,80,"0,00%",,,
|
||||
Black Templars,CAPTAIN IN PHOBOS ARMOUR,70,70,"0,00%",,,
|
||||
Black Templars,CAPTAIN IN TERMINATOR ARMOUR,95,85,"-10,53%",,,
|
||||
Black Templars,CAPTAIN WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Black Templars,CASTELLAN,70,70,"0,00%",,,
|
||||
Black Templars,CHAPLAIN,60,60,"0,00%",,,
|
||||
Black Templars,CHAPLAIN GRIMALDUS,110,110,"0,00%",,,
|
||||
Black Templars,CHAPLAIN IN TERMINATOR ARMOUR,75,75,"0,00%",,,
|
||||
Black Templars,CHAPLAIN ON BIKE,75,70,"-6,67%",,,
|
||||
Black Templars,CHAPLAIN WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Black Templars,COMPANY HEROES,105,105,"0,00%",,,
|
||||
Black Templars,CRUSADE ANCIENT,55,45,"-18,18%",,,
|
||||
Black Templars,DESOLATION SQUAD,200,180,"-10,00%",210,,
|
||||
Black Templars,DREADNOUGHT,135,135,"0,00%",,,
|
||||
Black Templars,DROP POD,70,70,"0,00%",,,
|
||||
Black Templars,ELIMINATOR SQUAD,85,75,"-11,76%",,,
|
||||
Black Templars,EMPEROR’S CHAMPION,100,100,"0,00%",,,
|
||||
Black Templars,ERADICATOR SQUAD WITH HEAVY BOLTERS,,70,,,80,
|
||||
Black Templars,EXECRATOR,60,60,"0,00%",,,
|
||||
Black Templars,GLADIATOR LANCER,160,160,"0,00%",,170,
|
||||
Black Templars,GLADIATOR REAPER,160,160,"0,00%",,170,
|
||||
Black Templars,GLADIATOR VALIANT,150,150,"0,00%",,160,
|
||||
Black Templars,HAMMERFALL BUNKER,175,175,"0,00%",,,
|
||||
Black Templars,HIGH MARSHAL HELBRECHT,120,120,"0,00%",,,
|
||||
Black Templars,IMPULSOR,85,85,"0,00%",,,
|
||||
Black Templars,INVADER ATV,60,60,"0,00%",,,
|
||||
Black Templars,INVICTOR TACTICAL WARSUIT,125,125,"0,00%",,,
|
||||
Black Templars,JUDICIAR,70,55,"-21,43%",,,
|
||||
Black Templars,LAND RAIDER,220,220,"0,00%",,240,
|
||||
Black Templars,LAND RAIDER CRUSADER,220,220,"0,00%",,240,
|
||||
Black Templars,LAND RAIDER REDEEMER,270,250,"-7,41%",,270,
|
||||
Black Templars,LAND SPEEDER,,95,,,105,
|
||||
Black Templars,LIEUTENANT,55,45,"-18,18%",,,
|
||||
Black Templars,LIEUTENANT IN PHOBOS ARMOUR,55,45,"-18,18%",,,
|
||||
Black Templars,LIEUTENANT IN REIVER ARMOUR,55,45,"-18,18%",,,
|
||||
Black Templars,LIEUTENANT WITH COMBI-WEAPON,85,85,"0,00%",,,
|
||||
Black Templars,MARSHAL,80,80,"0,00%",90,,
|
||||
Black Templars,PREDATOR ANNIHILATOR,135,135,"0,00%",,145,
|
||||
Black Templars,PREDATOR DESTRUCTOR,140,140,"0,00%",,150,
|
||||
Black Templars,RAZORBACK,95,95,"0,00%",,,
|
||||
Black Templars,REDEMPTOR DREADNOUGHT,205,195,"-4,88%",,210,
|
||||
Black Templars,REPULSOR,180,170,"-5,56%",,190,
|
||||
Black Templars,REPULSOR EXECUTIONER,235,245,"4,26%",,265,
|
||||
Black Templars,RHINO,75,75,"0,00%",,,
|
||||
Black Templars,STORM SPEEDER HAILSTRIKE,115,105,"-8,70%",,115,
|
||||
Black Templars,STORM SPEEDER HAMMERSTRIKE,125,130,"4,00%",,140,
|
||||
Black Templars,STORM SPEEDER THUNDERSTRIKE,135,135,"0,00%",,145,
|
||||
Black Templars,STORMHAWK INTERCEPTOR,155,155,"0,00%",,,
|
||||
Black Templars,STORMRAVEN GUNSHIP,280,280,"0,00%",300,,
|
||||
Black Templars,STORMTALON GUNSHIP,165,165,"0,00%",,,
|
||||
Black Templars,SUPPRESSOR SQUAD,75,75,"0,00%",,,
|
||||
Black Templars,TACTICAL SQUAD,140,140,"0,00%",,,
|
||||
Black Templars,TECHMARINE,55,55,"0,00%",,,
|
||||
Black Templars,THUNDERHAWK GUNSHIP,840,840,"0,00%",,,
|
||||
Black Templars,VINDICATOR,185,185,"0,00%",,200,
|
||||
Black Templars,WHIRLWIND,190,175,"-7,89%",195,,
|
||||
|
127
csv/blood-angels.csv
Normal file
127
csv/blood-angels.csv
Normal file
@@ -0,0 +1,127 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Blood Angels,1 Firestrike Servo-Turrets,75,75,"0,00%",,,
|
||||
Blood Angels,10 Assault Intercessor Squad,,150,,,,
|
||||
Blood Angels,10 Assault Intercessors With Jump Packs,,180,,,190,
|
||||
Blood Angels,10 Death Company Marines,,160,,,170,
|
||||
Blood Angels,10 Death Company Marines With Bolt Rifles,,155,,,165,
|
||||
Blood Angels,10 Death Company Marines With Jump Packs,,230,,,245,
|
||||
Blood Angels,10 Devastator Squad,,200,,,,
|
||||
Blood Angels,10 Heavy Intercessor Squad,,200,,,,
|
||||
Blood Angels,10 Hellblaster Squad,,220,,,,
|
||||
Blood Angels,10 Incursor Squad,,150,,,160,
|
||||
Blood Angels,10 Infernus Squad,,170,,,,
|
||||
Blood Angels,10 Infiltrator Squad,,180,,,190,
|
||||
Blood Angels,10 Intercessor Squad,,150,,,,
|
||||
Blood Angels,10 Reiver Squad,,150,,,,
|
||||
Blood Angels,10 Scout Squad,,120,,,130,
|
||||
Blood Angels,10 Sternguard Veteran Squad,,190,,,,
|
||||
Blood Angels,10 Terminator Assault Squad,,310,,,,
|
||||
Blood Angels,10 Terminator Squad,,320,,,,
|
||||
Blood Angels,10 Vanguard Veteran Squad With Jump Packs,,210,,,220,
|
||||
Blood Angels,2 Firestrike Servo-Turrets,,150,,,,
|
||||
Blood Angels,3 Aggressor Squad,95,90,"-5,26%",,100,
|
||||
Blood Angels,3 Bladeguard Veteran Squad,80,85,"6,25%",,95,
|
||||
Blood Angels,3 Centurion Assault Squad,150,150,"0,00%",,,
|
||||
Blood Angels,3 Centurion Devastator Squad,175,175,"0,00%",,,
|
||||
Blood Angels,3 Eradicator Squad,90,90,"0,00%",,100,
|
||||
Blood Angels,3 Inceptor Squad,120,120,"0,00%",,135,
|
||||
Blood Angels,3 Outrider Squad,80,75,"-6,25%",,,
|
||||
Blood Angels,3 Sanguinary Guard,125,125,"0,00%",,145,
|
||||
Blood Angels,5 Assault Intercessor Squad,75,80,"6,67%",,,
|
||||
Blood Angels,5 Assault Intercessors With Jump Packs,90,95,"5,56%",,105,
|
||||
Blood Angels,5 Death Company Marines,85,85,"0,00%",,95,
|
||||
Blood Angels,5 Death Company Marines With Bolt Rifles,85,80,"-5,88%",,90,
|
||||
Blood Angels,5 Death Company Marines With Jump Packs,120,120,"0,00%",,135,
|
||||
Blood Angels,5 Devastator Squad,120,120,"0,00%",,,
|
||||
Blood Angels,5 Heavy Intercessor Squad,100,100,"0,00%",,,
|
||||
Blood Angels,5 Hellblaster Squad,110,110,"0,00%",,,
|
||||
Blood Angels,5 Incursor Squad,80,85,"6,25%",,95,
|
||||
Blood Angels,5 Infernus Squad,90,85,"-5,56%",,,
|
||||
Blood Angels,5 Infiltrator Squad,100,110,"10,00%",,120,
|
||||
Blood Angels,5 Intercessor Squad,80,80,"0,00%",,,
|
||||
Blood Angels,5 Reiver Squad,80,75,"-6,25%",,,
|
||||
Blood Angels,5 Scout Squad,70,65,"-7,14%",,75,
|
||||
Blood Angels,5 Sternguard Veteran Squad,100,100,"0,00%",,,
|
||||
Blood Angels,5 Terminator Assault Squad,180,155,"-13,89%",,,
|
||||
Blood Angels,5 Terminator Squad,170,160,"-5,88%",,,
|
||||
Blood Angels,5 Vanguard Veteran Squad With Jump Packs,100,105,"5,00%",,115,
|
||||
Blood Angels,6 Aggressor Squad,,180,,,190,
|
||||
Blood Angels,6 Bladeguard Veteran Squad,,170,,,180,
|
||||
Blood Angels,6 Centurion Assault Squad,,300,,,,
|
||||
Blood Angels,6 Centurion Devastator Squad,,350,,,,
|
||||
Blood Angels,6 Eradicator Squad,,180,,,190,
|
||||
Blood Angels,6 Inceptor Squad,,240,,,255,
|
||||
Blood Angels,6 Outrider Squad,,140,,,,
|
||||
Blood Angels,6 Sanguinary Guard,,260,,,280,
|
||||
Blood Angels,ANCIENT,50,40,"-20,00%",,,
|
||||
Blood Angels,ANCIENT IN TERMINATOR ARMOUR,75,65,"-13,33%",,,
|
||||
Blood Angels,APOTHECARY,50,40,"-20,00%",,,
|
||||
Blood Angels,APOTHECARY BIOLOGIS,70,70,"0,00%",,,
|
||||
Blood Angels,ASTORATH,85,85,"0,00%",,,
|
||||
Blood Angels,ASTRAEUS,525,525,"0,00%",575,,
|
||||
Blood Angels,BAAL PREDATOR,125,125,"0,00%",,135,
|
||||
Blood Angels,BALLISTUS DREADNOUGHT,150,150,"0,00%",,160,
|
||||
Blood Angels,BLADEGUARD ANCIENT,45,40,"-11,11%",,,
|
||||
Blood Angels,BLOOD ANGELS CAPTAIN,80,80,"0,00%",,,
|
||||
Blood Angels,BRUTALIS DREADNOUGHT,160,150,"-6,25%",,160,
|
||||
Blood Angels,CAPTAIN,80,80,"0,00%",,,
|
||||
Blood Angels,CAPTAIN IN GRAVIS ARMOUR,80,80,"0,00%",,,
|
||||
Blood Angels,CAPTAIN IN PHOBOS ARMOUR,70,70,"0,00%",,,
|
||||
Blood Angels,CAPTAIN IN TERMINATOR ARMOUR,95,85,"-10,53%",,,
|
||||
Blood Angels,CAPTAIN WITH JUMP PACK,75,80,"6,67%",,,
|
||||
Blood Angels,CHAPLAIN,60,60,"0,00%",,,
|
||||
Blood Angels,CHAPLAIN IN TERMINATOR ARMOUR,75,75,"0,00%",,,
|
||||
Blood Angels,CHAPLAIN ON BIKE,75,70,"-6,67%",,,
|
||||
Blood Angels,CHAPLAIN WITH JUMP PACK,75,80,"6,67%",,,
|
||||
Blood Angels,CHIEF LIBRARIAN MEPHISTON,120,110,"-8,33%",,,
|
||||
Blood Angels,COMMANDER DANTE,120,125,"4,17%",,,
|
||||
Blood Angels,COMPANY HEROES,105,105,"0,00%",,,
|
||||
Blood Angels,DEATH COMPANY CAPTAIN,70,70,"0,00%",,,
|
||||
Blood Angels,DEATH COMPANY CAPTAIN WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Blood Angels,DEATH COMPANY DREADNOUGHT,160,150,"-6,25%",,160,
|
||||
Blood Angels,DESOLATION SQUAD,200,180,"-10,00%",210,,
|
||||
Blood Angels,DREADNOUGHT,135,135,"0,00%",,,
|
||||
Blood Angels,DROP POD,70,70,"0,00%",,,
|
||||
Blood Angels,ELIMINATOR SQUAD,85,75,"-11,76%",,,
|
||||
Blood Angels,ERADICATOR SQUAD WITH HEAVY BOLTERS,,70,,,80,
|
||||
Blood Angels,GLADIATOR LANCER,160,160,"0,00%",,170,
|
||||
Blood Angels,GLADIATOR REAPER,160,160,"0,00%",,170,
|
||||
Blood Angels,GLADIATOR VALIANT,150,150,"0,00%",,160,
|
||||
Blood Angels,HAMMERFALL BUNKER,175,175,"0,00%",,,
|
||||
Blood Angels,IMPULSOR,80,80,"0,00%",,,
|
||||
Blood Angels,INVADER ATV,60,60,"0,00%",,,
|
||||
Blood Angels,INVICTOR TACTICAL WARSUIT,125,125,"0,00%",,,
|
||||
Blood Angels,JUDICIAR,70,55,"-21,43%",,,
|
||||
Blood Angels,LAND RAIDER,220,220,"0,00%",,240,
|
||||
Blood Angels,LAND RAIDER CRUSADER,220,220,"0,00%",,240,
|
||||
Blood Angels,LAND RAIDER REDEEMER,270,250,"-7,41%",,270,
|
||||
Blood Angels,LAND SPEEDER,,95,,,105,
|
||||
Blood Angels,LEMARTES,100,100,"0,00%",,,
|
||||
Blood Angels,LIBRARIAN,65,60,"-7,69%",,,
|
||||
Blood Angels,LIBRARIAN IN PHOBOS ARMOUR,70,70,"0,00%",,,
|
||||
Blood Angels,LIBRARIAN IN TERMINATOR ARMOUR,75,75,"0,00%",,,
|
||||
Blood Angels,LIEUTENANT,55,45,"-18,18%",,,
|
||||
Blood Angels,LIEUTENANT IN PHOBOS ARMOUR,55,45,"-18,18%",,,
|
||||
Blood Angels,LIEUTENANT IN REIVER ARMOUR,55,45,"-18,18%",,,
|
||||
Blood Angels,LIEUTENANT WITH COMBI-WEAPON,85,85,"0,00%",,,
|
||||
Blood Angels,PREDATOR ANNIHILATOR,135,135,"0,00%",,145,
|
||||
Blood Angels,PREDATOR DESTRUCTOR,140,140,"0,00%",,150,
|
||||
Blood Angels,RAZORBACK,95,95,"0,00%",,,
|
||||
Blood Angels,REDEMPTOR DREADNOUGHT,205,195,"-4,88%",,210,
|
||||
Blood Angels,REPULSOR,180,170,"-5,56%",,190,
|
||||
Blood Angels,REPULSOR EXECUTIONER,230,230,"0,00%",,250,
|
||||
Blood Angels,RHINO,75,75,"0,00%",,,
|
||||
Blood Angels,SANGUINARY PRIEST,75,75,"0,00%",,85,
|
||||
Blood Angels,STORM SPEEDER HAILSTRIKE,115,105,"-8,70%",,115,
|
||||
Blood Angels,STORM SPEEDER HAMMERSTRIKE,125,130,"4,00%",,140,
|
||||
Blood Angels,STORM SPEEDER THUNDERSTRIKE,135,135,"0,00%",,145,
|
||||
Blood Angels,STORMHAWK INTERCEPTOR,155,155,"0,00%",,,
|
||||
Blood Angels,STORMRAVEN GUNSHIP,280,280,"0,00%",300,,
|
||||
Blood Angels,STORMTALON GUNSHIP,165,165,"0,00%",,,
|
||||
Blood Angels,SUPPRESSOR SQUAD,75,75,"0,00%",,,
|
||||
Blood Angels,TACTICAL SQUAD,140,140,"0,00%",,,
|
||||
Blood Angels,TECHMARINE,55,55,"0,00%",,,
|
||||
Blood Angels,THE SANGUINOR,130,130,"0,00%",,,
|
||||
Blood Angels,THUNDERHAWK GUNSHIP,840,840,"0,00%",,,
|
||||
Blood Angels,VINDICATOR,185,185,"0,00%",,200,
|
||||
Blood Angels,WHIRLWIND,190,175,"-7,89%",195,,
|
||||
|
64
csv/chaos-daemons.csv
Normal file
64
csv/chaos-daemons.csv
Normal file
@@ -0,0 +1,64 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Chaos Daemons,1 Beasts Of Nurgle,65,70,"7,69%",,,
|
||||
Chaos Daemons,1 Hellflayers,80,80,"0,00%",,,
|
||||
Chaos Daemons,10 Flesh Hounds,,150,,,,
|
||||
Chaos Daemons,10 Seekers,,155,,,,
|
||||
Chaos Daemons,2 Beasts Of Nurgle,,140,,,,
|
||||
Chaos Daemons,2 Hellflayers,,160,,,,
|
||||
Chaos Daemons,3 Bloodcrushers,110,95,"-13,64%",,105,
|
||||
Chaos Daemons,3 Fiends,95,90,"-5,26%",,,
|
||||
Chaos Daemons,3 Flamers,65,65,"0,00%",,,
|
||||
Chaos Daemons,3 Nurglings,40,45,"12,50%",,,
|
||||
Chaos Daemons,3 Plague Drones,110,110,"0,00%",,,
|
||||
Chaos Daemons,3 Screamers,80,80,"0,00%",,,
|
||||
Chaos Daemons,5 Flesh Hounds,75,75,"0,00%",,,
|
||||
Chaos Daemons,5 Seekers,80,80,"0,00%",,,
|
||||
Chaos Daemons,6 Bloodcrushers,,180,,,190,
|
||||
Chaos Daemons,6 Fiends,,180,,,,
|
||||
Chaos Daemons,6 Flamers,,130,,,,
|
||||
Chaos Daemons,6 Nurglings,,90,,,,
|
||||
Chaos Daemons,6 Plague Drones,,220,,,,
|
||||
Chaos Daemons,6 Screamers,,160,,,,
|
||||
Chaos Daemons,BE’LAKOR,375,390,"4,00%",,,
|
||||
Chaos Daemons,BLOODLETTERS,110,110,"0,00%",,,
|
||||
Chaos Daemons,BLOODMASTER,65,65,"0,00%",,,
|
||||
Chaos Daemons,BLOODTHIRSTER,305,320,"4,92%",,335,
|
||||
Chaos Daemons,BLUE HORRORS,125,125,"0,00%",,,
|
||||
Chaos Daemons,BURNING CHARIOT,115,115,"0,00%",,,
|
||||
Chaos Daemons,CHANGECASTER,60,60,"0,00%",,,
|
||||
Chaos Daemons,CONTORTED EPITOME,100,100,"0,00%",,,
|
||||
Chaos Daemons,DAEMON PRINCE OF CHAOS,190,165,"-13,16%",,,
|
||||
Chaos Daemons,DAEMON PRINCE OF CHAOS WITH WINGS,180,190,"5,56%",,,
|
||||
Chaos Daemons,DAEMONETTES,100,90,"-10,00%",,,
|
||||
Chaos Daemons,EPIDEMIUS,80,80,"0,00%",,,
|
||||
Chaos Daemons,EXALTED FLAMER,65,65,"0,00%",,,
|
||||
Chaos Daemons,FATESKIMMER,95,95,"0,00%",,,
|
||||
Chaos Daemons,FECULENT GNARLMAW,100,100,"0,00%",,,
|
||||
Chaos Daemons,FLUXMASTER,60,80,"33,33%",,,
|
||||
Chaos Daemons,GREAT UNCLEAN ONE,250,265,"6,00%",,280,
|
||||
Chaos Daemons,HORTICULOUS SLIMUX,120,120,"0,00%",,,
|
||||
Chaos Daemons,INFERNAL ENRAPTURESS,60,60,"0,00%",,,
|
||||
Chaos Daemons,KAIROS FATEWEAVER,295,295,"0,00%",,,
|
||||
Chaos Daemons,KARANAK,75,70,"-6,67%",,,
|
||||
Chaos Daemons,KEEPER OF SECRETS,240,255,"6,25%",,270,
|
||||
Chaos Daemons,LORD OF CHANGE,285,300,"5,26%",,315,
|
||||
Chaos Daemons,PINK HORRORS,140,150,"7,14%",,,
|
||||
Chaos Daemons,PLAGUEBEARERS,110,115,"4,55%",,,
|
||||
Chaos Daemons,POXBRINGER,55,55,"0,00%",,,
|
||||
Chaos Daemons,RENDMASTER ON BLOOD THRONE,165,150,"-9,09%",,170,
|
||||
Chaos Daemons,ROTIGUS,265,280,"5,66%",,,
|
||||
Chaos Daemons,SHALAXI HELBANE,340,340,"0,00%",,,
|
||||
Chaos Daemons,SKARBRAND,305,315,"3,28%",,,
|
||||
Chaos Daemons,SKULL ALTAR,105,105,"0,00%",,,
|
||||
Chaos Daemons,SKULL CANNON,95,90,"-5,26%",,,
|
||||
Chaos Daemons,SKULLMASTER,100,85,"-15,00%",,,
|
||||
Chaos Daemons,SKULLTAKER,85,85,"0,00%",,,
|
||||
Chaos Daemons,SLOPPITY BILEPIPER,55,55,"0,00%",,,
|
||||
Chaos Daemons,SOUL GRINDER,180,180,"0,00%",,195,
|
||||
Chaos Daemons,SPOILPOX SCRIVENER,60,60,"0,00%",,,
|
||||
Chaos Daemons,SYLL’ESSKE,120,120,"0,00%",,,
|
||||
Chaos Daemons,THE BLUE SCRIBES,75,75,"0,00%",,,
|
||||
Chaos Daemons,THE CHANGELING,90,105,"16,67%",,,
|
||||
Chaos Daemons,THE MASQUE OF SLAANESH,95,95,"0,00%",,,
|
||||
Chaos Daemons,TORMENTBRINGER,140,135,"-3,57%",,,
|
||||
Chaos Daemons,TRANCEWEAVER,60,60,"0,00%",,,
|
||||
|
21
csv/chaos-knights.csv
Normal file
21
csv/chaos-knights.csv
Normal file
@@ -0,0 +1,21 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Chaos Knights,CHAOS ACASTUS KNIGHT ASTERIUS,765,785,"2,61%",860,,
|
||||
Chaos Knights,CHAOS ACASTUS KNIGHT PORPHYRION,700,725,"3,57%",800,,
|
||||
Chaos Knights,CHAOS CERASTUS KNIGHT ACHERON,385,370,"-3,90%",385,,
|
||||
Chaos Knights,CHAOS CERASTUS KNIGHT ATRAPOS,395,395,"0,00%",415,,
|
||||
Chaos Knights,CHAOS CERASTUS KNIGHT CASTIGATOR,385,370,"-3,90%",385,,
|
||||
Chaos Knights,CHAOS CERASTUS KNIGHT LANCER,385,395,"2,60%",415,,
|
||||
Chaos Knights,CHAOS QUESTORIS KNIGHT MAGAERA,375,375,"0,00%",390,,
|
||||
Chaos Knights,CHAOS QUESTORIS KNIGHT STYRIX,375,365,"-2,67%",380,,
|
||||
Chaos Knights,KNIGHT ABOMINANT,355,355,"0,00%",,370,
|
||||
Chaos Knights,KNIGHT DESECRATOR,355,355,"0,00%",,370,
|
||||
Chaos Knights,KNIGHT DESPOILER,390,380,"-2,56%",400,,
|
||||
Chaos Knights,KNIGHT RAMPAGER,365,365,"0,00%",,380,
|
||||
Chaos Knights,KNIGHT RUINATOR,355,340,"-4,23%",,355,
|
||||
Chaos Knights,KNIGHT TYRANT,410,400,"-2,44%",420,,
|
||||
Chaos Knights,WAR DOG BRIGAND,140,140,"0,00%",,,
|
||||
Chaos Knights,WAR DOG EXECUTIONER,130,130,"0,00%",,,
|
||||
Chaos Knights,WAR DOG HUNTSMAN,140,140,"0,00%",,,
|
||||
Chaos Knights,WAR DOG KARNIVORE,150,155,"3,33%",,,
|
||||
Chaos Knights,WAR DOG MOIRAX,150,150,"0,00%",,160,
|
||||
Chaos Knights,WAR DOG STALKER,140,140,"0,00%",,,
|
||||
|
66
csv/chaos-space-marines.csv
Normal file
66
csv/chaos-space-marines.csv
Normal file
@@ -0,0 +1,66 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Chaos Space Marines,10 Chaos Terminator Squad,,360,,,,
|
||||
Chaos Space Marines,10 Chosen,,250,,260,,
|
||||
Chaos Space Marines,10 Cultist Mob,50,50,"0,00%",,,
|
||||
Chaos Space Marines,10 Legionaries,,170,,,,
|
||||
Chaos Space Marines,10 Nemesis Claw,,190,,,,
|
||||
Chaos Space Marines,10 Possessed,,250,,,260,
|
||||
Chaos Space Marines,10 Raptors,,210,,,220,
|
||||
Chaos Space Marines,10 Red Corsairs Raiders,,210,,,220,
|
||||
Chaos Space Marines,10 Warp Talons,,280,,,290,
|
||||
Chaos Space Marines,16 Accursed Cultists,,195,,215,,
|
||||
Chaos Space Marines,20 Cultist Mob,,90,,,,
|
||||
Chaos Space Marines,3 Chaos Bikers,70,70,"0,00%",,,
|
||||
Chaos Space Marines,5 Chaos Terminator Squad,180,180,"0,00%",,,
|
||||
Chaos Space Marines,5 Chosen,125,125,"0,00%",135,,
|
||||
Chaos Space Marines,5 Legionaries,90,90,"0,00%",,,
|
||||
Chaos Space Marines,5 Nemesis Claw,110,110,"0,00%",,,
|
||||
Chaos Space Marines,5 Possessed,120,120,"0,00%",,130,
|
||||
Chaos Space Marines,5 Raptors,110,110,"0,00%",,120,
|
||||
Chaos Space Marines,5 Red Corsairs Raiders,110,110,"0,00%",,120,
|
||||
Chaos Space Marines,5 Warp Talons,125,125,"0,00%",,135,
|
||||
Chaos Space Marines,6 Chaos Bikers,,130,,,,
|
||||
Chaos Space Marines,8 Accursed Cultists,90,90,"0,00%",110,,
|
||||
Chaos Space Marines,ABADDON THE DESPOILER,270,285,"5,56%",,,
|
||||
Chaos Space Marines,CHAOS LAND RAIDER,220,220,"0,00%",,240,
|
||||
Chaos Space Marines,CHAOS LORD,90,90,"0,00%",,,
|
||||
Chaos Space Marines,CHAOS LORD IN TERMINATOR ARMOUR,85,85,"0,00%",,,
|
||||
Chaos Space Marines,CHAOS LORD WITH JUMP PACK,80,90,"12,50%",,,
|
||||
Chaos Space Marines,CHAOS PREDATOR ANNIHILATOR,135,135,"0,00%",,145,
|
||||
Chaos Space Marines,CHAOS PREDATOR DESTRUCTOR,140,140,"0,00%",,150,
|
||||
Chaos Space Marines,CHAOS RHINO,75,75,"0,00%",,,
|
||||
Chaos Space Marines,CHAOS SPAWN,70,60,"-14,29%",,,
|
||||
Chaos Space Marines,CHAOS VINDICATOR,185,185,"0,00%",,195,
|
||||
Chaos Space Marines,CULTIST FIREBRAND,45,45,"0,00%",,50,
|
||||
Chaos Space Marines,CYPHER,90,90,"0,00%",,,
|
||||
Chaos Space Marines,DARK APOSTLE,65,65,"0,00%",,,
|
||||
Chaos Space Marines,DARK COMMUNE,90,90,"0,00%",100,,
|
||||
Chaos Space Marines,DEFILER,250,300,"20,00%",330,,
|
||||
Chaos Space Marines,FABIUS BILE,100,100,"0,00%",,,
|
||||
Chaos Space Marines,FELLGOR BEASTMEN,70,60,"-14,29%",,,
|
||||
Chaos Space Marines,FORGEFIEND,170,160,"-5,88%",,170,
|
||||
Chaos Space Marines,HAARKEN WORLDCLAIMER,90,90,"0,00%",,,
|
||||
Chaos Space Marines,HAVOCS,125,125,"0,00%",,135,
|
||||
Chaos Space Marines,HELBRUTE,130,130,"0,00%",,,
|
||||
Chaos Space Marines,HELDRAKE,205,175,"-14,63%",,,
|
||||
Chaos Space Marines,HERETIC ASTARTES DAEMON PRINCE,165,165,"0,00%",,,
|
||||
Chaos Space Marines,HERETIC ASTARTES DAEMON PRINCE WITH WINGS,,180,,,,
|
||||
Chaos Space Marines,HURON BLACKHEART,120,120,"0,00%",,,
|
||||
Chaos Space Marines,KHORNE LORD OF SKULLS,450,450,"0,00%",475,,
|
||||
Chaos Space Marines,KRAVEK MORNE,,120,,,,
|
||||
Chaos Space Marines,LORD DISCORDANT ON HELSTALKER,160,160,"0,00%",,,
|
||||
Chaos Space Marines,MASTER OF EXECUTIONS,80,70,"-12,50%",,,
|
||||
Chaos Space Marines,MASTER OF POSSESSION,60,60,"0,00%",,,
|
||||
Chaos Space Marines,MASTERS OF THE MAELSTROM,115,135,"17,39%",,,
|
||||
Chaos Space Marines,MAULERFIEND,130,130,"0,00%",,,
|
||||
Chaos Space Marines,MUTILATORS,200,180,"-10,00%",,190,
|
||||
Chaos Space Marines,NOCTILITH CROWN,125,125,"0,00%",,,
|
||||
Chaos Space Marines,OBLITERATORS,160,160,"0,00%",,170,
|
||||
Chaos Space Marines,RED CORSAIRS REAVE-CAPTAIN,75,70,"-6,67%",,,
|
||||
Chaos Space Marines,SORCERER,60,60,"0,00%",,,
|
||||
Chaos Space Marines,SORCERER IN TERMINATOR ARMOUR,80,80,"0,00%",,,
|
||||
Chaos Space Marines,TRAITOR ENFORCER,55,70,"27,27%",,,
|
||||
Chaos Space Marines,TRAITOR GUARDSMEN SQUAD,70,70,"0,00%",,,
|
||||
Chaos Space Marines,VASHTORR THE ARKIFANE,175,205,"17,14%",,,
|
||||
Chaos Space Marines,VENOMCRAWLER,110,110,"0,00%",,120,
|
||||
Chaos Space Marines,WARPSMITH,70,60,"-14,29%",,,
|
||||
|
5
csv/chaos-titan-legions.csv
Normal file
5
csv/chaos-titan-legions.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Chaos Titan Legions,CHAOS REAVER TITAN,,2200,,,,
|
||||
Chaos Titan Legions,CHAOS WARBRINGER NEMESIS TITAN,,2600,,,,
|
||||
Chaos Titan Legions,CHAOS WARHOUND TITAN,,1100,,,,
|
||||
Chaos Titan Legions,CHAOS WARLORD TITAN,,3500,,,,
|
||||
|
127
csv/dark-angels.csv
Normal file
127
csv/dark-angels.csv
Normal file
@@ -0,0 +1,127 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Dark Angels,1 Firestrike Servo-Turrets,75,75,"0,00%",,,
|
||||
Dark Angels,10 Assault Intercessor Squad,,150,,,,
|
||||
Dark Angels,10 Assault Intercessors With Jump Packs,,160,,,170,
|
||||
Dark Angels,10 Deathwing Terminator Squad,,330,,,,
|
||||
Dark Angels,10 Devastator Squad,,200,,,,
|
||||
Dark Angels,10 Heavy Intercessor Squad,,200,,,,
|
||||
Dark Angels,10 Hellblaster Squad,,220,,,,
|
||||
Dark Angels,10 Incursor Squad,,150,,,160,
|
||||
Dark Angels,10 Infernus Squad,,170,,,,
|
||||
Dark Angels,10 Infiltrator Squad,,180,,,190,
|
||||
Dark Angels,10 Intercessor Squad,,150,,,,
|
||||
Dark Angels,10 Reiver Squad,,150,,,,
|
||||
Dark Angels,10 Scout Squad,,120,,,130,
|
||||
Dark Angels,10 Sternguard Veteran Squad,,190,,,,
|
||||
Dark Angels,10 Terminator Assault Squad,,310,,,,
|
||||
Dark Angels,10 Terminator Squad,,320,,,,
|
||||
Dark Angels,10 Vanguard Veteran Squad With Jump Packs,,200,,,210,
|
||||
Dark Angels,2 Firestrike Servo-Turrets,,150,,,,
|
||||
Dark Angels,3 Aggressor Squad,95,90,"-5,26%",,100,
|
||||
Dark Angels,3 Bladeguard Veteran Squad,80,80,"0,00%",,90,
|
||||
Dark Angels,3 Centurion Assault Squad,150,150,"0,00%",,,
|
||||
Dark Angels,3 Centurion Devastator Squad,175,175,"0,00%",,,
|
||||
Dark Angels,3 Eradicator Squad,90,90,"0,00%",,100,
|
||||
Dark Angels,3 Inceptor Squad,120,120,"0,00%",,135,
|
||||
Dark Angels,3 Inner Circle Companions,90,80,"-11,11%",,90,
|
||||
Dark Angels,3 Outrider Squad,80,70,"-12,50%",,,
|
||||
Dark Angels,3 Ravenwing Black Knights,80,75,"-6,25%",,85,
|
||||
Dark Angels,5 Assault Intercessor Squad,75,75,"0,00%",,,
|
||||
Dark Angels,5 Assault Intercessors With Jump Packs,90,85,"-5,56%",,95,
|
||||
Dark Angels,5 Deathwing Terminator Squad,180,165,"-8,33%",,,
|
||||
Dark Angels,5 Devastator Squad,120,120,"0,00%",,,
|
||||
Dark Angels,5 Heavy Intercessor Squad,100,100,"0,00%",,,
|
||||
Dark Angels,5 Hellblaster Squad,110,110,"0,00%",,,
|
||||
Dark Angels,5 Incursor Squad,80,85,"6,25%",,95,
|
||||
Dark Angels,5 Infernus Squad,90,85,"-5,56%",,,
|
||||
Dark Angels,5 Infiltrator Squad,100,110,"10,00%",,120,
|
||||
Dark Angels,5 Intercessor Squad,80,80,"0,00%",,,
|
||||
Dark Angels,5 Reiver Squad,80,75,"-6,25%",,,
|
||||
Dark Angels,5 Scout Squad,70,65,"-7,14%",,75,
|
||||
Dark Angels,5 Sternguard Veteran Squad,100,100,"0,00%",,,
|
||||
Dark Angels,5 Terminator Assault Squad,180,155,"-13,89%",,,
|
||||
Dark Angels,5 Terminator Squad,170,160,"-5,88%",,,
|
||||
Dark Angels,5 Vanguard Veteran Squad With Jump Packs,100,100,"0,00%",,110,
|
||||
Dark Angels,6 Aggressor Squad,,180,,,190,
|
||||
Dark Angels,6 Bladeguard Veteran Squad,,160,,,170,
|
||||
Dark Angels,6 Centurion Assault Squad,,300,,,,
|
||||
Dark Angels,6 Centurion Devastator Squad,,350,,,,
|
||||
Dark Angels,6 Eradicator Squad,,180,,,190,
|
||||
Dark Angels,6 Inceptor Squad,,240,,,255,
|
||||
Dark Angels,6 Inner Circle Companions,,170,,,180,
|
||||
Dark Angels,6 Outrider Squad,,140,,,,
|
||||
Dark Angels,6 Ravenwing Black Knights,,150,,,160,
|
||||
Dark Angels,ANCIENT,50,40,"-20,00%",,,
|
||||
Dark Angels,ANCIENT IN TERMINATOR ARMOUR,75,65,"-13,33%",,,
|
||||
Dark Angels,APOTHECARY,50,40,"-20,00%",,,
|
||||
Dark Angels,APOTHECARY BIOLOGIS,70,70,"0,00%",,,
|
||||
Dark Angels,ASMODAI,70,70,"0,00%",,,
|
||||
Dark Angels,ASTRAEUS,525,525,"0,00%",575,,
|
||||
Dark Angels,AZRAEL,125,140,"12,00%",,,
|
||||
Dark Angels,BALLISTUS DREADNOUGHT,150,150,"0,00%",,160,
|
||||
Dark Angels,BELIAL,85,75,"-11,76%",,,
|
||||
Dark Angels,BLADEGUARD ANCIENT,45,40,"-11,11%",,,
|
||||
Dark Angels,BRUTALIS DREADNOUGHT,160,150,"-6,25%",,160,
|
||||
Dark Angels,CAPTAIN,80,80,"0,00%",,,
|
||||
Dark Angels,CAPTAIN IN GRAVIS ARMOUR,80,80,"0,00%",,,
|
||||
Dark Angels,CAPTAIN IN PHOBOS ARMOUR,70,70,"0,00%",,,
|
||||
Dark Angels,CAPTAIN IN TERMINATOR ARMOUR,95,85,"-10,53%",,,
|
||||
Dark Angels,CAPTAIN WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Dark Angels,CHAPLAIN,60,60,"0,00%",,,
|
||||
Dark Angels,CHAPLAIN IN TERMINATOR ARMOUR,75,75,"0,00%",,,
|
||||
Dark Angels,CHAPLAIN ON BIKE,75,70,"-6,67%",,,
|
||||
Dark Angels,CHAPLAIN WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Dark Angels,COMPANY HEROES,105,105,"0,00%",,,
|
||||
Dark Angels,DEATHWING KNIGHTS,250,240,"-4,00%",260,,
|
||||
Dark Angels,DESOLATION SQUAD,200,180,"-10,00%",210,,
|
||||
Dark Angels,DREADNOUGHT,135,135,"0,00%",,,
|
||||
Dark Angels,DROP POD,70,70,"0,00%",,,
|
||||
Dark Angels,ELIMINATOR SQUAD,85,75,"-11,76%",,,
|
||||
Dark Angels,ERADICATOR SQUAD WITH HEAVY BOLTERS,,70,,,80,
|
||||
Dark Angels,EZEKIEL,75,75,"0,00%",,,
|
||||
Dark Angels,GLADIATOR LANCER,160,160,"0,00%",,170,
|
||||
Dark Angels,GLADIATOR REAPER,160,160,"0,00%",,170,
|
||||
Dark Angels,GLADIATOR VALIANT,150,150,"0,00%",,160,
|
||||
Dark Angels,HAMMERFALL BUNKER,175,175,"0,00%",,,
|
||||
Dark Angels,IMPULSOR,80,80,"0,00%",,,
|
||||
Dark Angels,INVADER ATV,60,60,"0,00%",,,
|
||||
Dark Angels,INVICTOR TACTICAL WARSUIT,125,125,"0,00%",,,
|
||||
Dark Angels,JUDICIAR,70,55,"-21,43%",,,
|
||||
Dark Angels,LAND RAIDER,220,220,"0,00%",,240,
|
||||
Dark Angels,LAND RAIDER CRUSADER,220,220,"0,00%",,240,
|
||||
Dark Angels,LAND RAIDER REDEEMER,270,250,"-7,41%",,270,
|
||||
Dark Angels,LAND SPEEDER,,95,,,105,
|
||||
Dark Angels,LAND SPEEDER VENGEANCE,120,120,"0,00%",,130,
|
||||
Dark Angels,LAZARUS,70,70,"0,00%",,,
|
||||
Dark Angels,LIBRARIAN,65,60,"-7,69%",,,
|
||||
Dark Angels,LIBRARIAN IN PHOBOS ARMOUR,70,70,"0,00%",,,
|
||||
Dark Angels,LIBRARIAN IN TERMINATOR ARMOUR,75,75,"0,00%",,,
|
||||
Dark Angels,LIEUTENANT,55,45,"-18,18%",,,
|
||||
Dark Angels,LIEUTENANT IN PHOBOS ARMOUR,55,45,"-18,18%",,,
|
||||
Dark Angels,LIEUTENANT IN REIVER ARMOUR,55,45,"-18,18%",,,
|
||||
Dark Angels,LIEUTENANT WITH COMBI-WEAPON,85,85,"0,00%",,,
|
||||
Dark Angels,LION EL’JONSON,315,285,"-9,52%",,,
|
||||
Dark Angels,NEPHILIM JETFIGHTER,195,195,"0,00%",,,
|
||||
Dark Angels,PREDATOR ANNIHILATOR,135,135,"0,00%",,145,
|
||||
Dark Angels,PREDATOR DESTRUCTOR,140,140,"0,00%",,150,
|
||||
Dark Angels,RAVENWING COMMAND SQUAD,120,115,"-4,17%",,125,
|
||||
Dark Angels,RAVENWING DARK TALON,210,200,"-4,76%",,,
|
||||
Dark Angels,RAVENWING DARKSHROUD,100,80,"-20,00%",,,
|
||||
Dark Angels,RAZORBACK,95,95,"0,00%",,,
|
||||
Dark Angels,REDEMPTOR DREADNOUGHT,205,195,"-4,88%",,210,
|
||||
Dark Angels,REPULSOR,180,170,"-5,56%",,190,
|
||||
Dark Angels,REPULSOR EXECUTIONER,230,230,"0,00%",,250,
|
||||
Dark Angels,RHINO,75,75,"0,00%",,,
|
||||
Dark Angels,SAMMAEL,115,105,"-8,70%",,,
|
||||
Dark Angels,STORM SPEEDER HAILSTRIKE,115,105,"-8,70%",,115,
|
||||
Dark Angels,STORM SPEEDER HAMMERSTRIKE,125,130,"4,00%",,140,
|
||||
Dark Angels,STORM SPEEDER THUNDERSTRIKE,135,135,"0,00%",,145,
|
||||
Dark Angels,STORMHAWK INTERCEPTOR,155,155,"0,00%",,,
|
||||
Dark Angels,STORMRAVEN GUNSHIP,280,280,"0,00%",300,,
|
||||
Dark Angels,STORMTALON GUNSHIP,165,165,"0,00%",,,
|
||||
Dark Angels,SUPPRESSOR SQUAD,75,75,"0,00%",,,
|
||||
Dark Angels,TACTICAL SQUAD,140,140,"0,00%",,,
|
||||
Dark Angels,TECHMARINE,55,55,"0,00%",,,
|
||||
Dark Angels,THUNDERHAWK GUNSHIP,840,840,"0,00%",,,
|
||||
Dark Angels,VINDICATOR,185,185,"0,00%",,200,
|
||||
Dark Angels,WHIRLWIND,190,175,"-7,89%",195,,
|
||||
|
47
csv/death-guard.csv
Normal file
47
csv/death-guard.csv
Normal file
@@ -0,0 +1,47 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Death Guard,1 Beasts Of Nurgle,65,70,"7,69%",,,
|
||||
Death Guard,1 Myphitic Blight-Haulers,100,100,"0,00%",,,
|
||||
Death Guard,10 Blightlord Terminators,,370,,,,
|
||||
Death Guard,10 Plague Marines,,190,,,,
|
||||
Death Guard,10 Poxwalkers,65,65,"0,00%",,,
|
||||
Death Guard,2 Beasts Of Nurgle,,140,,,,
|
||||
Death Guard,2 Myphitic Blight-Haulers,,200,,,,
|
||||
Death Guard,20 Poxwalkers,,130,,,,
|
||||
Death Guard,3 Blightlord Terminators,115,115,"0,00%",,,
|
||||
Death Guard,3 Deathshroud Terminators,160,160,"0,00%",,170,
|
||||
Death Guard,3 Nurglings,40,45,"12,50%",,,
|
||||
Death Guard,3 Plague Drones,115,110,"-4,35%",,,
|
||||
Death Guard,5 Blightlord Terminators,,185,,,,
|
||||
Death Guard,5 Plague Marines,95,90,"-5,26%",,,
|
||||
Death Guard,6 Deathshroud Terminators,,320,,,330,
|
||||
Death Guard,6 Nurglings,,90,,,,
|
||||
Death Guard,6 Plague Drones,,220,,,,
|
||||
Death Guard,7 Plague Marines,,125,,,,
|
||||
Death Guard,BIOLOGUS PUTRIFIER,60,60,"0,00%",,,
|
||||
Death Guard,CHAOS LAND RAIDER,220,220,"0,00%",,240,
|
||||
Death Guard,CHAOS PREDATOR ANNIHILATOR,135,135,"0,00%",,145,
|
||||
Death Guard,CHAOS PREDATOR DESTRUCTOR,145,145,"0,00%",,155,
|
||||
Death Guard,CHAOS RHINO,85,85,"0,00%",,,
|
||||
Death Guard,CHAOS SPAWN,70,80,"14,29%",,,
|
||||
Death Guard,DAEMON PRINCE OF NURGLE,195,195,"0,00%",,,
|
||||
Death Guard,DAEMON PRINCE OF NURGLE WITH WINGS,180,170,"-5,56%",,,
|
||||
Death Guard,DEFILER,250,290,"16,00%",320,,
|
||||
Death Guard,FOETID BLOAT-DRONE,100,100,"0,00%",,110,
|
||||
Death Guard,FOETID BLOAT-DRONE WITH HEAVY BLIGHT LAUNCHER,,125,,,135,
|
||||
Death Guard,FOUL BLIGHTSPAWN,75,65,"-13,33%",,,
|
||||
Death Guard,GREAT UNCLEAN ONE,250,265,"6,00%",,280,
|
||||
Death Guard,HELBRUTE,115,110,"-4,35%",,,
|
||||
Death Guard,ICON BEARER,45,45,"0,00%",,,
|
||||
Death Guard,LORD OF CONTAGION,120,120,"0,00%",,,
|
||||
Death Guard,LORD OF POXES,75,65,"-13,33%",,,
|
||||
Death Guard,LORD OF VIRULENCE,100,100,"0,00%",,,
|
||||
Death Guard,MALIGNANT PLAGUECASTER,60,60,"0,00%",,,
|
||||
Death Guard,MIASMIC MALIGNIFIER,105,105,"0,00%",,,
|
||||
Death Guard,MORTARION,380,400,"5,26%",,,
|
||||
Death Guard,NOXIOUS BLIGHTBRINGER,50,60,"20,00%",,,
|
||||
Death Guard,PLAGUE SURGEON,50,50,"0,00%",,,
|
||||
Death Guard,PLAGUEBEARERS,110,115,"4,55%",,,
|
||||
Death Guard,PLAGUEBURST CRAWLER,210,185,"-11,90%",210,,
|
||||
Death Guard,ROTIGUS,265,280,"5,66%",,,
|
||||
Death Guard,TALLYMAN,50,60,"20,00%",,,
|
||||
Death Guard,TYPHUS,100,100,"0,00%",,,
|
||||
|
112
csv/deathwatch.csv
Normal file
112
csv/deathwatch.csv
Normal file
@@ -0,0 +1,112 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Deathwatch,1 Firestrike Servo-Turrets,75,75,"0,00%",,,
|
||||
Deathwatch,10 Assault Intercessor Squad,,150,,,,
|
||||
Deathwatch,10 Assault Intercessors With Jump Packs,,160,,,170,
|
||||
Deathwatch,10 Deathwatch Terminator Squad,,330,,,,
|
||||
Deathwatch,10 Deathwatch Veterans,,190,,,,
|
||||
Deathwatch,10 Decimus Kill Team,,190,,,,
|
||||
Deathwatch,10 Heavy Intercessor Squad,,200,,,,
|
||||
Deathwatch,10 Hellblaster Squad,,220,,,,
|
||||
Deathwatch,10 Incursor Squad,,150,,,160,
|
||||
Deathwatch,10 Infernus Squad,,170,,,,
|
||||
Deathwatch,10 Infiltrator Squad,,180,,,190,
|
||||
Deathwatch,10 Intercessor Squad,,150,,,,
|
||||
Deathwatch,10 Reiver Squad,,150,,,,
|
||||
Deathwatch,10 Sternguard Veteran Squad,,190,,,,
|
||||
Deathwatch,10 Vanguard Veteran Squad With Jump Packs,,200,,,210,
|
||||
Deathwatch,2 Firestrike Servo-Turrets,,150,,,,
|
||||
Deathwatch,3 Aggressor Squad,95,90,"-5,26%",,100,
|
||||
Deathwatch,3 Bladeguard Veteran Squad,80,80,"0,00%",,90,
|
||||
Deathwatch,3 Centurion Assault Squad,150,150,"0,00%",,,
|
||||
Deathwatch,3 Centurion Devastator Squad,175,175,"0,00%",,,
|
||||
Deathwatch,3 Eradicator Squad,90,90,"0,00%",,100,
|
||||
Deathwatch,3 Inceptor Squad,120,120,"0,00%",,135,
|
||||
Deathwatch,3 Outrider Squad,80,70,"-12,50%",,,
|
||||
Deathwatch,5 Assault Intercessor Squad,75,75,"0,00%",,,
|
||||
Deathwatch,5 Assault Intercessors With Jump Packs,90,85,"-5,56%",,95,
|
||||
Deathwatch,5 Deathwatch Terminator Squad,190,180,"-5,26%",,,
|
||||
Deathwatch,5 Deathwatch Veterans,100,100,"0,00%",,,
|
||||
Deathwatch,5 Decimus Kill Team,100,100,"0,00%",,,
|
||||
Deathwatch,5 Heavy Intercessor Squad,100,100,"0,00%",,,
|
||||
Deathwatch,5 Hellblaster Squad,110,110,"0,00%",,,
|
||||
Deathwatch,5 Incursor Squad,80,85,"6,25%",,95,
|
||||
Deathwatch,5 Infernus Squad,90,85,"-5,56%",,,
|
||||
Deathwatch,5 Infiltrator Squad,100,110,"10,00%",,120,
|
||||
Deathwatch,5 Intercessor Squad,80,80,"0,00%",,,
|
||||
Deathwatch,5 Reiver Squad,80,75,"-6,25%",,,
|
||||
Deathwatch,5 Sternguard Veteran Squad,100,100,"0,00%",,,
|
||||
Deathwatch,5 Vanguard Veteran Squad With Jump Packs,100,100,"0,00%",,110,
|
||||
Deathwatch,6 Aggressor Squad,,180,,,190,
|
||||
Deathwatch,6 Bladeguard Veteran Squad,,160,,,170,
|
||||
Deathwatch,6 Centurion Assault Squad,,300,,,,
|
||||
Deathwatch,6 Centurion Devastator Squad,,350,,,,
|
||||
Deathwatch,6 Eradicator Squad,,180,,,190,
|
||||
Deathwatch,6 Inceptor Squad,,240,,,255,
|
||||
Deathwatch,6 Outrider Squad,,140,,,,
|
||||
Deathwatch,ANCIENT,50,40,"-20,00%",,,
|
||||
Deathwatch,ANCIENT IN TERMINATOR ARMOUR,75,65,"-13,33%",,,
|
||||
Deathwatch,APOTHECARY,50,40,"-20,00%",,,
|
||||
Deathwatch,APOTHECARY BIOLOGIS,70,70,"0,00%",,,
|
||||
Deathwatch,ASTRAEUS,525,525,"0,00%",575,,
|
||||
Deathwatch,BALLISTUS DREADNOUGHT,150,150,"0,00%",,160,
|
||||
Deathwatch,BLADEGUARD ANCIENT,45,40,"-11,11%",,,
|
||||
Deathwatch,BRUTALIS DREADNOUGHT,160,150,"-6,25%",,160,
|
||||
Deathwatch,CAPTAIN,80,80,"0,00%",,,
|
||||
Deathwatch,CAPTAIN IN GRAVIS ARMOUR,80,80,"0,00%",,,
|
||||
Deathwatch,CAPTAIN IN PHOBOS ARMOUR,70,70,"0,00%",,,
|
||||
Deathwatch,CAPTAIN IN TERMINATOR ARMOUR,95,85,"-10,53%",,,
|
||||
Deathwatch,CAPTAIN WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Deathwatch,CHAPLAIN,60,60,"0,00%",,,
|
||||
Deathwatch,CHAPLAIN IN TERMINATOR ARMOUR,75,75,"0,00%",,,
|
||||
Deathwatch,CHAPLAIN ON BIKE,75,70,"-6,67%",,,
|
||||
Deathwatch,CHAPLAIN WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Deathwatch,COMPANY HEROES,105,105,"0,00%",,,
|
||||
Deathwatch,CORVUS BLACKSTAR,180,180,"0,00%",,,
|
||||
Deathwatch,DESOLATION SQUAD,200,180,"-10,00%",210,,
|
||||
Deathwatch,DREADNOUGHT,135,135,"0,00%",,,
|
||||
Deathwatch,DROP POD,70,70,"0,00%",,,
|
||||
Deathwatch,ELIMINATOR SQUAD,85,75,"-11,76%",,,
|
||||
Deathwatch,ERADICATOR SQUAD WITH HEAVY BOLTERS,,70,,,80,
|
||||
Deathwatch,FORTIS KILL TEAM,180,195,"8,33%",,210,
|
||||
Deathwatch,GLADIATOR LANCER,160,160,"0,00%",,170,
|
||||
Deathwatch,GLADIATOR REAPER,160,160,"0,00%",,170,
|
||||
Deathwatch,GLADIATOR VALIANT,150,150,"0,00%",,160,
|
||||
Deathwatch,HAMMERFALL BUNKER,175,175,"0,00%",,,
|
||||
Deathwatch,IMPULSOR,80,80,"0,00%",,,
|
||||
Deathwatch,INDOMITOR KILL TEAM,265,275,"3,77%",,290,
|
||||
Deathwatch,INVADER ATV,60,60,"0,00%",,,
|
||||
Deathwatch,INVICTOR TACTICAL WARSUIT,125,125,"0,00%",,,
|
||||
Deathwatch,JUDICIAR,70,55,"-21,43%",,,
|
||||
Deathwatch,LAND RAIDER,220,220,"0,00%",,240,
|
||||
Deathwatch,LAND RAIDER CRUSADER,220,220,"0,00%",,240,
|
||||
Deathwatch,LAND RAIDER REDEEMER,270,250,"-7,41%",,270,
|
||||
Deathwatch,LAND SPEEDER,,95,,,105,
|
||||
Deathwatch,LIBRARIAN,65,60,"-7,69%",,,
|
||||
Deathwatch,LIBRARIAN IN PHOBOS ARMOUR,70,70,"0,00%",,,
|
||||
Deathwatch,LIBRARIAN IN TERMINATOR ARMOUR,75,75,"0,00%",,,
|
||||
Deathwatch,LIEUTENANT,55,45,"-18,18%",,,
|
||||
Deathwatch,LIEUTENANT IN PHOBOS ARMOUR,55,45,"-18,18%",,,
|
||||
Deathwatch,LIEUTENANT IN REIVER ARMOUR,55,45,"-18,18%",,,
|
||||
Deathwatch,LIEUTENANT WITH COMBI-WEAPON,85,85,"0,00%",,,
|
||||
Deathwatch,PREDATOR ANNIHILATOR,135,135,"0,00%",,145,
|
||||
Deathwatch,PREDATOR DESTRUCTOR,140,140,"0,00%",,150,
|
||||
Deathwatch,RAZORBACK,95,95,"0,00%",,,
|
||||
Deathwatch,REDEMPTOR DREADNOUGHT,205,195,"-4,88%",,210,
|
||||
Deathwatch,REPULSOR,180,170,"-5,56%",,190,
|
||||
Deathwatch,REPULSOR EXECUTIONER,230,230,"0,00%",,250,
|
||||
Deathwatch,RHINO,75,75,"0,00%",,,
|
||||
Deathwatch,SPECTRUS KILL TEAM,180,170,"-5,56%",,180,
|
||||
Deathwatch,STORM SPEEDER HAILSTRIKE,115,105,"-8,70%",,115,
|
||||
Deathwatch,STORM SPEEDER HAMMERSTRIKE,125,130,"4,00%",,140,
|
||||
Deathwatch,STORM SPEEDER THUNDERSTRIKE,135,135,"0,00%",,145,
|
||||
Deathwatch,STORMHAWK INTERCEPTOR,155,155,"0,00%",,,
|
||||
Deathwatch,STORMRAVEN GUNSHIP,280,280,"0,00%",300,,
|
||||
Deathwatch,STORMTALON GUNSHIP,165,165,"0,00%",,,
|
||||
Deathwatch,SUPPRESSOR SQUAD,75,75,"0,00%",,,
|
||||
Deathwatch,TALONSTRIKE KILL TEAM,275,265,"-3,64%",,280,
|
||||
Deathwatch,TECHMARINE,55,55,"0,00%",,,
|
||||
Deathwatch,THUNDERHAWK GUNSHIP,840,840,"0,00%",,,
|
||||
Deathwatch,VINDICATOR,185,185,"0,00%",,200,
|
||||
Deathwatch,WATCH CAPTAIN ARTEMIS,65,65,"0,00%",,,
|
||||
Deathwatch,WATCH MASTER,95,95,"0,00%",,,
|
||||
Deathwatch,WHIRLWIND,190,175,"-7,89%",195,,
|
||||
|
32
csv/drukhari.csv
Normal file
32
csv/drukhari.csv
Normal file
@@ -0,0 +1,32 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Drukhari,1 Cronos,55,55,"0,00%",,,
|
||||
Drukhari,1 Talos,80,75,"-6,25%",,85,
|
||||
Drukhari,10 Hellions,,180,,,190,
|
||||
Drukhari,10 Incubi,,180,,,190,
|
||||
Drukhari,10 Mandrakes,,160,,,170,
|
||||
Drukhari,10 Wracks,,120,,,,
|
||||
Drukhari,2 Cronos,,100,,,,
|
||||
Drukhari,2 Talos,,150,,,160,
|
||||
Drukhari,3 Reavers,70,75,"7,14%",,85,
|
||||
Drukhari,5 Hellions,85,90,"5,88%",,100,
|
||||
Drukhari,5 Incubi,90,90,"0,00%",,100,
|
||||
Drukhari,5 Mandrakes,75,80,"6,67%",,90,
|
||||
Drukhari,5 Wracks,60,60,"0,00%",,,
|
||||
Drukhari,6 Reavers,,150,,,160,
|
||||
Drukhari,8 Wracks,,100,,,,
|
||||
Drukhari,ARCHON,80,80,"0,00%",,,
|
||||
Drukhari,DRAZHAR,85,85,"0,00%",,,
|
||||
Drukhari,HAEMONCULUS,60,50,"-16,67%",,,
|
||||
Drukhari,HAND OF THE ARCHON,125,115,"-8,00%",,,
|
||||
Drukhari,KABALITE WARRIORS,115,110,"-4,35%",,,
|
||||
Drukhari,LADY MALYS,100,100,"0,00%",,,
|
||||
Drukhari,LELITH HESPERAX,85,80,"-5,88%",,,
|
||||
Drukhari,RAIDER,85,85,"0,00%",,,
|
||||
Drukhari,RAVAGER,110,100,"-9,09%",,110,
|
||||
Drukhari,RAZORWING JETFIGHTER,170,170,"0,00%",,,
|
||||
Drukhari,SCOURGES WITH HEAVY WEAPONS,130,110,"-15,38%",,120,
|
||||
Drukhari,SCOURGES WITH SHARDCARBINES,75,75,"0,00%",,85,
|
||||
Drukhari,SUCCUBUS,50,50,"0,00%",,,
|
||||
Drukhari,VENOM,70,70,"0,00%",,,
|
||||
Drukhari,VOIDRAVEN BOMBER,245,245,"0,00%",,,
|
||||
Drukhari,WYCHES,90,90,"0,00%",,,
|
||||
|
29
csv/emperors-children.csv
Normal file
29
csv/emperors-children.csv
Normal file
@@ -0,0 +1,29 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Emperor's Children,10 Infractors,,160,,,,
|
||||
Emperor's Children,10 Seekers,,155,,,,
|
||||
Emperor's Children,10 Tormentors,,160,,,,
|
||||
Emperor's Children,3 Fiends,95,90,"-5,26%",,,
|
||||
Emperor's Children,3 Flawless Blades,100,95,"-5,00%",,,
|
||||
Emperor's Children,5 Infractors,85,85,"0,00%",,,
|
||||
Emperor's Children,5 Seekers,80,80,"0,00%",,,
|
||||
Emperor's Children,5 Tormentors,85,80,"-5,88%",,,
|
||||
Emperor's Children,6 Fiends,,180,,,,
|
||||
Emperor's Children,6 Flawless Blades,,190,,,,
|
||||
Emperor's Children,CHAOS LAND RAIDER,220,220,"0,00%",,240,
|
||||
Emperor's Children,CHAOS RHINO,80,80,"0,00%",,,
|
||||
Emperor's Children,CHAOS SPAWN,70,70,"0,00%",,,
|
||||
Emperor's Children,CHAOS TERMINATORS,145,145,"0,00%",,,
|
||||
Emperor's Children,DAEMON PRINCE OF SLAANESH,180,170,"-5,56%",185,,
|
||||
Emperor's Children,DAEMON PRINCE OF SLAANESH WITH WINGS,,205,,235,,
|
||||
Emperor's Children,DAEMONETTES,90,90,"0,00%",,,
|
||||
Emperor's Children,DEFILER,250,290,"16,00%",320,,
|
||||
Emperor's Children,FULGRIM,340,350,"2,94%",,,
|
||||
Emperor's Children,HELDRAKE,195,175,"-10,26%",,,
|
||||
Emperor's Children,KEEPER OF SECRETS,240,255,"6,25%",,270,
|
||||
Emperor's Children,LORD EXULTANT,80,80,"0,00%",,90,
|
||||
Emperor's Children,LORD KAKOPHONIST,70,70,"0,00%",,,
|
||||
Emperor's Children,LUCIUS THE ETERNAL,150,130,"-13,33%",,,
|
||||
Emperor's Children,MAULERFIEND,130,130,"0,00%",,140,
|
||||
Emperor's Children,NOISE MARINES,145,145,"0,00%",,160,
|
||||
Emperor's Children,SHALAXI HELBANE,340,340,"0,00%",,,
|
||||
Emperor's Children,SORCERER,60,60,"0,00%",,,
|
||||
|
33
csv/genestealer-cults.csv
Normal file
33
csv/genestealer-cults.csv
Normal file
@@ -0,0 +1,33 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Genestealer Cults,1 Achilles Ridgerunners,95,95,"0,00%",,105,
|
||||
Genestealer Cults,10 Aberrants,,300,,,310,
|
||||
Genestealer Cults,10 Acolyte Hybrids With Autopistols,,130,,,,
|
||||
Genestealer Cults,10 Acolyte Hybrids With Hand Flamers,,150,,,,
|
||||
Genestealer Cults,10 Atalan Jackals,,160,,,170,
|
||||
Genestealer Cults,10 Hybrid Metamorphs,,150,,,160,
|
||||
Genestealer Cults,10 Neophyte Hybrids,65,70,"7,69%",,,
|
||||
Genestealer Cults,10 Purestrain Genestealers,,140,,,150,
|
||||
Genestealer Cults,2 Achilles Ridgerunners,,160,,,170,
|
||||
Genestealer Cults,20 Neophyte Hybrids,,145,,,,
|
||||
Genestealer Cults,5 Aberrants,135,150,"11,11%",,160,
|
||||
Genestealer Cults,5 Acolyte Hybrids With Autopistols,65,70,"7,69%",,,
|
||||
Genestealer Cults,5 Acolyte Hybrids With Hand Flamers,70,75,"7,14%",,,
|
||||
Genestealer Cults,5 Atalan Jackals,85,90,"5,88%",,100,
|
||||
Genestealer Cults,5 Hybrid Metamorphs,70,75,"7,14%",,85,
|
||||
Genestealer Cults,5 Purestrain Genestealers,75,80,"6,67%",,90,
|
||||
Genestealer Cults,ABOMINANT,85,85,"0,00%",,95,
|
||||
Genestealer Cults,ACOLYTE ICONWARD,50,50,"0,00%",,,
|
||||
Genestealer Cults,BENEFICTUS,70,75,"7,14%",,,
|
||||
Genestealer Cults,BIOPHAGUS,50,50,"0,00%",,,
|
||||
Genestealer Cults,CLAMAVUS,50,50,"0,00%",,,
|
||||
Genestealer Cults,GOLIATH ROCKGRINDER,120,120,"0,00%",,130,
|
||||
Genestealer Cults,GOLIATH TRUCK,85,85,"0,00%",,,
|
||||
Genestealer Cults,JACKAL ALPHUS,55,55,"0,00%",,,
|
||||
Genestealer Cults,KELERMORPH,60,60,"0,00%",,,
|
||||
Genestealer Cults,LOCUS,45,35,"-22,22%",,,
|
||||
Genestealer Cults,MAGUS,50,50,"0,00%",,,
|
||||
Genestealer Cults,NEXOS,60,60,"0,00%",,,
|
||||
Genestealer Cults,PATRIARCH,75,80,"6,67%",,,
|
||||
Genestealer Cults,PRIMUS,70,70,"0,00%",,,
|
||||
Genestealer Cults,REDUCTUS SABOTEUR,65,70,"7,69%",,,
|
||||
Genestealer Cults,SANCTUS,50,65,"30,00%",,,
|
||||
|
37
csv/grey-knights.csv
Normal file
37
csv/grey-knights.csv
Normal file
@@ -0,0 +1,37 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Grey Knights,10 Brotherhood Terminator Squad,,375,,,,385
|
||||
Grey Knights,10 Interceptor Squad,,250,,,260,
|
||||
Grey Knights,10 Paladin Squad,,450,,,465,
|
||||
Grey Knights,10 Purgation Squad,,220,,,230,
|
||||
Grey Knights,10 Purifier Squad,,260,,,270,
|
||||
Grey Knights,10 Strike Squad,,240,,,,
|
||||
Grey Knights,4 Brotherhood Terminator Squad,150,140,"-6,67%",,,150
|
||||
Grey Knights,4 Paladin Squad,180,170,"-5,56%",,185,
|
||||
Grey Knights,5 Brotherhood Terminator Squad,,175,,,,185
|
||||
Grey Knights,5 Interceptor Squad,125,125,"0,00%",,135,
|
||||
Grey Knights,5 Paladin Squad,,215,,,230,
|
||||
Grey Knights,5 Purgation Squad,115,110,"-4,35%",,120,
|
||||
Grey Knights,5 Purifier Squad,125,130,"4,00%",,140,
|
||||
Grey Knights,5 Strike Squad,120,120,"0,00%",,,
|
||||
Grey Knights,8 Brotherhood Terminator Squad,,300,,,,310
|
||||
Grey Knights,8 Paladin Squad,,360,,,375,
|
||||
Grey Knights,BROTHER-CAPTAIN,90,95,"5,56%",,,
|
||||
Grey Knights,BROTHERHOOD CHAMPION,70,70,"0,00%",,,
|
||||
Grey Knights,BROTHERHOOD CHAPLAIN,65,65,"0,00%",,,
|
||||
Grey Knights,BROTHERHOOD LIBRARIAN,80,90,"12,50%",100,,
|
||||
Grey Knights,BROTHERHOOD TECHMARINE,70,70,"0,00%",,,
|
||||
Grey Knights,CASTELLAN CROWE,90,100,"11,11%",,,
|
||||
Grey Knights,GRAND MASTER,95,95,"0,00%",,,
|
||||
Grey Knights,GRAND MASTER IN NEMESIS DREADKNIGHT,225,200,"-11,11%",,215,
|
||||
Grey Knights,GRAND MASTER VOLDUS,110,140,"27,27%",,,
|
||||
Grey Knights,GREY KNIGHTS THUNDERHAWK GUNSHIP,805,805,"0,00%",855,,
|
||||
Grey Knights,LAND RAIDER,220,220,"0,00%",,240,
|
||||
Grey Knights,LAND RAIDER CRUSADER,220,220,"0,00%",,240,
|
||||
Grey Knights,LAND RAIDER REDEEMER,270,250,"-7,41%",,270,
|
||||
Grey Knights,NEMESIS DREADKNIGHT,210,195,"-7,14%",,210,
|
||||
Grey Knights,RAZORBACK,85,85,"0,00%",,,
|
||||
Grey Knights,RHINO,80,80,"0,00%",,,
|
||||
Grey Knights,STORMHAWK INTERCEPTOR,160,160,"0,00%",,,
|
||||
Grey Knights,STORMRAVEN GUNSHIP,280,280,"0,00%",300,,
|
||||
Grey Knights,STORMTALON GUNSHIP,170,170,"0,00%",,,
|
||||
Grey Knights,VENERABLE DREADNOUGHT,140,130,"-7,14%",,140,
|
||||
|
33
csv/imperial-agents.csv
Normal file
33
csv/imperial-agents.csv
Normal file
@@ -0,0 +1,33 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Imperial Agents,10 Aquila Kill Team,,200,,,,
|
||||
Imperial Agents,10 Deathwatch Kill Team,,190,,,,
|
||||
Imperial Agents,12 Inquisitorial Agents,,100,,,,
|
||||
Imperial Agents,5 Aquila Kill Team,100,100,"0,00%",,,
|
||||
Imperial Agents,5 Deathwatch Kill Team,100,100,"0,00%",,,
|
||||
Imperial Agents,6 Inquisitorial Agents,60,50,"-16,67%",,,
|
||||
Imperial Agents,CALLIDUS ASSASSIN,100,100,"0,00%",,,
|
||||
Imperial Agents,CORVUS BLACKSTAR,180,180,"0,00%",,,
|
||||
Imperial Agents,CULEXUS ASSASSIN,85,85,"0,00%",,,
|
||||
Imperial Agents,EVERSOR ASSASSIN,120,110,"-8,33%",,,
|
||||
Imperial Agents,EXACTION SQUAD,90,90,"0,00%",,,
|
||||
Imperial Agents,GREY KNIGHTS TERMINATOR SQUAD,210,190,"-9,52%",,,
|
||||
Imperial Agents,IMPERIAL NAVY BREACHERS,90,90,"0,00%",,,
|
||||
Imperial Agents,IMPERIAL RHINO,75,75,"0,00%",,,
|
||||
Imperial Agents,INQUISITOR,65,55,"-15,38%",,,
|
||||
Imperial Agents,INQUISITOR COTEAZ,95,75,"-21,05%",,,
|
||||
Imperial Agents,INQUISITOR DRAXUS,110,75,"-31,82%",,,
|
||||
Imperial Agents,INQUISITOR GREYFAX,65,65,"0,00%",,,
|
||||
Imperial Agents,INQUISITOR KROYLE,100,100,"0,00%",,,
|
||||
Imperial Agents,INQUISITORIAL CHIMERA,70,70,"0,00%",,,
|
||||
Imperial Agents,MINISTORUM PRIEST,40,40,"0,00%",,,
|
||||
Imperial Agents,NAVIGATOR,75,60,"-20,00%",,,
|
||||
Imperial Agents,ROGUE TRADER ENTOURAGE,105,75,"-28,57%",,,
|
||||
Imperial Agents,SANCTIFIERS,100,100,"0,00%",,,
|
||||
Imperial Agents,SISTERS OF BATTLE IMMOLATOR,115,100,"-13,04%",,,
|
||||
Imperial Agents,SISTERS OF BATTLE SQUAD,100,100,"0,00%",,,
|
||||
Imperial Agents,SUBDUCTOR SQUAD,100,85,"-15,00%",,,
|
||||
Imperial Agents,VIGILANT SQUAD,85,85,"0,00%",,,
|
||||
Imperial Agents,VINDICARE ASSASSIN,125,110,"-12,00%",,,
|
||||
Imperial Agents,VOIDSMEN-AT-ARMS,70,50,"-28,57%",,,
|
||||
Imperial Agents,WATCH CAPTAIN ARTEMIS,65,65,"0,00%",,,
|
||||
Imperial Agents,WATCH MASTER,105,95,"-9,52%",,,
|
||||
|
23
csv/imperial-knights.csv
Normal file
23
csv/imperial-knights.csv
Normal file
@@ -0,0 +1,23 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Imperial Knights,ACASTUS KNIGHT ASTERIUS,765,785,"2,61%",860,,
|
||||
Imperial Knights,ACASTUS KNIGHT PORPHYRION,700,725,"3,57%",800,,
|
||||
Imperial Knights,ARMIGER HELVERIN,135,140,"3,70%",,,
|
||||
Imperial Knights,ARMIGER MOIRAX,150,150,"0,00%",,160,
|
||||
Imperial Knights,ARMIGER WARGLAIVE,140,140,"0,00%",,,
|
||||
Imperial Knights,CANIS REX,415,415,"0,00%",,,
|
||||
Imperial Knights,CERASTUS KNIGHT ACHERON,395,380,"-3,80%",395,,
|
||||
Imperial Knights,CERASTUS KNIGHT ATRAPOS,405,405,"0,00%",425,,
|
||||
Imperial Knights,CERASTUS KNIGHT CASTIGATOR,395,380,"-3,80%",395,,
|
||||
Imperial Knights,CERASTUS KNIGHT LANCER,395,415,"5,06%",435,,
|
||||
Imperial Knights,KNIGHT CASTELLAN,410,400,"-2,44%",420,,
|
||||
Imperial Knights,KNIGHT CRUSADER,385,395,"2,60%",415,,
|
||||
Imperial Knights,KNIGHT DEFENDER,415,400,"-3,61%",420,,
|
||||
Imperial Knights,KNIGHT DESTRIER,250,265,"6,00%",,280,
|
||||
Imperial Knights,KNIGHT ERRANT,355,355,"0,00%",,370,
|
||||
Imperial Knights,KNIGHT GALLANT,355,355,"0,00%",,370,
|
||||
Imperial Knights,KNIGHT PALADIN,375,375,"0,00%",,390,
|
||||
Imperial Knights,KNIGHT PRECEPTOR,365,365,"0,00%",,380,
|
||||
Imperial Knights,KNIGHT VALIANT,410,400,"-2,44%",,415,
|
||||
Imperial Knights,KNIGHT WARDEN,375,375,"0,00%",,390,
|
||||
Imperial Knights,QUESTORIS KNIGHT MAGAERA,385,385,"0,00%",400,,
|
||||
Imperial Knights,QUESTORIS KNIGHT STYRIX,385,375,"-2,60%",390,,
|
||||
|
30
csv/leagues-of-votann.csv
Normal file
30
csv/leagues-of-votann.csv
Normal file
@@ -0,0 +1,30 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Leagues of Votann,1 Kapricus Defenders,70,65,"-7,14%",,,
|
||||
Leagues of Votann,10 Cthonian Beserks,,200,,,,
|
||||
Leagues of Votann,10 Einhyr Hearthguard,,270,,,280,
|
||||
Leagues of Votann,2 Kapricus Defenders,,130,,,,
|
||||
Leagues of Votann,3 Brôkhyr Thunderkyn,80,80,"0,00%",,90,
|
||||
Leagues of Votann,3 Hernkyn Pioneers,80,80,"0,00%",,90,
|
||||
Leagues of Votann,3 Ironkin Steeljacks With Heavy Volkanite Disintegrators,,80,,,90,
|
||||
Leagues of Votann,3 Ironkin Steeljacks With Melee Weapons,85,80,"-5,88%",,90,
|
||||
Leagues of Votann,5 Cthonian Beserks,100,100,"0,00%",,,
|
||||
Leagues of Votann,5 Einhyr Hearthguard,135,130,"-3,70%",,140,
|
||||
Leagues of Votann,6 Brôkhyr Thunderkyn,,160,,,170,
|
||||
Leagues of Votann,6 Hernkyn Pioneers,,160,,,170,
|
||||
Leagues of Votann,6 Ironkin Steeljacks With Heavy Volkanite Disintegrators,,160,,,170,
|
||||
Leagues of Votann,6 Ironkin Steeljacks With Melee Weapons,,160,,,170,
|
||||
Leagues of Votann,ARKANYST EVALUATOR,65,70,"7,69%",,,
|
||||
Leagues of Votann,BEREHK STORNBRÖW,95,95,"0,00%",,,
|
||||
Leagues of Votann,BRÔKHYR IRON-MASTER,75,75,"0,00%",,,
|
||||
Leagues of Votann,BURI AEGNIRSSEN,95,95,"0,00%",,,
|
||||
Leagues of Votann,CTHONIAN EARTHSHAKERS,110,110,"0,00%",,,
|
||||
Leagues of Votann,EINHYR CHAMPION,70,65,"-7,14%",,,
|
||||
Leagues of Votann,GRIMNYR,65,65,"0,00%",,,
|
||||
Leagues of Votann,HEARTHKYN WARRIORS,100,100,"0,00%",,,
|
||||
Leagues of Votann,HEKATON LAND FORTRESS,240,250,"4,17%",265,,
|
||||
Leagues of Votann,HERNKYN YAEGIRS,90,90,"0,00%",,,
|
||||
Leagues of Votann,KAPRICUS CARRIER,75,75,"0,00%",,,
|
||||
Leagues of Votann,KÂHL,65,65,"0,00%",,,
|
||||
Leagues of Votann,MEMNYR STRATEGIST,45,45,"0,00%",,,
|
||||
Leagues of Votann,SAGITAUR,90,85,"-5,56%",,,
|
||||
Leagues of Votann,ÛTHAR THE DESTINED,95,90,"-5,26%",,,
|
||||
|
72
csv/necrons.csv
Normal file
72
csv/necrons.csv
Normal file
@@ -0,0 +1,72 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Necrons,1 Canoptek Spyders,75,65,"-13,33%",,,
|
||||
Necrons,1 Convergence Of Dominion,60,60,"0,00%",,,
|
||||
Necrons,1 Lokhust Destroyers,40,40,"0,00%",,50,
|
||||
Necrons,1 Lokhust Heavy Destroyers,55,50,"-9,09%",,60,
|
||||
Necrons,10 Deathmarks,,120,,,130,
|
||||
Necrons,10 Flayed Ones,,100,,,,
|
||||
Necrons,10 Immortals,,140,,,,
|
||||
Necrons,10 Lychguard,,160,,,,
|
||||
Necrons,10 Necron Warriors,90,80,"-11,11%",,,
|
||||
Necrons,10 Triarch Praetorians,,160,,,,
|
||||
Necrons,2 Canoptek Spyders,,110,,,,
|
||||
Necrons,2 Convergence Of Dominion,,120,,,,
|
||||
Necrons,2 Lokhust Destroyers,,55,,,65,
|
||||
Necrons,2 Lokhust Heavy Destroyers,,100,,,110,
|
||||
Necrons,20 Necron Warriors,,190,,,,
|
||||
Necrons,3 Canoptek Scarab Swarms,40,40,"0,00%",,,
|
||||
Necrons,3 Canoptek Wraiths,110,95,"-13,64%",115,,
|
||||
Necrons,3 Convergence Of Dominion,,180,,,,
|
||||
Necrons,3 Lokhust Destroyers,,80,,,90,
|
||||
Necrons,3 Lokhust Heavy Destroyers,,150,,,160,
|
||||
Necrons,3 Ophydian Destroyers,80,80,"0,00%",,90,
|
||||
Necrons,3 Skorpekh Destroyers,90,85,"-5,56%",,95,
|
||||
Necrons,3 Tomb Blades,75,70,"-6,67%",,80,
|
||||
Necrons,5 Deathmarks,60,60,"0,00%",,70,
|
||||
Necrons,5 Flayed Ones,60,55,"-8,33%",,,
|
||||
Necrons,5 Immortals,70,70,"0,00%",,,
|
||||
Necrons,5 Lychguard,85,80,"-5,88%",,,
|
||||
Necrons,5 Triarch Praetorians,90,80,"-11,11%",,,
|
||||
Necrons,6 Canoptek Scarab Swarms,,80,,,,
|
||||
Necrons,6 Canoptek Wraiths,,220,,240,,
|
||||
Necrons,6 Lokhust Destroyers,,160,,,170,
|
||||
Necrons,6 Ophydian Destroyers,,145,,,155,
|
||||
Necrons,6 Skorpekh Destroyers,,170,,,180,
|
||||
Necrons,6 Tomb Blades,,140,,,150,
|
||||
Necrons,ANNIHILATION BARGE,105,95,"-9,52%",,,
|
||||
Necrons,CANOPTEK DOOMSTALKER,140,140,"0,00%",,,
|
||||
Necrons,CANOPTEK MACROCYTES,85,70,"-17,65%",,,
|
||||
Necrons,CANOPTEK REANIMATOR,75,70,"-6,67%",,,
|
||||
Necrons,CANOPTEK TOMB CRAWLERS,50,50,"0,00%",,,
|
||||
Necrons,CATACOMB COMMAND BARGE,120,120,"0,00%",,,
|
||||
Necrons,CHRONOMANCER,65,80,"23,08%",90,,
|
||||
Necrons,CRYPTOTHRALLS,60,60,"0,00%",,,
|
||||
Necrons,C’TAN SHARD OF THE DECEIVER,310,330,"6,45%",,,
|
||||
Necrons,C’TAN SHARD OF THE NIGHTBRINGER,340,360,"5,88%",,,
|
||||
Necrons,C’TAN SHARD OF THE VOID DRAGON,330,345,"4,55%",,,
|
||||
Necrons,DOOM SCYTHE,230,200,"-13,04%",,,
|
||||
Necrons,DOOMSDAY ARK,200,200,"0,00%",,220,
|
||||
Necrons,GEOMANCER,75,75,"0,00%",,,
|
||||
Necrons,GHOST ARK,115,100,"-13,04%",,,
|
||||
Necrons,HEXMARK DESTROYER,75,75,"0,00%",,,
|
||||
Necrons,ILLUMINOR SZERAS,165,175,"6,06%",,,
|
||||
Necrons,IMOTEKH THE STORMLORD,100,100,"0,00%",,,
|
||||
Necrons,LOKHUST LORD,80,70,"-12,50%",,,
|
||||
Necrons,MONOLITH,400,420,"5,00%",440,,
|
||||
Necrons,NEKROSOR AMMENTAR,185,175,"-5,41%",,,
|
||||
Necrons,NIGHT SCYTHE,145,125,"-13,79%",,,
|
||||
Necrons,OBELISK,300,280,"-6,67%",310,,
|
||||
Necrons,ORIKAN THE DIVINER,80,90,"12,50%",,,
|
||||
Necrons,OVERLORD,85,90,"5,88%",,,
|
||||
Necrons,OVERLORD WITH TRANSLOCATION SHROUD,85,90,"5,88%",,,
|
||||
Necrons,PLASMANCER,55,55,"0,00%",,,
|
||||
Necrons,PSYCHOMANCER,55,55,"0,00%",,,
|
||||
Necrons,ROYAL WARDEN,50,50,"0,00%",,,
|
||||
Necrons,SERAPTEK HEAVY CONSTRUCT,540,540,"0,00%",570,,
|
||||
Necrons,SKORPEKH LORD,90,90,"0,00%",,,
|
||||
Necrons,TECHNOMANCER,80,80,"0,00%",90,,
|
||||
Necrons,TESSERACT VAULT,425,445,"4,71%",465,,
|
||||
Necrons,THE SILENT KING,400,400,"0,00%",,,
|
||||
Necrons,TRANSCENDENT C’TAN,325,340,"4,62%",360,,
|
||||
Necrons,TRAZYN THE INFINITE,75,65,"-13,33%",,,
|
||||
Necrons,TRIARCH STALKER,110,110,"0,00%",,120,
|
||||
|
76
csv/orks.csv
Normal file
76
csv/orks.csv
Normal file
@@ -0,0 +1,76 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Orks,1 Mek Gunz,50,45,"-10,00%",,55,
|
||||
Orks,10 Beast Snagga Boyz,95,90,"-5,26%",,,
|
||||
Orks,10 Boyz,80,75,"-6,25%",,,
|
||||
Orks,10 Burna Boyz,,120,,,,
|
||||
Orks,10 Flash Gitz,,150,,,160,
|
||||
Orks,10 Lootas,,100,,,110,
|
||||
Orks,10 Nobz,,210,,,220,
|
||||
Orks,10 Stormboyz,,130,,,,
|
||||
Orks,11 Gretchin,40,45,"12,50%",,,
|
||||
Orks,2 Meganobz,65,60,"-7,69%",,80,
|
||||
Orks,2 Mek Gunz,,90,,,100,
|
||||
Orks,20 Beast Snagga Boyz,,170,,,,
|
||||
Orks,20 Boyz,,150,,,,
|
||||
Orks,22 Gretchin,,80,,,,
|
||||
Orks,3 Deffkoptas,80,75,"-6,25%",,,
|
||||
Orks,3 Killa Kans,125,120,"-4,00%",,130,
|
||||
Orks,3 Meganobz,,90,,,110,
|
||||
Orks,3 Mek Gunz,,135,,,145,
|
||||
Orks,3 Warbikers,65,60,"-7,69%",,,
|
||||
Orks,4 Squighog Boyz,150,140,"-6,67%",,,
|
||||
Orks,5 Burna Boyz,,60,,,,
|
||||
Orks,5 Flash Gitz,80,75,"-6,25%",,85,
|
||||
Orks,5 Lootas,,50,,,60,
|
||||
Orks,5 Meganobz,,150,,,170,
|
||||
Orks,5 Nobz,105,105,"0,00%",,115,
|
||||
Orks,5 Stormboyz,65,65,"0,00%",,,
|
||||
Orks,6 Deffkoptas,,140,,,,
|
||||
Orks,6 Killa Kans,,240,,,250,
|
||||
Orks,6 Meganobz,,180,,,200,
|
||||
Orks,6 Warbikers,,120,,,,
|
||||
Orks,8 Squighog Boyz,,270,,,,
|
||||
Orks,BANNERNOB,,50,,,,
|
||||
Orks,BATTLEWAGON,160,145,"-9,38%",,,
|
||||
Orks,BEASTBOSS,80,80,"0,00%",,,
|
||||
Orks,BEASTBOSS ON SQUIGOSAUR,110,95,"-13,64%",,,
|
||||
Orks,BIG MEK,70,70,"0,00%",,,
|
||||
Orks,BIG MEK DAKKARIG,,100,,,110,
|
||||
Orks,BIG MEK IN MEGA ARMOUR,90,80,"-11,11%",,,
|
||||
Orks,BIG MEK WITH SHOKK ATTACK GUN,80,70,"-12,50%",,80,
|
||||
Orks,BIGBOSS,,55,,,,
|
||||
Orks,BIG’ED BOSSBUNKA,135,135,"0,00%",,,
|
||||
Orks,BLITZA-BOMMER,115,105,"-8,70%",,,
|
||||
Orks,BOOMDAKKA SNAZZWAGON,70,70,"0,00%",,,
|
||||
Orks,BOSS SNIKROT,75,75,"0,00%",,,
|
||||
Orks,BREAKA BOYZ,140,125,"-10,71%",,135,
|
||||
Orks,BURNA-BOMMER,125,115,"-8,00%",,,
|
||||
Orks,DAKKAJET,135,125,"-7,41%",,,
|
||||
Orks,DEFF DREAD,120,110,"-8,33%",,120,
|
||||
Orks,DEFFKILLA WARTRIKE,80,70,"-12,50%",,,
|
||||
Orks,GARGANTUAN SQUIGGOTH,440,440,"0,00%",490,,
|
||||
Orks,GHAZGHKULL THRAKA,235,235,"0,00%",,,
|
||||
Orks,GORKANAUT,265,255,"-3,77%",,275,
|
||||
Orks,HUNTA RIG,135,125,"-7,41%",,,
|
||||
Orks,KILL RIG,155,145,"-6,45%",,,
|
||||
Orks,KOMMANDOS,120,120,"0,00%",,,
|
||||
Orks,KUSTOM BOOSTA-BLASTA,70,70,"0,00%",,,
|
||||
Orks,MEGATRAKK SCRAPJET,75,75,"0,00%",,,
|
||||
Orks,MEK,45,45,"0,00%",,,
|
||||
Orks,MORKANAUT,280,270,"-3,57%",,290,
|
||||
Orks,MOZROG SKRAGBAD,145,125,"-13,79%",,,
|
||||
Orks,PAINBOSS,70,60,"-14,29%",,,
|
||||
Orks,PAINBOY,80,80,"0,00%",,,
|
||||
Orks,RUKKATRUKK SQUIGBUGGY,95,85,"-10,53%",,,
|
||||
Orks,SHOKKJUMP DRAGSTA,70,70,"0,00%",,,
|
||||
Orks,STOMPA,800,600,"-25,00%",700,,
|
||||
Orks,TANKBUSTAS,140,125,"-10,71%",,135,
|
||||
Orks,TRUKK,70,65,"-7,14%",,,
|
||||
Orks,WARBOSS,75,75,"0,00%",,,
|
||||
Orks,WARBOSS IN MEGA ARMOUR,80,80,"0,00%",,,
|
||||
Orks,WARTRAKK,,60,,,,
|
||||
Orks,WAZBOM BLASTAJET,175,165,"-5,71%",,,
|
||||
Orks,WAZDAKKA GUTSMEK,175,175,"0,00%",,,
|
||||
Orks,WEIRDBOY,65,65,"0,00%",,,
|
||||
Orks,WURRBOY,60,60,"0,00%",,,
|
||||
Orks,ZODGROD WORTSNAGGA,90,80,"-11,11%",,,
|
||||
|
128
csv/space-marines.csv
Normal file
128
csv/space-marines.csv
Normal file
@@ -0,0 +1,128 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Space Marines,1 Firestrike Servo-Turrets,75,75,"0,00%",,,
|
||||
Space Marines,10 Assault Intercessor Squad,,150,,,,
|
||||
Space Marines,10 Assault Intercessors With Jump Packs,,160,,,170,
|
||||
Space Marines,10 Devastator Squad,,200,,,,
|
||||
Space Marines,10 Heavy Intercessor Squad,,200,,,,
|
||||
Space Marines,10 Hellblaster Squad,,220,,,,
|
||||
Space Marines,10 Incursor Squad,,150,,,160,
|
||||
Space Marines,10 Infernus Squad,,170,,,,
|
||||
Space Marines,10 Infiltrator Squad,,180,,,190,
|
||||
Space Marines,10 Intercessor Squad,,150,,,,
|
||||
Space Marines,10 Reiver Squad,,150,,,,
|
||||
Space Marines,10 Scout Squad,,120,,,130,
|
||||
Space Marines,10 Sternguard Veteran Squad,,190,,,,
|
||||
Space Marines,10 Terminator Assault Squad,,310,,,,
|
||||
Space Marines,10 Terminator Squad,,320,,,,
|
||||
Space Marines,10 Vanguard Veteran Squad With Jump Packs,,200,,,210,
|
||||
Space Marines,2 Firestrike Servo-Turrets,,150,,,,
|
||||
Space Marines,3 Aggressor Squad,95,90,"-5,26%",,100,
|
||||
Space Marines,3 Bladeguard Veteran Squad,80,80,"0,00%",,90,
|
||||
Space Marines,3 Centurion Assault Squad,150,150,"0,00%",,,
|
||||
Space Marines,3 Centurion Devastator Squad,175,175,"0,00%",,,
|
||||
Space Marines,3 Eradicator Squad,90,90,"0,00%",,100,
|
||||
Space Marines,3 Inceptor Squad,120,120,"0,00%",,135,
|
||||
Space Marines,3 Outrider Squad,80,70,"-12,50%",,,
|
||||
Space Marines,3 Victrix Honour Guard,110,110,"0,00%",130,,
|
||||
Space Marines,5 Assault Intercessor Squad,75,75,"0,00%",,,
|
||||
Space Marines,5 Assault Intercessors With Jump Packs,90,85,"-5,56%",,95,
|
||||
Space Marines,5 Devastator Squad,120,120,"0,00%",,,
|
||||
Space Marines,5 Heavy Intercessor Squad,100,100,"0,00%",,,
|
||||
Space Marines,5 Hellblaster Squad,110,110,"0,00%",,,
|
||||
Space Marines,5 Incursor Squad,80,85,"6,25%",,95,
|
||||
Space Marines,5 Infernus Squad,90,85,"-5,56%",,,
|
||||
Space Marines,5 Infiltrator Squad,100,110,"10,00%",,120,
|
||||
Space Marines,5 Intercessor Squad,80,80,"0,00%",,,
|
||||
Space Marines,5 Reiver Squad,80,75,"-6,25%",,,
|
||||
Space Marines,5 Scout Squad,70,65,"-7,14%",,75,
|
||||
Space Marines,5 Sternguard Veteran Squad,100,100,"0,00%",,,
|
||||
Space Marines,5 Terminator Assault Squad,180,155,"-13,89%",,,
|
||||
Space Marines,5 Terminator Squad,170,160,"-5,88%",,,
|
||||
Space Marines,5 Vanguard Veteran Squad With Jump Packs,100,100,"0,00%",,110,
|
||||
Space Marines,6 Aggressor Squad,,180,,,190,
|
||||
Space Marines,6 Bladeguard Veteran Squad,,160,,,170,
|
||||
Space Marines,6 Centurion Assault Squad,,300,,,,
|
||||
Space Marines,6 Centurion Devastator Squad,,350,,,,
|
||||
Space Marines,6 Eradicator Squad,,180,,,190,
|
||||
Space Marines,6 Inceptor Squad,,240,,,255,
|
||||
Space Marines,6 Outrider Squad,,140,,,,
|
||||
Space Marines,6 Victrix Honour Guard,,220,,240,,
|
||||
Space Marines,ADRAX AGATONE,85,80,"-5,88%",,,
|
||||
Space Marines,AETHON SHAAN,110,100,"-9,09%",,,
|
||||
Space Marines,ANCIENT,50,40,"-20,00%",,,
|
||||
Space Marines,ANCIENT IN TERMINATOR ARMOUR,75,65,"-13,33%",,,
|
||||
Space Marines,APOTHECARY,50,40,"-20,00%",,,
|
||||
Space Marines,APOTHECARY BIOLOGIS,70,70,"0,00%",,,
|
||||
Space Marines,ASTRAEUS,525,525,"0,00%",575,,
|
||||
Space Marines,BALLISTUS DREADNOUGHT,150,150,"0,00%",,160,
|
||||
Space Marines,BLADEGUARD ANCIENT,45,40,"-11,11%",,,
|
||||
Space Marines,BRUTALIS DREADNOUGHT,160,150,"-6,25%",,160,
|
||||
Space Marines,CAANOK VAR,100,90,"-10,00%",,,
|
||||
Space Marines,CAPTAIN,80,80,"0,00%",,,
|
||||
Space Marines,CAPTAIN IN GRAVIS ARMOUR,80,80,"0,00%",,,
|
||||
Space Marines,CAPTAIN IN PHOBOS ARMOUR,70,70,"0,00%",,,
|
||||
Space Marines,CAPTAIN IN TERMINATOR ARMOUR,95,85,"-10,53%",,,
|
||||
Space Marines,CAPTAIN TITUS,90,90,"0,00%",,,
|
||||
Space Marines,CAPTAIN WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Space Marines,CATO SICARIUS,95,95,"0,00%",,,
|
||||
Space Marines,CHAPLAIN,60,60,"0,00%",,,
|
||||
Space Marines,CHAPLAIN IN TERMINATOR ARMOUR,75,75,"0,00%",,,
|
||||
Space Marines,CHAPLAIN ON BIKE,75,70,"-6,67%",,,
|
||||
Space Marines,CHAPLAIN WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Space Marines,CHIEF LIBRARIAN TIGURIUS,75,95,"26,67%",,,
|
||||
Space Marines,COMPANY HEROES,105,105,"0,00%",,,
|
||||
Space Marines,DARNATH LYSANDER,100,100,"0,00%",,,
|
||||
Space Marines,DESOLATION SQUAD,200,180,"-10,00%",210,,
|
||||
Space Marines,DREADNOUGHT,135,135,"0,00%",,,
|
||||
Space Marines,DROP POD,70,70,"0,00%",,,
|
||||
Space Marines,ELIMINATOR SQUAD,85,75,"-11,76%",,,
|
||||
Space Marines,ERADICATOR SQUAD WITH HEAVY BOLTERS,,70,,,80,
|
||||
Space Marines,GLADIATOR LANCER,160,160,"0,00%",,170,
|
||||
Space Marines,GLADIATOR REAPER,160,160,"0,00%",,170,
|
||||
Space Marines,GLADIATOR VALIANT,150,150,"0,00%",,160,
|
||||
Space Marines,HAMMERFALL BUNKER,175,175,"0,00%",,,
|
||||
Space Marines,IMPULSOR,80,80,"0,00%",,,
|
||||
Space Marines,INVADER ATV,60,60,"0,00%",,,
|
||||
Space Marines,INVICTOR TACTICAL WARSUIT,125,125,"0,00%",,,
|
||||
Space Marines,IRON FATHER FEIRROS,95,85,"-10,53%",,,
|
||||
Space Marines,JUDICIAR,70,55,"-21,43%",,,
|
||||
Space Marines,KAYVAAN SHRIKE,100,100,"0,00%",,,
|
||||
Space Marines,KOR’SARRO KHAN,60,55,"-8,33%",,,
|
||||
Space Marines,LAND RAIDER,220,220,"0,00%",,240,
|
||||
Space Marines,LAND RAIDER CRUSADER,220,220,"0,00%",,240,
|
||||
Space Marines,LAND RAIDER REDEEMER,270,250,"-7,41%",,270,
|
||||
Space Marines,LAND SPEEDER,,95,,,105,
|
||||
Space Marines,LIBRARIAN,65,60,"-7,69%",,,
|
||||
Space Marines,LIBRARIAN IN PHOBOS ARMOUR,70,70,"0,00%",,,
|
||||
Space Marines,LIBRARIAN IN TERMINATOR ARMOUR,75,75,"0,00%",,,
|
||||
Space Marines,LIEUTENANT,55,45,"-18,18%",,,
|
||||
Space Marines,LIEUTENANT IN PHOBOS ARMOUR,55,45,"-18,18%",,,
|
||||
Space Marines,LIEUTENANT IN REIVER ARMOUR,55,45,"-18,18%",,,
|
||||
Space Marines,LIEUTENANT WITH COMBI-WEAPON,85,85,"0,00%",,,
|
||||
Space Marines,MARNEUS CALGAR IN ARMOUR OF ANTILOCHUS,140,140,"0,00%",,,
|
||||
Space Marines,PEDRO KANTOR,90,80,"-11,11%",,,
|
||||
Space Marines,PREDATOR ANNIHILATOR,135,135,"0,00%",,145,
|
||||
Space Marines,PREDATOR DESTRUCTOR,140,140,"0,00%",,150,
|
||||
Space Marines,RAZORBACK,95,95,"0,00%",,,
|
||||
Space Marines,REDEMPTOR DREADNOUGHT,205,195,"-4,88%",,210,
|
||||
Space Marines,REPULSOR,180,170,"-5,56%",,190,
|
||||
Space Marines,REPULSOR EXECUTIONER,230,240,"4,35%",,260,
|
||||
Space Marines,RHINO,75,75,"0,00%",,,
|
||||
Space Marines,ROBOUTE GUILLIMAN,340,340,"0,00%",,,
|
||||
Space Marines,STORM SPEEDER HAILSTRIKE,115,105,"-8,70%",,115,
|
||||
Space Marines,STORM SPEEDER HAMMERSTRIKE,125,130,"4,00%",,140,
|
||||
Space Marines,STORM SPEEDER THUNDERSTRIKE,135,135,"0,00%",,145,
|
||||
Space Marines,STORMHAWK INTERCEPTOR,155,155,"0,00%",,,
|
||||
Space Marines,STORMRAVEN GUNSHIP,280,280,"0,00%",300,,
|
||||
Space Marines,STORMTALON GUNSHIP,165,165,"0,00%",,,
|
||||
Space Marines,SUBODEN KHAN,115,100,"-13,04%",,,
|
||||
Space Marines,SUPPRESSOR SQUAD,75,75,"0,00%",,,
|
||||
Space Marines,TACTICAL SQUAD,140,140,"0,00%",,,
|
||||
Space Marines,TECHMARINE,55,55,"0,00%",,,
|
||||
Space Marines,THUNDERHAWK GUNSHIP,840,840,"0,00%",,,
|
||||
Space Marines,TOR GARADON,90,80,"-11,11%",,,
|
||||
Space Marines,URIEL VENTRIS,95,95,"0,00%",,,
|
||||
Space Marines,VINDICATOR,185,185,"0,00%",,200,
|
||||
Space Marines,VULKAN HE’STAN,100,85,"-15,00%",,,
|
||||
Space Marines,WARDENS OF ULTRAMAR,105,110,"4,76%",,,
|
||||
Space Marines,WHIRLWIND,190,175,"-7,89%",195,,
|
||||
|
130
csv/space-wolves.csv
Normal file
130
csv/space-wolves.csv
Normal file
@@ -0,0 +1,130 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Space Wolves,1 Firestrike Servo-Turrets,75,75,"0,00%",,,
|
||||
Space Wolves,10 Assault Intercessor Squad,,150,,,,
|
||||
Space Wolves,10 Assault Intercessors With Jump Packs,,160,,,170,
|
||||
Space Wolves,10 Blood Claws,135,135,"0,00%",,,
|
||||
Space Wolves,10 Fenrisian Wolves,,75,,,80,
|
||||
Space Wolves,10 Heavy Intercessor Squad,,200,,,,
|
||||
Space Wolves,10 Hellblaster Squad,,220,,,,
|
||||
Space Wolves,10 Incursor Squad,,150,,,160,
|
||||
Space Wolves,10 Infernus Squad,,170,,,,
|
||||
Space Wolves,10 Infiltrator Squad,,180,,,190,
|
||||
Space Wolves,10 Intercessor Squad,,150,,,,
|
||||
Space Wolves,10 Reiver Squad,,150,,,,
|
||||
Space Wolves,10 Scout Squad,,120,,,130,
|
||||
Space Wolves,10 Sternguard Veteran Squad,,190,,,,
|
||||
Space Wolves,10 Terminator Assault Squad,,310,,,,
|
||||
Space Wolves,10 Terminator Squad,,320,,,,
|
||||
Space Wolves,10 Vanguard Veteran Squad With Jump Packs,,200,,,210,
|
||||
Space Wolves,10 Wolf Guard Terminators,,300,,310,,
|
||||
Space Wolves,10 Wulfen,,170,,,180,
|
||||
Space Wolves,10 Wulfen With Storm Shields,,200,,,210,
|
||||
Space Wolves,12 Wolf Scouts,,190,,,200,
|
||||
Space Wolves,2 Firestrike Servo-Turrets,,150,,,,
|
||||
Space Wolves,20 Blood Claws,,270,,,,
|
||||
Space Wolves,3 Aggressor Squad,95,90,"-5,26%",,100,
|
||||
Space Wolves,3 Bladeguard Veteran Squad,80,80,"0,00%",,90,
|
||||
Space Wolves,3 Centurion Assault Squad,150,150,"0,00%",,,
|
||||
Space Wolves,3 Centurion Devastator Squad,175,175,"0,00%",,,
|
||||
Space Wolves,3 Eradicator Squad,90,90,"0,00%",,100,
|
||||
Space Wolves,3 Inceptor Squad,120,120,"0,00%",,135,
|
||||
Space Wolves,3 Outrider Squad,80,70,"-12,50%",,,
|
||||
Space Wolves,3 Thunderwolf Cavalry,115,100,"-13,04%",,110,
|
||||
Space Wolves,5 Assault Intercessor Squad,75,75,"0,00%",,,
|
||||
Space Wolves,5 Assault Intercessors With Jump Packs,90,85,"-5,56%",,95,
|
||||
Space Wolves,5 Fenrisian Wolves,40,45,"12,50%",,50,
|
||||
Space Wolves,5 Heavy Intercessor Squad,100,100,"0,00%",,,
|
||||
Space Wolves,5 Hellblaster Squad,110,110,"0,00%",,,
|
||||
Space Wolves,5 Incursor Squad,80,85,"6,25%",,95,
|
||||
Space Wolves,5 Infernus Squad,90,85,"-5,56%",,,
|
||||
Space Wolves,5 Infiltrator Squad,100,110,"10,00%",,120,
|
||||
Space Wolves,5 Intercessor Squad,80,80,"0,00%",,,
|
||||
Space Wolves,5 Reiver Squad,80,75,"-6,25%",,,
|
||||
Space Wolves,5 Scout Squad,70,65,"-7,14%",,75,
|
||||
Space Wolves,5 Sternguard Veteran Squad,100,100,"0,00%",,,
|
||||
Space Wolves,5 Terminator Assault Squad,180,155,"-13,89%",,,
|
||||
Space Wolves,5 Terminator Squad,170,160,"-5,88%",,,
|
||||
Space Wolves,5 Vanguard Veteran Squad With Jump Packs,100,100,"0,00%",,110,
|
||||
Space Wolves,5 Wolf Guard Terminators,170,150,"-11,76%",160,,
|
||||
Space Wolves,5 Wulfen,85,85,"0,00%",,95,
|
||||
Space Wolves,5 Wulfen With Storm Shields,100,100,"0,00%",,110,
|
||||
Space Wolves,6 Aggressor Squad,,180,,,190,
|
||||
Space Wolves,6 Bladeguard Veteran Squad,,160,,,170,
|
||||
Space Wolves,6 Centurion Assault Squad,,300,,,,
|
||||
Space Wolves,6 Centurion Devastator Squad,,350,,,,
|
||||
Space Wolves,6 Eradicator Squad,,180,,,190,
|
||||
Space Wolves,6 Inceptor Squad,,240,,,255,
|
||||
Space Wolves,6 Outrider Squad,,140,,,,
|
||||
Space Wolves,6 Thunderwolf Cavalry,,200,,,210,
|
||||
Space Wolves,6 Wolf Scouts,105,95,"-9,52%",,105,
|
||||
Space Wolves,ANCIENT,50,40,"-20,00%",,,
|
||||
Space Wolves,ANCIENT IN TERMINATOR ARMOUR,75,65,"-13,33%",,,
|
||||
Space Wolves,ARJAC ROCKFIST,105,105,"0,00%",,,
|
||||
Space Wolves,ASTRAEUS,525,525,"0,00%",575,,
|
||||
Space Wolves,BALLISTUS DREADNOUGHT,150,150,"0,00%",,160,
|
||||
Space Wolves,BJORN THE FELL-HANDED,160,160,"0,00%",,,
|
||||
Space Wolves,BLADEGUARD ANCIENT,45,40,"-11,11%",,,
|
||||
Space Wolves,BRUTALIS DREADNOUGHT,160,150,"-6,25%",,160,
|
||||
Space Wolves,CAPTAIN,80,80,"0,00%",,,
|
||||
Space Wolves,CAPTAIN IN GRAVIS ARMOUR,80,80,"0,00%",,,
|
||||
Space Wolves,CAPTAIN IN PHOBOS ARMOUR,70,70,"0,00%",,,
|
||||
Space Wolves,CAPTAIN IN TERMINATOR ARMOUR,95,85,"-10,53%",,,
|
||||
Space Wolves,CAPTAIN WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Space Wolves,CHAPLAIN,60,60,"0,00%",,,
|
||||
Space Wolves,CHAPLAIN IN TERMINATOR ARMOUR,75,75,"0,00%",,,
|
||||
Space Wolves,CHAPLAIN ON BIKE,75,70,"-6,67%",,,
|
||||
Space Wolves,CHAPLAIN WITH JUMP PACK,75,75,"0,00%",,,
|
||||
Space Wolves,COMPANY HEROES,105,105,"0,00%",,,
|
||||
Space Wolves,DESOLATION SQUAD,200,180,"-10,00%",210,,
|
||||
Space Wolves,DREADNOUGHT,135,135,"0,00%",,,
|
||||
Space Wolves,DROP POD,70,70,"0,00%",,,
|
||||
Space Wolves,ELIMINATOR SQUAD,85,75,"-11,76%",,,
|
||||
Space Wolves,ERADICATOR SQUAD WITH HEAVY BOLTERS,,70,,,80,
|
||||
Space Wolves,GLADIATOR LANCER,160,160,"0,00%",,170,
|
||||
Space Wolves,GLADIATOR REAPER,160,160,"0,00%",,170,
|
||||
Space Wolves,GLADIATOR VALIANT,150,150,"0,00%",,160,
|
||||
Space Wolves,GREY HUNTERS,165,165,"0,00%",,,
|
||||
Space Wolves,HAMMERFALL BUNKER,175,175,"0,00%",,,
|
||||
Space Wolves,IMPULSOR,80,80,"0,00%",,,
|
||||
Space Wolves,INVADER ATV,60,60,"0,00%",,,
|
||||
Space Wolves,INVICTOR TACTICAL WARSUIT,125,125,"0,00%",,,
|
||||
Space Wolves,IRON PRIEST,55,55,"0,00%",,,
|
||||
Space Wolves,JUDICIAR,70,55,"-21,43%",,,
|
||||
Space Wolves,LAND RAIDER,220,220,"0,00%",,240,
|
||||
Space Wolves,LAND RAIDER CRUSADER,220,220,"0,00%",,240,
|
||||
Space Wolves,LAND RAIDER REDEEMER,270,250,"-7,41%",,270,
|
||||
Space Wolves,LAND SPEEDER,,95,,,105,
|
||||
Space Wolves,LIBRARIAN,65,60,"-7,69%",,,
|
||||
Space Wolves,LIBRARIAN IN PHOBOS ARMOUR,70,70,"0,00%",,,
|
||||
Space Wolves,LIBRARIAN IN TERMINATOR ARMOUR,75,75,"0,00%",,,
|
||||
Space Wolves,LIEUTENANT,55,45,"-18,18%",,,
|
||||
Space Wolves,LIEUTENANT IN PHOBOS ARMOUR,55,45,"-18,18%",,,
|
||||
Space Wolves,LIEUTENANT IN REIVER ARMOUR,55,45,"-18,18%",,,
|
||||
Space Wolves,LIEUTENANT WITH COMBI-WEAPON,85,85,"0,00%",,,
|
||||
Space Wolves,LOGAN GRIMNAR,110,110,"0,00%",,,
|
||||
Space Wolves,MURDERFANG,150,150,"0,00%",,,
|
||||
Space Wolves,NJAL STORMCALLER,85,85,"0,00%",,,
|
||||
Space Wolves,PREDATOR ANNIHILATOR,135,135,"0,00%",,145,
|
||||
Space Wolves,PREDATOR DESTRUCTOR,140,140,"0,00%",,150,
|
||||
Space Wolves,RAGNAR BLACKMANE,100,100,"0,00%",,,
|
||||
Space Wolves,RAZORBACK,95,95,"0,00%",,,
|
||||
Space Wolves,REDEMPTOR DREADNOUGHT,205,195,"-4,88%",,210,
|
||||
Space Wolves,REPULSOR,180,170,"-5,56%",,190,
|
||||
Space Wolves,REPULSOR EXECUTIONER,230,230,"0,00%",,250,
|
||||
Space Wolves,RHINO,75,75,"0,00%",,,
|
||||
Space Wolves,STORM SPEEDER HAILSTRIKE,115,105,"-8,70%",,115,
|
||||
Space Wolves,STORM SPEEDER HAMMERSTRIKE,125,130,"4,00%",,140,
|
||||
Space Wolves,STORM SPEEDER THUNDERSTRIKE,135,135,"0,00%",,145,
|
||||
Space Wolves,STORMHAWK INTERCEPTOR,155,155,"0,00%",,,
|
||||
Space Wolves,STORMRAVEN GUNSHIP,280,280,"0,00%",300,,
|
||||
Space Wolves,STORMTALON GUNSHIP,165,165,"0,00%",,,
|
||||
Space Wolves,SUPPRESSOR SQUAD,75,75,"0,00%",,,
|
||||
Space Wolves,TECHMARINE,55,55,"0,00%",,,
|
||||
Space Wolves,THUNDERHAWK GUNSHIP,840,840,"0,00%",,,
|
||||
Space Wolves,ULRIK THE SLAYER,70,70,"0,00%",,,
|
||||
Space Wolves,VENERABLE DREADNOUGHT,130,125,"-3,85%",,135,
|
||||
Space Wolves,VINDICATOR,185,185,"0,00%",,200,
|
||||
Space Wolves,WHIRLWIND,190,175,"-7,89%",195,,
|
||||
Space Wolves,WOLF GUARD BATTLE LEADER,65,65,"0,00%",,,
|
||||
Space Wolves,WOLF PRIEST,70,70,"0,00%",,,
|
||||
Space Wolves,WULFEN DREADNOUGHT,135,135,"0,00%",,145,
|
||||
|
54
csv/tau-empire.csv
Normal file
54
csv/tau-empire.csv
Normal file
@@ -0,0 +1,54 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
T'au Empire,1 Broadside Battlesuits,80,75,"-6,25%",,95,
|
||||
T'au Empire,1 Krootox Riders,40,45,"12,50%",,,
|
||||
T'au Empire,1 Piranhas,60,65,"8,33%",,75,
|
||||
T'au Empire,10 Kroot Carnivores,65,65,"0,00%",,,
|
||||
T'au Empire,10 Kroot Hounds,,65,,,,
|
||||
T'au Empire,10 Vespid Stingwings,,115,,,,
|
||||
T'au Empire,2 Broadside Battlesuits,,150,,,170,
|
||||
T'au Empire,2 Krootox Riders,,60,,,,
|
||||
T'au Empire,2 Piranhas,,110,,,120,
|
||||
T'au Empire,20 Kroot Carnivores,,130,,,,
|
||||
T'au Empire,3 Broadside Battlesuits,,240,,,260,
|
||||
T'au Empire,3 Krootox Rampagers,85,85,"0,00%",,95,
|
||||
T'au Empire,3 Krootox Riders,,90,,,,
|
||||
T'au Empire,3 Piranhas,,165,,,175,
|
||||
T'au Empire,5 Kroot Hounds,40,45,"12,50%",,,
|
||||
T'au Empire,5 Vespid Stingwings,65,70,"7,69%",,,
|
||||
T'au Empire,6 Krootox Rampagers,,170,,,180,
|
||||
T'au Empire,AX-1-0 TIGER SHARK,315,315,"0,00%",375,,
|
||||
T'au Empire,BREACHER TEAM,90,90,"0,00%",,,
|
||||
T'au Empire,CADRE FIREBLADE,50,50,"0,00%",,,
|
||||
T'au Empire,COMMANDER FARSIGHT,85,80,"-5,88%",,,
|
||||
T'au Empire,COMMANDER IN COLDSTAR BATTLESUIT,95,95,"0,00%",,,
|
||||
T'au Empire,COMMANDER IN ENFORCER BATTLESUIT,80,80,"0,00%",,,
|
||||
T'au Empire,COMMANDER SHADOWSUN,100,100,"0,00%",,,
|
||||
T'au Empire,CRISIS FIREKNIFE BATTLESUITS,120,100,"-16,67%",,110,
|
||||
T'au Empire,CRISIS STARSCYTHE BATTLESUITS,110,90,"-18,18%",,100,
|
||||
T'au Empire,CRISIS SUNFORGE BATTLESUITS,140,125,"-10,71%",,135,
|
||||
T'au Empire,DARKSTRIDER,60,60,"0,00%",,,
|
||||
T'au Empire,DEVILFISH,85,85,"0,00%",,,
|
||||
T'au Empire,ETHEREAL,50,50,"0,00%",,,
|
||||
T'au Empire,FIRESIGHT TEAM,60,55,"-8,33%",,,
|
||||
T'au Empire,GHOSTKEEL BATTLESUIT,160,150,"-6,25%",,160,
|
||||
T'au Empire,HAMMERHEAD GUNSHIP,145,140,"-3,45%",,150,
|
||||
T'au Empire,KROOT FARSTALKERS,85,75,"-11,76%",,85,
|
||||
T'au Empire,KROOT FLESH SHAPER,45,45,"0,00%",,,
|
||||
T'au Empire,KROOT LONE-SPEAR,80,80,"0,00%",,,
|
||||
T'au Empire,KROOT TRAIL SHAPER,55,50,"-9,09%",,,
|
||||
T'au Empire,KROOT WAR SHAPER,50,60,"20,00%",,,
|
||||
T'au Empire,MANTA,2100,2100,"0,00%",,,
|
||||
T'au Empire,PATHFINDER TEAM,90,80,"-11,11%",,,
|
||||
T'au Empire,RAZORSHARK STRIKE FIGHTER,170,160,"-5,88%",,,
|
||||
T'au Empire,RIPTIDE BATTLESUIT,200,180,"-10,00%",,210,
|
||||
T'au Empire,SKY RAY GUNSHIP,150,140,"-6,67%",,,
|
||||
T'au Empire,STEALTH BATTLESUITS,100,100,"0,00%",,110,
|
||||
T'au Empire,STORMSURGE,360,360,"0,00%",385,,
|
||||
T'au Empire,STRIKE TEAM,70,70,"0,00%",,,
|
||||
T'au Empire,SUN SHARK BOMBER,160,150,"-6,25%",,,
|
||||
T'au Empire,TA’UNAR SUPREMACY ARMOUR,790,790,"0,00%",890,,
|
||||
T'au Empire,THE TWIN LANCE,185,205,"10,81%",,,
|
||||
T'au Empire,TIDEWALL DRONEPORT,85,85,"0,00%",,,
|
||||
T'au Empire,TIDEWALL GUNRIG,90,90,"0,00%",,,
|
||||
T'au Empire,TIDEWALL SHIELDLINE,85,85,"0,00%",,,
|
||||
T'au Empire,TIGER SHARK,325,325,"0,00%",375,,
|
||||
|
43
csv/thousand-sons.csv
Normal file
43
csv/thousand-sons.csv
Normal file
@@ -0,0 +1,43 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Thousand Sons,10 Rubric Marines,,190,,,,200
|
||||
Thousand Sons,10 Scarab Occult Terminators,,370,,,385,
|
||||
Thousand Sons,10 Tzaangors,70,75,"7,14%",,,
|
||||
Thousand Sons,2 Sekhetar Robots,80,80,"0,00%",,,
|
||||
Thousand Sons,20 Tzaangors,,145,,,,
|
||||
Thousand Sons,3 Flamers,65,65,"0,00%",,,
|
||||
Thousand Sons,3 Screamers,80,80,"0,00%",,,
|
||||
Thousand Sons,3 Tzaangor Enlightened,45,45,"0,00%",,,
|
||||
Thousand Sons,3 Tzaangor Enlightened With Fatecaster Greatbows,,55,,,,
|
||||
Thousand Sons,4 Sekhetar Robots,,160,,,,
|
||||
Thousand Sons,5 Rubric Marines,100,100,"0,00%",,,110
|
||||
Thousand Sons,5 Scarab Occult Terminators,180,180,"0,00%",,195,
|
||||
Thousand Sons,6 Flamers,,130,,,,
|
||||
Thousand Sons,6 Screamers,,160,,,,
|
||||
Thousand Sons,6 Tzaangor Enlightened,,90,,,,
|
||||
Thousand Sons,6 Tzaangor Enlightened With Fatecaster Greatbows,,110,,,,
|
||||
Thousand Sons,AHRIMAN,100,100,"0,00%",,,
|
||||
Thousand Sons,BLUE HORRORS,90,90,"0,00%",,,
|
||||
Thousand Sons,CHAOS LAND RAIDER,220,220,"0,00%",,240,
|
||||
Thousand Sons,CHAOS PREDATOR ANNIHILATOR,130,130,"0,00%",,140,
|
||||
Thousand Sons,CHAOS PREDATOR DESTRUCTOR,130,130,"0,00%",,140,
|
||||
Thousand Sons,CHAOS RHINO,90,90,"0,00%",,,
|
||||
Thousand Sons,CHAOS SPAWN,65,65,"0,00%",,,
|
||||
Thousand Sons,CHAOS VINDICATOR,185,185,"0,00%",,195,
|
||||
Thousand Sons,DAEMON PRINCE OF TZEENTCH,180,170,"-5,56%",,,
|
||||
Thousand Sons,DAEMON PRINCE OF TZEENTCH WITH WINGS,170,170,"0,00%",180,,
|
||||
Thousand Sons,DEFILER,250,290,"16,00%",320,,
|
||||
Thousand Sons,EXALTED SORCERER,80,90,"12,50%",,,
|
||||
Thousand Sons,EXALTED SORCERER ON DISC OF TZEENTCH,100,90,"-10,00%",,,
|
||||
Thousand Sons,FORGEFIEND,130,135,"3,85%",,145,
|
||||
Thousand Sons,HELBRUTE,110,110,"0,00%",,,
|
||||
Thousand Sons,HELDRAKE,215,175,"-18,60%",,,
|
||||
Thousand Sons,INFERNAL MASTER,95,95,"0,00%",,,
|
||||
Thousand Sons,KAIROS FATEWEAVER,295,295,"0,00%",,,
|
||||
Thousand Sons,LORD OF CHANGE,285,300,"5,26%",,315,
|
||||
Thousand Sons,MAGNUS THE RED,435,455,"4,60%",,,
|
||||
Thousand Sons,MAULERFIEND,120,120,"0,00%",,130,
|
||||
Thousand Sons,MUTALITH VORTEX BEAST,170,170,"0,00%",,190,
|
||||
Thousand Sons,PINK HORRORS,115,115,"0,00%",,,
|
||||
Thousand Sons,SORCERER,80,85,"6,25%",,,
|
||||
Thousand Sons,SORCERER IN TERMINATOR ARMOUR,85,95,"11,76%",,105,
|
||||
Thousand Sons,TZAANGOR SHAMAN,60,60,"0,00%",,,
|
||||
|
5
csv/titan-legions.csv
Normal file
5
csv/titan-legions.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Titan Legions,REAVER TITAN,,2200,,,,
|
||||
Titan Legions,WARBRINGER NEMESIS TITAN,,2600,,,,
|
||||
Titan Legions,WARHOUND TITAN,,1100,,,,
|
||||
Titan Legions,WARLORD TITAN,,3500,,,,
|
||||
|
75
csv/tyranids.csv
Normal file
75
csv/tyranids.csv
Normal file
@@ -0,0 +1,75 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
Tyranids,1 Biovores,50,60,"20,00%",,,
|
||||
Tyranids,1 Carnifexes,90,90,"0,00%",,,
|
||||
Tyranids,1 Mucolid Spores,30,30,"0,00%",,,
|
||||
Tyranids,1 Pyrovores,40,45,"12,50%",,55,
|
||||
Tyranids,1 Ripper Swarms,25,30,"20,00%",,,
|
||||
Tyranids,10 Barbgaunts,,110,,,,
|
||||
Tyranids,10 Gargoyles,85,80,"-5,88%",,,
|
||||
Tyranids,10 Genestealers,,140,,,150,
|
||||
Tyranids,10 Hormagaunts,65,70,"7,69%",,,
|
||||
Tyranids,10 Termagants,60,60,"0,00%",,,
|
||||
Tyranids,11 Neurogaunts,45,45,"0,00%",,,
|
||||
Tyranids,2 Biovores,,100,,,,
|
||||
Tyranids,2 Carnifexes,,180,,,,
|
||||
Tyranids,2 Mucolid Spores,,60,,,,
|
||||
Tyranids,2 Pyrovores,,65,,,75,
|
||||
Tyranids,2 Ripper Swarms,,40,,,,
|
||||
Tyranids,20 Gargoyles,,155,,,,
|
||||
Tyranids,20 Hormagaunts,,120,,,,
|
||||
Tyranids,20 Termagants,,110,,,,
|
||||
Tyranids,22 Neurogaunts,,90,,,,
|
||||
Tyranids,3 Biovores,,140,,,,
|
||||
Tyranids,3 Hive Guard,90,80,"-11,11%",,90,
|
||||
Tyranids,3 Pyrovores,,95,,,105,
|
||||
Tyranids,3 Ripper Swarms,,50,,,,
|
||||
Tyranids,3 Spore Mines,55,55,"0,00%",,,
|
||||
Tyranids,3 Tyranid Warriors With Melee Bio-Weapons,75,75,"0,00%",,,
|
||||
Tyranids,3 Tyranid Warriors With Ranged Bio-Weapons,65,60,"-7,69%",,,
|
||||
Tyranids,3 Tyrant Guard,80,80,"0,00%",,,
|
||||
Tyranids,3 Venomthropes,70,55,"-21,43%",,,
|
||||
Tyranids,3 Von Ryan’S Leapers,70,60,"-14,29%",,,
|
||||
Tyranids,3 Zoanthropes,100,100,"0,00%",,,
|
||||
Tyranids,5 Barbgaunts,55,55,"0,00%",,,
|
||||
Tyranids,5 Genestealers,75,75,"0,00%",,85,
|
||||
Tyranids,6 Hive Guard,,160,,,170,
|
||||
Tyranids,6 Spore Mines,,110,,,,
|
||||
Tyranids,6 Tyranid Warriors With Melee Bio-Weapons,,150,,,,
|
||||
Tyranids,6 Tyranid Warriors With Ranged Bio-Weapons,,120,,,,
|
||||
Tyranids,6 Tyrant Guard,,160,,,,
|
||||
Tyranids,6 Venomthropes,,110,,,,
|
||||
Tyranids,6 Von Ryan’S Leapers,,120,,,,
|
||||
Tyranids,6 Zoanthropes,,200,,,,
|
||||
Tyranids,BROODLORD,80,80,"0,00%",,,
|
||||
Tyranids,DEATHLEAPER,80,80,"0,00%",,,
|
||||
Tyranids,EXOCRINE,140,140,"0,00%",,150,
|
||||
Tyranids,HARPY,215,185,"-13,95%",,,
|
||||
Tyranids,HARRIDAN,610,610,"0,00%",660,,
|
||||
Tyranids,HARUSPEX,125,125,"0,00%",,135,
|
||||
Tyranids,HIEROPHANT,810,810,"0,00%",910,,
|
||||
Tyranids,HIVE CRONE,200,170,"-15,00%",,,
|
||||
Tyranids,HIVE TYRANT,195,195,"0,00%",,,
|
||||
Tyranids,HYPERADAPTED RAVENERS,165,165,"0,00%",,175,
|
||||
Tyranids,LICTOR,60,60,"0,00%",,,
|
||||
Tyranids,MALECEPTOR,170,180,"5,88%",,190,
|
||||
Tyranids,MAWLOC,135,135,"0,00%",,,
|
||||
Tyranids,NEUROLICTOR,70,80,"14,29%",,90,
|
||||
Tyranids,NEUROTYRANT,105,115,"9,52%",,,
|
||||
Tyranids,NORN ASSIMILATOR,275,250,"-9,09%",270,,
|
||||
Tyranids,NORN EMISSARY,260,250,"-3,85%",270,,
|
||||
Tyranids,OLD ONE EYE,150,140,"-6,67%",,,
|
||||
Tyranids,PARASITE OF MORTREX,80,70,"-12,50%",,,
|
||||
Tyranids,PSYCHOPHAGE,110,110,"0,00%",,,
|
||||
Tyranids,RAVENERS,125,125,"0,00%",,135,
|
||||
Tyranids,SCREAMER-KILLER,125,125,"0,00%",,135,
|
||||
Tyranids,SPOROCYST,145,145,"0,00%",,,
|
||||
Tyranids,TERVIGON,160,160,"0,00%",,,
|
||||
Tyranids,THE RED TERROR,130,130,"0,00%",,,
|
||||
Tyranids,THE SWARMLORD,220,210,"-4,55%",,,
|
||||
Tyranids,TOXICRENE,150,135,"-10,00%",,,
|
||||
Tyranids,TRYGON,140,140,"0,00%",,,
|
||||
Tyranids,TYRANID PRIME WITH LASH WHIP,65,75,"15,38%",,,
|
||||
Tyranids,TYRANNOCYTE,105,90,"-14,29%",,,
|
||||
Tyranids,TYRANNOFEX,200,180,"-10,00%",,190,
|
||||
Tyranids,WINGED HIVE TYRANT,170,185,"8,82%",,,
|
||||
Tyranids,WINGED TYRANID PRIME,65,65,"0,00%",,,
|
||||
|
38
csv/world-eaters.csv
Normal file
38
csv/world-eaters.csv
Normal file
@@ -0,0 +1,38 @@
|
||||
Faction,Unidad,Coste original,Coste nuevo,% de cambio,2 o más unidades,3 o más unidades,4 o más unidades
|
||||
World Eaters,10 Chaos Terminators,,350,,,360,
|
||||
World Eaters,10 Flesh Hounds,,150,,,,
|
||||
World Eaters,10 Jakhals,65,65,"0,00%",,,
|
||||
World Eaters,10 Khorne Berzerkers,180,180,"0,00%",,,
|
||||
World Eaters,20 Jakhals,,130,,,,
|
||||
World Eaters,20 Khorne Berzerkers,,345,,,,
|
||||
World Eaters,3 Bloodcrushers,110,95,"-13,64%",,105,
|
||||
World Eaters,3 Eightbound,135,135,"0,00%",,145,
|
||||
World Eaters,3 Exalted Eightbound,140,140,"0,00%",,150,
|
||||
World Eaters,5 Chaos Terminators,175,175,"0,00%",,185,
|
||||
World Eaters,5 Flesh Hounds,75,75,"0,00%",,,
|
||||
World Eaters,6 Bloodcrushers,,180,,,190,
|
||||
World Eaters,6 Eightbound,,270,,,280,
|
||||
World Eaters,6 Exalted Eightbound,,280,,,290,
|
||||
World Eaters,ANGRON,340,350,"2,94%",,,
|
||||
World Eaters,BLOODLETTERS,90,90,"0,00%",,,
|
||||
World Eaters,BLOODTHIRSTER,305,320,"4,92%",,335,
|
||||
World Eaters,CHAOS LAND RAIDER,220,220,"0,00%",,240,
|
||||
World Eaters,CHAOS PREDATOR ANNIHILATOR,145,135,"-6,90%",,145,
|
||||
World Eaters,CHAOS PREDATOR DESTRUCTOR,145,135,"-6,90%",,145,
|
||||
World Eaters,CHAOS RHINO,85,85,"0,00%",,,
|
||||
World Eaters,CHAOS SPAWN,90,95,"5,56%",,,
|
||||
World Eaters,DAEMON PRINCE OF KHORNE,200,200,"0,00%",,,
|
||||
World Eaters,DAEMON PRINCE OF KHORNE WITH WINGS,180,170,"-5,56%",,,
|
||||
World Eaters,DEFILER,250,270,"8,00%",300,,
|
||||
World Eaters,FORGEFIEND,165,160,"-3,03%",,175,
|
||||
World Eaters,GOREMONGERS,75,75,"0,00%",,,
|
||||
World Eaters,HELBRUTE,120,120,"0,00%",,,
|
||||
World Eaters,HELDRAKE,200,175,"-12,50%",,,
|
||||
World Eaters,KHORNE LORD OF SKULLS,505,505,"0,00%",535,,
|
||||
World Eaters,KHÂRN THE BETRAYER,100,115,"15,00%",,,
|
||||
World Eaters,LORD INVOCATUS,110,110,"0,00%",,,
|
||||
World Eaters,LORD ON JUGGERNAUT,105,105,"0,00%",,,
|
||||
World Eaters,MASTER OF EXECUTIONS,60,60,"0,00%",,,
|
||||
World Eaters,MAULERFIEND,150,145,"-3,33%",,155,
|
||||
World Eaters,SKARBRAND,305,315,"3,28%",,,
|
||||
World Eaters,SLAUGHTERBOUND,100,100,"0,00%",,,
|
||||
|
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
# WH40K Points Comparator — Docker Compose
|
||||
# Multi-stage Dockerfile builds the React app and serves via nginx.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Edit HOST below to match your domain
|
||||
# 2. Edit CERT_RESOLVER to match your Traefik cert resolver
|
||||
# 3. docker compose up -d --build
|
||||
#
|
||||
# If you don't use Traefik, remove the labels and expose a port directly:
|
||||
# ports:
|
||||
# - "8080:80"
|
||||
|
||||
services:
|
||||
wh40k-site:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: wh40k-site:latest
|
||||
container_name: wh40k-site
|
||||
restart: unless-stopped
|
||||
# Direct port access (uncomment if not using Traefik):
|
||||
# ports:
|
||||
# - "8080:80"
|
||||
networks:
|
||||
- hermes-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=hermes-net"
|
||||
- "traefik.http.routers.wh40k-site.entrypoints=websecure"
|
||||
- "traefik.http.routers.wh40k-site.rule=Host(`wh40k.damascusfront.net`)"
|
||||
- "traefik.http.routers.wh40k-site.tls=true"
|
||||
- "traefik.http.routers.wh40k-site.tls.certresolver=cloudflare"
|
||||
- "traefik.http.services.wh40k-site.loadbalancer.server.port=80"
|
||||
|
||||
networks:
|
||||
hermes-net:
|
||||
external: true
|
||||
name: hermes-net
|
||||
58
index.html
Normal file
58
index.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>WH40K 10th Edition — Faction Points Compendium</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
max-width: 900px; margin: 40px auto; padding: 0 20px;
|
||||
background: #0d1117; color: #e6edf3; }
|
||||
h1 { color: #c9d1d9; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
|
||||
h2 { color: #8b949e; font-size: 1.1em; margin-top: 30px; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
||||
padding: 20px; margin: 15px 0; }
|
||||
.card h3 { margin-top: 0; color: #c9d1d9; }
|
||||
a.btn { display: inline-block; background: #238636; color: white; padding: 10px 18px;
|
||||
border-radius: 6px; text-decoration: none; font-weight: 600; margin: 4px 8px 4px 0; }
|
||||
a.btn.alt { background: #1f6feb; }
|
||||
a.btn:hover { opacity: 0.9; }
|
||||
code { background: #0d1117; padding: 2px 6px; border-radius: 3px; color: #79c0ff; }
|
||||
.meta { color: #8b949e; font-size: 0.9em; }
|
||||
.legend { display: flex; gap: 20px; margin: 10px 0; }
|
||||
.legend span { padding: 4px 12px; border-radius: 4px; font-size: 0.9em; }
|
||||
.pos { background: #c6efce; color: #006100; }
|
||||
.neg { background: #ffc7ce; color: #9c0006; }
|
||||
.neutral { background: #d9e1f2; color: #1f4e78; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WH40K 10th Edition — Faction Points Compendium</h1>
|
||||
<p class="meta">30 factions · 1,820 unit rows · mfm.warhammer-community.com + Full_armies_10th.pdf</p>
|
||||
|
||||
<h2>📊 Excel workbook (recommended)</h2>
|
||||
<div class="card">
|
||||
<h3>wh40k_factions_all.xlsx</h3>
|
||||
<p>One workbook, 30 sheets (one per faction) plus a Summary sheet. The <code>% change</code> column is color-coded:</p>
|
||||
<div class="legend">
|
||||
<span class="pos">Positive change (green)</span>
|
||||
<span class="neg">Negative change (red)</span>
|
||||
<span class="neutral">No change (neutral)</span>
|
||||
</div>
|
||||
<a class="btn" href="/wh40k_factions_all.xlsx" download>⬇ Download xlsx (108 KB)</a>
|
||||
</div>
|
||||
|
||||
<h2>📄 CSV downloads</h2>
|
||||
<div class="card">
|
||||
<h3>Per-faction + combined</h3>
|
||||
<a class="btn alt" href="/csv/_all_factions.csv" download>⬇ All factions (single file, 91 KB)</a>
|
||||
<a class="btn alt" href="/csv/" >📁 Browse 30 per-faction files</a>
|
||||
</div>
|
||||
|
||||
<h2>📚 Source data</h2>
|
||||
<div class="card meta">
|
||||
<p><strong>Live MFM (Munitorum Field Manual) prices:</strong> scraped from <code>mfm.warhammer-community.com</code> on 2026-06-17.</p>
|
||||
<p><strong>Original (pre-MFM) points:</strong> parsed from <code>Full_armies_10th.pdf</code> via pymupdf.</p>
|
||||
<p><strong>Known gaps:</strong> Titan Legions and Chaos Titan Legions have no live MFM pages. Chapter supplements (BT, BA, DA, SW, DW) inherit from the Space Marines codex.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
286
join_and_export.py
Normal file
286
join_and_export.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Join PDF (original) vs live MFM (new) and emit per-faction CSVs + combined.
|
||||
|
||||
Output schema (matches the user's reference image):
|
||||
Faction, Unidad, Coste original, Coste nuevo, % de cambio,
|
||||
2 o más unidades, 3 o más unidades, 4 o más unidades
|
||||
|
||||
Key:
|
||||
- For each unit present in the live site (source of truth for "new"),
|
||||
find the matching cost row in the PDF by name + size.
|
||||
- The "base" cost row is the cheapest / smallest tier (the "YOUR UNIT
|
||||
COSTS" tier from the PDF, or the "YOUR 1ST TO 2ND UNITS COST" /
|
||||
"YOUR 1ST UNIT COSTS" tier from the live site).
|
||||
- The "2 o más" / "3 o más" / "4 o más" columns are populated from any
|
||||
other cost rows for the same unit (looking up by unit size).
|
||||
- If a unit is in the live site but not the PDF, original is blank.
|
||||
- If a unit is in the PDF but not the live site, it's omitted (the
|
||||
PDF is a snapshot — the live site is current).
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path("/root/wh40k-factions")
|
||||
PDF = ROOT / "pdf_data.json"
|
||||
LIVE = ROOT / "live_data.json"
|
||||
CSV_DIR = ROOT / "csv"
|
||||
CSV_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Chapter supplements inherit unit data from the parent faction's PDF section.
|
||||
# When the live site lists a generic SM unit (Aggressor Squad, Intercessor Squad,
|
||||
# etc.) and the chapter's PDF supplement doesn't include it, fall back to the
|
||||
# parent Space Marines PDF entries for "original".
|
||||
INHERIT_FROM_PARENT = {
|
||||
"black-templars": "space-marines",
|
||||
"blood-angels": "space-marines",
|
||||
"dark-angels": "space-marines",
|
||||
"space-wolves": "space-marines",
|
||||
"deathwatch": "space-marines",
|
||||
"emperors-children":"chaos-space-marines",
|
||||
"thousand-sons": "chaos-space-marines",
|
||||
"world-eaters": "chaos-space-marines",
|
||||
"death-guard": "chaos-space-marines",
|
||||
}
|
||||
|
||||
# Spanish column headers (matching the image)
|
||||
HEADERS = [
|
||||
"Faction",
|
||||
"Unidad",
|
||||
"Coste original",
|
||||
"Coste nuevo",
|
||||
"% de cambio",
|
||||
"2 o más unidades",
|
||||
"3 o más unidades",
|
||||
"4 o más unidades",
|
||||
]
|
||||
|
||||
|
||||
def normalize_unit_name(s: str) -> str:
|
||||
"""Normalize a unit name for cross-source matching.
|
||||
|
||||
The PDF has 'Title Case' (e.g. 'Broadside Battlesuits'), the live site
|
||||
has 'UPPERCASE' (e.g. 'BROADSIDE BATTLESUITS'). Normalize:
|
||||
- uppercase
|
||||
- strip leading/trailing whitespace
|
||||
- collapse multiple spaces
|
||||
- remove apostrophes and smart quotes
|
||||
"""
|
||||
s = s.upper()
|
||||
s = s.replace("\u2019", "").replace("'", "")
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def normalize_size(s: str) -> str:
|
||||
"""Normalize a size string like '1 model', '1 models', '10 models'."""
|
||||
s = s.lower().strip()
|
||||
s = s.replace("\u2019", "").replace("'", "")
|
||||
# '1 model' and '1 models' both become '1 model'
|
||||
if "model" in s:
|
||||
m = re.match(r"(\d+)\s+models?", s)
|
||||
if m:
|
||||
return f"{m.group(1)} model"
|
||||
return s
|
||||
|
||||
|
||||
# Tiers considered "bulk" cost (more units). Live site tier names.
|
||||
# (These are looked up by exact label inside the join logic.)
|
||||
BULK_TIERS = { # noqa: F841 — kept for documentation
|
||||
"YOUR 3RD + UNIT COSTS",
|
||||
"YOUR 2ND + UNIT COSTS",
|
||||
}
|
||||
|
||||
|
||||
def pct_change(orig, new):
|
||||
if not orig or orig == 0:
|
||||
return ""
|
||||
delta = (new - orig) / orig * 100
|
||||
# Match the image's format: 2 decimals, comma decimal separator
|
||||
s = f"{abs(delta):.2f}"
|
||||
return f"{'-' if delta < 0 else ''}{s}".replace(".", ",") + "%"
|
||||
|
||||
|
||||
def fmt_pts(p):
|
||||
if p is None:
|
||||
return ""
|
||||
return str(p)
|
||||
|
||||
|
||||
def _size_n(size_str: str) -> int:
|
||||
m = re.match(r"(\d+)", size_str)
|
||||
return int(m.group(1)) if m else 1
|
||||
|
||||
|
||||
def find_tier_cost_at_size(model_rows, tier_label: str, size_n: int):
|
||||
"""Find the row matching a tier label and a specific model count.
|
||||
|
||||
Live site tier labels we care about for the bulk columns:
|
||||
'YOUR 2ND + UNIT COSTS' -> 2 o más
|
||||
'YOUR 3RD + UNIT COSTS' -> 3 o más
|
||||
'YOUR 4TH + UNIT COSTS' -> 4 o más (if it ever exists)
|
||||
"""
|
||||
for r in model_rows:
|
||||
tier = (r.get("tier") or "").upper().strip()
|
||||
if tier == tier_label:
|
||||
n = _size_n(r["size"])
|
||||
if n == size_n:
|
||||
return r["pts"]
|
||||
return None
|
||||
|
||||
|
||||
def join_faction(faction_slug: str, pdf: dict, live: dict, all_pdf: dict) -> list[dict]:
|
||||
"""Build the comparison rows for one faction."""
|
||||
pdf_units = pdf.get("units", {})
|
||||
live_units = live.get("units", {})
|
||||
|
||||
# Build PDF lookup: (unit_normalized, size_normalized) -> row
|
||||
pdf_idx = {}
|
||||
for unit, rows in pdf_units.items():
|
||||
nu = normalize_unit_name(unit)
|
||||
for r in rows:
|
||||
if "model" not in r["size"].lower():
|
||||
continue
|
||||
ns = normalize_size(r["size"])
|
||||
pdf_idx[(nu, ns)] = r
|
||||
|
||||
# Inherit parent-faction PDF entries for chapter supplements.
|
||||
# If a unit isn't in the chapter's PDF, look it up in the parent.
|
||||
parent = INHERIT_FROM_PARENT.get(faction_slug)
|
||||
if parent and parent in all_pdf:
|
||||
for unit, rows in all_pdf[parent].get("units", {}).items():
|
||||
nu = normalize_unit_name(unit)
|
||||
for r in rows:
|
||||
if "model" not in r["size"].lower():
|
||||
continue
|
||||
ns = normalize_size(r["size"])
|
||||
# don't overwrite chapter-specific entries
|
||||
if (nu, ns) not in pdf_idx:
|
||||
pdf_idx[(nu, ns)] = r
|
||||
|
||||
rows = []
|
||||
|
||||
# Walk live units (source of truth for "new")
|
||||
for unit, unit_rows in live_units.items():
|
||||
nu = normalize_unit_name(unit)
|
||||
# skip wargear-only units
|
||||
model_rows = [r for r in unit_rows if "model" in r["size"].lower()]
|
||||
if not model_rows:
|
||||
continue
|
||||
|
||||
# Pick the base tier: prefer YOUR UNIT COSTS, else first tier.
|
||||
base_tier = None
|
||||
for r in model_rows:
|
||||
tier = (r.get("tier") or "").upper().strip()
|
||||
if tier == "YOUR UNIT COSTS":
|
||||
base_tier = tier
|
||||
break
|
||||
if base_tier is None:
|
||||
base_tier = (model_rows[0].get("tier") or "").upper().strip()
|
||||
|
||||
# Find all distinct sizes in the base tier -> one row per size
|
||||
base_size_rows = [r for r in model_rows
|
||||
if (r.get("tier") or "").upper().strip() == base_tier]
|
||||
if not base_size_rows:
|
||||
# fallback: any rows
|
||||
base_size_rows = model_rows
|
||||
|
||||
# Distinct sizes in display order (smallest first)
|
||||
seen_sizes = set()
|
||||
base_sizes = []
|
||||
for r in base_size_rows:
|
||||
ns_r = normalize_size(r["size"])
|
||||
n_r = _size_n(r["size"])
|
||||
key = (n_r, ns_r)
|
||||
if key in seen_sizes:
|
||||
continue
|
||||
seen_sizes.add(key)
|
||||
base_sizes.append((ns_r, n_r))
|
||||
|
||||
if not base_sizes:
|
||||
continue
|
||||
|
||||
for ns, base_n in base_sizes:
|
||||
# Find the live row matching this size+base tier
|
||||
live_row = next((r for r in base_size_rows
|
||||
if normalize_size(r["size"]) == ns), None)
|
||||
if not live_row:
|
||||
continue
|
||||
new_pts = live_row["pts"]
|
||||
|
||||
# Find original in PDF
|
||||
pdf_row = pdf_idx.get((nu, ns))
|
||||
orig_pts = pdf_row["pts"] if pdf_row else None
|
||||
|
||||
# Bulk columns: cost of THIS size in 2ND+ / 3RD+ / 4TH+ tiers
|
||||
bulk_2 = find_tier_cost_at_size(model_rows, "YOUR 2ND + UNIT COSTS", base_n)
|
||||
bulk_3 = find_tier_cost_at_size(model_rows, "YOUR 3RD + UNIT COSTS", base_n)
|
||||
bulk_4 = find_tier_cost_at_size(model_rows, "YOUR 4TH + UNIT COSTS", base_n)
|
||||
|
||||
# Display the unit name with the size prefix if there's only one
|
||||
# size in the base tier, use the unit name alone; if multiple
|
||||
# sizes, prefix with the count.
|
||||
if len(base_sizes) == 1:
|
||||
display_unit = unit
|
||||
else:
|
||||
display_unit = f"{base_n} {unit.title()}"
|
||||
|
||||
rows.append({
|
||||
"Faction": live.get("name", pdf.get("name", faction_slug)),
|
||||
"Unidad": display_unit,
|
||||
"Coste original": fmt_pts(orig_pts),
|
||||
"Coste nuevo": fmt_pts(new_pts),
|
||||
"% de cambio": pct_change(orig_pts, new_pts) if orig_pts else "",
|
||||
"2 o más unidades": fmt_pts(bulk_2),
|
||||
"3 o más unidades": fmt_pts(bulk_3),
|
||||
"4 o más unidades": fmt_pts(bulk_4),
|
||||
})
|
||||
|
||||
# Sort: by Faction then Unidad
|
||||
rows.sort(key=lambda r: (r["Faction"], r["Unidad"]))
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
pdf = json.loads(PDF.read_text())
|
||||
live = json.loads(LIVE.read_text())
|
||||
if not pdf or not live:
|
||||
print("ERR: pdf or live data missing — run parse_pdf.py and scrape_live.py first")
|
||||
return 1
|
||||
|
||||
all_rows: list[dict] = []
|
||||
summary = []
|
||||
|
||||
# Build per-faction CSVs
|
||||
for slug in sorted(set(list(pdf.keys()) + list(live.keys()))):
|
||||
p = pdf.get(slug, {"name": slug, "units": {}})
|
||||
l = live.get(slug, {"name": slug, "units": {}})
|
||||
rows = join_faction(slug, p, l, pdf)
|
||||
if not rows:
|
||||
print(f" {slug}: no rows (skipped)")
|
||||
continue
|
||||
out_path = CSV_DIR / f"{slug}.csv"
|
||||
with out_path.open("w", newline="", encoding="utf-8") as f:
|
||||
w = csv.DictWriter(f, fieldnames=HEADERS)
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
all_rows.extend(rows)
|
||||
n_with_orig = sum(1 for r in rows if r["Coste original"])
|
||||
summary.append((slug, l.get("name", slug), len(rows), n_with_orig))
|
||||
print(f" {slug:25s} {len(rows):3d} rows ({n_with_orig} with original)")
|
||||
|
||||
# Combined
|
||||
combined = CSV_DIR / "_all_factions.csv"
|
||||
with combined.open("w", newline="", encoding="utf-8") as f:
|
||||
w = csv.DictWriter(f, fieldnames=HEADERS)
|
||||
w.writeheader()
|
||||
w.writerows(all_rows)
|
||||
|
||||
print()
|
||||
print(f"per-faction CSVs: {CSV_DIR}/*.csv ({len(summary)} files)")
|
||||
print(f"combined CSV: {combined} ({len(all_rows)} rows total)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
22916
live_data.json
Normal file
22916
live_data.json
Normal file
File diff suppressed because it is too large
Load Diff
214
parse_pdf.py
Normal file
214
parse_pdf.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Parse the MFM PDF into structured data.
|
||||
|
||||
Output: /root/wh40k-factions/pdf_data.json
|
||||
|
||||
Each page in the PDF has lines like:
|
||||
UnitName
|
||||
<N> models<N dots> <K> pts
|
||||
<N> models<N dots> <K> pts
|
||||
NextUnitName
|
||||
...
|
||||
A unit is followed by one or more "<n> models / <k> pts" rows. Sometimes
|
||||
unit names are followed by additional cost lines, sometimes wargear tables
|
||||
are inline. The trick: skip until we find a header, then a unit name is
|
||||
the next non-empty, non-numeric line.
|
||||
|
||||
Some pages have "DETACHMENT ENHANCEMENTS" sections — skip them.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf
|
||||
|
||||
PDF = "/root/.hermes/cache/documents/doc_5ee1da27de4e_Full_armies_10th.pdf"
|
||||
OUT = Path("/root/wh40k-factions/pdf_data.json")
|
||||
|
||||
PAGE_TO_FACTION = {
|
||||
2: "Adepta Sororitas",
|
||||
3: "Adeptus Custodes",
|
||||
4: "Adeptus Mechanicus",
|
||||
5: "Adeptus Titanicus",
|
||||
6: "Aeldari",
|
||||
7: "Ynnari",
|
||||
8: "Astra Militarum",
|
||||
9: "Black Templars",
|
||||
10: "Black Templars",
|
||||
11: "Blood Angels",
|
||||
12: "Chaos Daemons",
|
||||
13: "Chaos Daemons",
|
||||
14: "Chaos Knights",
|
||||
15: "Chaos Space Marines",
|
||||
16: "Chaos Space Marines",
|
||||
17: "Dark Angels",
|
||||
18: "Death Guard",
|
||||
19: "Deathwatch",
|
||||
20: "Drukhari",
|
||||
21: "Emperor's Children",
|
||||
22: "Genestealer Cults",
|
||||
23: "Grey Knights",
|
||||
24: "Imperial Agents",
|
||||
25: "Imperial Agents",
|
||||
26: "Imperial Knights",
|
||||
27: "Leagues of Votann",
|
||||
28: "Necrons",
|
||||
29: "Necrons",
|
||||
30: "Orks",
|
||||
31: "Orks",
|
||||
32: "Space Marines",
|
||||
33: "Space Marines",
|
||||
34: "Space Wolves",
|
||||
35: "Tau Empire", # PDF uses straight apostrophe, normalize
|
||||
36: "Thousand Sons",
|
||||
37: "Tyranids",
|
||||
38: "Tyranids",
|
||||
39: "World Eaters",
|
||||
}
|
||||
|
||||
# A line that says "<n> models" or "1 model" optionally followed by a pts value
|
||||
COST_LINE_RE = re.compile(
|
||||
r"^\s*(\d+)\s+models?\s*\x08*[.\s\x00-\x1f]*?(\d+)\s*pts?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Just a "1 model" line (singular model count) — pts may be on a separate line
|
||||
SIZE_ONLY_RE = re.compile(r"^\s*(\d+)\s+models?\s*\x08*\s*$", re.IGNORECASE)
|
||||
PTS_LINE_RE = re.compile(r"^\s*(\d+)\s*pts?\s*$", re.IGNORECASE)
|
||||
|
||||
# Section markers that should be skipped
|
||||
SKIP_SECTIONS = (
|
||||
"WARGEAR OPTIONS", "DETACHMENT ENHANCEMENTS", "STRATAGEMS",
|
||||
"ARMY RULE", "ENHANCEMENTS", "LITANIES", "PSYCHIC", "FACTION PACK",
|
||||
)
|
||||
|
||||
# Unit names start with a capital letter and are not too long.
|
||||
def is_unit_name(s: str) -> bool:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
return False
|
||||
if len(s) > 80:
|
||||
return False
|
||||
if not s[0].isalpha():
|
||||
return False
|
||||
# reject lines that look numeric / header
|
||||
if any(kw in s.upper() for kw in ("CODEX:", "INDEX:", "FACTION", "WARGEAR",
|
||||
"DETACHMENT", "ENHANCEMENT", "STRATAGEM")):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def slug(name: str) -> str:
|
||||
s = name.lower().replace("\u2019", "").replace("'", "")
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
|
||||
return s
|
||||
|
||||
|
||||
def clean_line(s: str) -> str:
|
||||
"""Strip control chars and trailing dot leaders (PDFium renders them as 0xFFFD)."""
|
||||
s = s.replace("\x08", "").replace("\x0c", "")
|
||||
# remove dot leaders (literal dots, 0xFFFD replacement chars, and spaces)
|
||||
s = re.sub(r"[\.\uFFFD]{2,}", "", s)
|
||||
s = re.sub(r"\s{2,}", " ", s) # collapse double spaces
|
||||
return s.strip()
|
||||
|
||||
|
||||
def parse_page(text: str) -> dict[str, list[dict]]:
|
||||
units: dict[str, list[dict]] = {}
|
||||
raw_lines = text.split("\n")
|
||||
current_unit: str | None = None
|
||||
pending_size: str | None = None
|
||||
skip = False
|
||||
|
||||
for raw in raw_lines:
|
||||
line = clean_line(raw)
|
||||
if not line:
|
||||
continue
|
||||
upper = line.upper()
|
||||
|
||||
# Section break
|
||||
if any(kw in upper for kw in SKIP_SECTIONS):
|
||||
skip = True
|
||||
current_unit = None
|
||||
pending_size = None
|
||||
continue
|
||||
if skip:
|
||||
# section ends when we see a new CODEX/INDEX/FACTION PACK header
|
||||
if "CODEX" in upper or "INDEX" in upper or "FACTION PACK" in upper:
|
||||
skip = False
|
||||
else:
|
||||
continue
|
||||
if upper.startswith("CODEX:") or upper.startswith("INDEX:"):
|
||||
continue
|
||||
|
||||
# Try cost line with both size and pts
|
||||
m = COST_LINE_RE.match(line)
|
||||
if m:
|
||||
if current_unit:
|
||||
units.setdefault(current_unit, []).append({
|
||||
"size": f"{m.group(1)} models",
|
||||
"pts": int(m.group(2)),
|
||||
})
|
||||
pending_size = None
|
||||
# Do NOT reset current_unit — the next line may be another
|
||||
# size variant for the same unit (e.g. "10 models ... 140 pts"
|
||||
# right after "3 models ... 45 pts"). current_unit is cleared
|
||||
# only when we encounter a new unit-name line.
|
||||
continue
|
||||
# Try size-only line
|
||||
m = SIZE_ONLY_RE.match(line)
|
||||
if m:
|
||||
pending_size = f"{m.group(1)} models"
|
||||
continue
|
||||
# Try pts-only line (completes a pending size)
|
||||
m = PTS_LINE_RE.match(line)
|
||||
if m and pending_size and current_unit:
|
||||
units.setdefault(current_unit, []).append({
|
||||
"size": pending_size,
|
||||
"pts": int(m.group(1)),
|
||||
})
|
||||
pending_size = None
|
||||
# Do NOT reset current_unit — same reason as above.
|
||||
continue
|
||||
if m:
|
||||
# pts without a known size — drop pending
|
||||
pending_size = None
|
||||
continue
|
||||
|
||||
# Otherwise this is a unit name (or noise)
|
||||
if is_unit_name(line):
|
||||
current_unit = line
|
||||
pending_size = None
|
||||
|
||||
return units
|
||||
|
||||
|
||||
def main():
|
||||
doc = pymupdf.open(PDF)
|
||||
out: dict[str, dict] = {}
|
||||
for page_idx in range(doc.page_count):
|
||||
page_num = page_idx + 1
|
||||
if page_num not in PAGE_TO_FACTION:
|
||||
continue
|
||||
faction = PAGE_TO_FACTION[page_num]
|
||||
s = slug(faction)
|
||||
if s not in out:
|
||||
out[s] = {"name": faction.replace("Tau", "T'au"), "pages": [], "units": {}}
|
||||
out[s]["pages"].append(page_num)
|
||||
page_units = parse_page(doc[page_idx].get_text())
|
||||
for unit, costs in page_units.items():
|
||||
existing = out[s]["units"].get(unit, [])
|
||||
seen = {(c["size"], c["pts"]) for c in existing}
|
||||
for c in costs:
|
||||
if (c["size"], c["pts"]) not in seen:
|
||||
existing.append(c)
|
||||
seen.add((c["size"], c["pts"]))
|
||||
out[s]["units"][unit] = existing
|
||||
|
||||
OUT.write_text(json.dumps(out, indent=2, ensure_ascii=False))
|
||||
print(f"wrote {OUT}")
|
||||
print(f"factions parsed: {len(out)}")
|
||||
for s, data in out.items():
|
||||
print(f" {s:25s}: {len(data['units']):3d} units ({', '.join(map(str, data['pages']))})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
158
parse_pdf114.py
Normal file
158
parse_pdf114.py
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parse MFM_1.14.pdf into per-faction JSON files.
|
||||
Output: /root/wh40k-factions/pdf114/<slug>.json
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from parse_pdf import parse_page
|
||||
|
||||
PDF = "/root/.hermes/cache/documents/doc_7f90137b6664_MFM_1.14.pdf"
|
||||
OUT_DIR = Path("/root/wh40k-factions/pdf114")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
PAGE_TO_SLUG = {
|
||||
1: None,
|
||||
2: "adepta-sororitas",
|
||||
3: "adeptus-custodes",
|
||||
4: "adeptus-mechanicus",
|
||||
5: "adeptus-titanicus",
|
||||
6: "aeldari",
|
||||
7: "aeldari", # forge world
|
||||
8: "astra-militarum",
|
||||
9: "astra-militarum", # forge world (Death Korps, Earthshaker, etc.)
|
||||
10: "black-templars",
|
||||
11: "blood-angels",
|
||||
12: "chaos-daemons",
|
||||
13: "chaos-knights",
|
||||
14: "chaos-space-marines",
|
||||
15: "dark-angels",
|
||||
16: "death-guard",
|
||||
17: "deathwatch",
|
||||
18: "drukhari",
|
||||
19: "genestealer-cults",
|
||||
20: "grey-knights",
|
||||
21: "imperial-agents",
|
||||
22: "imperial-knights",
|
||||
23: "leagues-of-votann",
|
||||
24: "necrons",
|
||||
25: "orks",
|
||||
26: "orks", # detachment enhancements (skipped by parser)
|
||||
27: "space-marines",
|
||||
28: "space-marines", # forge world (Predator etc.)
|
||||
29: "space-wolves",
|
||||
30: "tau-empire",
|
||||
31: "thousand-sons",
|
||||
32: "tyranids",
|
||||
33: "tyranids", # detachment enhancements (skipped)
|
||||
34: "world-eaters",
|
||||
}
|
||||
|
||||
NAME_BY_SLUG = {
|
||||
"adepta-sororitas": "Adepta Sororitas",
|
||||
"adeptus-custodes": "Adeptus Custodes",
|
||||
"adeptus-mechanicus": "Adeptus Mechanicus",
|
||||
"adeptus-titanicus": "Adeptus Titanicus",
|
||||
"aeldari": "Aeldari",
|
||||
"astra-militarum": "Astra Militarum",
|
||||
"black-templars": "Black Templars",
|
||||
"blood-angels": "Blood Angels",
|
||||
"chaos-daemons": "Chaos Daemons",
|
||||
"chaos-knights": "Chaos Knights",
|
||||
"chaos-space-marines": "Chaos Space Marines",
|
||||
"dark-angels": "Dark Angels",
|
||||
"death-guard": "Death Guard",
|
||||
"deathwatch": "Deathwatch",
|
||||
"drukhari": "Drukhari",
|
||||
"genestealer-cults": "Genestealer Cults",
|
||||
"grey-knights": "Grey Knights",
|
||||
"imperial-agents": "Imperial Agents",
|
||||
"imperial-knights": "Imperial Knights",
|
||||
"leagues-of-votann": "Leagues of Votann",
|
||||
"necrons": "Necrons",
|
||||
"orks": "Orks",
|
||||
"space-marines": "Space Marines",
|
||||
"space-wolves": "Space Wolves",
|
||||
"tau-empire": "T'au Empire",
|
||||
"thousand-sons": "Thousand Sons",
|
||||
"tyranids": "Tyranids",
|
||||
"world-eaters": "World Eaters",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
doc = pymupdf.open(PDF)
|
||||
print(f"PDF: {PDF} pages={doc.page_count}", flush=True)
|
||||
|
||||
accum = {}
|
||||
for page_idx in range(doc.page_count):
|
||||
page_num = page_idx + 1
|
||||
s = PAGE_TO_SLUG.get(page_num)
|
||||
if not s:
|
||||
print(f" page {page_num:2d}: skipped (title/blank)", flush=True)
|
||||
continue
|
||||
if s not in accum:
|
||||
accum[s] = {
|
||||
"slug": s,
|
||||
"name": NAME_BY_SLUG.get(s, s),
|
||||
"source": Path(PDF).name,
|
||||
"version": "1.14",
|
||||
"extracted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"pages": [],
|
||||
"n_units": 0,
|
||||
"n_rows": 0,
|
||||
"units": {},
|
||||
}
|
||||
accum[s]["pages"].append(page_num)
|
||||
page_units = parse_page(doc[page_idx].get_text())
|
||||
n_added = 0
|
||||
for unit, costs in page_units.items():
|
||||
existing = accum[s]["units"].setdefault(unit, [])
|
||||
seen = {(c["size"], c["pts"]) for c in existing}
|
||||
for c in costs:
|
||||
if (c["size"], c["pts"]) not in seen:
|
||||
existing.append(c)
|
||||
seen.add((c["size"], c["pts"]))
|
||||
n_added += 1
|
||||
print(f" page {page_num:2d} -> {s:<22} +{n_added:>3} entries", flush=True)
|
||||
doc.close()
|
||||
|
||||
manifest = {"pdf": Path(PDF).name, "version": "1.14", "factions": []}
|
||||
total_units = total_rows = 0
|
||||
for s, data in sorted(accum.items()):
|
||||
data["n_units"] = len(data["units"])
|
||||
data["n_rows"] = sum(len(v) for v in data["units"].values())
|
||||
out_path = OUT_DIR / f"{s}.json"
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
total_units += data["n_units"]
|
||||
total_rows += data["n_rows"]
|
||||
manifest["factions"].append({
|
||||
"slug": s, "name": data["name"],
|
||||
"pages": data["pages"],
|
||||
"n_units": data["n_units"],
|
||||
"n_rows": data["n_rows"],
|
||||
"file": out_path.name,
|
||||
})
|
||||
print(f" {s:<22} {data['n_units']:>3} units / {data['n_rows']:>3} rows -> {out_path.name}", flush=True)
|
||||
|
||||
manifest["generated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
manifest["n_factions"] = len(manifest["factions"])
|
||||
manifest["total_units"] = total_units
|
||||
manifest["total_rows"] = total_rows
|
||||
with open(OUT_DIR / "_manifest.json", "w") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"\nWrote {len(manifest['factions'])} faction files to {OUT_DIR}/")
|
||||
print(f"Total: {total_units} units / {total_rows} size-rows")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
165
parse_pdf23.py
Normal file
165
parse_pdf23.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parse MFM_2.3_March_2025.pdf into per-faction JSON files.
|
||||
Output: /root/wh40k-factions/pdf23/<slug>.json
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from parse_pdf import parse_page
|
||||
|
||||
PDF = "/root/.hermes/cache/documents/doc_cb9ee828b86b_MFM_2.3_March_2025.pdf"
|
||||
OUT_DIR = Path("/root/wh40k-factions/pdf23")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Same page mapping as 3.2 (verified — same 38-page layout)
|
||||
PAGE_TO_SLUG = {
|
||||
1: None,
|
||||
2: "adepta-sororitas",
|
||||
3: "adeptus-custodes",
|
||||
4: "adeptus-mechanicus",
|
||||
5: "adeptus-titanicus",
|
||||
6: "aeldari",
|
||||
7: "ynnari",
|
||||
8: "astra-militarum",
|
||||
9: "astra-militarum", # detachment enhancements (skipped)
|
||||
10: "black-templars",
|
||||
11: "blood-angels",
|
||||
12: "chaos-daemons",
|
||||
13: "chaos-daemons", # detachment enhancements
|
||||
14: "chaos-knights",
|
||||
15: "chaos-space-marines",
|
||||
16: "chaos-space-marines", # detachment enhancements
|
||||
17: "dark-angels",
|
||||
18: "death-guard",
|
||||
19: "deathwatch",
|
||||
20: "drukhari",
|
||||
21: "emperors-children",
|
||||
22: "genestealer-cults",
|
||||
23: "grey-knights",
|
||||
24: "imperial-agents",
|
||||
25: "imperial-agents",
|
||||
26: "imperial-knights",
|
||||
27: "leagues-of-votann",
|
||||
28: "necrons",
|
||||
29: "orks",
|
||||
30: "orks", # detachment enhancements
|
||||
31: "space-marines",
|
||||
32: "space-marines", # forge world
|
||||
33: "space-wolves",
|
||||
34: "tau-empire",
|
||||
35: "thousand-sons",
|
||||
36: "tyranids",
|
||||
37: "tyranids", # detachment enhancements
|
||||
38: "world-eaters",
|
||||
}
|
||||
|
||||
NAME_BY_SLUG = {
|
||||
"adepta-sororitas": "Adepta Sororitas",
|
||||
"adeptus-custodes": "Adeptus Custodes",
|
||||
"adeptus-mechanicus": "Adeptus Mechanicus",
|
||||
"adeptus-titanicus": "Adeptus Titanicus",
|
||||
"aeldari": "Aeldari",
|
||||
"ynnari": "Ynnari",
|
||||
"astra-militarum": "Astra Militarum",
|
||||
"black-templars": "Black Templars",
|
||||
"blood-angels": "Blood Angels",
|
||||
"chaos-daemons": "Chaos Daemons",
|
||||
"chaos-knights": "Chaos Knights",
|
||||
"chaos-space-marines": "Chaos Space Marines",
|
||||
"dark-angels": "Dark Angels",
|
||||
"death-guard": "Death Guard",
|
||||
"deathwatch": "Deathwatch",
|
||||
"drukhari": "Drukhari",
|
||||
"emperors-children": "Emperor's Children",
|
||||
"genestealer-cults": "Genestealer Cults",
|
||||
"grey-knights": "Grey Knights",
|
||||
"imperial-agents": "Imperial Agents",
|
||||
"imperial-knights": "Imperial Knights",
|
||||
"leagues-of-votann": "Leagues of Votann",
|
||||
"necrons": "Necrons",
|
||||
"orks": "Orks",
|
||||
"space-marines": "Space Marines",
|
||||
"space-wolves": "Space Wolves",
|
||||
"tau-empire": "T'au Empire",
|
||||
"thousand-sons": "Thousand Sons",
|
||||
"tyranids": "Tyranids",
|
||||
"world-eaters": "World Eaters",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
doc = pymupdf.open(PDF)
|
||||
print(f"PDF: {PDF} pages={doc.page_count}", flush=True)
|
||||
|
||||
accum = {}
|
||||
for page_idx in range(doc.page_count):
|
||||
page_num = page_idx + 1
|
||||
s = PAGE_TO_SLUG.get(page_num)
|
||||
if not s:
|
||||
print(f" page {page_num:2d}: skipped (title/blank)", flush=True)
|
||||
continue
|
||||
if s not in accum:
|
||||
accum[s] = {
|
||||
"slug": s,
|
||||
"name": NAME_BY_SLUG.get(s, s),
|
||||
"source": Path(PDF).name,
|
||||
"version": "2.3",
|
||||
"extracted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"pages": [],
|
||||
"n_units": 0,
|
||||
"n_rows": 0,
|
||||
"units": {},
|
||||
}
|
||||
accum[s]["pages"].append(page_num)
|
||||
page_units = parse_page(doc[page_idx].get_text())
|
||||
n_added = 0
|
||||
for unit, costs in page_units.items():
|
||||
existing = accum[s]["units"].setdefault(unit, [])
|
||||
seen = {(c["size"], c["pts"]) for c in existing}
|
||||
for c in costs:
|
||||
if (c["size"], c["pts"]) not in seen:
|
||||
existing.append(c)
|
||||
seen.add((c["size"], c["pts"]))
|
||||
n_added += 1
|
||||
print(f" page {page_num:2d} -> {s:<22} +{n_added:>3} entries", flush=True)
|
||||
doc.close()
|
||||
|
||||
manifest = {"pdf": Path(PDF).name, "version": "2.3", "factions": []}
|
||||
total_units = total_rows = 0
|
||||
for s, data in sorted(accum.items()):
|
||||
data["n_units"] = len(data["units"])
|
||||
data["n_rows"] = sum(len(v) for v in data["units"].values())
|
||||
out_path = OUT_DIR / f"{s}.json"
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
total_units += data["n_units"]
|
||||
total_rows += data["n_rows"]
|
||||
manifest["factions"].append({
|
||||
"slug": s, "name": data["name"],
|
||||
"pages": data["pages"],
|
||||
"n_units": data["n_units"],
|
||||
"n_rows": data["n_rows"],
|
||||
"file": out_path.name,
|
||||
})
|
||||
print(f" {s:<22} {data['n_units']:>3} units / {data['n_rows']:>3} rows -> {out_path.name}", flush=True)
|
||||
|
||||
manifest["generated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
manifest["n_factions"] = len(manifest["factions"])
|
||||
manifest["total_units"] = total_units
|
||||
manifest["total_rows"] = total_rows
|
||||
with open(OUT_DIR / "_manifest.json", "w") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"\nWrote {len(manifest['factions'])} faction files to {OUT_DIR}/")
|
||||
print(f"Total: {total_units} units / {total_rows} size-rows")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
171
parse_pdf32.py
Normal file
171
parse_pdf32.py
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parse field_manual_3.2.pdf into per-faction JSON files,
|
||||
reusing the fixed parser from parse_pdf.py.
|
||||
|
||||
Output: /root/wh40k-factions/pdf32/<slug>.json
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from parse_pdf import parse_page, slug, clean_line
|
||||
|
||||
PDF = "/root/.hermes/cache/documents/doc_b0e211b5e744_field_manual_3.2.pdf"
|
||||
OUT_DIR = Path("/root/wh40k-factions/pdf32")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Same page mapping as 4.3 (verified — same order, same factions)
|
||||
PAGE_TO_SLUG = {
|
||||
1: None,
|
||||
2: "adepta-sororitas",
|
||||
3: "adeptus-custodes",
|
||||
4: "adeptus-mechanicus",
|
||||
5: "adeptus-titanicus",
|
||||
6: "aeldari",
|
||||
7: "ynnari",
|
||||
8: "astra-militarum",
|
||||
9: "astra-militarum", # detachment enhancements (skipped by parser)
|
||||
10: "black-templars",
|
||||
11: "blood-angels",
|
||||
12: "chaos-daemons",
|
||||
13: "chaos-daemons", # detachment enhancements
|
||||
14: "chaos-knights",
|
||||
15: "chaos-space-marines",
|
||||
16: "chaos-space-marines", # detachment enhancements
|
||||
17: "dark-angels",
|
||||
18: "death-guard",
|
||||
19: "deathwatch",
|
||||
20: "drukhari",
|
||||
21: "emperors-children",
|
||||
22: "genestealer-cults",
|
||||
23: "grey-knights",
|
||||
24: "imperial-agents",
|
||||
25: "imperial-agents",
|
||||
26: "imperial-knights",
|
||||
27: "leagues-of-votann",
|
||||
28: "necrons",
|
||||
29: "orks",
|
||||
30: "orks", # detachment enhancements
|
||||
31: "space-marines",
|
||||
32: "space-marines", # forge world units (Pedro Kantor, Predator, etc.)
|
||||
33: "space-wolves",
|
||||
34: "tau-empire",
|
||||
35: "thousand-sons",
|
||||
36: "tyranids",
|
||||
37: "tyranids", # detachment enhancements
|
||||
38: "world-eaters",
|
||||
}
|
||||
|
||||
NAME_BY_SLUG = {
|
||||
"adepta-sororitas": "Adepta Sororitas",
|
||||
"adeptus-custodes": "Adeptus Custodes",
|
||||
"adeptus-mechanicus": "Adeptus Mechanicus",
|
||||
"adeptus-titanicus": "Adeptus Titanicus",
|
||||
"aeldari": "Aeldari",
|
||||
"ynnari": "Ynnari",
|
||||
"astra-militarum": "Astra Militarum",
|
||||
"black-templars": "Black Templars",
|
||||
"blood-angels": "Blood Angels",
|
||||
"chaos-daemons": "Chaos Daemons",
|
||||
"chaos-knights": "Chaos Knights",
|
||||
"chaos-space-marines": "Chaos Space Marines",
|
||||
"dark-angels": "Dark Angels",
|
||||
"death-guard": "Death Guard",
|
||||
"deathwatch": "Deathwatch",
|
||||
"drukhari": "Drukhari",
|
||||
"emperors-children": "Emperor's Children",
|
||||
"genestealer-cults": "Genestealer Cults",
|
||||
"grey-knights": "Grey Knights",
|
||||
"imperial-agents": "Imperial Agents",
|
||||
"imperial-knights": "Imperial Knights",
|
||||
"leagues-of-votann": "Leagues of Votann",
|
||||
"necrons": "Necrons",
|
||||
"orks": "Orks",
|
||||
"space-marines": "Space Marines",
|
||||
"space-wolves": "Space Wolves",
|
||||
"tau-empire": "T'au Empire",
|
||||
"thousand-sons": "Thousand Sons",
|
||||
"tyranids": "Tyranids",
|
||||
"world-eaters": "World Eaters",
|
||||
}
|
||||
|
||||
# World Eaters is page 38 in this PDF (no separate page 39)
|
||||
# Check if it exists
|
||||
PAGE_TO_SLUG[38] = "world-eaters"
|
||||
|
||||
|
||||
def main():
|
||||
doc = pymupdf.open(PDF)
|
||||
print(f"PDF: {PDF} pages={doc.page_count}", flush=True)
|
||||
|
||||
accum = {}
|
||||
for page_idx in range(doc.page_count):
|
||||
page_num = page_idx + 1
|
||||
s = PAGE_TO_SLUG.get(page_num)
|
||||
if not s:
|
||||
print(f" page {page_num:2d}: skipped (title/blank)", flush=True)
|
||||
continue
|
||||
if s not in accum:
|
||||
accum[s] = {
|
||||
"slug": s,
|
||||
"name": NAME_BY_SLUG.get(s, s),
|
||||
"source": Path(PDF).name,
|
||||
"version": "3.2",
|
||||
"extracted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"pages": [],
|
||||
"n_units": 0,
|
||||
"n_rows": 0,
|
||||
"units": {},
|
||||
}
|
||||
accum[s]["pages"].append(page_num)
|
||||
page_units = parse_page(doc[page_idx].get_text())
|
||||
n_added = 0
|
||||
for unit, costs in page_units.items():
|
||||
existing = accum[s]["units"].setdefault(unit, [])
|
||||
seen = {(c["size"], c["pts"]) for c in existing}
|
||||
for c in costs:
|
||||
if (c["size"], c["pts"]) not in seen:
|
||||
existing.append(c)
|
||||
seen.add((c["size"], c["pts"]))
|
||||
n_added += 1
|
||||
print(f" page {page_num:2d} -> {s:<22} +{n_added:>3} entries", flush=True)
|
||||
doc.close()
|
||||
|
||||
manifest = {"pdf": Path(PDF).name, "version": "3.2", "factions": []}
|
||||
total_units = total_rows = 0
|
||||
for s, data in sorted(accum.items()):
|
||||
data["n_units"] = len(data["units"])
|
||||
data["n_rows"] = sum(len(v) for v in data["units"].values())
|
||||
out_path = OUT_DIR / f"{s}.json"
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
total_units += data["n_units"]
|
||||
total_rows += data["n_rows"]
|
||||
manifest["factions"].append({
|
||||
"slug": s, "name": data["name"],
|
||||
"pages": data["pages"],
|
||||
"n_units": data["n_units"],
|
||||
"n_rows": data["n_rows"],
|
||||
"file": out_path.name,
|
||||
})
|
||||
print(f" {s:<22} {data['n_units']:>3} units / {data['n_rows']:>3} rows -> {out_path.name}", flush=True)
|
||||
|
||||
manifest["generated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
manifest["n_factions"] = len(manifest["factions"])
|
||||
manifest["total_units"] = total_units
|
||||
manifest["total_rows"] = total_rows
|
||||
with open(OUT_DIR / "_manifest.json", "w") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"\nWrote {len(manifest['factions'])} faction files to {OUT_DIR}/")
|
||||
print(f"Total: {total_units} units / {total_rows} size-rows")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
215
parse_pdf_per_faction.py
Normal file
215
parse_pdf_per_faction.py
Normal file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract the MFM PDF (Full_armies_10th.pdf) into per-faction JSON files,
|
||||
mirroring the shape of the live scraper output in /root/wh40k-factions/live/.
|
||||
|
||||
Output:
|
||||
/root/wh40k-factions/pdf/<slug>.json (one per faction)
|
||||
/root/wh40k-factions/pdf/_manifest.json
|
||||
|
||||
Schema per file (matches live scraper):
|
||||
{
|
||||
"slug": "astra-militarum",
|
||||
"name": "Astra Militarum",
|
||||
"source": "Full_armies_10th.pdf",
|
||||
"version": "v4.3", # parsed from page 1 header
|
||||
"extracted_at": "2026-06-17T...",
|
||||
"pages": [8, 9], # all PDF pages that contribute units
|
||||
"n_units": 84,
|
||||
"n_rows": 162,
|
||||
"units": {
|
||||
"Valkyrie": [
|
||||
{"size": "1 model", "pts": 190}
|
||||
],
|
||||
"...": [...]
|
||||
}
|
||||
}
|
||||
|
||||
Page-to-faction mapping is fixed: page 9 is Astra Militarum forge world
|
||||
(was incorrectly mapped to Black Templars in the original parse_pdf.py).
|
||||
Detachment-enhancements-only pages are mapped to their parent faction but
|
||||
contribute zero units (parser skips them).
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
# Reuse the parser + regexes + helpers from the original script.
|
||||
from parse_pdf import parse_page, slug, clean_line
|
||||
|
||||
PDF = "/root/.hermes/cache/documents/doc_ed3e1a0bd12e_Full_armies_10th.pdf"
|
||||
OUT_DIR = Path("/root/wh40k-factions/pdf")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Slug for each page. Fixed mapping (the original had page 9 wrong:
|
||||
# page 9 contains Astra Militarum forge-world units, not Black Templars).
|
||||
PAGE_TO_SLUG = {
|
||||
1: None, # title page
|
||||
2: "adepta-sororitas",
|
||||
3: "adeptus-custodes",
|
||||
4: "adeptus-mechanicus",
|
||||
5: "adeptus-titanicus", # Forge World Titans (Adeptus Titanicus)
|
||||
6: "aeldari",
|
||||
7: "ynnari", # Ynnari subset of Aeldari
|
||||
8: "astra-militarum",
|
||||
9: "astra-militarum", # AM forge world (Valkyrie, Wyvern, etc.)
|
||||
10: "black-templars",
|
||||
11: "blood-angels",
|
||||
12: "chaos-daemons",
|
||||
13: "chaos-daemons", # detachment enhancements (skipped by parser)
|
||||
14: "chaos-knights",
|
||||
15: "chaos-space-marines",
|
||||
16: "chaos-space-marines", # detachment enhancements
|
||||
17: "dark-angels",
|
||||
18: "death-guard",
|
||||
19: "deathwatch",
|
||||
20: "drukhari",
|
||||
21: "emperors-children",
|
||||
22: "genestealer-cults",
|
||||
23: "grey-knights",
|
||||
24: "imperial-agents", # rules page (no units)
|
||||
25: "imperial-agents", # units
|
||||
26: "imperial-knights",
|
||||
27: "leagues-of-votann",
|
||||
28: "necrons",
|
||||
29: "necrons", # detachment enhancements
|
||||
30: "orks",
|
||||
31: "orks", # detachment enhancements
|
||||
32: "space-marines",
|
||||
33: "space-marines", # forge world units (Predator etc.)
|
||||
34: "space-wolves",
|
||||
35: "tau-empire",
|
||||
36: "thousand-sons",
|
||||
37: "tyranids",
|
||||
38: "tyranids", # detachment enhancements
|
||||
39: "world-eaters",
|
||||
}
|
||||
|
||||
# Display name per slug. For names with apostrophes, use curly form for display.
|
||||
NAME_BY_SLUG = {
|
||||
"adepta-sororitas": "Adepta Sororitas",
|
||||
"adeptus-custodes": "Adeptus Custodes",
|
||||
"adeptus-mechanicus": "Adeptus Mechanicus",
|
||||
"adeptus-titanicus": "Adeptus Titanicus",
|
||||
"aeldari": "Aeldari",
|
||||
"ynnari": "Ynnari",
|
||||
"astra-militarum": "Astra Militarum",
|
||||
"black-templars": "Black Templars",
|
||||
"blood-angels": "Blood Angels",
|
||||
"chaos-daemons": "Chaos Daemons",
|
||||
"chaos-knights": "Chaos Knights",
|
||||
"chaos-space-marines": "Chaos Space Marines",
|
||||
"dark-angels": "Dark Angels",
|
||||
"death-guard": "Death Guard",
|
||||
"deathwatch": "Deathwatch",
|
||||
"drukhari": "Drukhari",
|
||||
"emperors-children": "Emperor's Children",
|
||||
"genestealer-cults": "Genestealer Cults",
|
||||
"grey-knights": "Grey Knights",
|
||||
"imperial-agents": "Imperial Agents",
|
||||
"imperial-knights": "Imperial Knights",
|
||||
"leagues-of-votann": "Leagues of Votann",
|
||||
"necrons": "Necrons",
|
||||
"orks": "Orks",
|
||||
"space-marines": "Space Marines",
|
||||
"space-wolves": "Space Wolves",
|
||||
"tau-empire": "T'au Empire",
|
||||
"thousand-sons": "Thousand Sons",
|
||||
"tyranids": "Tyranids",
|
||||
"world-eaters": "World Eaters",
|
||||
}
|
||||
|
||||
|
||||
def extract_version(doc) -> str | None:
|
||||
"""Pull the MFM version from the title page (e.g. 'VERSION 4.3')."""
|
||||
try:
|
||||
text = doc[0].get_text()
|
||||
except Exception:
|
||||
return None
|
||||
m = re.search(r"VERSION\s+([\d.]+)", text, re.IGNORECASE)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def main():
|
||||
if not Path(PDF).exists():
|
||||
print(f"ERROR: PDF not found at {PDF}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
doc = pymupdf.open(PDF)
|
||||
version = extract_version(doc)
|
||||
print(f"PDF: {PDF} pages={doc.page_count} version={version}", flush=True)
|
||||
|
||||
# accumulator per faction slug
|
||||
accum: dict[str, dict] = {}
|
||||
for page_idx in range(doc.page_count):
|
||||
page_num = page_idx + 1
|
||||
s = PAGE_TO_SLUG.get(page_num)
|
||||
if not s:
|
||||
print(f" page {page_num:2d}: skipped (title/blank)", flush=True)
|
||||
continue
|
||||
if s not in accum:
|
||||
accum[s] = {
|
||||
"slug": s,
|
||||
"name": NAME_BY_SLUG[s],
|
||||
"source": Path(PDF).name,
|
||||
"version": version,
|
||||
"extracted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"pages": [],
|
||||
"n_units": 0,
|
||||
"n_rows": 0,
|
||||
"units": {},
|
||||
}
|
||||
accum[s]["pages"].append(page_num)
|
||||
page_units = parse_page(doc[page_idx].get_text())
|
||||
n_added = 0
|
||||
for unit, costs in page_units.items():
|
||||
existing = accum[s]["units"].setdefault(unit, [])
|
||||
seen = {(c["size"], c["pts"]) for c in existing}
|
||||
for c in costs:
|
||||
if (c["size"], c["pts"]) not in seen:
|
||||
existing.append(c)
|
||||
seen.add((c["size"], c["pts"]))
|
||||
n_added += 1
|
||||
print(f" page {page_num:2d} -> {s:<22} +{n_added:>3} entries", flush=True)
|
||||
doc.close()
|
||||
|
||||
# finalize counts + write files
|
||||
manifest = {"pdf": Path(PDF).name, "version": version, "factions": []}
|
||||
total_units = total_rows = 0
|
||||
for s, data in sorted(accum.items()):
|
||||
data["n_units"] = len(data["units"])
|
||||
data["n_rows"] = sum(len(v) for v in data["units"].values())
|
||||
out_path = OUT_DIR / f"{s}.json"
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
total_units += data["n_units"]
|
||||
total_rows += data["n_rows"]
|
||||
manifest["factions"].append({
|
||||
"slug": s, "name": data["name"],
|
||||
"pages": data["pages"],
|
||||
"n_units": data["n_units"],
|
||||
"n_rows": data["n_rows"],
|
||||
"file": out_path.name,
|
||||
})
|
||||
print(f" {s:<22} {data['n_units']:>3} units / {data['n_rows']:>3} rows "
|
||||
f"-> {out_path.name}", flush=True)
|
||||
|
||||
manifest["generated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
manifest["n_factions"] = len(manifest["factions"])
|
||||
manifest["total_units"] = total_units
|
||||
manifest["total_rows"] = total_rows
|
||||
with open(OUT_DIR / "_manifest.json", "w") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"\nWrote {len(manifest['factions'])} faction files to {OUT_DIR}/")
|
||||
print(f"Total: {total_units} units / {total_rows} size-rows")
|
||||
print(f"Manifest: {OUT_DIR / '_manifest.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
6426
pdf_data.json
Normal file
6426
pdf_data.json
Normal file
File diff suppressed because it is too large
Load Diff
3
react-app/Dockerfile
Normal file
3
react-app/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM nginx:alpine
|
||||
COPY dist/ /usr/share/nginx/html/
|
||||
RUN printf 'server {\n listen 80;\n server_name _;\n root /usr/share/nginx/html;\n index index.html;\n gzip on;\n gzip_types text/plain text/css application/javascript application/json image/svg+xml;\n gzip_min_length 256;\n \n # CORS headers for module scripts\n add_header Access-Control-Allow-Origin "*" always;\n \n location ~* \\.(js|css|json|png|jpg|webp|svg|ico|woff2?)$ {\n expires 1h;\n add_header Cache-Control "public, max-age=3600";\n add_header Access-Control-Allow-Origin "*" always;\n }\n \n location / {\n try_files $uri $uri/ /index.html;\n }\n}\n' > /etc/nginx/conf.d/default.conf
|
||||
23
react-app/docker-compose.yml
Normal file
23
react-app/docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
wh40k-site:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: wh40k-site:latest
|
||||
container_name: wh40k-site
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hermes-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=hermes-net"
|
||||
- "traefik.http.routers.wh40k-site.entrypoints=websecure"
|
||||
- "traefik.http.routers.wh40k-site.rule=Host(`wh40k.damascusfront.net`)"
|
||||
- "traefik.http.routers.wh40k-site.tls=true"
|
||||
- "traefik.http.routers.wh40k-site.tls.certresolver=cloudflare"
|
||||
- "traefik.http.services.wh40k-site.loadbalancer.server.port=80"
|
||||
|
||||
networks:
|
||||
hermes-net:
|
||||
external: true
|
||||
name: hermes-net
|
||||
29
react-app/index.html
Normal file
29
react-app/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0a0e14">
|
||||
<link rel="icon" type="image/png" href="./favicon.png">
|
||||
<link rel="apple-touch-icon" href="./favicon.png">
|
||||
|
||||
<!-- Open Graph / Discord embed -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="WH40K Points Comparator">
|
||||
<meta property="og:description" content="Compare Warhammer 40,000 unit points across Munitorum Field Manual versions. Track price changes, view historical trends, and find the biggest winners and losers.">
|
||||
<meta property="og:image" content="./og-image.png">
|
||||
<meta property="og:url" content="https://wh40k.damascusfront.net/">
|
||||
|
||||
<!-- Twitter embed -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="WH40K Points Comparator">
|
||||
<meta name="twitter:description" content="Compare Warhammer 40,000 unit points across Munitorum Field Manual versions. Track price changes, view historical trends, and find the biggest winners and losers.">
|
||||
<meta name="twitter:image" content="./og-image.png">
|
||||
|
||||
<title>WH40K Points Comparator — MFM v4.3</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2062
react-app/package-lock.json
generated
Normal file
2062
react-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
react-app/package.json
Normal file
24
react-app/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "react-app",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/material": "^9.1.1",
|
||||
"@mui/x-data-grid": "^9.5.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
1
react-app/public/data.json
Normal file
1
react-app/public/data.json
Normal file
File diff suppressed because one or more lines are too long
BIN
react-app/public/favicon.png
Normal file
BIN
react-app/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 920 KiB |
BIN
react-app/public/og-image.png
Normal file
BIN
react-app/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 829 KiB |
508
react-app/src/App.jsx
Normal file
508
react-app/src/App.jsx
Normal file
@@ -0,0 +1,508 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react'
|
||||
import {
|
||||
Box, Container, Typography, TextField, Select, MenuItem, InputLabel,
|
||||
FormControl, Stack, IconButton, Modal, Paper,
|
||||
useMediaQuery, useTheme,
|
||||
} from '@mui/material'
|
||||
import { DataGrid } from '@mui/x-data-grid'
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
function sizeShort(size) {
|
||||
return size.replace(/\s*models?$/, '')
|
||||
}
|
||||
|
||||
function pctLabel(pct) {
|
||||
if (pct === null || pct === undefined) return '—'
|
||||
const sign = pct > 0 ? '+' : ''
|
||||
return `${sign}${pct.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function ptsLabel(pts) {
|
||||
if (pts === null || pts === undefined) return '—'
|
||||
return pts > 0 ? `+${pts}` : `${pts}`
|
||||
}
|
||||
|
||||
// Color: red = more expensive (bad for player), green = cheaper (good for player)
|
||||
function changeColor(val) {
|
||||
if (val === null || val === undefined) return 'text.secondary'
|
||||
if (val > 0) return '#f85149' // costlier = red
|
||||
if (val < 0) return '#3fb950' // cheaper = green
|
||||
return 'text.secondary'
|
||||
}
|
||||
|
||||
// ── Size dropdown cell ──
|
||||
|
||||
function SizeCell({ row, onSelect }) {
|
||||
const sizes = row.sizes || []
|
||||
if (sizes.length <= 1) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>
|
||||
{sizeShort(row.size)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={row.size}
|
||||
onChange={(e) => { e.stopPropagation(); onSelect(row, e.target.value) }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="outlined"
|
||||
IconComponent={() => null}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
height: 24,
|
||||
'& .MuiSelect-select': { py: 0.15, px: 0.5, fontSize: { xs: '0.65rem', sm: '0.75rem' }, fontWeight: 600, paddingRight: '0.5px !important' },
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(59,130,246,0.3)' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'primary.main' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'primary.main' },
|
||||
}}
|
||||
renderValue={(v) => sizeShort(v)}
|
||||
>
|
||||
{sizes.map((s) => (
|
||||
<MenuItem key={s.size} value={s.size} sx={{ fontSize: '0.8rem' }}>
|
||||
{sizeShort(s.size)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Line graph modal ──
|
||||
|
||||
function GraphModal({ row, open, onClose }) {
|
||||
const [graphSize, setGraphSize] = useState(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (row) setGraphSize(row.size)
|
||||
}, [row])
|
||||
|
||||
if (!row) return null
|
||||
|
||||
const sizes = row.sizes || []
|
||||
const activeSize = graphSize || row.size
|
||||
const activeSizeData = sizes.find(s => s.size === activeSize) || sizes[0]
|
||||
const history = activeSizeData?.history || []
|
||||
|
||||
const W = 640, H = 360, padL = 70, padR = 24, padT = 24, padB = 48
|
||||
const chartW = W - padL - padR
|
||||
const chartH = H - padT - padB
|
||||
|
||||
const pts = history.map(h => h.pts)
|
||||
const minPts = Math.min(...pts, 0)
|
||||
const maxPts = Math.max(...pts, 1)
|
||||
const ptsRange = maxPts - minPts || 1
|
||||
const padY = ptsRange * 0.15
|
||||
const yMin = Math.max(0, minPts - padY)
|
||||
const yMax = maxPts + padY
|
||||
const yRange = yMax - yMin || 1
|
||||
|
||||
const n = history.length
|
||||
const xFor = (i) => n <= 1 ? chartW / 2 + padL : padL + (i / (n - 1)) * chartW
|
||||
const yFor = (v) => padT + chartH - ((v - yMin) / yRange) * chartH
|
||||
|
||||
const linePath = history.map((h, i) => `${i === 0 ? 'M' : 'L'} ${xFor(i)} ${yFor(h.pts)}`).join(' ')
|
||||
const areaPath = history.length > 1
|
||||
? `${linePath} L ${xFor(n - 1)} ${padT + chartH} L ${xFor(0)} ${padT + chartH} Z`
|
||||
: ''
|
||||
|
||||
const yTicks = []
|
||||
const tickCount = Math.min(4, Math.ceil(yRange / 10))
|
||||
for (let i = 0; i <= tickCount; i++) {
|
||||
const v = yMin + (yRange * i / tickCount)
|
||||
yTicks.push({ v: Math.round(v), y: yFor(v) })
|
||||
}
|
||||
|
||||
const fmtDate = (d) => {
|
||||
const dt = new Date(d)
|
||||
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' })
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: { xs: 0, sm: 2 }, width: '100%', height: '100%' }}>
|
||||
<Paper sx={{
|
||||
maxWidth: { xs: '100%', sm: 720 },
|
||||
width: '100%',
|
||||
height: { xs: '100%', sm: 'auto' },
|
||||
maxHeight: { xs: '100%', sm: '90vh' },
|
||||
overflow: 'auto', p: { xs: 1, sm: 3 },
|
||||
borderRadius: { xs: 0, sm: 2 },
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ fontSize: { xs: '1rem', sm: '1.1rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.72rem', sm: '0.8rem' } }}>{row.faction_name}</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: 'text.secondary', flexShrink: 0 }}>✕</IconButton>
|
||||
</Box>
|
||||
|
||||
{sizes.length > 1 && (
|
||||
<FormControl size="small" sx={{ mb: 2, minWidth: 140 }}>
|
||||
<InputLabel>Model count</InputLabel>
|
||||
<Select
|
||||
value={activeSize}
|
||||
label="Model count"
|
||||
onChange={(e) => setGraphSize(e.target.value)}
|
||||
renderValue={(v) => sizeShort(v) + ' models'}
|
||||
>
|
||||
{sizes.map((s) => (
|
||||
<MenuItem key={s.size} value={s.size}>{sizeShort(s.size)} models</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{history.length > 0 ? (
|
||||
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<Box sx={{ width: '100%', maxWidth: { xs: '100%', sm: 680 } }}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
|
||||
{yTicks.map((t, i) => (
|
||||
<g key={i}>
|
||||
<line x1={padL} y1={t.y} x2={W - padR} y2={t.y} stroke="#d0d0d0" strokeWidth={0.5} />
|
||||
<text x={padL - 10} y={t.y + 4} textAnchor="end" fontSize={13} fontWeight={500} fill="wheat">{t.v}</text>
|
||||
</g>
|
||||
))}
|
||||
{areaPath && <path d={areaPath} fill="rgba(59,130,246,0.10)" />}
|
||||
<path d={linePath} fill="none" stroke="#3b82f6" strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" />
|
||||
{history.map((h, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={xFor(i)} cy={yFor(h.pts)} r={5} fill="#3b82f6" stroke="#fff" strokeWidth={2} />
|
||||
<text x={xFor(i)} y={yFor(h.pts) - 12} textAnchor="middle" fontSize={14} fontWeight={700} fill="wheat">{h.pts}</text>
|
||||
<text x={xFor(i)} y={H - padB + 20} textAnchor="middle" fontSize={11} fontWeight={500} fill="wheat">{fmtDate(h.date)}</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||
No historical data for this unit.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{history.length > 1 && (
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{history[0].version}: {history[0].pts}pts
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{history[history.length - 1].version}: {history[history.length - 1].pts}pts
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main App ──
|
||||
|
||||
export default function App() {
|
||||
const [data, setData] = useState(null)
|
||||
// Read initial state from URL params
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const [query, setQuery] = useState(params.get('q') || '')
|
||||
const [faction, setFaction] = useState(params.get('faction') || 'adepta-sororitas')
|
||||
const [dir, setDir] = useState(params.get('dir') || '')
|
||||
const [sizeChoice, setSizeChoice] = useState({})
|
||||
const [modalRow, setModalRow] = useState(null)
|
||||
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch('./data.json').then(r => r.json()).then(setData).catch(console.error)
|
||||
}, [])
|
||||
|
||||
// Sync filter state to URL whenever it changes
|
||||
React.useEffect(() => {
|
||||
const p = new URLSearchParams()
|
||||
if (query) p.set('q', query)
|
||||
if (faction) p.set('faction', faction)
|
||||
if (dir) p.set('dir', dir)
|
||||
const qs = p.toString()
|
||||
const newUrl = qs ? `?${qs}` : window.location.pathname
|
||||
window.history.replaceState(null, '', newUrl)
|
||||
}, [query, faction, dir])
|
||||
|
||||
const selectSize = useCallback((row, sizeLabel) => {
|
||||
const key = `${row.faction}|${row.name}`
|
||||
setSizeChoice(prev => ({ ...prev, [key]: sizeLabel }))
|
||||
}, [])
|
||||
|
||||
const augmented = useMemo(() => {
|
||||
if (!data) return []
|
||||
return data.units.map(u => {
|
||||
const key = `${u.faction}|${u.name}`
|
||||
const chosen = sizeChoice[key]
|
||||
const active = (chosen && u.sizes.find(s => s.size === chosen)) || u.sizes.find(s => s.size === u.default_size) || u.sizes[0]
|
||||
return {
|
||||
...u,
|
||||
size: active.size,
|
||||
original: active.original,
|
||||
new: active.new,
|
||||
change_pct: active.change_pct,
|
||||
change_pts: active.change_pts,
|
||||
tier: active.tier,
|
||||
}
|
||||
})
|
||||
}, [data, sizeChoice])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!augmented.length) return []
|
||||
let view = augmented
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q) {
|
||||
view = view.filter(u =>
|
||||
u.name.toLowerCase().includes(q) ||
|
||||
u.faction_name.toLowerCase().includes(q) ||
|
||||
u.size.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
if (faction) view = view.filter(u => u.faction === faction)
|
||||
if (dir === 'up') view = view.filter(u => u.change_pct !== null && u.change_pct > 0)
|
||||
else if (dir === 'down') view = view.filter(u => u.change_pct !== null && u.change_pct < 0)
|
||||
else if (dir === 'no-change') view = view.filter(u => u.change_pct === 0)
|
||||
else if (dir === 'new-only') view = view.filter(u => u.original === null && u.new !== null)
|
||||
else if (dir === 'old-only') view = view.filter(u => u.original !== null && u.new === null)
|
||||
return view
|
||||
}, [augmented, query, faction, dir])
|
||||
|
||||
// Movers — based on the currently filtered view
|
||||
const movers = useMemo(() => {
|
||||
if (!filtered.length) return { drops: [], rises: [] }
|
||||
const drops = filtered.filter(u => u.change_pct !== null && u.change_pct < 0)
|
||||
.sort((a, b) => a.change_pct - b.change_pct).slice(0, 5)
|
||||
const rises = filtered.filter(u => u.change_pct !== null && u.change_pct > 0)
|
||||
.sort((a, b) => b.change_pct - a.change_pct).slice(0, 5)
|
||||
return { drops, rises }
|
||||
}, [filtered])
|
||||
|
||||
const showFactionCol = !faction
|
||||
const showFactionInMovers = !faction // hide faction name in movers when filtered to a faction
|
||||
|
||||
// Columns: all flex-based
|
||||
const columns = useMemo(() => {
|
||||
const cols = [
|
||||
{
|
||||
field: 'name', headerName: 'Unit', flex: 3, minWidth: 80,
|
||||
renderCell: (p) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
fontSize: { xs: '0.68rem', sm: '0.8rem' },
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
'&:hover': { textDecoration: 'underline', color: 'primary.main' },
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{p.row.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'size', headerName: '#', flex: 0.6, minWidth: 36,
|
||||
renderCell: (p) => <SizeCell row={p.row} onSelect={selectSize} />,
|
||||
},
|
||||
{
|
||||
field: 'original', headerName: 'Old', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right',
|
||||
renderCell: (p) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
|
||||
<Typography sx={{ fontFamily: 'monospace', fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>{p.row.original ?? '—'}</Typography>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'new', headerName: 'New', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right',
|
||||
renderCell: (p) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
|
||||
<Typography sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>{p.row.new ?? '—'}</Typography>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'change_pct', headerName: 'Δ %', flex: 1, minWidth: 44, align: 'right', headerAlign: 'right',
|
||||
renderCell: (p) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
|
||||
<Typography sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: { xs: '0.68rem', sm: '0.8rem' }, color: changeColor(p.row.change_pct) }}>{pctLabel(p.row.change_pct)}</Typography>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// Δ pts column — desktop only
|
||||
if (!isMobile) {
|
||||
cols.splice(4, 0, {
|
||||
field: 'change_pts', headerName: 'Δ pts', flex: 0.8, minWidth: 40, align: 'right', headerAlign: 'right',
|
||||
renderCell: (p) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
|
||||
<Typography sx={{ fontFamily: 'monospace', fontSize: { xs: '0.68rem', sm: '0.8rem' }, color: changeColor(p.row.change_pts) }}>{ptsLabel(p.row.change_pts)}</Typography>
|
||||
</Box>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (showFactionCol) {
|
||||
cols.unshift({
|
||||
field: 'faction_name', headerName: 'Faction', flex: 1.5, minWidth: 80,
|
||||
renderCell: (p) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.68rem', sm: '0.8rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.row.faction_name}</Typography>
|
||||
</Box>
|
||||
),
|
||||
})
|
||||
}
|
||||
return cols
|
||||
}, [selectSize, showFactionCol, isMobile])
|
||||
|
||||
if (!data) return <Box sx={{ p: 4, color: 'text.secondary' }}>Loading…</Box>
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
{/* Sticky filter bar */}
|
||||
<Box sx={{
|
||||
borderBottom: 1, borderColor: 'divider', bgcolor: 'background.paper',
|
||||
position: 'sticky', top: 0, zIndex: 1100,
|
||||
}}>
|
||||
<Container maxWidth="xl" sx={{ py: { xs: 0.75, sm: 1 }, px: { xs: 1, sm: 2 } }}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ display: { xs: 'none', sm: 'block' }, mb: 0.5, fontSize: '1.1rem' }}>
|
||||
Points Comparator
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ display: { xs: 'none', sm: 'block' }, mb: 1, fontSize: '0.75rem' }}>
|
||||
Codex vs. MFM v4.3 · {data.stats.total_rows.toLocaleString()} units · <span style={{ color: '#3fb950' }}>green = cheaper</span> · <span style={{ color: '#f85149' }}>red = costlier</span>
|
||||
</Typography>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 0.5, sm: 1 }} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Unit name…"
|
||||
sx={{ flex: 2, minWidth: { xs: '100%', sm: 180 } }}
|
||||
/>
|
||||
<Stack direction="row" spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
|
||||
<FormControl size="small" sx={{ flex: 1, minWidth: 0 }}>
|
||||
<InputLabel>Faction</InputLabel>
|
||||
<Select value={faction} label="Faction" onChange={(e) => setFaction(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{data.factions.map(slug => (
|
||||
<MenuItem key={slug} value={slug}>{data.faction_names[slug] || slug}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ flex: 1, minWidth: 0 }}>
|
||||
<InputLabel>{isMobile ? 'Δ' : 'Change'}</InputLabel>
|
||||
<Select value={dir} label={isMobile ? 'Δ' : 'Change'} onChange={(e) => setDir(e.target.value)}>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="up">↑ Costlier</MenuItem>
|
||||
<MenuItem value="down">↓ Cheaper</MenuItem>
|
||||
<MenuItem value="no-change">— No change</MenuItem>
|
||||
<MenuItem value="new-only">+ New only</MenuItem>
|
||||
<MenuItem value="old-only">− Removed</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Movers — based on current filtered view, click opens modal */}
|
||||
<Container maxWidth="xl" sx={{ py: { xs: 0.5, sm: 2 }, px: { xs: 1, sm: 2 } }}>
|
||||
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 2 }, flexDirection: 'row' }}>
|
||||
{/* Drops = cheaper = green (good for player) */}
|
||||
<Paper sx={{ flex: 1, p: { xs: 0.5, sm: 2 }, borderRadius: { xs: 1, sm: 2 }, borderLeft: 3, borderColor: 'success.main', minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 0.25, fontSize: { xs: '0.65rem', sm: '0.8rem' }, color: 'success.main' }}>↓ Cheaper</Typography>
|
||||
<Stack spacing={0}>
|
||||
{movers.drops.map((u, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', py: 0.15, px: 0.25, borderRadius: 0.5, '&:hover': { bgcolor: 'rgba(63,185,80,0.08)' } }}
|
||||
onClick={() => setModalRow(u)}>
|
||||
<Box sx={{ minWidth: 0, overflow: 'hidden', flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ fontSize: { xs: '0.6rem', sm: '0.8rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', '&:hover': { color: 'primary.main' } }}>{u.name}</Typography>
|
||||
{showFactionInMovers && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.55rem', sm: '0.7rem' }, display: { xs: 'none', sm: 'block' } }}>{u.faction_name}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', color: 'success.main', fontWeight: 700, fontSize: { xs: '0.55rem', sm: '0.8rem' }, whiteSpace: 'nowrap', ml: 0.5 }}>
|
||||
{u.original}→{u.new} ({pctLabel(u.change_pct)})
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
{/* Rises = costlier = red (bad for player) */}
|
||||
<Paper sx={{ flex: 1, p: { xs: 0.5, sm: 2 }, borderRadius: { xs: 1, sm: 2 }, borderLeft: 3, borderColor: 'error.main', minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 0.25, fontSize: { xs: '0.65rem', sm: '0.8rem' }, color: 'error.main' }}>↑ Costlier</Typography>
|
||||
<Stack spacing={0}>
|
||||
{movers.rises.map((u, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', py: 0.15, px: 0.25, borderRadius: 0.5, '&:hover': { bgcolor: 'rgba(248,81,73,0.08)' } }}
|
||||
onClick={() => setModalRow(u)}>
|
||||
<Box sx={{ minWidth: 0, overflow: 'hidden', flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ fontSize: { xs: '0.6rem', sm: '0.8rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', '&:hover': { color: 'primary.main' } }}>{u.name}</Typography>
|
||||
{showFactionInMovers && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.55rem', sm: '0.7rem' }, display: { xs: 'none', sm: 'block' } }}>{u.faction_name}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', color: 'error.main', fontWeight: 700, fontSize: { xs: '0.55rem', sm: '0.8rem' }, whiteSpace: 'nowrap', ml: 0.5 }}>
|
||||
{u.original}→{u.new} ({pctLabel(u.change_pct)})
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Data Grid */}
|
||||
<Container maxWidth="xl" sx={{ pb: 4, px: { xs: 1, sm: 2 } }}>
|
||||
<Paper sx={{ borderRadius: 2, overflow: 'hidden' }}>
|
||||
{/* Count label — inside the table card, anchored */}
|
||||
<Box sx={{ px: { xs: 1, sm: 2 }, py: 1, borderBottom: 1, borderColor: 'divider', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.65rem', sm: '0.7rem' } }}>
|
||||
<b style={{ color: 'text.primary' }}>{filtered.length.toLocaleString()}</b> units
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.6rem', sm: '0.65rem' }, display: { xs: 'none', sm: 'block' } }}>
|
||||
Click a unit for points history →
|
||||
</Typography>
|
||||
</Box>
|
||||
<DataGrid
|
||||
rows={filtered}
|
||||
columns={columns}
|
||||
getRowId={(row) => `${row.faction}|${row.name}`}
|
||||
density="compact"
|
||||
autoHeight
|
||||
hideFooter
|
||||
disableColumnMenu
|
||||
onCellClick={(p) => {
|
||||
if (p.field === 'size') return
|
||||
setModalRow(p.row)
|
||||
}}
|
||||
sx={{
|
||||
border: 'none',
|
||||
width: '100%',
|
||||
'& .MuiDataGrid-columnHeaders': { bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' },
|
||||
'& .MuiDataGrid-columnHeader': { fontSize: { xs: '0.6rem', sm: '0.75rem' }, textTransform: 'uppercase', fontWeight: 600 },
|
||||
'& .MuiDataGrid-columnHeaderTitle': { fontSize: { xs: '0.6rem', sm: '0.75rem' } },
|
||||
'& .MuiDataGrid-columnSeparator': { display: 'none' },
|
||||
'& .MuiDataGrid-iconButtonContainer': { display: 'none' },
|
||||
'& .MuiDataGrid-row': { cursor: 'pointer', '&:hover': { bgcolor: 'rgba(59,130,246,0.04)' } },
|
||||
'& .MuiDataGrid-cell': { borderColor: 'divider', py: { xs: 0.5, sm: 0.5 }, display: 'flex', alignItems: 'center', overflow: 'hidden' },
|
||||
'& .MuiDataGrid-virtualScroller': { overflowX: 'hidden' },
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
{/* Graph Modal */}
|
||||
<GraphModal row={modalRow} open={!!modalRow} onClose={() => setModalRow(null)} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
49
react-app/src/main.jsx
Normal file
49
react-app/src/main.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material'
|
||||
import App from './App.jsx'
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
background: {
|
||||
default: '#0a0e14',
|
||||
paper: '#11161e',
|
||||
},
|
||||
primary: { main: '#3b82f6' },
|
||||
secondary: { main: '#60a5fa' },
|
||||
success: { main: '#3fb950' },
|
||||
error: { main: '#f85149' },
|
||||
text: {
|
||||
primary: '#e6edf3',
|
||||
secondary: '#7d8590',
|
||||
},
|
||||
divider: '#232b38',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif',
|
||||
fontSize: 14,
|
||||
},
|
||||
shape: { borderRadius: 8 },
|
||||
components: {
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: { backgroundImage: 'none' },
|
||||
},
|
||||
},
|
||||
MuiTableCell: {
|
||||
styleOverrides: {
|
||||
root: { borderBottomColor: '#232b38' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
20
react-app/vite.config.js
Normal file
20
react-app/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
base: './',
|
||||
modulePreload: { polyfill: true },
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'assets/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[name]-[hash][extname]',
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 9102,
|
||||
},
|
||||
})
|
||||
259
scrape_live.py
Normal file
259
scrape_live.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Scrape live MFM data for all 30 factions.
|
||||
|
||||
Output: /root/wh40k-factions/live_data.json
|
||||
{
|
||||
"<faction-slug>": {
|
||||
"name": "T'au Empire",
|
||||
"version": "v1.0",
|
||||
"url": "...",
|
||||
"units": {
|
||||
"Broadside Battlesuits": [
|
||||
{
|
||||
"size": "1 models",
|
||||
"pts": 75,
|
||||
"tier": "YOUR 1ST TO 2ND UNITS COST"
|
||||
},
|
||||
{"size": "2 models", "pts": 150, "tier": "YOUR 1ST TO 2ND UNITS COST"},
|
||||
...
|
||||
{"size": "1 models", "pts": 95, "tier": "YOUR 3RD + UNIT COSTS"},
|
||||
],
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
FACTIONS = [
|
||||
("adepta-sororitas", "Adepta Sororitas", "https://mfm.warhammer-community.com/en/adepta-sororitas"),
|
||||
("adeptus-custodes", "Adeptus Custodes", "https://mfm.warhammer-community.com/en/adeptus-custodes"),
|
||||
("adeptus-mechanicus", "Adeptus Mechanicus", "https://mfm.warhammer-community.com/en/adeptus-mechanicus"),
|
||||
("aeldari", "Aeldari", "https://mfm.warhammer-community.com/en/aeldari"),
|
||||
("astra-militarum", "Astra Militarum", "https://mfm.warhammer-community.com/en/astra-militarum"),
|
||||
("black-templars", "Black Templars", "https://mfm.warhammer-community.com/en/black-templars"),
|
||||
("blood-angels", "Blood Angels", "https://mfm.warhammer-community.com/en/blood-angels"),
|
||||
("chaos-daemons", "Chaos Daemons", "https://mfm.warhammer-community.com/en/chaos-daemons"),
|
||||
("chaos-knights", "Chaos Knights", "https://mfm.warhammer-community.com/en/chaos-knights"),
|
||||
("chaos-space-marines", "Chaos Space Marines", "https://mfm.warhammer-community.com/en/chaos-space-marines"),
|
||||
("chaos-titan-legions", "Chaos Titan Legions", "https://mfm.warhammer-community.com/en/chaos-titan-legions"),
|
||||
("dark-angels", "Dark Angels", "https://mfm.warhammer-community.com/en/dark-angels"),
|
||||
("death-guard", "Death Guard", "https://mfm.warhammer-community.com/en/death-guard"),
|
||||
("deathwatch", "Deathwatch", "https://mfm.warhammer-community.com/en/deathwatch"),
|
||||
("drukhari", "Drukhari", "https://mfm.warhammer-community.com/en/drukhari"),
|
||||
("emperors-children", "Emperor's Children", "https://mfm.warhammer-community.com/en/emperors-children"),
|
||||
("genestealer-cults", "Genestealer Cults", "https://mfm.warhammer-community.com/en/genestealer-cults"),
|
||||
("grey-knights", "Grey Knights", "https://mfm.warhammer-community.com/en/grey-knights"),
|
||||
("imperial-agents", "Imperial Agents", "https://mfm.warhammer-community.com/en/imperial-agents"),
|
||||
("imperial-knights", "Imperial Knights", "https://mfm.warhammer-community.com/en/imperial-knights"),
|
||||
("leagues-of-votann", "Leagues of Votann", "https://mfm.warhammer-community.com/en/leagues-of-votann"),
|
||||
("necrons", "Necrons", "https://mfm.warhammer-community.com/en/necrons"),
|
||||
("orks", "Orks", "https://mfm.warhammer-community.com/en/orks"),
|
||||
("space-marines", "Space Marines", "https://mfm.warhammer-community.com/en/space-marines"),
|
||||
("space-wolves", "Space Wolves", "https://mfm.warhammer-community.com/en/space-wolves"),
|
||||
("tau-empire", "T'au Empire", "https://mfm.warhammer-community.com/en/tau-empire"),
|
||||
("thousand-sons", "Thousand Sons", "https://mfm.warhammer-community.com/en/thousand-sons"),
|
||||
("titan-legions", "Titan Legions", "https://mfm.warhammer-community.com/en/titan-legions"),
|
||||
("tyranids", "Tyranids", "https://mfm.warhammer-community.com/en/tyranids"),
|
||||
("world-eaters", "World Eaters", "https://mfm.warhammer-community.com/en/world-eaters"),
|
||||
]
|
||||
|
||||
OUT = Path("/root/wh40k-factions/live_data.json")
|
||||
|
||||
# JavaScript extractor — runs in the page context. Returns a list of:
|
||||
# {unit: str, tier: str|null, size: str, pts: int}
|
||||
EXTRACT_JS = r"""
|
||||
() => {
|
||||
// Walk the entire body. The MFM site renders unit cards with class
|
||||
// "flex flex-col space-y-1 m-1 print:break-inside-avoid-page"
|
||||
// Each card has:
|
||||
// - a unit-name heading (h2/h3)
|
||||
// - one or more tier headers (e.g. "YOUR 1ST TO 2ND UNITS COST")
|
||||
// - a list of <li> items with "<n> models" and "<k> pts"
|
||||
const out = [];
|
||||
const cards = document.querySelectorAll('div.flex.flex-col.space-y-1.m-1');
|
||||
for (const card of cards) {
|
||||
// Unit name: first heading child
|
||||
const heading = card.querySelector('h1, h2, h3, h4, [class*="font-bold"], [class*="uppercase"]');
|
||||
if (!heading) continue;
|
||||
const unit = heading.innerText.trim();
|
||||
if (!unit) continue;
|
||||
|
||||
// Now find tier headers and cost lists within the card
|
||||
// The DOM order is: heading, tier1-label, tier1-list, tier2-label, tier2-list, ...
|
||||
// Tier labels are short text in CAPS containing "UNIT" or "MODEL"
|
||||
// Lists are <ul><li>...</li></ul>
|
||||
const children = Array.from(card.children);
|
||||
let currentTier = null;
|
||||
for (const child of children) {
|
||||
const txt = (child.innerText || '').trim();
|
||||
if (!txt) continue;
|
||||
if (/^YOUR\b/i.test(txt) && (txt.includes('UNIT') || txt.includes('COST') || txt.includes('MODEL'))) {
|
||||
currentTier = txt.replace(/\s+/g, ' ');
|
||||
continue;
|
||||
}
|
||||
if (child.tagName === 'UL' || child.tagName === 'OL' || child.querySelector('li')) {
|
||||
const items = child.querySelectorAll('li');
|
||||
for (const li of items) {
|
||||
const liText = (li.innerText || '').trim();
|
||||
// Format: "<n> models\n<k> pts" or "<n> model\n<k> pts"
|
||||
const m = liText.match(/(\d+)\s+models?\s*\n?\s*(\d+)\s*pts?/i);
|
||||
if (m) {
|
||||
out.push({
|
||||
unit: unit,
|
||||
tier: currentTier,
|
||||
size: m[1) + ' models',
|
||||
pts: parseInt(m[2], 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
"""
|
||||
|
||||
# Wait — the JS had a syntax error above. Rewrite cleanly:
|
||||
|
||||
EXTRACT_JS = r"""
|
||||
() => {
|
||||
const out = [];
|
||||
// Card root: <div class="flex flex-col space-y-1 m-1 print:break-inside-avoid-page">
|
||||
// First child is the unit-name banner. Then come tier blocks:
|
||||
// <div class="space-y-1">
|
||||
// <div ...>TIER LABEL</div>
|
||||
// <ul><li><span>SIZE</span><span>PTS</span></li>...</ul>
|
||||
// </div>
|
||||
const cards = document.querySelectorAll('div.flex.flex-col.space-y-1.m-1');
|
||||
for (const card of cards) {
|
||||
const header = card.firstElementChild;
|
||||
if (!header) continue;
|
||||
const unit = (header.innerText || '').trim();
|
||||
if (!unit) continue;
|
||||
|
||||
// Find all <ul> within the card and walk backwards to find the
|
||||
// most recent tier label
|
||||
const uls = card.querySelectorAll('ul');
|
||||
for (const ul of uls) {
|
||||
// Find the tier label: walk up to the .space-y-1 wrapper, then
|
||||
// take its first child (the label div)
|
||||
let tier = null;
|
||||
let parent = ul.parentElement; // .space-y-1
|
||||
if (parent) {
|
||||
const labelDiv = parent.querySelector(':scope > div');
|
||||
if (labelDiv) tier = (labelDiv.innerText || '').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
for (const li of ul.querySelectorAll('li')) {
|
||||
const spans = li.querySelectorAll('span');
|
||||
if (spans.length < 2) continue;
|
||||
const size = (spans[0].innerText || '').trim();
|
||||
const ptsText = (spans[1].innerText || '').trim();
|
||||
const pts = parseInt(ptsText.replace(/[^\d]/g, ''), 10);
|
||||
if (!size || isNaN(pts)) continue;
|
||||
out.push({ unit, tier, size, pts });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def fetch_one(p, slug: str, name: str, url: str, idx: int, total: int) -> dict:
|
||||
print(f"[{idx:>2}/{total}] {name} -> {url}", flush=True)
|
||||
start = time.time()
|
||||
status = {
|
||||
"slug": slug, "name": name, "url": url,
|
||||
"ok": False, "n_units": 0, "n_rows": 0, "error": None,
|
||||
"elapsed_s": 0.0,
|
||||
}
|
||||
try:
|
||||
context = p.chromium.launch_persistent_context(
|
||||
user_data_dir=f"/tmp/wh40k-live-{slug}",
|
||||
executable_path="/usr/bin/chromium",
|
||||
headless=True,
|
||||
viewport={"width": 1280, "height": 1800},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = context.new_page()
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=20000)
|
||||
except Exception as e:
|
||||
print(f" networkidle timeout (continuing): {e}", flush=True)
|
||||
page.wait_for_timeout(2500)
|
||||
|
||||
# Detect version
|
||||
version = page.evaluate("""
|
||||
() => {
|
||||
const m = (document.body.innerText || '').match(/v\\d+\\.\\d+/);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
""")
|
||||
|
||||
rows = page.evaluate(EXTRACT_JS)
|
||||
context.close()
|
||||
|
||||
# Group rows by unit
|
||||
units: dict[str, list[dict]] = {}
|
||||
for r in rows:
|
||||
units.setdefault(r["unit"], []).append({
|
||||
"size": r["size"],
|
||||
"pts": r["pts"],
|
||||
"tier": r["tier"],
|
||||
})
|
||||
|
||||
status["ok"] = True
|
||||
status["n_units"] = len(units)
|
||||
status["n_rows"] = len(rows)
|
||||
status["version"] = version
|
||||
print(f" OK units={len(units)} rows={len(rows)} version={version}", flush=True)
|
||||
return {
|
||||
"slug": slug,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"version": version,
|
||||
"units": units,
|
||||
"_status": status,
|
||||
}
|
||||
except Exception as e:
|
||||
status["error"] = repr(e)
|
||||
status["elapsed_s"] = round(time.time() - start, 2)
|
||||
print(f" FAIL {status['error']}", flush=True)
|
||||
return {
|
||||
"slug": slug,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"version": None,
|
||||
"units": {},
|
||||
"_status": status,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out: dict = {}
|
||||
with sync_playwright() as p:
|
||||
for i, (slug, name, url) in enumerate(FACTIONS, 1):
|
||||
r = fetch_one(p, slug, name, url, i, len(FACTIONS))
|
||||
out[slug] = r
|
||||
if i < len(FACTIONS):
|
||||
time.sleep(2.0) # politeness between pages
|
||||
OUT.write_text(json.dumps(out, indent=2, ensure_ascii=False))
|
||||
print(f"\nwrote {OUT}")
|
||||
ok = sum(1 for r in out.values() if r["_status"]["ok"])
|
||||
print(f"summary: {ok}/{len(out)} factions scraped")
|
||||
return 0 if ok == len(out) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
114
scrape_live_per_faction.py
Normal file
114
scrape_live_per_faction.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Re-scrape live MFM data for all 30 factions, one JSON file per faction.
|
||||
|
||||
Output:
|
||||
/root/wh40k-factions/live/<slug>.json (one per faction)
|
||||
/root/wh40k-factions/live/_manifest.json (index of all factions + counts)
|
||||
"""
|
||||
import json, sys, time
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from scrape_live import FACTIONS, EXTRACT_JS
|
||||
|
||||
OUT_DIR = Path("/root/wh40k-factions/live")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def fetch_one(context, slug: str, name: str, url: str) -> dict:
|
||||
page = context.new_page()
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=20000)
|
||||
except Exception as e:
|
||||
print(f" networkidle timeout (continuing): {e}", flush=True)
|
||||
page.wait_for_timeout(2500)
|
||||
|
||||
# Detect version
|
||||
version = page.evaluate("""
|
||||
() => {
|
||||
const m = (document.body.innerText || '').match(/v\\d+\\.\\d+/);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
""")
|
||||
|
||||
rows = page.evaluate(EXTRACT_JS)
|
||||
# Group rows by unit
|
||||
units = {}
|
||||
for r in rows:
|
||||
units.setdefault(r["unit"], []).append({
|
||||
"size": r["size"],
|
||||
"pts": r["pts"],
|
||||
"tier": r["tier"],
|
||||
})
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"version": version,
|
||||
"fetched_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"n_units": len(units),
|
||||
"n_rows": len(rows),
|
||||
"units": units,
|
||||
}
|
||||
finally:
|
||||
page.close()
|
||||
|
||||
|
||||
def main():
|
||||
manifest = {"factions": []}
|
||||
n_total = len(FACTIONS)
|
||||
print(f"Scraping {n_total} factions to {OUT_DIR}/", flush=True)
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(executable_path="/usr/bin/chromium", headless=True)
|
||||
try:
|
||||
for idx, (slug, name, url) in enumerate(FACTIONS, 1):
|
||||
t0 = time.time()
|
||||
print(f"[{idx:>2}/{n_total}] {name} -> {url}", flush=True)
|
||||
context = browser.new_context(
|
||||
user_agent=("Mozilla/5.0 (X11; Linux x86_64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/148.0.0.0 Safari/537.36"),
|
||||
viewport={"width": 1280, "height": 1800},
|
||||
)
|
||||
try:
|
||||
data = fetch_one(context, slug, name, url)
|
||||
out_path = OUT_DIR / f"{slug}.json"
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
print(f" {data['n_units']:>3} units / {data['n_rows']:>3} rows "
|
||||
f"-> {out_path.name} ({time.time()-t0:.1f}s)", flush=True)
|
||||
manifest["factions"].append({
|
||||
"slug": slug, "name": name, "url": url,
|
||||
"version": data["version"],
|
||||
"n_units": data["n_units"], "n_rows": data["n_rows"],
|
||||
"file": out_path.name,
|
||||
"elapsed_s": round(time.time() - t0, 1),
|
||||
})
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}", flush=True)
|
||||
manifest["factions"].append({
|
||||
"slug": slug, "name": name, "url": url,
|
||||
"error": str(e),
|
||||
})
|
||||
finally:
|
||||
context.close()
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
manifest["generated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
with open(OUT_DIR / "_manifest.json", "w") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
|
||||
n_ok = sum(1 for f in manifest["factions"] if "error" not in f)
|
||||
n_err = len(manifest["factions"]) - n_ok
|
||||
print(f"\nDone. {n_ok}/{n_total} factions OK, {n_err} errors.")
|
||||
print(f"Manifest: {OUT_DIR / '_manifest.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
361
site/app.js
Normal file
361
site/app.js
Normal file
@@ -0,0 +1,361 @@
|
||||
// WH40K Points Comparator — clean rewrite
|
||||
// Event delegation for all interactive elements. No per-render listener attachment.
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const state = {
|
||||
data: null,
|
||||
view: [],
|
||||
query: "",
|
||||
faction: "",
|
||||
dir: "",
|
||||
sort: "change_pct_desc",
|
||||
mode: "table",
|
||||
sizeChoice: new Map(), // key: "faction|name" → size label
|
||||
};
|
||||
|
||||
// ─── boot ────────────────────────────────────────────
|
||||
|
||||
async function boot() {
|
||||
const res = await fetch("data.json");
|
||||
if (!res.ok) throw new Error(`Failed to load data.json: ${res.status}`);
|
||||
state.data = await res.json();
|
||||
|
||||
// Populate faction dropdown
|
||||
const sel = $("faction");
|
||||
for (const slug of state.data.factions) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = slug;
|
||||
opt.textContent = state.data.faction_names[slug] || slug;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
// Header meta
|
||||
$("meta").textContent =
|
||||
`${state.data.stats.total_rows} units · ` +
|
||||
`${state.data.stats.rows_with_both} with both old & new · ` +
|
||||
`generated ${state.data.generated_at.slice(0, 10)}`;
|
||||
|
||||
renderMovers();
|
||||
applyFilters();
|
||||
|
||||
// ── Wire up controls (these never get re-created) ──
|
||||
$("q").addEventListener("input", (e) => { state.query = e.target.value; applyFilters(); });
|
||||
$("faction").addEventListener("change", (e) => { state.faction = e.target.value; applyFilters(); });
|
||||
$("dir").addEventListener("change", (e) => { state.dir = e.target.value; applyFilters(); });
|
||||
$("sort").addEventListener("change", (e) => { state.sort = e.target.value; applyFilters(); });
|
||||
$("view").addEventListener("change", (e) => { state.mode = e.target.value; render(); });
|
||||
|
||||
// ── Event delegation for #rows (handles all future renders) ──
|
||||
$("rows").addEventListener("click", (e) => {
|
||||
// Size toggle button?
|
||||
const sizeBtn = e.target.closest(".size-btn");
|
||||
if (sizeBtn) {
|
||||
e.stopPropagation();
|
||||
const u = state.data.units.find(x => x.faction === sizeBtn.dataset.fac && x.name === sizeBtn.dataset.name);
|
||||
if (u) cycleSize(u);
|
||||
return;
|
||||
}
|
||||
// Table header sort?
|
||||
const th = e.target.closest("th[data-sort]");
|
||||
if (th) {
|
||||
const map = { faction: "faction", name: "name", size: "size",
|
||||
old: "original", new: "new", change_pts: "change_pts", change_pct: "change_pct" };
|
||||
const k = map[th.dataset.sort];
|
||||
if (!k) return;
|
||||
state.sort = state.sort.startsWith(k)
|
||||
? `${k}_${state.sort.endsWith("_desc") ? "asc" : "desc"}`
|
||||
: `${k}_desc`;
|
||||
// Sync the sort dropdown
|
||||
$("sort").value = state.sort;
|
||||
applyFilters();
|
||||
return;
|
||||
}
|
||||
// Row click → modal
|
||||
const row = e.target.closest("[data-fac][data-name]");
|
||||
if (row && !e.target.closest(".size-btn")) {
|
||||
openModal(row.dataset.fac, row.dataset.name);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Event delegation for movers ──
|
||||
$("movers").addEventListener("click", (e) => {
|
||||
const mover = e.target.closest(".mover-row");
|
||||
if (!mover) return;
|
||||
$("q").value = mover.dataset.name;
|
||||
state.query = mover.dataset.name;
|
||||
applyFilters();
|
||||
$("q").focus();
|
||||
});
|
||||
|
||||
// ── Measure controls height for sticky offset ──
|
||||
updateStickyTop();
|
||||
window.addEventListener("resize", updateStickyTop);
|
||||
}
|
||||
|
||||
function updateStickyTop() {
|
||||
const el = $("controls-wrap");
|
||||
if (el) {
|
||||
const h = el.getBoundingClientRect().height;
|
||||
document.documentElement.style.setProperty("--sticky-top", h + "px");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── size variant helpers ────────────────────────────
|
||||
|
||||
function unitKey(u) { return `${u.faction}|${u.name}`; }
|
||||
|
||||
function getActiveSize(u) {
|
||||
const chosen = state.sizeChoice.get(unitKey(u));
|
||||
if (chosen) {
|
||||
const found = u.sizes.find(s => s.size === chosen);
|
||||
if (found) return found;
|
||||
}
|
||||
return u.sizes.find(s => s.size === u.default_size) || u.sizes[0];
|
||||
}
|
||||
|
||||
function cycleSize(u) {
|
||||
const cur = getActiveSize(u).size;
|
||||
const idx = u.sizes.findIndex(s => s.size === cur);
|
||||
const next = u.sizes[(idx + 1) % u.sizes.length];
|
||||
state.sizeChoice.set(unitKey(u), next.size);
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// ─── movers ──────────────────────────────────────────
|
||||
|
||||
function renderMovers() {
|
||||
const aug = state.data.units.map(u => {
|
||||
const a = getActiveSize(u);
|
||||
return { ...u, size: a.size, original: a.original, new: a.new, change_pct: a.change_pct, change_pts: a.change_pts };
|
||||
});
|
||||
|
||||
const drops = aug.filter(u => u.change_pct !== null && u.change_pct < 0)
|
||||
.sort((a, b) => a.change_pct - b.change_pct).slice(0, 5);
|
||||
const rises = aug.filter(u => u.change_pct !== null && u.change_pct > 0)
|
||||
.sort((a, b) => b.change_pct - a.change_pct).slice(0, 5);
|
||||
|
||||
$("top-drops").innerHTML = drops.map(u => moverRow(u, "down")).join("");
|
||||
$("top-rises").innerHTML = rises.map(u => moverRow(u, "up")).join("");
|
||||
$("movers").hidden = false;
|
||||
}
|
||||
|
||||
function moverRow(u, kind) {
|
||||
const arrow = kind === "up" ? "↑" : "↓";
|
||||
const cls = kind === "up" ? "rise" : "drop";
|
||||
const pct = u.change_pct.toFixed(1);
|
||||
return `
|
||||
<div class="mover-row ${cls}" data-name="${escapeAttr(u.name)}">
|
||||
<div class="mover-info">
|
||||
<div class="mover-name">${escapeHtml(u.name)}</div>
|
||||
<div class="mover-meta">${escapeHtml(u.faction_name)} · ${escapeHtml(u.size)}</div>
|
||||
</div>
|
||||
<div class="mover-costs">${u.original ?? "—"} → ${u.new ?? "—"}</div>
|
||||
<div class="mover-delta ${cls}">${arrow} ${pct}%</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── filtering / sorting ─────────────────────────────
|
||||
|
||||
function applyFilters() {
|
||||
const q = state.query.trim().toLowerCase();
|
||||
const fac = state.faction;
|
||||
const dir = state.dir;
|
||||
|
||||
let view = state.data.units.map(u => {
|
||||
const a = getActiveSize(u);
|
||||
return { ...u, size: a.size, original: a.original, new: a.new,
|
||||
change_pct: a.change_pct, change_pts: a.change_pts };
|
||||
});
|
||||
|
||||
if (q) {
|
||||
view = view.filter(u =>
|
||||
u.name.toLowerCase().includes(q) ||
|
||||
u.faction_name.toLowerCase().includes(q) ||
|
||||
u.size.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (fac) view = view.filter(u => u.faction === fac);
|
||||
|
||||
if (dir === "up") view = view.filter(u => u.change_pct !== null && u.change_pct > 0);
|
||||
else if (dir === "down") view = view.filter(u => u.change_pct !== null && u.change_pct < 0);
|
||||
else if (dir === "no-change") view = view.filter(u => u.change_pct === 0);
|
||||
else if (dir === "new-only") view = view.filter(u => u.original === null && u.new !== null);
|
||||
else if (dir === "old-only") view = view.filter(u => u.original !== null && u.new === null);
|
||||
|
||||
const [k, d] = state.sort.endsWith("_asc") ? [state.sort.replace("_asc", ""), 1] :
|
||||
state.sort.endsWith("_desc") ? [state.sort.replace("_desc", ""), -1] :
|
||||
[state.sort, 1];
|
||||
view.sort((a, b) => {
|
||||
let x = a[k], y = b[k];
|
||||
if (x === null) return 1;
|
||||
if (y === null) return -1;
|
||||
if (typeof x === "string") return d * x.localeCompare(y);
|
||||
return d * (x - y);
|
||||
});
|
||||
|
||||
state.view = view;
|
||||
render();
|
||||
}
|
||||
|
||||
// ─── rendering ────────────────────────────────────────
|
||||
|
||||
function render() {
|
||||
const view = state.view;
|
||||
$("count").innerHTML = `Showing <b>${view.length.toLocaleString()}</b> of ${state.data.stats.total_rows.toLocaleString()} units`;
|
||||
|
||||
if (view.length === 0) {
|
||||
$("rows").innerHTML = `<div class="empty">No matches — try clearing filters.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.mode === "compact") {
|
||||
$("rows").innerHTML = view.map(compactRow).join("");
|
||||
} else {
|
||||
$("rows").innerHTML = `
|
||||
<table class="row-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="faction">Faction</th>
|
||||
<th data-sort="name">Unit</th>
|
||||
<th data-sort="size">Size</th>
|
||||
<th class="num" data-sort="old">Original</th>
|
||||
<th class="num" data-sort="new">New</th>
|
||||
<th class="num" data-sort="change_pts">Δ pts</th>
|
||||
<th class="num" data-sort="change_pct">Δ %</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${view.map(tableRow).join("")}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function sizeControl(u) {
|
||||
if (u.sizes.length <= 1) {
|
||||
return `<span class="size-static">${escapeHtml(u.size)}</span>`;
|
||||
}
|
||||
if (u.equal_costs) {
|
||||
const labels = u.sizes.map(s => s.size.replace(/\s*models?$/, "")).join(" or ");
|
||||
return `<span class="size-static" title="Same cost at all sizes">${escapeHtml(labels)} models</span>`;
|
||||
}
|
||||
const active = u.size;
|
||||
const sizeShort = active.replace(/\s*models?$/, "");
|
||||
const count = u.sizes.length;
|
||||
return `<button class="size-btn" data-fac="${escapeAttr(u.faction)}" data-name="${escapeAttr(u.name)}" title="Click to cycle (${count} sizes)">${escapeHtml(sizeShort)}<span class="size-btn-badge">${count}</span></button>`;
|
||||
}
|
||||
|
||||
function tableRow(u) {
|
||||
const orig = u.original ?? "—";
|
||||
const newp = u.new ?? "—";
|
||||
const dp = u.change_pts === null ? "—" : (u.change_pts > 0 ? `+${u.change_pts}` : u.change_pts);
|
||||
const pc = u.change_pct === null ? "—" : `${u.change_pct > 0 ? "+" : ""}${u.change_pct.toFixed(1)}%`;
|
||||
let pill = `<span class="pill none">—</span>`;
|
||||
if (u.change_pct !== null) {
|
||||
if (u.change_pct > 0) pill = `<span class="pill up">↑ ${u.change_pct.toFixed(1)}%</span>`;
|
||||
else if (u.change_pct < 0) pill = `<span class="pill down">↓ ${Math.abs(u.change_pct).toFixed(1)}%</span>`;
|
||||
else pill = `<span class="pill same">— 0%</span>`;
|
||||
} else if (u.new !== null && u.original === null) {
|
||||
pill = `<span class="pill new">NEW</span>`;
|
||||
} else if (u.new === null && u.original !== null) {
|
||||
pill = `<span class="pill gone">REMOVED</span>`;
|
||||
}
|
||||
return `
|
||||
<tr data-fac="${escapeAttr(u.faction)}" data-name="${escapeAttr(u.name)}">
|
||||
<td class="col-faction" data-label="Faction">${escapeHtml(u.faction_name)}</td>
|
||||
<td class="col-unit" data-label="Unit">${escapeHtml(u.name)}</td>
|
||||
<td class="col-size" data-label="Size">${sizeControl(u)}</td>
|
||||
<td class="num" data-label="Original">${orig}</td>
|
||||
<td class="num" data-label="New">${newp}</td>
|
||||
<td class="num" data-label="Δ pts">${dp}</td>
|
||||
<td class="num" data-label="Δ %">${pc}</td>
|
||||
<td class="col-status" data-label="Status">${pill}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function compactRow(u) {
|
||||
const orig = u.original ?? "—";
|
||||
const newp = u.new ?? "—";
|
||||
const pc = u.change_pct;
|
||||
const pcStr = pc === null ? "—" :
|
||||
pc > 0 ? `<span class="pill up">↑ ${pc.toFixed(1)}%</span>` :
|
||||
pc < 0 ? `<span class="pill down">↓ ${Math.abs(pc).toFixed(1)}%</span>` :
|
||||
`<span class="pill same">— 0%</span>`;
|
||||
return `
|
||||
<div class="compact-row" data-fac="${escapeAttr(u.faction)}" data-name="${escapeAttr(u.name)}">
|
||||
<div class="compact-main">
|
||||
<div class="compact-name">${escapeHtml(u.name)}</div>
|
||||
<div class="compact-sub">${escapeHtml(u.faction_name)} · ${sizeControl(u)}</div>
|
||||
</div>
|
||||
<div class="compact-nums">
|
||||
<span class="compact-orig">${orig}</span>
|
||||
<span class="compact-arrow">→</span>
|
||||
<span class="compact-new">${newp}</span>
|
||||
<span class="compact-pct">${pcStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── modal ────────────────────────────────────────────
|
||||
|
||||
function openModal(fac, name) {
|
||||
const u = state.data.units.find(x => x.faction === fac && x.name === name);
|
||||
if (!u) return;
|
||||
const factionName = state.data.faction_names[fac] || fac;
|
||||
const activeSize = getActiveSize(u).size;
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "modal-backdrop";
|
||||
backdrop.innerHTML = `
|
||||
<div class="modal">
|
||||
<button class="modal-close" type="button" aria-label="Close">✕</button>
|
||||
<div class="modal-title">${escapeHtml(name)}</div>
|
||||
<div class="modal-faction">${escapeHtml(factionName)}</div>
|
||||
<div class="modal-sizes-label">${u.sizes.length} size variant${u.sizes.length !== 1 ? 's' : ''}</div>
|
||||
<div class="modal-variants">
|
||||
${u.sizes.map(s => {
|
||||
const active = activeSize === s.size;
|
||||
return `
|
||||
<div class="variant-row ${active ? 'active' : ''}">
|
||||
<div class="variant-size">${escapeHtml(s.size)}${active ? ' <span class="variant-current">● current</span>' : ''}</div>
|
||||
<div class="variant-nums">
|
||||
<span class="variant-orig">${s.original ?? "—"}</span>
|
||||
<span class="variant-arrow">→</span>
|
||||
<span class="variant-new">${s.new ?? "—"}</span>
|
||||
</div>
|
||||
<div class="variant-delta">
|
||||
${s.change_pct === null ? "—" :
|
||||
s.change_pct > 0 ? `<span class="pill up">↑ ${s.change_pct.toFixed(1)}%</span>` :
|
||||
s.change_pct < 0 ? `<span class="pill down">↓ ${Math.abs(s.change_pct).toFixed(1)}%</span>` :
|
||||
`<span class="pill same">— 0%</span>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(backdrop);
|
||||
const close = () => backdrop.remove();
|
||||
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); });
|
||||
backdrop.querySelector(".modal-close").addEventListener("click", close);
|
||||
document.addEventListener("keydown", function esc(e) {
|
||||
if (e.key === "Escape") { close(); document.removeEventListener("keydown", esc); }
|
||||
});
|
||||
}
|
||||
|
||||
// ─── utils ────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
function escapeAttr(s) { return escapeHtml(s).replace(/'/g, "'"); }
|
||||
|
||||
boot().catch(err => {
|
||||
document.body.innerHTML = `<pre style="color:#f85149;padding:20px;">${err.message}</pre>`;
|
||||
});
|
||||
1
site/data.json
Normal file
1
site/data.json
Normal file
File diff suppressed because one or more lines are too long
90
site/index.html
Normal file
90
site/index.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0a0e14">
|
||||
<title>WH40K Points Comparator — MFM v4.3</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<div class="header-text">
|
||||
<h1>Points Comparator</h1>
|
||||
<p class="subtitle">Codex vs. MFM v4.3 · 30 factions · 1,462 units</p>
|
||||
</div>
|
||||
<div id="meta" class="meta"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="controls-wrap" id="controls-wrap">
|
||||
<section class="controls">
|
||||
<div class="control control-search">
|
||||
<label for="q">Search</label>
|
||||
<input id="q" type="search" placeholder="Unit name…" autocomplete="off">
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="faction">Faction</label>
|
||||
<select id="faction">
|
||||
<option value="">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="dir">Change</label>
|
||||
<select id="dir">
|
||||
<option value="">All</option>
|
||||
<option value="up">↑ Costlier</option>
|
||||
<option value="down">↓ Cheaper</option>
|
||||
<option value="no-change">— No change</option>
|
||||
<option value="new-only">+ New only</option>
|
||||
<option value="old-only">− Removed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="sort">Sort</label>
|
||||
<select id="sort">
|
||||
<option value="change_pct_desc">% change (worst)</option>
|
||||
<option value="change_pct_asc">% change (best)</option>
|
||||
<option value="name">Name (A→Z)</option>
|
||||
<option value="faction">Faction (A→Z)</option>
|
||||
<option value="new">New cost (high→low)</option>
|
||||
<option value="old">Original cost (high→low)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control control-view">
|
||||
<label for="view">View</label>
|
||||
<select id="view">
|
||||
<option value="table">Table</option>
|
||||
<option value="compact">Compact</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="movers" id="movers" hidden>
|
||||
<div class="movers-header">
|
||||
<h2>Biggest movers</h2>
|
||||
<p>Top 5 drops & rises</p>
|
||||
</div>
|
||||
<div class="movers-grid">
|
||||
<div class="movers-col drops" id="top-drops"></div>
|
||||
<div class="movers-col rises" id="top-rises"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="results">
|
||||
<div id="count" class="count"></div>
|
||||
<div id="rows"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Data: <code>Full_armies_10th.pdf</code> (codex) +
|
||||
<code>mfm.warhammer-community.com</code> (live MFM). Weapon upgrades excluded.</p>
|
||||
</footer>
|
||||
|
||||
<script src="app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
538
site/style.css
Normal file
538
site/style.css
Normal file
@@ -0,0 +1,538 @@
|
||||
/* ── WH40K Points Comparator — clean, sharp, effective ── */
|
||||
|
||||
:root {
|
||||
--bg: #0a0e14;
|
||||
--bg-alt: #0d1117;
|
||||
--panel: #11161e;
|
||||
--panel-2: #161c26;
|
||||
--panel-3: #1c2330;
|
||||
--border: #232b38;
|
||||
--border-2: #2d3744;
|
||||
--text: #e6edf3;
|
||||
--text-dim: #adbac7;
|
||||
--muted: #7d8590;
|
||||
--accent: #3b82f6;
|
||||
--accent-2: #60a5fa;
|
||||
--green: #3fb950;
|
||||
--red: #f85149;
|
||||
--grey: #6e7681;
|
||||
--gold: #e3b341;
|
||||
--green-bg: rgba(63, 185, 80, 0.12);
|
||||
--red-bg: rgba(248, 81, 73, 0.12);
|
||||
--gold-bg: rgba(227, 179, 65, 0.10);
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--sticky-top: 0px;
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
html, body { overflow-x: hidden; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 24px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
.meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Controls (sticky) ── */
|
||||
.controls-wrap {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: rgba(10, 14, 20, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.controls {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 0.7fr;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
}
|
||||
.control { display: flex; flex-direction: column; gap: 4px; }
|
||||
.control label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.control input, .control select {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font);
|
||||
min-height: 38px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.control input {
|
||||
cursor: text;
|
||||
}
|
||||
.control input:focus, .control select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.control select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%237d8590'%3E%3Cpath d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
/* ── Movers ── */
|
||||
main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px 40px;
|
||||
}
|
||||
.movers {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.movers-header h2 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
.movers-header p {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
margin: 2px 0 14px;
|
||||
}
|
||||
.movers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.movers-col { display: flex; flex-direction: column; gap: 5px; }
|
||||
.movers-col.drops .mover-row {
|
||||
border-left: 3px solid var(--red);
|
||||
}
|
||||
.movers-col.rises .mover-row {
|
||||
border-left: 3px solid var(--green);
|
||||
}
|
||||
.mover-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.mover-row:hover { border-color: var(--border-2); background: var(--panel-3); }
|
||||
.mover-name { font-weight: 600; }
|
||||
.mover-meta { color: var(--muted); font-size: 0.72rem; margin-top: 1px; }
|
||||
.mover-costs {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mover-delta {
|
||||
font-family: var(--mono);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mover-delta.drop { color: var(--red); }
|
||||
.mover-delta.rise { color: var(--green); }
|
||||
|
||||
/* ── Count ── */
|
||||
.count {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.count b { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* ── Table ── */
|
||||
.row-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.row-table thead { position: sticky; top: var(--sticky-top); z-index: 5; }
|
||||
.row-table th {
|
||||
background: var(--panel-3);
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-table th:hover { color: var(--text); }
|
||||
.row-table th.num { text-align: right; }
|
||||
.row-table td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.row-table tr:last-child td { border-bottom: none; }
|
||||
.row-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.row-table tbody tr:hover { background: rgba(59, 130, 246, 0.04); }
|
||||
.row-table .col-faction { color: var(--text-dim); font-size: 0.8rem; }
|
||||
.row-table .col-unit { font-weight: 600; }
|
||||
.row-table .col-size { white-space: nowrap; }
|
||||
.row-table .col-status { white-space: nowrap; }
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
/* ── Pills ── */
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill.up { background: var(--green-bg); color: var(--green); }
|
||||
.pill.down { background: var(--red-bg); color: var(--red); }
|
||||
.pill.same { background: rgba(110,118,129,0.12); color: var(--grey); }
|
||||
.pill.new { background: rgba(59,130,246,0.15); color: var(--accent-2); }
|
||||
.pill.gone { background: rgba(110,118,129,0.12); color: var(--muted); }
|
||||
.pill.none { background: transparent; color: var(--muted); }
|
||||
|
||||
/* ── Size toggle ── */
|
||||
.size-btn {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: var(--accent-2);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 10px 4px 8px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.size-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.22);
|
||||
border-color: var(--accent-2);
|
||||
}
|
||||
.size-btn:active { transform: scale(0.96); }
|
||||
.size-btn-badge {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
color: var(--accent-2);
|
||||
border-radius: 8px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.size-static {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ── Compact mode ── */
|
||||
.compact-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.compact-row:hover { border-color: var(--accent); }
|
||||
.compact-main { flex: 1; min-width: 0; }
|
||||
.compact-name { font-weight: 600; font-size: 0.9rem; }
|
||||
.compact-sub { color: var(--muted); font-size: 0.75rem; margin-top: 2px;
|
||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.compact-nums {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.compact-orig { color: var(--muted); }
|
||||
.compact-arrow { color: var(--muted); }
|
||||
.compact-new { font-weight: 600; }
|
||||
|
||||
/* ── Empty ── */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
footer {
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
footer code {
|
||||
color: var(--accent-2);
|
||||
background: var(--panel-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--radius);
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.modal-faction {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
margin: 2px 0 16px;
|
||||
}
|
||||
.modal-sizes-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px; right: 16px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border-2);
|
||||
color: var(--muted);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 32px; height: 32px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); border-color: var(--text); }
|
||||
.variant-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 6px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.variant-row.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
.variant-size { font-weight: 500; font-size: 0.88rem; }
|
||||
.variant-current { color: var(--accent-2); font-size: 0.7rem; margin-left: 4px; }
|
||||
.variant-nums {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.variant-orig { color: var(--muted); }
|
||||
.variant-arrow { color: var(--muted); }
|
||||
.variant-new { font-weight: 600; }
|
||||
.variant-delta { white-space: nowrap; }
|
||||
|
||||
/* ── Responsive: tablet ── */
|
||||
@media (max-width: 1000px) {
|
||||
.controls { grid-template-columns: 2fr 1fr 1fr 1fr; }
|
||||
.control-view { display: none; }
|
||||
.movers-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.controls { grid-template-columns: 1fr 1fr; gap: 8px; padding: 10px 16px; }
|
||||
.control-search { grid-column: 1 / -1; }
|
||||
main { padding: 16px 16px 32px; }
|
||||
.header-inner { padding: 14px 16px; }
|
||||
h1 { font-size: 1.1rem; }
|
||||
.meta { display: none; }
|
||||
.mover-row { grid-template-columns: 1fr auto; }
|
||||
.mover-costs { display: none; }
|
||||
}
|
||||
|
||||
/* ── Responsive: phone ── */
|
||||
@media (max-width: 600px) {
|
||||
body { font-size: 15px; }
|
||||
|
||||
/* Controls: single column */
|
||||
.controls {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.control input, .control select {
|
||||
min-height: 44px;
|
||||
font-size: 1rem;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.control select { padding-right: 34px; }
|
||||
|
||||
/* Table → card layout */
|
||||
.row-table thead { display: none; }
|
||||
.row-table, .row-table tbody, .row-table tr, .row-table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.row-table { border: none; background: transparent; }
|
||||
.row-table tbody tr {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.row-table td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.88rem;
|
||||
text-align: right;
|
||||
}
|
||||
.row-table td:last-child { border-bottom: none; }
|
||||
.row-table td::before {
|
||||
content: attr(data-label);
|
||||
color: var(--muted);
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.row-table td.col-unit::before { display: none; }
|
||||
.row-table td.col-unit {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
padding-top: 12px;
|
||||
border-bottom: 1px solid var(--border-2);
|
||||
}
|
||||
.row-table td.col-faction::before { display: none; }
|
||||
.row-table td.col-faction {
|
||||
color: var(--accent-2);
|
||||
font-size: 0.75rem;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 2px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Compact mode: stack */
|
||||
.compact-row { flex-direction: column; align-items: flex-start; gap: 6px; }
|
||||
.compact-nums { margin-left: 0; flex-wrap: wrap; }
|
||||
|
||||
/* Modal: full screen */
|
||||
.modal-backdrop { padding: 0; }
|
||||
.modal {
|
||||
max-width: 100%;
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
BIN
wh40k-factions-csv.tar.gz
Normal file
BIN
wh40k-factions-csv.tar.gz
Normal file
Binary file not shown.
BIN
wh40k_factions_all.xlsx
Normal file
BIN
wh40k_factions_all.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user