From 368f42bfeaa58e9fe62e49ee933b75339bb7dbc7 Mon Sep 17 00:00:00 2001 From: NilsUeter Date: Wed, 24 Jun 2026 19:13:45 +0200 Subject: [PATCH] add a skew-o-meter --- src/10th/ShortSummaryTable.jsx | 234 +++++++++++++++++++++++++++++++-- 1 file changed, 220 insertions(+), 14 deletions(-) diff --git a/src/10th/ShortSummaryTable.jsx b/src/10th/ShortSummaryTable.jsx index e87084a..193dd98 100644 --- a/src/10th/ShortSummaryTable.jsx +++ b/src/10th/ShortSummaryTable.jsx @@ -67,7 +67,10 @@ const getNameMatchScore = (statName = "", modelName = "") => { } let prefixMatches = 0; - const maxPrefixLength = Math.min(normalizedStat.length, normalizedModel.length); + const maxPrefixLength = Math.min( + normalizedStat.length, + normalizedModel.length, + ); for (let i = 0; i < maxPrefixLength; i++) { if (normalizedStat[i] !== normalizedModel[i]) { break; @@ -118,8 +121,114 @@ const getUnitTotalOc = (unit) => 0, ) || 0; -export const ShortSummaryTable = ({ force, primaryColor, name, subtitle, points }) => { +const getUnitTotalModels = (unit) => + unit?.models?.reduce((sum, model) => sum + (model.count || 0), 0) || 1; + +const getUnitPointsPerModel = (unit) => { + const totalModels = getUnitTotalModels(unit); + return totalModels > 0 ? (unit?.cost?.points || 0) / totalModels : 0; +}; + +const getDefensiveProfile = (unit, stat = {}, modelCount = 1) => { + const toughness = stat.toughness || 0; + const wounds = stat.wounds || 0; + const save = stat.save || 0; + const pointsPerModel = getUnitPointsPerModel(unit); + const cheapBodies = pointsPerModel > 0 && pointsPerModel <= 10; + const cheapMultiWoundBodies = pointsPerModel > 0 && pointsPerModel <= 15; + const eliteCost = pointsPerModel >= 20; + const numerousBodies = modelCount >= 15 || (modelCount >= 10 && cheapBodies); + const hordeBodies = + (wounds <= 2 && cheapBodies && numerousBodies) || + (toughness <= 4 && wounds <= 1); + const swarmBodies = toughness <= 4 && wounds >= 3 && cheapMultiWoundBodies; + + if (toughness >= 10) { + return "Heavy vehicles / monsters"; + } + if (toughness >= 7) { + return "Light vehicles / monsters"; + } + if (hordeBodies || swarmBodies) { + return "Horde / Swarm"; + } + if (toughness >= 6 || (eliteCost && wounds >= 3) || (save > 0 && save <= 3)) { + return "Elite bodies"; + } + return "Mixed infantry"; +}; + +const getSkewMeterData = (units) => { + const profileWeights = new Map(); + let totalWeight = 0; + let totalModels = 0; + + for (const unit of units) { + const modelStats = unit.modelStats?.length ? unit.modelStats : [{}]; + totalModels += getUnitTotalModels(unit); + const weightedProfiles = modelStats.map((stat) => { + const modelCount = getModelCountForStat(unit, stat); + const woundShare = Math.max((stat.wounds || 1) * modelCount, modelCount); + return { + profile: getDefensiveProfile(unit, stat, modelCount), + woundShare, + }; + }); + const unitWoundShare = weightedProfiles.reduce( + (sum, profile) => sum + profile.woundShare, + 0, + ); + + for (const { profile, woundShare } of weightedProfiles) { + const weight = + unitWoundShare > 0 + ? (unit.cost.points * woundShare) / unitWoundShare + : 0; + + profileWeights.set(profile, (profileWeights.get(profile) || 0) + weight); + totalWeight += weight; + } + } + + const profiles = Array.from(profileWeights, ([name, weight]) => ({ + name, + weight, + share: totalWeight > 0 ? weight / totalWeight : 0, + })).sort((a, b) => b.weight - a.weight); + const dominantProfile = profiles[0] || { name: "No profile", share: 0 }; + const score = Math.round( + Math.min(100, Math.max(0, ((dominantProfile.share - 0.35) / 0.55) * 100)), + ); + + let label = "Balanced"; + if (score >= 80) { + label = "Oops, all stat-check"; + } else if (score >= 50) { + label = "Oh Lawd He Skewin"; + } else if (score >= 35) { + label = "Moderate skew"; + } else if (score >= 20) { + label = "Light skew"; + } + + return { + score, + label, + dominantProfile, + profiles, + totalModels, + }; +}; + +export const ShortSummaryTable = ({ + force, + primaryColor, + name, + subtitle, + points, +}) => { const [hide, setHide] = useState(false); + const [hideSkewMeter, setHideSkewMeter] = useState(false); const { units, factionRules, rules, catalog } = force; const sortedUnits = units.slice().sort((a, b) => { @@ -196,10 +305,36 @@ export const ShortSummaryTable = ({ force, primaryColor, name, subtitle, points (sum, unit) => sum + getUnitTotalWounds(unit), 0, ); + const totalArmyPoints = sortedUnits.reduce( + (sum, unit) => sum + unit.cost.points, + 0, + ); + const canShowSkewMeter = sortedUnits.length > 1 && totalArmyPoints >= 500; + const showSkewMeter = canShowSkewMeter && !hideSkewMeter; + const skewMeterData = showSkewMeter ? getSkewMeterData(sortedUnits) : null; return ( <>
+ {canShowSkewMeter && ( + + )}
); - })} + })}
{sortedUnits.length} Units @@ -386,23 +523,39 @@ export const ShortSummaryTable = ({ force, primaryColor, name, subtitle, points
{/* Sum of all OC values */} - {sortedUnits.reduce((sum, unit) => sum + getUnitTotalOc(unit), 0)} + {sortedUnits.reduce( + (sum, unit) => sum + getUnitTotalOc(unit), + 0, + )}
-
{totalArmyWounds > 0 - ? ( - sortedUnits.reduce((sum, unit) => sum + unit.cost.points, 0) / - totalArmyWounds - ).toFixed(1) - : "—"} -
-
{sortedUnits.reduce((sum, unit) => sum + unit.cost.points, 0)}{" "} +
+ {" "} + {totalArmyWounds > 0 + ? ( + sortedUnits.reduce( + (sum, unit) => sum + unit.cost.points, + 0, + ) / totalArmyWounds + ).toFixed(1) + : "—"} +
+
+ {" "} + {sortedUnits.reduce( + (sum, unit) => sum + unit.cost.points, + 0, + )}{" "} pts
-
+
+ {showSkewMeter && ( + + )} + { + const topProfiles = data.profiles; + + return ( +
+
+
+
+ Skew-O-Meter +
+
+ {data.label} +
+
+
+ {data.score} + /100 +
+
+ +
+
+
+ +
+ {topProfiles.map((profile, index) => ( +
+
+ + {profile.name} +
+ + {Math.round(profile.share * 100)}% + +
+ ))} +
+
+ ); +}; + const ChartComponent = ({ data, title }) => { return (