Switch frontend react state from vanilla to redux (#1)

Also fixes issues with the catalogue parsing

Reviewed-on: #1
Co-authored-by: Loic Prieto <loic@the-fire-archmage.ninja>
Co-committed-by: Loic Prieto <loic@the-fire-archmage.ninja>
This commit is contained in:
Loic Prieto 2025-09-01 20:53:15 +00:00 committed by loic
parent af03a4dc44
commit 4f5bd1f851
61 changed files with 2365 additions and 1725 deletions

View file

@ -10,7 +10,7 @@
</parent>
<artifactId>bsdata-parser</artifactId>
<version>1.0.2</version>
<version>1.1.0</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

View file

@ -9,16 +9,15 @@ import org.dom4j.DocumentFactory;
import org.dom4j.Node;
import org.dom4j.io.SAXReader;
import javax.print.Doc;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class CatalogueParser {
private Map<String, InvulnerableSave> invulnerableEffects;
private Map<String, Weapon> sharedWeapons;
private Map<String, Node> sharedUnitProfiles;
private Set<String> validEntriesIds;
private static final Pattern DICE_VALUE_REGEXP = Pattern.compile("([2-6])\\+");
@ -32,7 +31,7 @@ public class CatalogueParser {
public enum InputType {
CLASSPATH,
FILESYSTEM;
FILESYSTEM
}
public Set<UnitProfile> parseUnits(String inputResource, InputType inputType) {
@ -48,6 +47,7 @@ public class CatalogueParser {
// keyed by id, pointed by the targetId attribute
invulnerableEffects = parseInvulnerabilityReferences(doc);
sharedWeapons = parseSharedWeapons(doc);
sharedUnitProfiles = parseSharedUnitProfiles(doc);
validEntriesIds = buildValidEntries(doc);
// Parse units
@ -83,12 +83,25 @@ public class CatalogueParser {
// Conversely, some Unit type entries are modeled as Model, even though they're a Unit. Depends on who writes
// the catalogue.
if (isSingleModelUnit(unitNode)) {
var singleModel = Set.of(parseModelProfile(unitNode, true));
var singleModel = Set.of(parseModelProfile(unitNode, unitNode, true));
unit.setModels(singleModel);
} else {
// Parse direct model children
var models = unitNode.selectNodes(".//cs:selectionEntry[@type='model']").stream()
.map(this::parseModelProfile)
.collect(Collectors.toSet());
.map(modelNode -> parseModelProfile(modelNode, unitNode))
.collect(Collectors.toCollection(LinkedHashSet::new));
// Parse models referenced via entryLinks (for composite units like Cadian Shock Troops)
var referencedModelNodes = unitNode.selectNodes(".//cs:entryLink[@type='selectionEntry']");
for (var entryLink : referencedModelNodes) {
var targetId = entryLink.valueOf("@targetId");
var referencedModel = findSharedSelectionEntry(unitNode.getDocument(), targetId);
if (referencedModel != null && "model".equals(referencedModel.valueOf("@type"))) {
models.add(parseModelProfile(referencedModel, unitNode));
}
}
unit.setModels(models);
}
@ -112,13 +125,26 @@ public class CatalogueParser {
* as leaders or vehicles, but some other times, those are just shared models that can be referenced by other entries.
* This function looks at the entries that are supposed to be units by looking at catalogue>entryLinks>entryLink items,
* which define which root entries can be selected by the user to add as unit.
* For library catalogues, all shared selection entries are considered valid since they're meant to be referenced.
* This function builds a list of ids that are valid units or models-as-unit for the sharedSelection entries.
*/
private Set<String> buildValidEntries(Document rootNode) {
return rootNode.selectNodes("/cs:catalogue/cs:entryLinks/cs:entryLink[@type='selectionEntry']")
.stream()
.map(n->n.valueOf("@targetId"))
.collect(Collectors.toSet());
var catalogueNode = rootNode.selectSingleNode("/cs:catalogue");
var isLibrary = "true".equals(catalogueNode.valueOf("@library"));
if (isLibrary) {
// For library catalogues, all sharedSelectionEntries are valid
return rootNode.selectNodes("/cs:catalogue/cs:sharedSelectionEntries/cs:selectionEntry")
.stream()
.map(n->n.valueOf("@id"))
.collect(Collectors.toSet());
} else {
// For regular catalogues, only entries referenced in entryLinks are valid
return rootNode.selectNodes("/cs:catalogue/cs:entryLinks/cs:entryLink[@type='selectionEntry']")
.stream()
.map(n->n.valueOf("@targetId"))
.collect(Collectors.toSet());
}
}
private void parseUnitEffects(Node entityNode, UnitProfile unit) {
@ -149,10 +175,10 @@ public class CatalogueParser {
}
private ModelProfile parseModelProfile(Node modelNode) {
return parseModelProfile(modelNode, false);
private ModelProfile parseModelProfile(Node modelNode, Node unitNode) {
return parseModelProfile(modelNode, unitNode, false);
}
private ModelProfile parseModelProfile(Node modelNode, boolean isSingleModelUnit) {
private ModelProfile parseModelProfile(Node modelNode, Node unitNode, boolean isSingleModelUnit) {
var model = new ModelProfile();
model.setId(modelNode.valueOf("@id"));
model.setName(modelNode.valueOf("@name"));
@ -166,7 +192,27 @@ public class CatalogueParser {
}
// Parse unit profile characteristics (toughness, save, wounds)
// First, try to find a direct Unit profile in the model
var unitProfile = modelNode.selectSingleNode("./cs:profiles/cs:profile[@typeName='Unit']");
// If no direct profile, check for infoLinks that reference shared Unit profiles
if (unitProfile == null) {
var infoLinks = modelNode.selectNodes("./cs:infoLinks/cs:infoLink[@type='profile']");
for (var infoLink : infoLinks) {
var targetId = infoLink.valueOf("@targetId");
var sharedProfile = sharedUnitProfiles.get(targetId);
if (sharedProfile != null) {
unitProfile = sharedProfile;
break; // Use the first Unit profile we find
}
}
}
// If still no profile, inherit from the unit level (e.g., Arco-Flagellants)
if (unitProfile == null && unitNode != null) {
unitProfile = unitNode.selectSingleNode("./cs:profiles/cs:profile[@typeName='Unit']");
}
if (unitProfile != null) {
var toughness = getCharacteristic(unitProfile, "T", Integer.class);
var normalSave = getCharacteristic(unitProfile, "SV", Integer.class);
@ -256,15 +302,15 @@ public class CatalogueParser {
// Invulnerable abilities may appear in two places, as they are often either an ability or a wargear
// update. Abilities appear on sharedSelectionEntries or on sharedProfiles
Map<String, InvulnerableSave> invulnEffects = new HashMap<>();
Map<String, InvulnerableSave> invulnEffects;
// Wargear that grants invulnerability
var sharedWargearWithAbilities = root.selectNodes("/cs:catalogue/cs:sharedSelectionEntries/cs:selectionEntry[type='upgrade' and (cs:profiles/cs:profile/@typeName='Abilities')]");
sharedWargearWithAbilities.stream()
invulnEffects = sharedWargearWithAbilities.stream()
.filter(selectionEntry -> selectionEntry
.selectNodes("./cs:profiles/cs:profile[typeName='Abilities']/cs:characteristics/cs:characteristic[name='Description']")
.stream().map(Node::getText).map(String::toLowerCase)
.anyMatch(desc -> desc.contains("invuln") && desc.contains("save")))
.selectNodes("./cs:profiles/cs:profile[typeName='Abilities']/cs:characteristics/cs:characteristic[name='Description']")
.stream().map(Node::getText).map(String::toLowerCase)
.anyMatch(desc -> desc.contains("invuln") && desc.contains("save")))
.flatMap(selectionEntry -> {
var id = selectionEntry.valueOf("@id");
return selectionEntry.selectNodes("./cs:profiles/cs:profile[typeNAme='Abilities']/cs:characteristics/cs:characteristic[name='Description']")
@ -272,18 +318,14 @@ public class CatalogueParser {
.filter(desc -> desc.contains("invuln") && desc.contains("save"))
.map(CatalogueParser::parseInvulFromDesc)
.filter(Optional::isPresent).map(Optional::get)
.map(invulnEffect -> Tuple.of(id, invulnEffect));})
.forEach(t -> {
invulnEffects.put(t._1, t._2);
});
.map(invulnEffect -> Tuple.of(id, invulnEffect));
}).collect(Collectors.toMap(t -> t._1, t -> t._2, (_, b) -> b));
// Shared abilities
root.selectNodes("/cs:catalogue/cs:sharedProfiles/cs:profile[typeName='Abilities']").stream()
.filter(profileNode -> {
return profileNode.selectNodes("./cs:characteristics/cs:characteristic[name='Description']")
.stream().map(Node::getText).map(String::toLowerCase)
.anyMatch(t->t.contains("invuln") && t.contains("save"));
})
.filter(profileNode -> profileNode.selectNodes("./cs:characteristics/cs:characteristic[name='Description']")
.stream().map(Node::getText).map(String::toLowerCase)
.anyMatch(t->t.contains("invuln") && t.contains("save")))
.flatMap( profileNode -> {
var id = profileNode.valueOf("@id");
return profileNode.selectNodes("./cs:characteristics/cs:characteristic[name='Description']").stream()
@ -291,7 +333,7 @@ public class CatalogueParser {
.filter(t->t.contains("invuln") && t.contains("save"))
.map(CatalogueParser::parseInvulFromDesc)
.filter(Optional::isPresent).map(Optional::get)
.map( invulnEffect -> Tuple.of((String)id, invulnEffect));
.map( invulnEffect -> Tuple.of(id, invulnEffect));
}).forEach(t -> invulnEffects.put(t._1, t._2));
return invulnEffects;
@ -329,6 +371,21 @@ public class CatalogueParser {
return sharedWeapons;
}
/**
* Fetch all shared unit profiles from the catalogue that are later referenced by models via infoLinks.
* Keyed by profile id in BS catalogue.
*/
private static Map<String, Node> parseSharedUnitProfiles(Document root) {
Map<String, Node> sharedProfiles = new HashMap<>();
root.selectNodes("/cs:catalogue/cs:sharedProfiles/cs:profile[@typeName='Unit']")
.forEach(profileNode -> {
String id = profileNode.valueOf("@id");
sharedProfiles.put(id, profileNode);
});
return sharedProfiles;
}
private static Weapon parseWeaponNode(Node weaponNode) {
var weapon = new Weapon();
var id = weaponNode.valueOf("@id");
@ -387,7 +444,7 @@ public class CatalogueParser {
private static List<AntiUnitEffect> parseAntiEffects(Set<String> keywords) {
return keywords.stream().filter(kw -> kw.contains("Anti-"))
.map(kw -> {
// We can't split by " " since the unit type may have spaces. Instead we will fetch
// We can't split by " " since the unit type may have spaces. Instead, we will fetch
// the numerical value and then split by - the rest.
var value = Integer.parseInt(kw
.substring(kw.length() - 2)
@ -469,4 +526,11 @@ public class CatalogueParser {
return null;
}
/**
* Helper method to find a shared selection entry by its ID
*/
private static Node findSharedSelectionEntry(Document doc, String id) {
return doc.selectSingleNode("/cs:catalogue/cs:sharedSelectionEntries/cs:selectionEntry[@id='" + id + "']");
}
}

View file

@ -64,11 +64,13 @@ public class UnitProfilesYamlLoader {
u.setModels(parseModels(values));
u.setFaction(getString(values, "faction").orElseThrow());
var keywords = ((List<String>)values.get("unitKeywords")).stream()
// Better to return bogus keywords than dismissing the whole unit
var keywordsRaw = (List<String>) (values.containsKey("unitKeywords") ? values.get("unitKeywords") : List.of());
var keywords = keywordsRaw.stream()
.map(UnitProfilesYamlLoader::parseUnitTypeKeyword)
.filter(Optional::isPresent).map(Optional::get)
.collect(Collectors.toSet());
u.setUnitTypeKeywords(keywords);
u.setUnitTypeKeywords(keywords.isEmpty() ? Set.of(UnitTypeKeyword.INFANTRY) : keywords);
return u;
}

View file

@ -11,7 +11,7 @@
</parent>
<artifactId>web-client-backend</artifactId>
<version>1.2.3</version>
<version>1.2.8</version>
<properties>
<core.version>1.1.4</core.version>

View file

@ -74,11 +74,8 @@ public class Warhammer40kCalculatorWebApp implements Runnable, CommandLine.IVers
Javalin app = Javalin.create(cfg -> {
cfg.jsonMapper(new JavalinJackson(jacksonMapper, cfg.useVirtualThreads));
cfg.http.brotliAndGzipCompression(4, 4);
cfg.bundledPlugins.enableCors(cors -> {
cors.addRule(CorsPluginConfig.CorsRule::anyHost);
});
});
configureCors(app);
if(runMode.equals("production")) {
configureMetrics(app);
@ -88,6 +85,17 @@ public class Warhammer40kCalculatorWebApp implements Runnable, CommandLine.IVers
app.start(port);
}
private void configureCors(Javalin app) {
app.before(ctx -> {
ctx.header("Access-Control-Allow-Origin", "*");
ctx.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
ctx.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
});
app.options("/*", ctx -> {
ctx.status(200);
});
}
/**
* Retrieves information from maven's packager
*/

View file

@ -7,7 +7,7 @@ import tsparser from '@typescript-eslint/parser'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx,ts,tsx}'],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
@ -17,6 +17,7 @@ export default [
ecmaFeatures: { jsx: true },
sourceType: 'module',
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname || process.cwd(),
},
},
plugins: {
@ -34,6 +35,30 @@ export default [
// TypeScript-specific rules
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-empty-object-type': 'error',
},
},
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

View file

@ -1,62 +0,0 @@
# Design document for the frontend
The purpose of this document is to describe the Warhamm40k Calculator Web Client Frontend application.
## Overview
This application is to act as a front to the web-client backend application which provides a single endpoint to perform calculation and stats of a combat encounter between two units, with an attacker defined as a profile + weapons, and a defender which is defined simply as a profile.
The data for the weapons, profiles and everything else comes from a single http endpoint in the backend that provides all static data to be loaded by the frontend upon page load.
The application is a SPA with a single screen, composed of 4 panels. The results panel contains a button that launches a request to the backend to calculate the result of the attacker and defender information gathered from the formulary fields.
## Static data
When the application loads in the browser, it loads all static data from the /api/bootstrap endpoint which contains the following information:
{
"weaponRanges": ["ENUM_VALUE1", "ENUM_VALUE2",...];
"unitKeywords": ["INFANTRY", "VEHICLE",...],
"rerollTypes": ["ENUM_VALUE1", ...],
"rerollStages": ["ENUM_VALUE1", ...],
"unitProfiles": [UnitProfileSerialization1, ...]
}
To check the values and types of all of this static data, check in the `core` module of the 40kDamageCalculator java project, the model classes defined in the `y` package. Specifically classes UnitProfile, ModelProfile, Weapon, WeaponRangeType, UnitTypeKeyword and Reroll.
All this information is to be used to fill the different select boxes and options of the formulary where applicable.
## The UI Panels
A single formulary is shared between all panels, although the formulary itself is not used except as a place to gather data into a json document before sending it to the calculate endpoint when the button "Calculate" is clicked.
There are four panels: Options, Attack Panel, Defence Panel and Results Panel. Each of these handles a different aspect of the formulary.
### The Options panel
Contains metadata about the application and a theme switcher. The metadata of the application is application version, release notes and future milestones, which are shown each in a popup panel. This information comes from the /api/bootstrap endpoint. We're going to ignore the Options panel at the moment. A mockup of buttons and select boxes is enough.
### The Attack panel
The attack panel contains several very important and distinctive elements:
- a sub panel that allows to select an attacking unit that serves as the base of the attacker configuration. Several filters allow to filter this unit selection field, which is a searchable select box. The filter by its side is the "Faction" filter, which will limit which units are shown, corresponding to the unitProfile.faction field. Once a unit is selected, a sublist should show which models are available in the unit, all selected by default, and a third list should show the weapons of the selected models. Finally, three buttons: "Add unit's weapons" "Add model's weapon". These buttons add what their titles say to the weapons table.
- A weapons table that contains all weapons that the user has selected in the previous table. The table should show the following fields, for each weapon: Name, Number, Hit Roll, Weapons Attacks, Strength, AP, Damage and Abilities. All of these fields, except for Abilities, are editable. The Number field is the number of weapons of that type to include, and its initial value is the minimum number of bodies for the selected model multiplied by the number of weapons of that type that the model bears.
- A modifiers sub panel that shows the modifiers of the unit, with all fields editable, including: sustained hits, lethal hits, rapid fire, devastating wounds, rerolls, criticals, bonuses and maluses such as AP, wound roll, hit roll, strength, attacks. Finally also status of the field of battle like unit charged, remained stationary, unmodifiable hit roll, psychic attack, hazardous attack. Almost all augment bonuses like sustained hits, lethal hits, rapid fire, devastating wounds ,rerolls, are associated to a type of weapon (Ranged or Melee, or both). This will be refined once generated.
### The defence panel
The opposite of the attack panel. The defence panel contains information about the unit that is attacked. A simple select box that allows to select the defending unit, filtered with a faction select box like in the attack panel. When a unit is selected, editable stats for that unit are shown: which models exist with the stat profile: Toughness, Save and Invulnerable save. Whether they have Feel No Pain effects (editable). Unit keywords/categories. Reroll for saving. Bonuses and maluses such as "Has stealth", "wound malus if S>T" , "damage reduction", "ap reduction", and battlefield states like: "in stealth range", "in cover", "visible", "in Rapid Fire range", "in melta range". The last three should be selected by default.
### The results panel
Contains two elements:
- The calculate button which, when pressed gathers all the data of the formulary into a json document detailing an attack scenario:
- the attacking unit with its modifiers, models and weapons as edited in the attack modifiers panel and the weapons table.
- the defending unit, with its stats, modifiers and battlefield conditions as edited by the user
- A results subpanel that transforms the json received when calling the /api/calculate API endpoint. The returned result is of type `ninja.thefirearchmage.games.fourtykcalculator.core.AttackResolution` in the `core` java module.
The calculate button, when pressed, builds a json as described and sends a POST request to the `/api/calculate` endpoint.
## Technology
The application is a SPA based in React, with a single screen. It uses CSS to achieve information density for desktop or tablets screens, and an usable mobile-first UI for mobile. Only the CSS should change to achieve this.
Vite is used for building. Jest should be used to perform unit testing, testing the react components.
Typescript is used as much as possible.

View file

@ -1,15 +1,17 @@
{
"name": "40k-calculator-web-client-frontend",
"version": "1.2.0",
"version": "1.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "40k-calculator-web-client-frontend",
"version": "1.2.0",
"version": "1.7.1",
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"react-redux": "^9.2.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
@ -993,6 +995,32 @@
"node": ">= 8"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
"integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1259,6 +1287,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1325,7 +1365,7 @@
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"dev": true,
"devOptional": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -1339,6 +1379,12 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz",
@ -1827,7 +1873,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true
"devOptional": true
},
"node_modules/debug": {
"version": "4.4.1",
@ -2287,6 +2333,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -2722,6 +2778,29 @@
"react": "^19.1.1"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -2731,6 +2810,27 @@
"node": ">=0.10.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -2990,6 +3090,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz",

View file

@ -1,7 +1,7 @@
{
"name": "40k-calculator-web-client-frontend",
"private": true,
"version": "1.7.1",
"version": "1.8.1",
"type": "module",
"scripts": {
"dev": "vite",
@ -10,20 +10,22 @@
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"react-redux": "^9.2.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/node": "^24.2.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"globals": "^16.3.0",
"typescript": "^5.9.2",
"vite": "^7.0.4"

View file

@ -1,11 +1,11 @@
import { useState, useEffect } from 'react'
import { useEffect } from 'react'
import { useAppSelector, useAppDispatch } from './store/hooks'
import CollapsiblePanel from './components/CollapsiblePanel'
import OptionsPanel from './components/OptionsPanel'
import AttackPanel from './components/AttackPanel'
import DefencePanel from './components/DefencePanel'
import ResultsPanel from './components/ResultsPanel'
import { loadBootstrapData } from './services/api'
import { BootstrapData, FormData } from './types'
import OptionsPanel from './components/optionsPanel/OptionsPanel'
import AttackPanel from './components/attackPanel/AttackPanel'
import DefencePanel from './components/defencePanel/DefencePanel'
import ResultsPanel from './components/resultsPanel/ResultsPanel'
import { loadBootstrapDataThunk } from './store/AppSlice'
// Preload all bundled css files
import './assets/common-layout.css'
import './assets/pipeline-styles.css'
@ -19,29 +19,19 @@ import './assets/themes/orks.css'
import './assets/themes/tyranids.css'
function App() {
const [bootstrapData, setBootstrapData] = useState<BootstrapData | null>(null)
const [formData, setFormData] = useState<FormData>({
attack: {},
defence: {},
options: {}
})
const [validationErrors, setValidationErrors] = useState<Set<string>>(new Set())
const dispatch = useAppDispatch()
const { bootstrapData, loading, error } = useAppSelector((state) => state.app)
useEffect(() => {
loadBootstrapData()
.then(data => setBootstrapData(data))
.catch(err => console.error('Failed to load bootstrap data:', err))
}, [])
dispatch(loadBootstrapDataThunk())
}, [dispatch])
const updateFormData = (panel: keyof FormData, data: any) => {
setFormData(prev => ({
...prev,
[panel]: { ...prev[panel], ...data }
}))
if (loading || !bootstrapData) {
return <div className="loading">Loading...</div>
}
if (!bootstrapData) {
return <div className="loading">Loading...</div>
if (error) {
return <div className="error">Error loading application: {error}</div>
}
return (
@ -49,34 +39,19 @@ function App() {
<h1>Warhammer 40k <sup>10th</sup> Damage Calculator</h1>
<div className="panels-container">
<CollapsiblePanel title="Options" defaultOpen={false} className="options-panel">
<OptionsPanel
data={formData.options}
updateData={(data) => updateFormData('options', data)}
/>
<OptionsPanel />
</CollapsiblePanel>
<CollapsiblePanel title="Attack" defaultOpen={true} className="attack-panel">
<AttackPanel
data={formData.attack}
bootstrapData={bootstrapData}
updateData={(data) => updateFormData('attack', data)}
onValidationChange={setValidationErrors}
/>
<AttackPanel />
</CollapsiblePanel>
<CollapsiblePanel title="Defence" defaultOpen={true} className="defence-panel">
<DefencePanel
data={formData.defence}
bootstrapData={bootstrapData}
updateData={(data) => updateFormData('defence', data)}
/>
<DefencePanel />
</CollapsiblePanel>
<CollapsiblePanel title="Results" defaultOpen={true} className="results-panel">
<ResultsPanel
formData={formData}
validationErrors={validationErrors}
/>
<ResultsPanel />
</CollapsiblePanel>
</div>
</div>

View file

@ -1,750 +0,0 @@
import React, { useState, useEffect } from 'react'
import { AttackPanelProps, UnitProfile, ModelProfile, WeaponCount, Reroll } from '../types'
import WeaponsTable from './WeaponsTable'
import RerollsSection from './RerollsSection'
import SearchableSelect, { SearchableSelectOption } from './SearchableSelect'
import DiceExpressionInput from './DiceExpression'
const AttackPanel: React.FC<AttackPanelProps> = ({ data, bootstrapData, updateData, onValidationChange }) => {
const [selectedUnit, setSelectedUnit] = useState<UnitProfile | null>(null)
const [selectedModels, setSelectedModels] = useState<ModelProfile[]>([])
const [weapons, setWeapons] = useState<WeaponCount[]>([])
const [factionFilter, setFactionFilter] = useState<string>('')
const [rerolls, setRerolls] = useState<Reroll[]>(data.modifiers?.rerolls || [])
const [sustainedHitsValue, setSustainedHitsValue] = useState<string>('')
const [rapidFireBonusValue, setRapidFireBonusValue] = useState<string>('')
const [validationErrors, setValidationErrors] = useState<Set<string>>(new Set())
// Initialize validation state and dice expression values on mount
useEffect(() => {
// Initialize sustained hits with current data if present
if (data.sustainedHits?.flatValue) {
setSustainedHitsValue(data.sustainedHits.flatValue.toString())
} else if (data.sustainedHits?.numberOfDice && data.sustainedHits?.diceType) {
const diceExpr = `${data.sustainedHits.numberOfDice}d${data.sustainedHits.diceType}`
const flatPart = data.sustainedHits.flatValue ? `+${data.sustainedHits.flatValue}` : ''
setSustainedHitsValue(diceExpr + flatPart)
}
// Initialize rapid fire bonus with current data if present
if (data.modifiers?.rapidFireBonus?.flatValue) {
setRapidFireBonusValue(data.modifiers.rapidFireBonus.flatValue.toString())
} else if (data.modifiers?.rapidFireBonus?.numberOfDice && data.modifiers?.rapidFireBonus?.diceType) {
const diceExpr = `${data.modifiers.rapidFireBonus.numberOfDice}d${data.modifiers.rapidFireBonus.diceType}`
const flatPart = data.modifiers.rapidFireBonus.flatValue ? `+${data.modifiers.rapidFireBonus.flatValue}` : ''
setRapidFireBonusValue(diceExpr + flatPart)
}
// Initialize validation with empty state (all valid)
const initialErrors = new Set<string>()
setValidationErrors(initialErrors)
onValidationChange?.(initialErrors)
}, [])
const filteredUnits = bootstrapData.unitProfiles?.filter(unit =>
!factionFilter || unit.faction === factionFilter
) || []
const factions = [...new Set(bootstrapData.unitProfiles?.map(unit => unit.faction) || [])].sort()
// Convert factions to SearchableSelect options
const factionOptions: SearchableSelectOption[] = [
{ value: '', label: 'All Factions' },
...factions
.filter(faction => !["Imperium - Adeptus Mechanicus", "Imperium - Adepta Sororitas", "Imperiumm - Astra Militarum", "Imperium - Astra Militarum - Library"].includes(faction))
.map(faction => ({ value: faction, label: faction }))
]
// Convert units to SearchableSelect options
const unitOptions: SearchableSelectOption[] = [
{ value: '', label: 'Select Unit' },
...filteredUnits.sort((a, b) => a.name.localeCompare(b.name)).map(unit => ({
value: unit.id,
label: unit.name
}))
]
const handleUnitSelect = (unit: UnitProfile | undefined) => {
if (!unit) return
setSelectedUnit(unit)
setSelectedModels(unit.models || [])
updateData({ selectedUnit: unit })
}
const mergeWeapons = (existingWeapons: WeaponCount[], newWeapons: WeaponCount[]): WeaponCount[] => {
const mergedWeapons = [...existingWeapons]
newWeapons.forEach(newWeapon => {
const existingIndex = mergedWeapons.findIndex(existing =>
existing.weapon.id === newWeapon.weapon.id
)
if (existingIndex >= 0) {
// Weapon already exists, increment count
mergedWeapons[existingIndex] = {
...mergedWeapons[existingIndex],
count: mergedWeapons[existingIndex].count + newWeapon.count
}
} else {
// New weapon, add to array
mergedWeapons.push(newWeapon)
}
})
return mergedWeapons
}
const addUnitWeapons = () => {
if (!selectedUnit) return
const unitWeapons = selectedModels.flatMap(model =>
(model.wargear || []).map(weaponAmount => ({
weapon: weaponAmount.weapon,
count: weaponAmount.quantities.min || 1
}))
)
const mergedWeapons = mergeWeapons(weapons, unitWeapons)
setWeapons(mergedWeapons)
updateData({ weapons: mergedWeapons })
}
const addModelWeapons = (model: ModelProfile) => {
const modelWeapons = (model.wargear || []).map(weaponAmount => ({
weapon: weaponAmount.weapon,
count: 1
}))
const mergedWeapons = mergeWeapons(weapons, modelWeapons)
setWeapons(mergedWeapons)
updateData({ weapons: mergedWeapons })
}
const updateWeapon = (index: number, field: string, value: any) => {
const updatedWeapons = [...weapons]
if (field === 'count') {
updatedWeapons[index] = { ...updatedWeapons[index], count: value }
} else {
// For attacks and damage fields, store string values directly to allow partial typing
// The parsing will be handled when the calculation is performed
updatedWeapons[index] = {
...updatedWeapons[index],
weapon: {
...updatedWeapons[index].weapon,
[field]: value
}
}
}
setWeapons(updatedWeapons)
updateData({ weapons: updatedWeapons })
}
const updateWeaponId = (index: number, name: string) => {
if (name.trim()) {
const randomSuffix = Math.random().toString(36).substring(2, 8)
const newId = `${name.toLowerCase().replace(/\s+/g, '_')}_${randomSuffix}`
const updatedWeapons = [...weapons]
updatedWeapons[index] = {
...updatedWeapons[index],
weapon: {
...updatedWeapons[index].weapon,
id: newId
}
}
setWeapons(updatedWeapons)
updateData({ weapons: updatedWeapons })
}
}
const removeWeapon = (index: number) => {
const updatedWeapons = weapons.filter((_, i) => i !== index)
setWeapons(updatedWeapons)
updateData({ weapons: updatedWeapons })
}
const addWeapon = () => {
const randomSuffix = Math.random().toString(36).substring(2, 8)
const newWeapon = {
weapon: {
id: `custom_weapon_${randomSuffix}`,
name: 'Custom Weapon',
range: 'Ranged',
attacks: { flatValue: 1 },
hitRoll: 3,
strength: 4,
ap: 0,
damage: { flatValue: 1 },
lethalHits: false,
sustainedHits: undefined,
devastatingWounds: false,
hazardous: false,
heavy: false,
blast: false,
lance: false,
ignoresCover: false,
twinLinked: false,
indirectFire: false,
melta: undefined,
rapidFire: undefined,
psychic: false,
torrent: false,
antiUnitEffects: []
},
count: 1
}
const updatedWeapons = [...weapons, newWeapon]
setWeapons(updatedWeapons)
updateData({ weapons: updatedWeapons })
}
const handleRerollsChange = (newRerolls: Reroll[]) => {
setRerolls(newRerolls)
console.log('Updating rerolls:', newRerolls)
updateData({
modifiers: {
...data.modifiers,
rerolls: newRerolls
}
})
}
const parseDiceExpression = (value: string) => {
if (!value || value.trim() === '') return undefined
const pattern = /^(\d*)?[dD](\d+)(\+(\d+))?$|^(\d+)$/
const match = pattern.exec(value.trim())
if (!match) return undefined
if (match[5]) {
// Only flat value like "3"
return { flatValue: parseInt(match[5]) }
} else {
// Dice expression like "d6", "2d3", "2d3+1"
const numberOfDice = match[1] ? parseInt(match[1]) : 1
const diceType = parseInt(match[2])
const flatValue = match[4] ? parseInt(match[4]) : undefined
return {
numberOfDice,
diceType,
...(flatValue && { flatValue })
}
}
}
const handleSustainedHitsChange = (value: string) => {
setSustainedHitsValue(value)
const diceExpression = parseDiceExpression(value)
updateData({ sustainedHits: diceExpression })
}
const handleRapidFireBonusChange = (value: string) => {
setRapidFireBonusValue(value)
const diceExpression = parseDiceExpression(value)
updateModifier('rapidFireBonus', diceExpression)
}
const handleValidationChange = (isValid: boolean, fieldName: string) => {
const newErrors = new Set(validationErrors)
if (isValid) {
newErrors.delete(fieldName)
} else {
newErrors.add(fieldName)
}
setValidationErrors(newErrors)
onValidationChange?.(newErrors)
}
const getDisplayName = (value: string): string => {
return value.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ')
}
const updateModifier = (field: string, value: any) => {
updateData({
modifiers: {
...data.modifiers,
[field]: value
}
})
}
return (
<div className="panel-content">
<div className="subpanel unit-selection">
<h3>Unit Selection</h3>
<div className="filters">
<label>
Faction:
<SearchableSelect
options={factionOptions}
value={factionFilter}
onChange={setFactionFilter}
placeholder="Search factions..."
/>
</label>
</div>
<div className="unit-select">
<label>
Unit:
<SearchableSelect
options={unitOptions}
value={selectedUnit?.id || ''}
onChange={(value) => {
const unit = filteredUnits.find(u => u.id === value)
handleUnitSelect(unit)
}}
placeholder="Search units..."
/>
</label>
</div>
{selectedUnit && (
<div className="models-section">
<h4>Models</h4>
{selectedModels.map((model) => (
<div key={model.id} className="model-item">
<span>{model.name}</span>
<button onClick={() => addModelWeapons(model)}>
Add Model's Weapons
</button>
</div>
))}
<button onClick={addUnitWeapons}>Add Unit's Weapons</button>
</div>
)}
</div>
{/* Hit Modifiers */}
<div className="subpanel">
<h4>Hit Modifiers</h4>
<div className="modifier-grid">
<label>
Critical Hit Value:
<input
type="number"
name="criticalHitValue"
value={data.modifiers?.criticalHitValue || 6}
onChange={(e) => updateModifier('criticalHitValue', parseInt(e.target.value))}
min="2"
max="6"
/>
</label>
<label>
Unmodifiable Hit Roll:
<input
type="checkbox"
name="unmodifiableHitRoll"
checked={data.modifiers?.unmodifiableHitRoll || false}
onChange={(e) => updateModifier('unmodifiableHitRoll', e.target.checked)}
/>
</label>
<label>
Fish for Critical Hits:
<input
type="checkbox"
name="fishForCriticalHits"
checked={data.modifiers?.fishForCriticalHits || false}
onChange={(e) => updateModifier('fishForCriticalHits', e.target.checked)}
/>
</label>
<div className="modifier-field-group range-only">
<label htmlFor="hitBonusRange">Hit Bonus:</label>
<select
name="hitBonusRange"
value={data.modifiers?.hitBonusRange || ''}
onChange={(e) => updateModifier('hitBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</div>
<div className="modifier-field-group range-only">
<label htmlFor="hitMalusRange">Hit Malus:</label>
<select
name="hitMalusRange"
value={data.modifiers?.hitMalusRange || ''}
onChange={(e) => updateModifier('hitMalusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</div>
<label>
Lethal Hits:
<select
name="lethalHitsRange"
value={data.modifiers?.lethalHitsRange || ''}
onChange={(e) => updateModifier('lethalHitsRange', e.target.value || undefined)}
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</label>
</div>
</div>
{/* Wound Modifiers */}
<div className="subpanel">
<h4>Wound Modifiers</h4>
<div className="modifier-grid">
<label>
Critical Wound Value:
<input
type="number"
name="criticalWoundValue"
value={data.modifiers?.criticalWoundValue || 6}
onChange={(e) => updateModifier('criticalWoundValue', parseInt(e.target.value))}
min="2"
max="6"
/>
</label>
<label>
Fish for Critical Wounds:
<input
type="checkbox"
name="fishForCriticalWounds"
checked={data.modifiers?.fishForCriticalWounds || false}
onChange={(e) => updateModifier('fishForCriticalWounds', e.target.checked)}
/>
</label>
<div className="modifier-field-group range-only">
<label htmlFor="woundBonusRange">Wound Bonus:</label>
<select
name="woundBonusRange"
value={data.modifiers?.woundBonusRange || ''}
onChange={(e) => updateModifier('woundBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</div>
<div className="modifier-field-group range-only">
<label htmlFor="woundMalusRange">Wound Malus:</label>
<select
name="woundMalusRange"
value={data.modifiers?.woundMalusRange || ''}
onChange={(e) => updateModifier('woundMalusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</div>
<label>
Devastating Wounds:
<select
name="devastatingWoundsRange"
value={data.modifiers?.devastatingWoundsRange || ''}
onChange={(e) => updateModifier('devastatingWoundsRange', e.target.value || undefined)}
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</label>
</div>
</div>
{/* Attack Modifiers */}
<div className="subpanel">
<h4>Attack & Damage Modifiers</h4>
<div className="modifier-grid">
<div className="modifier-field-group range-and-value">
<label htmlFor="strengthBonusRange">Strength Bonus:</label>
<select
name="strengthBonusRange"
value={data.modifiers?.strengthBonusRange || ''}
onChange={(e) => updateModifier('strengthBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<input
type="number"
name="strengthBonus"
value={data.modifiers?.strengthBonus || 0}
onChange={(e) => updateModifier('strengthBonus', parseInt(e.target.value))}
className="value-input"
placeholder="+/-"
/>
</div>
<div className="modifier-field-group range-and-value">
<label htmlFor="apBonusRange">AP Bonus:</label>
<select
name="apBonusRange"
value={data.modifiers?.apBonusRange || ''}
onChange={(e) => updateModifier('apBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<input
type="number"
name="apBonus"
value={data.modifiers?.apBonus || 0}
onChange={(e) => updateModifier('apBonus', parseInt(e.target.value))}
className="value-input"
placeholder="+/-"
/>
</div>
<div className="modifier-field-group range-and-value">
<label htmlFor="attacksBonusRange">Attacks Bonus:</label>
<select
name="attacksBonusRange"
value={data.modifiers?.attacksBonusRange || ''}
onChange={(e) => updateModifier('attacksBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<input
type="number"
name="attacksBonus"
value={data.modifiers?.attacksBonus || 0}
onChange={(e) => updateModifier('attacksBonus', parseInt(e.target.value))}
className="value-input"
placeholder="+/-"
/>
</div>
<div className="modifier-field-group range-and-value">
<label htmlFor="attacksMalusRange">Attacks Malus:</label>
<select
name="attacksMalusRange"
value={data.modifiers?.attacksMalusRange || ''}
onChange={(e) => updateModifier('attacksMalusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<input
type="number"
name="attacksMalus"
value={data.modifiers?.attacksMalus || 0}
onChange={(e) => updateModifier('attacksMalus', parseInt(e.target.value))}
className="value-input"
placeholder="+/-"
/>
</div>
<label>
Reroll Attack Goal:
<input
type="number"
name="rerollAttackGoal"
value={data.modifiers?.rerollAttackGoal || 4}
onChange={(e) => updateModifier('rerollAttackGoal', parseInt(e.target.value))}
min="2"
max="6"
/>
</label>
<label>
Reroll Damage Goal:
<input
type="number"
name="rerollDamageGoal"
value={data.modifiers?.rerollDamageGoal || 4}
onChange={(e) => updateModifier('rerollDamageGoal', parseInt(e.target.value))}
min="2"
max="6"
/>
</label>
</div>
</div>
{/* Weapon Effects */}
<div className="subpanel">
<h4>Weapon Effects</h4>
<div className="modifier-grid">
<div className="modifier-field-group range-and-value">
<label htmlFor="sustainedHitsRange">Sustained Hits:</label>
<select
name="sustainedHitsRange"
value={data.modifiers?.sustainedHitsRange || ''}
onChange={(e) => updateModifier('sustainedHitsRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<DiceExpressionInput
value={sustainedHitsValue}
onChange={handleSustainedHitsChange}
onValidationChange={handleValidationChange}
name="sustainedHits"
placeholder="e.g., D6, 2D3+1, 3"
/>
</div>
<div className="modifier-field-group range-and-value">
<label htmlFor="rapidFireBonusRange">Rapid Fire:</label>
<select
name="rapidFireBonusRange"
value={data.modifiers?.rapidFireBonusRange || ''}
onChange={(e) => updateModifier('rapidFireBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<DiceExpressionInput
value={rapidFireBonusValue}
onChange={handleRapidFireBonusChange}
onValidationChange={handleValidationChange}
name="rapidFireBonus"
placeholder="e.g., D6, 2D3+1, 3"
/>
</div>
<label>
Lance:
<input
type="checkbox"
name="lance"
checked={data.modifiers?.lance || false}
onChange={(e) => updateModifier('lance', e.target.checked)}
/>
</label>
<label>
Heavy:
<input
type="checkbox"
name="heavy"
checked={data.modifiers?.heavy || false}
onChange={(e) => updateModifier('heavy', e.target.checked)}
/>
</label>
<label>
Hazardous:
<select
name="hazardousRange"
value={data.modifiers?.hazardousRange || ''}
onChange={(e) => updateModifier('hazardousRange', e.target.value || undefined)}
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</label>
<label>
Can Reroll Hazardous:
<input
type="checkbox"
name="canRerollHazardous"
checked={data.modifiers?.canRerollHazardous || false}
onChange={(e) => updateModifier('canRerollHazardous', e.target.checked)}
/>
</label>
</div>
</div>
{/* Unit State */}
<div className="subpanel">
<h4>Unit State</h4>
<div className="modifier-grid">
<label>
Unit Charged:
<input
type="checkbox"
name="unitCharged"
checked={data.modifiers?.unitCharged || false}
onChange={(e) => updateModifier('unitCharged', e.target.checked)}
/>
</label>
<label>
Unit Remained Stationary:
<input
type="checkbox"
name="unitHasRemainedStationary"
checked={data.modifiers?.unitHasRemainedStationary || false}
onChange={(e) => updateModifier('unitHasRemainedStationary', e.target.checked)}
/>
</label>
<label>
Is Psychic:
<input
type="checkbox"
name="isPsychic"
checked={data.modifiers?.isPsychic || false}
onChange={(e) => updateModifier('isPsychic', e.target.checked)}
/>
</label>
</div>
</div>
<RerollsSection
rerolls={rerolls}
bootstrapData={bootstrapData}
onRerollsChange={handleRerollsChange}
/>
<WeaponsTable
weapons={weapons}
updateWeapon={updateWeapon}
updateWeaponId={updateWeaponId}
removeWeapon={removeWeapon}
addWeapon={addWeapon}
bootstrapData={bootstrapData}
/>
</div>
)
}
export default AttackPanel

View file

@ -1,387 +0,0 @@
import React, { useState } from 'react'
import { DefencePanelProps, UnitProfile } from '../types'
import SearchableSelect, { SearchableSelectOption } from './SearchableSelect'
const formatKeywordDisplay = (keyword: string): string => {
return keyword
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
const DefencePanel: React.FC<DefencePanelProps> = ({ data, bootstrapData, updateData }) => {
const [selectedUnit, setSelectedUnit] = useState<UnitProfile | null>(null)
const [factionFilter, setFactionFilter] = useState<string>('')
const filteredUnits = bootstrapData.unitProfiles?.filter(unit =>
!factionFilter || unit.faction === factionFilter
) || []
const factions = [...new Set(bootstrapData.unitProfiles?.map(unit => unit.faction) || [])].sort()
// Convert factions to SearchableSelect options
const factionOptions: SearchableSelectOption[] = [
{ value: '', label: 'All Factions' },
...factions.map(faction => ({ value: faction, label: faction }))
]
// Convert units to SearchableSelect options
const unitOptions: SearchableSelectOption[] = [
{ value: '', label: 'Select Unit' },
...filteredUnits.sort((a, b) => a.name.localeCompare(b.name)).map(unit => ({
value: unit.id,
label: unit.name
}))
]
const handleUnitSelect = (unit: UnitProfile | undefined) => {
if (!unit) return
setSelectedUnit(unit)
updateData({
selectedUnit: unit,
models: unit.models?.map((model, index) => ({
...model,
selected: index === 0 // Select first model by default
})) || [],
keywords: unit.unitKeywords || []
})
}
const updateModelStat = (modelIndex: number, field: string, value: any) => {
const updatedModels = [...(data.models || [])]
if (field === 'selected' && value) {
// For radio button selection, unselect all others first
updatedModels.forEach((model, index) => {
updatedModels[index] = { ...model, selected: index === modelIndex }
})
} else {
updatedModels[modelIndex] = {
...updatedModels[modelIndex],
[field]: field === 'selected' ? value : parseInt(value) || value
}
}
updateData({ models: updatedModels })
}
return (
<div className="panel-content">
<div className="unit-selection">
<h3>Defending Unit</h3>
<div className="filters">
<label>
Faction:
<SearchableSelect
options={factionOptions}
value={factionFilter}
onChange={setFactionFilter}
placeholder="Search factions..."
/>
</label>
</div>
<div className="unit-select">
<label>
Unit:
<SearchableSelect
options={unitOptions}
value={selectedUnit?.id || ''}
onChange={(value) => {
const unit = filteredUnits.find(u => u.id === value)
handleUnitSelect(unit)
}}
placeholder="Search units..."
/>
</label>
</div>
</div>
{selectedUnit && data.models && (
<div className="models-stats">
<h3>Model Statistics</h3>
<table>
<thead>
<tr>
<th>Model</th>
<th>Select</th>
<th>Bodies</th>
<th>Toughness</th>
<th>Normal Save</th>
<th>Invuln Save</th>
<th>Wounds</th>
</tr>
</thead>
<tbody>
{data.models.map((model, index) => (
<tr key={model.id || index}>
<td>{model.name}</td>
<td>
<input
type="radio"
name="selectedModel"
checked={model.selected || false}
onChange={(e) => updateModelStat(index, 'selected', e.target.checked)}
/>
</td>
<td>
<input
type="number"
name={`model_${index}_bodies`}
value={model.bodies || 1}
onChange={(e) => updateModelStat(index, 'bodies', e.target.value)}
min="1"
/>
</td>
<td>
<input
type="number"
name={`model_${index}_toughness`}
value={model.toughness || 4}
onChange={(e) => updateModelStat(index, 'toughness', e.target.value)}
min="1"
max="12"
/>
</td>
<td>
<input
type="number"
name={`model_${index}_normalSave`}
value={model.normalSave || 3}
onChange={(e) => updateModelStat(index, 'normalSave', e.target.value)}
min="2"
max="6"
/>
</td>
<td>
<input
type="number"
name={`model_${index}_invulnerableSave`}
value={model.invulnerableSave || 7}
onChange={(e) => updateModelStat(index, 'invulnerableSave', e.target.value)}
min="2"
max="7"
/>
</td>
<td>
<input
type="number"
name={`model_${index}_wounds`}
value={model.wounds || 1}
onChange={(e) => updateModelStat(index, 'wounds', e.target.value)}
min="1"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="defence-modifiers">
<h3>Defence Modifiers</h3>
<div className="modifier-grid">
<label className="stealth-modifier">
Has Stealth:
<input
type="checkbox"
name="hasStealth"
checked={data.hasStealth || false}
onChange={(e) => updateData({ hasStealth: e.target.checked })}
/>
</label>
<label>
In Cover:
<input
type="checkbox"
name="inCover"
checked={data.inCover || false}
onChange={(e) => updateData({ inCover: e.target.checked })}
/>
</label>
<label>
Visible:
<input
type="checkbox"
name="visible"
checked={data.visible !== false}
onChange={(e) => updateData({ visible: e.target.checked })}
/>
</label>
<label className="stealth-modifier">
In Stealth Range:
<input
type="checkbox"
name="inStealthRange"
checked={data.modifiers?.inStealthRange || false}
onChange={(e) => updateData({
modifiers: { ...data.modifiers, inStealthRange: e.target.checked }
})}
/>
</label>
<label>
In Rapid Fire Range:
<input
type="checkbox"
name="inRapidFireRange"
checked={data.inRapidFireRange !== false}
onChange={(e) => updateData({ inRapidFireRange: e.target.checked })}
/>
</label>
<label>
In Melta Range:
<input
type="checkbox"
name="inMeltaRange"
checked={data.inMeltaRange !== false}
onChange={(e) => updateData({ inMeltaRange: e.target.checked })}
/>
</label>
<label>
Fish for 6+ Normal Saves:
<input
type="checkbox"
name="fishFor6NormalSaves"
checked={data.modifiers?.fishFor6NormalSaves || false}
onChange={(e) => updateData({
modifiers: { ...data.modifiers, fishFor6NormalSaves: e.target.checked }
})}
/>
</label>
<label>
Damage Reduction:
<input
type="number"
name="damageReduction"
value={data.damageReduction || 0}
onChange={(e) => updateData({ damageReduction: parseInt(e.target.value) })}
min="0"
/>
</label>
<label>
AP Reduction:
<input
type="number"
name="apReduction"
value={data.apReduction || 0}
onChange={(e) => updateData({ apReduction: parseInt(e.target.value) })}
min="0"
/>
</label>
<label>
Save Reroll:
<select
name="saveReroll"
value={data.modifiers?.reroll?.type || 'none'}
onChange={(e) => {
const value = e.target.value;
if (value === 'none') {
updateData({
modifiers: { ...data.modifiers, reroll: undefined }
});
} else {
updateData({
modifiers: {
...data.modifiers,
reroll: {
type: value,
stageType: 'save',
validWeaponRange: 'all'
}
}
});
}
}}
>
<option value="none">None</option>
<option value="on_ones">Reroll 1s</option>
<option value="on_failure">Reroll Failed</option>
<option value="any">Reroll All</option>
</select>
</label>
<div className="modifier-field-group range-and-value">
<label>Feel No Pain:</label>
<select
name="feelNoPainType"
value={data.modifiers?.feelNoPainEffects?.[0]?.type || 'none'}
onChange={(e) => {
const value = e.target.value;
if (value === 'none') {
updateData({
modifiers: { ...data.modifiers, feelNoPainEffects: undefined }
});
} else {
const rollValue = data.modifiers?.feelNoPainEffects?.[0]?.rollValue || 5;
updateData({
modifiers: {
...data.modifiers,
feelNoPainEffects: [{
type: value,
rollValue: rollValue
}]
}
});
}
}}
>
<option value="none">None</option>
<option value="all">All Damage</option>
<option value="mortal_wounds">Mortal Wounds</option>
<option value="psychic">Psychic</option>
</select>
{data.modifiers?.feelNoPainEffects?.[0] && (
<input
type="number"
name="feelNoPainValue"
value={data.modifiers?.feelNoPainEffects?.[0]?.rollValue || 5}
onChange={(e) => {
const rollValue = parseInt(e.target.value);
const currentType = data.modifiers?.feelNoPainEffects?.[0]?.type || 'all';
updateData({
modifiers: {
...data.modifiers,
feelNoPainEffects: [{
type: currentType,
rollValue: rollValue
}]
}
});
}}
min="2"
max="6"
placeholder="5+"
/>
)}
</div>
</div>
</div>
<div className="unit-keywords">
<h3>Unit Keywords</h3>
<div className="keywords-grid">
{bootstrapData.unitKeywords?.filter(keyword => keyword.toLowerCase() !== 'all').map(keyword => (
<label key={keyword}>
<input
type="checkbox"
name={`keyword_${keyword}`}
checked={data.keywords?.includes(keyword) || false}
onChange={(e) => {
const keywords = data.keywords || []
if (e.target.checked) {
updateData({ keywords: [...keywords, keyword] })
} else {
updateData({ keywords: keywords.filter(k => k !== keyword) })
}
}}
/>
{formatKeywordDisplay(keyword)}
</label>
)) || []}
</div>
</div>
</div>
)
}
export default DefencePanel

View file

@ -1,24 +1,19 @@
import React from 'react'
export interface ModalProps {
isOpen: boolean
onClose: () => void
isOpen: boolean
onClose: () => void
title: string
message: string
message?: string
children?: React.ReactNode
widthPercentage?: number
heightPercentage?: number
}
export enum ModalState {
Opened,
Closed
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
message,
children,
widthPercentage,
@ -28,7 +23,7 @@ const Modal: React.FC<ModalProps> = ({
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose()
onClose()
}
}

View file

@ -1,62 +0,0 @@
import React, { useState } from 'react'
import { OptionsPanelProps } from '../types'
import ThemeSwitcher from './ThemeSwitcher'
import { Modal, ModalState } from './Modal'
import ReleaseNotesModal from './ReleaseNotesModal'
import FutureFeaturesModal from './FutureFeaturesModal'
import KnownBugsModal from './KnownBugsModal'
const OptionsPanel: React.FC<OptionsPanelProps> = () => {
const [isReleaseNotesModalOpen, setIsReleaseNotesModalOpen] = useState(false)
const [isFutureFeaturesModalOpen, setIsFutureFeaturesModalOpen] = useState(false)
const [isKnownBugsModalOpen, setIsKnownBugsModalOpen] = useState(false)
const handleModalBtn = (modalName: string, state: ModalState) => {
const instruction = state === ModalState.Opened ? true : false;
switch(modalName) {
case "known-bugs":
setIsKnownBugsModalOpen(instruction);
break;
case "release-notes":
setIsReleaseNotesModalOpen(instruction);
break;
case "future-features":
setIsFutureFeaturesModalOpen(instruction);
break
}
}
return (
<>
<div className="options-content">
<div className="options-left">
<ThemeSwitcher />
</div>
<div className="options-right">
<div className="metadata-section">
<button className="metadata-btn" onClick={() => handleModalBtn("known-bugs", ModalState.Opened)}>Known Bugs</button>
<button className="metadata-btn" onClick={() => handleModalBtn("release-notes", ModalState.Opened)}>Release Notes</button>
<button className="metadata-btn" onClick={() => handleModalBtn("future-features", ModalState.Opened)}>Future Features</button>
</div>
</div>
</div>
<ReleaseNotesModal
isOpen={isReleaseNotesModalOpen}
onClose={() => handleModalBtn("release-notes", ModalState.Closed)}
/>
<FutureFeaturesModal
isOpen={isFutureFeaturesModalOpen}
onClose={() => handleModalBtn("future-features", ModalState.Closed)}
/>
<KnownBugsModal
isOpen={isKnownBugsModalOpen}
onClose={() => handleModalBtn("known-bugs", ModalState.Closed)}
/>
</>
)
}
export default OptionsPanel

View file

@ -1,171 +0,0 @@
import React, { useState } from 'react'
import { calculateDamage } from '../services/api'
import { ResultsPanelProps, AttackResolution, AttackScenario, AttackModifiers } from '../types'
import SummaryHeader from './SummaryHeader'
import AttackPipelineCard from './AttackPipelineCard'
const ResultsPanel: React.FC<ResultsPanelProps> = ({ formData, validationErrors }) => {
const [results, setResults] = useState<AttackResolution | null>(null)
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
// Helper function to parse dice expressions from strings
const parseDiceExpression = (value: string | any) => {
if (typeof value !== 'string' || !value || value.trim() === '') return value
const pattern = /^(\d*)?[dD](\d+)(\+(\d+))?$|^(\d+)$/
const match = pattern.exec(value.trim())
if (!match) return value
if (match[5]) {
// Only flat value like "3"
return { flatValue: parseInt(match[5]) }
} else {
// Dice expression like "d6", "2d3", "2d3+1"
const numberOfDice = match[1] ? parseInt(match[1]) : 1
const diceType = parseInt(match[2])
const flatValue = match[4] ? parseInt(match[4]) : undefined
return {
numberOfDice,
diceType,
...(flatValue && { flatValue })
}
}
}
const handleCalculate = async () => {
setLoading(true)
setError(null)
try {
if (!formData.defence.selectedUnit) {
throw new Error('No defending unit selected')
}
// Create attack modifiers from form data
const attackModifiers: AttackModifiers = {
...formData.attack.modifiers,
...(formData.attack.sustainedHits && { sustainedHits: formData.attack.sustainedHits }),
// Legacy support for direct properties (will be deprecated)
...(formData.attack.unitCharged && !formData.attack.modifiers?.unitCharged && { unitCharged: formData.attack.unitCharged }),
...(formData.attack.remainedStationary && !formData.attack.modifiers?.unitHasRemainedStationary && { unitHasRemainedStationary: formData.attack.remainedStationary }),
}
// Filter target unit to only include the selected model and remove UI-only properties
const selectedModel = formData.defence.models?.find(model => model.selected)
const cleanModel = selectedModel || formData.defence.selectedUnit.models?.[0]
// Remove UI-only properties that backend doesn't expect
const { selected, ...cleanModelData } = cleanModel || {}
// Create defence modifiers from form data
const defenceModifiers = {
...formData.defence.modifiers,
// Include direct properties as fallback (legacy support)
...(formData.defence.hasStealth !== undefined && { hasStealth: formData.defence.hasStealth }),
...(formData.defence.inCover !== undefined && { inCover: formData.defence.inCover }),
...(formData.defence.visible !== undefined && { visible: formData.defence.visible }),
...(formData.defence.inRapidFireRange !== undefined && { inRapidFireRange: formData.defence.inRapidFireRange }),
...(formData.defence.inMeltaRange !== undefined && { inMeltaRange: formData.defence.inMeltaRange }),
...(formData.defence.damageReduction !== undefined && { damageReductionModifier: formData.defence.damageReduction }),
...(formData.defence.apReduction !== undefined && { apReduction: formData.defence.apReduction }),
}
const targetUnit = {
...formData.defence.selectedUnit,
models: cleanModelData ? [cleanModelData] : [],
defenceModifiers: defenceModifiers
}
// Process weapons to convert string attacks and damage to DiceExpression objects
const processedWeapons = (formData.attack.weapons || []).map(weaponCount => ({
...weaponCount,
weapon: {
...weaponCount.weapon,
attacks: parseDiceExpression(weaponCount.weapon.attacks),
damage: parseDiceExpression(weaponCount.weapon.damage)
}
}))
// Create attack scenario matching backend DTO structure
const attackScenario: AttackScenario = {
weapons: processedWeapons,
target: targetUnit,
attackModifiers: attackModifiers
}
// Debug: Log the attack scenario to verify rerolls are included
console.log('Attack Scenario being sent:', JSON.stringify(attackScenario, null, 2))
const result = await calculateDamage(attackScenario)
setResults(result)
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setLoading(false)
}
}
const canCalculate = () => {
const hasRequiredData = formData.defence.selectedUnit &&
(formData.attack.weapons?.length || 0) > 0
const hasValidationErrors = validationErrors && validationErrors.size > 0
return hasRequiredData && !hasValidationErrors
}
return (
<div className="panel-content">
<div className="calculate-section">
<button
onClick={handleCalculate}
disabled={!canCalculate() || loading}
className="calculate-btn"
>
{loading ? 'Calculating...' : 'Calculate'}
</button>
{!canCalculate() && (
<div className="warning">
{validationErrors && validationErrors.size > 0 ? (
<p>Invalid dice expression in: {Array.from(validationErrors).join(', ')}</p>
) : (
<p>Please add at least one weapon and select defending unit</p>
)}
</div>
)}
</div>
{error && (
<div className="error-section">
<h3>Error</h3>
<p className="error-message">{error}</p>
</div>
)}
{results && (
<div className="results-section">
<SummaryHeader results={results} />
{results.weaponAttacksStats && results.weaponAttacksStats.length > 0 && (
<div className="weapons-pipeline">
<h4>Weapon Combat Resolution</h4>
<div className="pipeline-cards">
{results.weaponAttacksStats.map((weaponStat, index) => (
<AttackPipelineCard
key={index}
weaponStat={weaponStat}
index={index}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
export default ResultsPanel

View file

@ -1,39 +0,0 @@
import React, { useEffect } from 'react'
type ThemeType = 'light' | 'cadian' | 'necrons' | 'bloodangels' | 'orks' | 'blacktemplars' | 'tyranids' ;
const ThemeSwitcher: React.FC = () => {
const [selectedTheme, setSelectedTheme] = React.useState<ThemeType>(() => {
// Get initial theme from localStorage or default to 'light'
return (localStorage.getItem('theme') as ThemeType) || 'light'
})
useEffect(() => {
// Update body class
document.body.className = selectedTheme
// Save to localStorage so it persists
localStorage.setItem('theme', selectedTheme)
}, [selectedTheme])
return (
<div className="theme-section">
<label htmlFor="theme-select">Theme:</label>
<select
id="theme-select"
value={selectedTheme || 'light'}
onChange={(e) => setSelectedTheme(e.target.value as ThemeType)}
>
<option value="cadian">Astra Militarum</option>
<option value="blacktemplars">Black Templars</option>
<option value="bloodangels">Blood Angels</option>
<option value="light">Light</option>
<option value="necrons">Necrons</option>
<option value="orks">Orks</option>
<option value="tyranids">Tyranids</option>
</select>
</div>
)
}
export default ThemeSwitcher

View file

@ -0,0 +1,36 @@
import React from 'react'
import { useAppSelector } from '../../store/hooks'
import WeaponsTable from './sections/WeaponsTable/WeaponsTable'
import RerollsSection from './sections/RerollsSection'
import UnitSelection from './sections/UnitSelection'
import HitModifiers from './sections/HitModifiers'
import WoundModifiers from './sections/WoundModifiers'
import AttackModifiers from './sections/AttackModifiers'
import WeaponEffects from './sections/WeaponEffects'
import UnitState from './sections/UnitState'
const AttackPanel: React.FC = () => {
const { bootstrapData } = useAppSelector((state) => state.app)
// Don't render if bootstrapData is not available
if (!bootstrapData) {
return <div>Loading...</div>
}
return (
<div className="panel-content">
<UnitSelection />
<HitModifiers />
<WoundModifiers />
<AttackModifiers />
<WeaponEffects />
<UnitState />
<RerollsSection />
<WeaponsTable />
</div>
)
}
export default AttackPanel

View file

@ -0,0 +1,180 @@
import { createSlice } from '@reduxjs/toolkit'
import { UnitProfile, ModelProfile, WeaponCount, DiceExpression, AttackModifiers, Reroll } from '../../types'
import type { PayloadAction } from '@reduxjs/toolkit'
export interface AttackPanelState {
selectedUnit?: UnitProfile
selectedModels: ModelProfile[]
weapons: WeaponCount[]
factionFilter: string
rerolls: Reroll[]
validationErrors: Set<string>
modifiers: AttackModifiers
}
const initialState: AttackPanelState = {
selectedUnit: undefined,
selectedModels: [],
weapons: [],
factionFilter: '',
rerolls: [],
validationErrors: new Set<string>(),
modifiers: {
criticalHitValue: 6,
criticalWoundValue: 6,
rerollAttackGoal: 4,
rerollDamageGoal: 4
}
}
type WeaponUpdatePayload = {
index: number
field: string
value: string | number | boolean | DiceExpression
}
export const attackPanelSlice = createSlice({
name: 'attackPanel',
initialState,
reducers: {
setFactionFilter: (state, action: PayloadAction<string>) => {
state.factionFilter = action.payload
},
selectUnit: (state, action: PayloadAction<UnitProfile>) => {
state.selectedUnit = action.payload
state.selectedModels = action.payload.models || []
},
setSelectedModels: (state, action: PayloadAction<ModelProfile[]>) => {
state.selectedModels = action.payload
},
setWeapons: (state, action: PayloadAction<WeaponCount[]>) => {
state.weapons = action.payload
},
addWeapons: (state, action: PayloadAction<WeaponCount[]>) => {
const newWeapons = action.payload
const mergedWeapons = [...state.weapons]
newWeapons.forEach(newWeapon => {
const existingIndex = mergedWeapons.findIndex(existing =>
existing.weapon.id === newWeapon.weapon.id
)
if (existingIndex >= 0) {
mergedWeapons[existingIndex] = {
...mergedWeapons[existingIndex],
count: mergedWeapons[existingIndex].count + newWeapon.count
}
} else {
mergedWeapons.push(newWeapon)
}
})
state.weapons = mergedWeapons
},
updateWeapon: (state, action: PayloadAction<WeaponUpdatePayload>) => {
const { index, field, value } = action.payload
if (field === 'count') {
state.weapons[index] = { ...state.weapons[index], count: value }
} else {
state.weapons[index] = {
...state.weapons[index],
weapon: {
...state.weapons[index].weapon,
[field]: value
}
}
}
},
updateWeaponId: (state, action: PayloadAction<{ index: number, name: string }>) => {
const { index, name } = action.payload
if (name.trim()) {
const randomSuffix = Math.random().toString(36).substring(2, 8)
const newId = `${name.toLowerCase().replace(/\s+/g, '_')}_${randomSuffix}`
state.weapons[index] = {
...state.weapons[index],
weapon: {
...state.weapons[index].weapon,
id: newId
}
}
}
},
removeWeapon: (state, action: PayloadAction<number>) => {
state.weapons = state.weapons.filter((_, i) => i !== action.payload)
},
addWeapon: (state) => {
const randomSuffix = Math.random().toString(36).substring(2, 8)
const newWeapon = {
weapon: {
id: `custom_weapon_${randomSuffix}`,
name: 'Custom Weapon',
range: 'Ranged',
attacks: { flatValue: 1 },
hitRoll: 3,
strength: 4,
ap: 0,
damage: { flatValue: 1 },
lethalHits: false,
sustainedHits: undefined,
devastatingWounds: false,
hazardous: false,
heavy: false,
blast: false,
lance: false,
ignoresCover: false,
twinLinked: false,
indirectFire: false,
melta: undefined,
rapidFire: undefined,
psychic: false,
torrent: false,
antiUnitEffects: []
},
count: 1
}
state.weapons = [...state.weapons, newWeapon]
},
setRerolls: (state, action: PayloadAction<Reroll[]>) => {
state.rerolls = action.payload
state.modifiers.rerolls = action.payload
},
updateModifier: (state, action: PayloadAction<{ field: string, value: string | number | boolean | DiceExpression | undefined }>) => {
const { field, value } = action.payload
state.modifiers = {
...state.modifiers,
[field]: value
}
},
setValidationErrors: (state, action: PayloadAction<Set<string>>) => {
state.validationErrors = action.payload
},
addValidationError: (state, action: PayloadAction<string>) => {
state.validationErrors = new Set([...state.validationErrors, action.payload])
},
removeValidationError: (state, action: PayloadAction<string>) => {
const newErrors = new Set(state.validationErrors)
newErrors.delete(action.payload)
state.validationErrors = newErrors
}
}
})
export const {
setFactionFilter,
selectUnit,
setSelectedModels,
setWeapons,
addWeapons,
updateWeapon,
updateWeaponId,
removeWeapon,
addWeapon,
setRerolls,
updateModifier,
setValidationErrors,
addValidationError,
removeValidationError,
} = attackPanelSlice.actions
export default attackPanelSlice.reducer

View file

@ -0,0 +1,141 @@
import React from 'react'
import { useModifierState } from '../../../hooks/useModifierState'
import { getDisplayName } from '../../../utils/formatUtils'
const AttackModifiers: React.FC = React.memo(() => {
const { modifiers, bootstrapData, handleUpdateModifier } = useModifierState()
if (!bootstrapData) {
return null
}
return (
<div className="subpanel">
<h4>Attack & Damage Modifiers</h4>
<div className="modifier-grid">
<div className="modifier-field-group range-and-value">
<label htmlFor="strengthBonusRange">Strength Bonus:</label>
<select
name="strengthBonusRange"
value={modifiers?.strengthBonusRange || ''}
onChange={(e) => handleUpdateModifier('strengthBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<input
type="number"
name="strengthBonus"
value={modifiers?.strengthBonus || 0}
onChange={(e) => handleUpdateModifier('strengthBonus', parseInt(e.target.value))}
className="value-input"
placeholder="+/-"
/>
</div>
<div className="modifier-field-group range-and-value">
<label htmlFor="apBonusRange">AP Bonus:</label>
<select
name="apBonusRange"
value={modifiers?.apBonusRange || ''}
onChange={(e) => handleUpdateModifier('apBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<input
type="number"
name="apBonus"
value={modifiers?.apBonus || 0}
onChange={(e) => handleUpdateModifier('apBonus', parseInt(e.target.value))}
className="value-input"
placeholder="+/-"
/>
</div>
<div className="modifier-field-group range-and-value">
<label htmlFor="attacksBonusRange">Attacks Bonus:</label>
<select
name="attacksBonusRange"
value={modifiers?.attacksBonusRange || ''}
onChange={(e) => handleUpdateModifier('attacksBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<input
type="number"
name="attacksBonus"
value={modifiers?.attacksBonus || 0}
onChange={(e) => handleUpdateModifier('attacksBonus', parseInt(e.target.value))}
className="value-input"
placeholder="+/-"
/>
</div>
<div className="modifier-field-group range-and-value">
<label htmlFor="attacksMalusRange">Attacks Malus:</label>
<select
name="attacksMalusRange"
value={modifiers?.attacksMalusRange || ''}
onChange={(e) => handleUpdateModifier('attacksMalusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<input
type="number"
name="attacksMalus"
value={modifiers?.attacksMalus || 0}
onChange={(e) => handleUpdateModifier('attacksMalus', parseInt(e.target.value))}
className="value-input"
placeholder="+/-"
/>
</div>
<label>
Reroll Attack Goal:
<input
type="number"
name="rerollAttackGoal"
value={modifiers?.rerollAttackGoal || 4}
onChange={(e) => handleUpdateModifier('rerollAttackGoal', parseInt(e.target.value))}
min="2"
max="6"
/>
</label>
<label>
Reroll Damage Goal:
<input
type="number"
name="rerollDamageGoal"
value={modifiers?.rerollDamageGoal || 4}
onChange={(e) => handleUpdateModifier('rerollDamageGoal', parseInt(e.target.value))}
min="2"
max="6"
/>
</label>
</div>
</div>
)
})
AttackModifiers.displayName = 'AttackModifiers'
export default AttackModifiers

View file

@ -0,0 +1,99 @@
import React from 'react'
import { useModifierState } from '../../../hooks/useModifierState'
import { getDisplayName } from '../../../utils/formatUtils'
const HitModifiers: React.FC = React.memo(() => {
const { modifiers, bootstrapData, handleUpdateModifier } = useModifierState()
if (!bootstrapData) {
return null
}
return (
<div className="subpanel">
<h4>Hit Modifiers</h4>
<div className="modifier-grid">
<label>
Critical Hit Value:
<input
type="number"
name="criticalHitValue"
value={modifiers?.criticalHitValue || 6}
onChange={(e) => handleUpdateModifier('criticalHitValue', parseInt(e.target.value))}
min="2"
max="6"
/>
</label>
<label>
Unmodifiable Hit Roll:
<input
type="checkbox"
name="unmodifiableHitRoll"
checked={modifiers?.unmodifiableHitRoll || false}
onChange={(e) => handleUpdateModifier('unmodifiableHitRoll', e.target.checked)}
/>
</label>
<label>
Fish for Critical Hits:
<input
type="checkbox"
name="fishForCriticalHits"
checked={modifiers?.fishForCriticalHits || false}
onChange={(e) => handleUpdateModifier('fishForCriticalHits', e.target.checked)}
/>
</label>
<div className="modifier-field-group range-only">
<label htmlFor="hitBonusRange">Hit Bonus:</label>
<select
name="hitBonusRange"
value={modifiers?.hitBonusRange || ''}
onChange={(e) => handleUpdateModifier('hitBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</div>
<div className="modifier-field-group range-only">
<label htmlFor="hitMalusRange">Hit Malus:</label>
<select
name="hitMalusRange"
value={modifiers?.hitMalusRange || ''}
onChange={(e) => handleUpdateModifier('hitMalusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</div>
<label>
Lethal Hits:
<select
name="lethalHitsRange"
value={modifiers?.lethalHitsRange || ''}
onChange={(e) => handleUpdateModifier('lethalHitsRange', e.target.value || undefined)}
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</label>
</div>
</div>
)
})
HitModifiers.displayName = 'HitModifiers'
export default HitModifiers

View file

@ -1,13 +1,16 @@
import React from 'react'
import { Reroll, BootstrapData } from '../types'
import { useAppSelector, useAppDispatch } from '../../../store/hooks'
import { Reroll } from '../../../types'
import { setRerolls } from '../AttackPanelSlice'
interface RerollsSectionProps {
rerolls: Reroll[]
bootstrapData: BootstrapData
onRerollsChange: (rerolls: Reroll[]) => void
}
const RerollsSection: React.FC = () => {
const dispatch = useAppDispatch()
const { rerolls } = useAppSelector((state) => state.attackPanel)
const { bootstrapData } = useAppSelector((state) => state.app)
const RerollsSection: React.FC<RerollsSectionProps> = ({ rerolls, bootstrapData, onRerollsChange }) => {
if (!bootstrapData) {
return <div>Loading...</div>
}
const addReroll = () => {
// Filter out 'save' stage and get the first valid stage for attacking
const attackStages = bootstrapData.rerollStages.filter(stage => stage.toLowerCase() !== 'save')
@ -16,18 +19,18 @@ const RerollsSection: React.FC<RerollsSectionProps> = ({ rerolls, bootstrapData,
type: bootstrapData.rerollTypes[0] || 'on_ones',
validWeaponRange: bootstrapData.weaponRanges[0] || 'all'
}
onRerollsChange([...rerolls, newReroll])
dispatch(setRerolls([...rerolls, newReroll]))
}
const updateReroll = (index: number, field: keyof Reroll, value: string | number) => {
const updatedRerolls = [...rerolls]
updatedRerolls[index] = { ...updatedRerolls[index], [field]: value }
onRerollsChange(updatedRerolls)
dispatch(setRerolls(updatedRerolls))
}
const removeReroll = (index: number) => {
const updatedRerolls = rerolls.filter((_, i) => i !== index)
onRerollsChange(updatedRerolls)
dispatch(setRerolls(updatedRerolls))
}
// Convert enum values to display names

View file

@ -0,0 +1,120 @@
import React from 'react'
import { useAppSelector, useAppDispatch } from '../../../store/hooks'
import { UnitProfile, ModelProfile } from '../../../types'
import SearchableSelect, { SearchableSelectOption } from '../../SearchableSelect'
import {
setFactionFilter,
selectUnit,
setSelectedModels,
addWeapons,
} from '../AttackPanelSlice'
const UnitSelection: React.FC = () => {
const dispatch = useAppDispatch()
const { selectedUnit, selectedModels, factionFilter } = useAppSelector((state) => state.attackPanel)
const { bootstrapData } = useAppSelector((state) => state.app)
if (!bootstrapData) {
return null
}
const filteredUnits = bootstrapData.unitProfiles?.filter(unit =>
!factionFilter || unit.faction === factionFilter
) || []
const factions = [...new Set(bootstrapData.unitProfiles?.map(unit => unit.faction) || [])].sort()
// Convert factions to SearchableSelect options
const factionOptions: SearchableSelectOption[] = [
{ value: '', label: 'All Factions' },
...factions
.filter(faction => !["Imperium - Adeptus Mechanicus"].includes(faction))
.map(faction => ({ value: faction, label: faction }))
]
// Convert units to SearchableSelect options
const unitOptions: SearchableSelectOption[] = [
{ value: '', label: 'Select Unit' },
...filteredUnits.sort((a, b) => a.name.localeCompare(b.name)).map(unit => ({
value: unit.id,
label: unit.name
}))
]
const handleUnitSelect = (unit: UnitProfile | undefined) => {
if (!unit) return
dispatch(selectUnit(unit))
dispatch(setSelectedModels(unit.models || []))
}
const addUnitWeapons = () => {
if (!selectedUnit) return
const unitWeapons = selectedModels.flatMap(model =>
(model.wargear || []).map(weaponAmount => ({
weapon: weaponAmount.weapon,
count: weaponAmount.quantities.min || 1
}))
)
dispatch(addWeapons(unitWeapons))
}
const addModelWeapons = (model: ModelProfile) => {
const modelWeapons = (model.wargear || []).map(weaponAmount => ({
weapon: weaponAmount.weapon,
count: 1
}))
dispatch(addWeapons(modelWeapons))
}
return (
<div className="subpanel unit-selection">
<h3>Unit Selection</h3>
<div className="filters">
<label>
Faction:
<SearchableSelect
options={factionOptions}
value={factionFilter}
onChange={(value) => dispatch(setFactionFilter(value))}
placeholder="Search factions..."
/>
</label>
</div>
<div className="unit-select">
<label>
Unit:
<SearchableSelect
options={unitOptions}
value={selectedUnit?.id || ''}
onChange={(value) => {
const unit = filteredUnits.find(u => u.id === value)
handleUnitSelect(unit)
}}
placeholder="Search units..."
/>
</label>
</div>
{selectedUnit && (
<div className="models-section">
<h4>Models</h4>
{selectedModels.map((model) => (
<div key={model.id} className="model-item">
<span>{model.name}</span>
<button onClick={() => addModelWeapons(model)}>
Add Model's Weapons
</button>
</div>
))}
<button onClick={addUnitWeapons}>Add Unit's Weapons</button>
</div>
)}
</div>
)
}
export default UnitSelection

View file

@ -0,0 +1,45 @@
import React from 'react'
import { useModifierState } from '../../../hooks/useModifierState'
const UnitState: React.FC = React.memo(() => {
const { modifiers, handleUpdateModifier } = useModifierState()
return (
<div className="subpanel">
<h4>Unit State</h4>
<div className="modifier-grid">
<label>
Unit Charged:
<input
type="checkbox"
name="unitCharged"
checked={modifiers?.unitCharged || false}
onChange={(e) => handleUpdateModifier('unitCharged', e.target.checked)}
/>
</label>
<label>
Unit Remained Stationary:
<input
type="checkbox"
name="unitHasRemainedStationary"
checked={modifiers?.unitHasRemainedStationary || false}
onChange={(e) => handleUpdateModifier('unitHasRemainedStationary', e.target.checked)}
/>
</label>
<label>
Is Psychic:
<input
type="checkbox"
name="isPsychic"
checked={modifiers?.isPsychic || false}
onChange={(e) => handleUpdateModifier('isPsychic', e.target.checked)}
/>
</label>
</div>
</div>
)
})
UnitState.displayName = 'UnitState'
export default UnitState

View file

@ -0,0 +1,136 @@
import React, { useCallback, useEffect } from 'react'
import { useAppDispatch } from '../../../store/hooks'
import { useModifierState } from '../../../hooks/useModifierState'
import { setValidationErrors } from '../AttackPanelSlice'
import DiceExpressionInput from '../../DiceExpression'
import { parseDiceExpression, formatDiceExpression } from '../../../utils/diceExpressionUtils'
import { getDisplayName } from '../../../utils/formatUtils'
const WeaponEffects: React.FC = React.memo(() => {
const dispatch = useAppDispatch()
const { modifiers, bootstrapData, handleUpdateModifier } = useModifierState()
// Initialize validation state on mount
useEffect(() => {
const initialErrors = new Set<string>()
dispatch(setValidationErrors(initialErrors))
}, [dispatch])
const handleSustainedHitsChange = useCallback((value: string) => {
const diceExpression = parseDiceExpression(value)
handleUpdateModifier('sustainedHits', diceExpression)
}, [handleUpdateModifier])
const handleRapidFireBonusChange = useCallback((value: string) => {
const diceExpression = parseDiceExpression(value)
handleUpdateModifier('rapidFireBonus', diceExpression)
}, [handleUpdateModifier])
const handleValidationChange = useCallback(() => {
// Validation logic would go here if needed
}, [])
if (!bootstrapData) {
return null
}
return (
<div className="subpanel">
<h4>Weapon Effects</h4>
<div className="modifier-grid">
<div className="modifier-field-group range-and-value">
<label htmlFor="sustainedHitsRange">Sustained Hits:</label>
<select
name="sustainedHitsRange"
value={modifiers?.sustainedHitsRange || ''}
onChange={(e) => handleUpdateModifier('sustainedHitsRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<DiceExpressionInput
value={modifiers?.sustainedHits ? formatDiceExpression(modifiers.sustainedHits) : ''}
onChange={handleSustainedHitsChange}
onValidationChange={handleValidationChange}
name="sustainedHits"
placeholder="e.g., D6, 2D3+1, 3"
/>
</div>
<div className="modifier-field-group range-and-value">
<label htmlFor="rapidFireBonusRange">Rapid Fire:</label>
<select
name="rapidFireBonusRange"
value={modifiers?.rapidFireBonusRange || ''}
onChange={(e) => handleUpdateModifier('rapidFireBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
<DiceExpressionInput
value={modifiers?.rapidFireBonus ? formatDiceExpression(modifiers.rapidFireBonus) : ''}
onChange={handleRapidFireBonusChange}
onValidationChange={handleValidationChange}
name="rapidFireBonus"
placeholder="e.g., D6, 2D3+1, 3"
/>
</div>
<label>
Lance:
<input
type="checkbox"
name="lance"
checked={modifiers?.lance || false}
onChange={(e) => handleUpdateModifier('lance', e.target.checked)}
/>
</label>
<label>
Heavy:
<input
type="checkbox"
name="heavy"
checked={modifiers?.heavy || false}
onChange={(e) => handleUpdateModifier('heavy', e.target.checked)}
/>
</label>
<label>
Hazardous:
<select
name="hazardousRange"
value={modifiers?.hazardousRange || ''}
onChange={(e) => handleUpdateModifier('hazardousRange', e.target.value || undefined)}
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</label>
<label>
Can Reroll Hazardous:
<input
type="checkbox"
name="canRerollHazardous"
checked={modifiers?.canRerollHazardous || false}
onChange={(e) => handleUpdateModifier('canRerollHazardous', e.target.checked)}
/>
</label>
</div>
</div>
)
})
WeaponEffects.displayName = 'WeaponEffects'
export default WeaponEffects

View file

@ -1,11 +1,11 @@
import React, { useState, useEffect } from 'react'
import { Weapon, DiceExpression, AntiUnitEffect, BootstrapData } from '../types'
import DiceExpressionInput from './DiceExpression'
import { Weapon, DiceExpression, AntiUnitEffect, BootstrapData } from '../../../../types'
import DiceExpressionInput from '../../../DiceExpression'
interface EffectEditorProps {
weapon: Weapon
bootstrapData: BootstrapData
onUpdateWeapon: (field: string, value: any) => void
onUpdateWeapon: (field: string, value: string | number | boolean | DiceExpression | AntiUnitEffect[]) => void
onClose: () => void
}

View file

@ -1,24 +1,13 @@
import React, { useState } from 'react'
import { Weapon, DiceExpression, BootstrapData } from '../types'
import { Weapon, DiceExpression, BootstrapData } from '../../../../types'
import EffectEditor from './EffectEditor'
import { formatDiceExpression } from '../../../../utils/diceExpressionUtils'
interface EffectsDisplayProps {
weapon: Weapon
weaponIndex: number
bootstrapData: BootstrapData
onUpdateWeapon: (index: number, field: string, value: any) => void
}
const formatDiceExpression = (dice: DiceExpression): string => {
if (dice.numberOfDice && dice.diceType) {
let result = `${dice.numberOfDice}D${dice.diceType}`
if (dice.flatValue) {
result += `+${dice.flatValue}`
}
return result
}
if (dice.flatValue) return dice.flatValue.toString()
return '1'
onUpdateWeapon: (index: number, field: string, value: string | number | boolean | DiceExpression) => void
}
interface EffectItem {
@ -119,7 +108,7 @@ const EffectsDisplay: React.FC<EffectsDisplayProps> = ({ weapon, weaponIndex, bo
}
}
const handleUpdateWeapon = (field: string, value: any) => {
const handleUpdateWeapon = (field: string, value: string | number | boolean | DiceExpression) => {
onUpdateWeapon(weaponIndex, field, value)
}

View file

@ -1,24 +1,19 @@
import React, { useState } from 'react'
import { WeaponCount, DiceExpression, BootstrapData } from '../types'
import DiceExpressionInput from './DiceExpression'
import { WeaponCount, DiceExpression, BootstrapData } from '../../../../types'
import DiceExpressionInput from '../../../DiceExpression'
import EffectsDisplay from './EffectsDisplay'
import { getDisplayName } from '../../../../utils/formatUtils'
interface WeaponCardProps {
weaponCount: WeaponCount
index: number
updateWeapon: (index: number, field: string, value: any) => void
updateWeapon: (index: number, field: string, value: string | number | boolean | DiceExpression) => void
updateWeaponId: (index: number, name: string) => void
removeWeapon: (index: number) => void
bootstrapData: BootstrapData
onValidationChange?: (isValid: boolean, fieldName?: string) => void
}
const getDisplayName = (value: string): string => {
return value.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ')
}
const WeaponCard: React.FC<WeaponCardProps> = ({
weaponCount,
index,

View file

@ -1,38 +1,40 @@
import React from 'react'
import { Weapon, WeaponCount, DiceExpression, BootstrapData } from '../types'
import DiceExpressionInput from './DiceExpression'
import { useAppSelector, useAppDispatch } from '../../../../store/hooks'
import { DiceExpression } from '../../../../types'
import DiceExpressionInput from '../../../DiceExpression'
import WeaponCard from './WeaponCard'
import EffectsDisplay from './EffectsDisplay'
import { updateWeapon, updateWeaponId, removeWeapon, addWeapon } from '../../AttackPanelSlice'
import { getDisplayName } from '../../../../utils/formatUtils'
interface WeaponsTableProps {
weapons: WeaponCount[]
updateWeapon: (index: number, field: string, value: any) => void
updateWeaponId: (index: number, name: string) => void
removeWeapon: (index: number) => void
addWeapon: () => void
bootstrapData: BootstrapData
onValidationChange?: (isValid: boolean, fieldName?: string) => void
}
const formatDiceExpression = (dice: DiceExpression): string => {
if (dice.numberOfDice && dice.diceType) {
let result = `${dice.numberOfDice}D${dice.diceType}`
if (dice.flatValue) {
result += `+${dice.flatValue}`
}
return result
const WeaponsTable: React.FC<WeaponsTableProps> = ({ onValidationChange }) => {
const dispatch = useAppDispatch()
const { weapons } = useAppSelector((state) => state.attackPanel)
const { bootstrapData } = useAppSelector((state) => state.app)
if (!bootstrapData) {
return <div>Loading...</div>
}
if (dice.flatValue) return dice.flatValue.toString()
return '1'
}
const getDisplayName = (value: string): string => {
return value.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ')
}
const handleUpdateWeapon = (index: number, field: string, value: string | number | boolean | DiceExpression) => {
dispatch(updateWeapon({ index, field, value }))
}
const WeaponsTable: React.FC<WeaponsTableProps> = ({ weapons, updateWeapon, updateWeaponId, removeWeapon, addWeapon, bootstrapData, onValidationChange }) => {
const handleUpdateWeaponId = (index: number, name: string) => {
dispatch(updateWeaponId({ index, name }))
}
const handleRemoveWeapon = (index: number) => {
dispatch(removeWeapon(index))
}
const handleAddWeapon = () => {
dispatch(addWeapon())
}
return (
<div className="weapons-section">
<h3>Weapons</h3>
@ -63,8 +65,8 @@ const WeaponsTable: React.FC<WeaponsTableProps> = ({ weapons, updateWeapon, upda
type="text"
name={`weapon_${index}_name`}
value={weaponCount.weapon.name}
onChange={(e) => updateWeapon(index, 'name', e.target.value)}
onBlur={(e) => updateWeaponId(index, e.target.value)}
onChange={(e) => handleUpdateWeapon(index, 'name', e.target.value)}
onBlur={(e) => handleUpdateWeaponId(index, e.target.value)}
placeholder="Weapon name"
/>
</td>
@ -73,7 +75,7 @@ const WeaponsTable: React.FC<WeaponsTableProps> = ({ weapons, updateWeapon, upda
type="number"
name={`weapon_${index}_count`}
value={weaponCount.count}
onChange={(e) => updateWeapon(index, 'count', parseInt(e.target.value))}
onChange={(e) => handleUpdateWeapon(index, 'count', parseInt(e.target.value))}
min="1"
/>
</td>
@ -82,7 +84,7 @@ const WeaponsTable: React.FC<WeaponsTableProps> = ({ weapons, updateWeapon, upda
type="number"
name={`weapon_${index}_hitRoll`}
value={weaponCount.weapon.hitRoll || 3}
onChange={(e) => updateWeapon(index, 'hitRoll', parseInt(e.target.value))}
onChange={(e) => handleUpdateWeapon(index, 'hitRoll', parseInt(e.target.value))}
min="2"
max="6"
/>
@ -91,7 +93,7 @@ const WeaponsTable: React.FC<WeaponsTableProps> = ({ weapons, updateWeapon, upda
<DiceExpressionInput
name={`weapon_${index}_attacks`}
value={weaponCount.weapon.attacks}
onChange={(value) => updateWeapon(index, 'attacks', value)}
onChange={(value) => handleUpdateWeapon(index, 'attacks', value)}
onValidationChange={onValidationChange || (() => {})}
placeholder="e.g., D6, 2D3+1, 3" />
</td>
@ -100,7 +102,7 @@ const WeaponsTable: React.FC<WeaponsTableProps> = ({ weapons, updateWeapon, upda
type="number"
name={`weapon_${index}_strength`}
value={weaponCount.weapon.strength || 4}
onChange={(e) => updateWeapon(index, 'strength', parseInt(e.target.value))}
onChange={(e) => handleUpdateWeapon(index, 'strength', parseInt(e.target.value))}
min="1"
/>
</td>
@ -110,21 +112,21 @@ const WeaponsTable: React.FC<WeaponsTableProps> = ({ weapons, updateWeapon, upda
name={`weapon_${index}_ap`}
max="0"
value={weaponCount.weapon.ap || 0}
onChange={(e) => updateWeapon(index, 'ap', parseInt(e.target.value))}
onChange={(e) => handleUpdateWeapon(index, 'ap', parseInt(e.target.value))}
/>
</td>
<td>
<DiceExpressionInput
name={`weapon_${index}_damage`}
value={weaponCount.weapon.damage}
onChange={(value) => updateWeapon(index, 'damage', value)}
onChange={(value) => handleUpdateWeapon(index, 'damage', value)}
onValidationChange={onValidationChange || (() => {})}
placeholder="e.g., D6, 2D3+1, 3" />
</td>
<td>
<select
value={weaponCount.weapon.range}
onChange={(e) => updateWeapon(index, 'range', e.target.value)}
onChange={(e) => handleUpdateWeapon(index, 'range', e.target.value)}
>
{bootstrapData.weaponRanges.filter(range=>range !== "all").map(range => (
<option key={range} value={range}>
@ -138,10 +140,10 @@ const WeaponsTable: React.FC<WeaponsTableProps> = ({ weapons, updateWeapon, upda
weapon={weaponCount.weapon}
weaponIndex={index}
bootstrapData={bootstrapData}
onUpdateWeapon={updateWeapon}
onUpdateWeapon={handleUpdateWeapon}
/>
</td>
<td><button onClick={() => removeWeapon(index)}>Remove</button></td>
<td><button onClick={() => handleRemoveWeapon(index)}>Remove</button></td>
</tr>
))}
</tbody>
@ -160,9 +162,9 @@ const WeaponsTable: React.FC<WeaponsTableProps> = ({ weapons, updateWeapon, upda
key={index}
weaponCount={weaponCount}
index={index}
updateWeapon={updateWeapon}
updateWeaponId={updateWeaponId}
removeWeapon={removeWeapon}
updateWeapon={handleUpdateWeapon}
updateWeaponId={handleUpdateWeaponId}
removeWeapon={handleRemoveWeapon}
bootstrapData={bootstrapData}
onValidationChange={onValidationChange}
/>
@ -175,7 +177,7 @@ const WeaponsTable: React.FC<WeaponsTableProps> = ({ weapons, updateWeapon, upda
{/* Add Weapon Button - Shared */}
<div className="weapon-actions">
<button onClick={addWeapon} className="add-weapon-btn">
<button onClick={handleAddWeapon} className="add-weapon-btn">
Add Custom Weapon
</button>
</div>

View file

@ -0,0 +1,90 @@
import React from 'react'
import { useModifierState } from '../../../hooks/useModifierState'
import { getDisplayName } from '../../../utils/formatUtils'
const WoundModifiers: React.FC = React.memo(() => {
const { modifiers, bootstrapData, handleUpdateModifier } = useModifierState()
if (!bootstrapData) {
return null
}
return (
<div className="subpanel">
<h4>Wound Modifiers</h4>
<div className="modifier-grid">
<label>
Critical Wound Value:
<input
type="number"
name="criticalWoundValue"
value={modifiers?.criticalWoundValue || 6}
onChange={(e) => handleUpdateModifier('criticalWoundValue', parseInt(e.target.value))}
min="2"
max="6"
/>
</label>
<label>
Fish for Critical Wounds:
<input
type="checkbox"
name="fishForCriticalWounds"
checked={modifiers?.fishForCriticalWounds || false}
onChange={(e) => handleUpdateModifier('fishForCriticalWounds', e.target.checked)}
/>
</label>
<div className="modifier-field-group range-only">
<label htmlFor="woundBonusRange">Wound Bonus:</label>
<select
name="woundBonusRange"
value={modifiers?.woundBonusRange || ''}
onChange={(e) => handleUpdateModifier('woundBonusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</div>
<div className="modifier-field-group range-only">
<label htmlFor="woundMalusRange">Wound Malus:</label>
<select
name="woundMalusRange"
value={modifiers?.woundMalusRange || ''}
onChange={(e) => handleUpdateModifier('woundMalusRange', e.target.value || undefined)}
className="range-select"
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</div>
<label>
Devastating Wounds:
<select
name="devastatingWoundsRange"
value={modifiers?.devastatingWoundsRange || ''}
onChange={(e) => handleUpdateModifier('devastatingWoundsRange', e.target.value || undefined)}
>
<option value="">None</option>
{bootstrapData.weaponRanges.map(range => (
<option key={range} value={range}>
{getDisplayName(range)}
</option>
))}
</select>
</label>
</div>
</div>
)
})
WoundModifiers.displayName = 'WoundModifiers'
export default WoundModifiers

View file

@ -0,0 +1,188 @@
import React from 'react'
import { useDefenceModifierState } from '../../hooks/useDefenceModifierState'
const DefenceModifiers: React.FC = () => {
const {
handleUpdateConfiguration,
handleUpdateModifier,
modifiers
} = useDefenceModifierState()
const handleSaveRerollChange = (value: string) => {
if (value === 'none') {
handleUpdateConfiguration({
modifiers: { reroll: undefined }
})
} else {
handleUpdateConfiguration({
modifiers: {
reroll: {
type: value as 'on_ones' | 'on_failure' | 'any',
stageType: 'save',
validWeaponRange: 'all'
}
}
})
}
}
const handleFeelNoPainTypeChange = (value: string) => {
if (value === 'none') {
handleUpdateConfiguration({
modifiers: { feelNoPainEffects: undefined }
})
} else {
const rollValue = modifiers.feelNoPainEffects?.[0]?.rollValue || 5
handleUpdateConfiguration({
modifiers: {
feelNoPainEffects: [{
type: value as 'all' | 'mortal_wounds' | 'psychic',
rollValue: rollValue
}]
}
})
}
}
const handleFeelNoPainValueChange = (rollValue: number) => {
const currentType = modifiers.feelNoPainEffects?.[0]?.type || 'all'
handleUpdateConfiguration({
modifiers: {
feelNoPainEffects: [{
type: currentType,
rollValue: rollValue
}]
}
})
}
return (
<div className="defence-modifiers">
<h3>Defence Modifiers</h3>
<div className="modifier-grid">
<label className="stealth-modifier">
Has Stealth:
<input
type="checkbox"
name="hasStealth"
checked={modifiers.hasStealth || false}
onChange={(e) => handleUpdateModifier('hasStealth', e.target.checked)}
/>
</label>
<label>
In Cover:
<input
type="checkbox"
name="inCover"
checked={modifiers.inCover || false}
onChange={(e) => handleUpdateModifier('inCover', e.target.checked)}
/>
</label>
<label>
Visible:
<input
type="checkbox"
name="visible"
checked={modifiers.visible || false}
onChange={(e) => handleUpdateModifier('visible', e.target.checked)}
/>
</label>
<label className="stealth-modifier">
In Stealth Range:
<input
type="checkbox"
name="inStealthRange"
checked={modifiers.inStealthRange || false}
onChange={(e) => handleUpdateModifier('inStealthRange', e.target.checked)}
/>
</label>
<label>
In Rapid Fire Range:
<input
type="checkbox"
name="inRapidFireRange"
checked={modifiers.inRapidFireRange || false}
onChange={(e) => handleUpdateModifier('inRapidFireRange', e.target.checked)}
/>
</label>
<label>
In Melta Range:
<input
type="checkbox"
name="inMeltaRange"
checked={modifiers.inMeltaRange || false}
onChange={(e) => handleUpdateModifier('inMeltaRange', e.target.checked)}
/>
</label>
<label>
Fish for 6+ Normal Saves:
<input
type="checkbox"
name="fishFor6NormalSaves"
checked={modifiers.fishFor6NormalSaves || false}
onChange={(e) => handleUpdateModifier('fishFor6NormalSaves', e.target.checked)}
/>
</label>
<label>
Damage Reduction:
<input
type="number"
name="damageReduction"
value={modifiers.damageReductionModifier || 0}
onChange={(e) => handleUpdateModifier('damageReductionModifier', parseInt(e.target.value))}
min="0"
/>
</label>
<label>
AP Reduction:
<input
type="number"
name="apReduction"
value={modifiers.apReduction || 0}
onChange={(e) => handleUpdateModifier('apReduction', parseInt(e.target.value))}
min="0"
/>
</label>
<label>
Save Reroll:
<select
name="saveReroll"
value={modifiers.reroll?.type || 'none'}
onChange={(e) => handleSaveRerollChange(e.target.value)}
>
<option value="none">None</option>
<option value="on_ones">Reroll 1s</option>
<option value="on_failure">Reroll Failed</option>
<option value="any">Reroll All</option>
</select>
</label>
<div className="modifier-field-group range-and-value">
<label>Feel No Pain:</label>
<select
name="feelNoPainType"
value={modifiers.feelNoPainEffects?.[0]?.type || 'none'}
onChange={(e) => handleFeelNoPainTypeChange(e.target.value)}
>
<option value="none">None</option>
<option value="all">All Damage</option>
<option value="mortal_wounds">Mortal Wounds</option>
<option value="psychic">Psychic</option>
</select>
{modifiers.feelNoPainEffects?.[0] && (
<input
type="number"
name="feelNoPainValue"
value={modifiers.feelNoPainEffects[0].rollValue}
onChange={(e) => handleFeelNoPainValueChange(parseInt(e.target.value))}
min="2"
max="6"
placeholder="5+"
/>
)}
</div>
</div>
</div>
)
}
export default DefenceModifiers

View file

@ -0,0 +1,25 @@
import React from 'react'
import { useAppSelector } from '../../store/hooks'
import UnitSelection from './UnitSelection'
import ModelStats from './ModelStats'
import DefenceModifiers from './DefenceModifiers'
import UnitKeywords from './UnitKeywords'
const DefencePanel: React.FC = () => {
const { bootstrapData } = useAppSelector((state) => state.app)
if (!bootstrapData) {
return <div>Loading...</div>
}
return (
<div className="panel-content">
<UnitSelection />
<ModelStats />
<DefenceModifiers />
<UnitKeywords />
</div>
)
}
export default DefencePanel

View file

@ -0,0 +1,95 @@
import { createSlice } from '@reduxjs/toolkit'
import { UnitProfile, ModelProfile, DefenceModifiers } from '../../types'
import type { PayloadAction } from '@reduxjs/toolkit'
export interface DefencePanelState {
selectedUnit?: UnitProfile
factionFilter: string
models: ModelProfile[]
keywords: string[]
modifiers: DefenceModifiers
}
const initialState: DefencePanelState = {
selectedUnit: undefined,
factionFilter: '',
models: [],
keywords: [],
modifiers: {
hasStealth: false,
inCover: false,
visible: true,
inRapidFireRange: true,
inMeltaRange: true,
damageReductionModifier: 0,
apReduction: 0
}
}
type ModelConfigurationUpdate = Partial<Pick<ModelProfile, 'bodies' | 'toughness' | 'normalSave' | 'invulnerableSave' | 'wounds'>>
type DefenceConfigurationUpdate = {
modifiers?: Partial<DefencePanelState['modifiers']>
}
export const defencePanelSlice = createSlice({
name: 'defencePanel',
initialState,
reducers: {
setFactionFilter: (state, action: PayloadAction<string>) => {
state.factionFilter = action.payload
},
selectUnit: (state, action: PayloadAction<UnitProfile>) => {
state.selectedUnit = action.payload
state.models = action.payload.models?.map((model, index) => ({
...model,
selected: index === 0
})) || []
state.keywords = action.payload.unitKeywords || []
},
updateModelConfiguration: (state, action: PayloadAction<{
modelIndex: number
updates: ModelConfigurationUpdate | { selected: boolean }
}>) => {
const { modelIndex, updates } = action.payload
if ('selected' in updates && updates.selected) {
// Handle model selection (radio button behavior)
state.models.forEach((model, index) => {
model.selected = index === modelIndex
})
} else {
// Handle other model stat updates
if (state.models[modelIndex]) {
Object.assign(state.models[modelIndex], updates)
}
}
},
updateDefenceConfiguration: (state, action: PayloadAction<DefenceConfigurationUpdate>) => {
const { modifiers } = action.payload
// Update modifiers if provided
if (modifiers) {
state.modifiers = { ...state.modifiers, ...modifiers }
}
},
toggleKeyword: (state, action: PayloadAction<string>) => {
const keyword = action.payload
if (state.keywords.includes(keyword)) {
state.keywords = state.keywords.filter(k => k !== keyword)
} else {
state.keywords.push(keyword)
}
}
}
})
export const {
setFactionFilter,
selectUnit,
updateModelConfiguration,
updateDefenceConfiguration,
toggleKeyword,
} = defencePanelSlice.actions
export default defencePanelSlice.reducer

View file

@ -0,0 +1,116 @@
import React, { useCallback } from 'react'
import { useAppSelector, useAppDispatch } from '../../store/hooks'
import { updateModelConfiguration } from './DefencePanelSlice'
const ModelStats: React.FC = React.memo(() => {
const dispatch = useAppDispatch()
const { selectedUnit, models } = useAppSelector((state) => state.defencePanel)
const handleModelStatUpdate = useCallback((modelIndex: number, field: string, value: string | number | boolean) => {
if (field === 'selected') {
dispatch(updateModelConfiguration({
modelIndex,
updates: { selected: value }
}))
} else {
const numericValue = ['bodies', 'toughness', 'normalSave', 'invulnerableSave', 'wounds'].includes(field)
? parseInt(value as string) || value
: value
dispatch(updateModelConfiguration({
modelIndex,
updates: { [field]: numericValue }
}))
}
}, [dispatch])
if (!selectedUnit || !models.length) {
return null
}
return (
<div className="models-stats">
<h3>Model Statistics</h3>
<table>
<thead>
<tr>
<th>Model</th>
<th>Select</th>
<th>Bodies</th>
<th>Toughness</th>
<th>Normal Save</th>
<th>Invuln Save</th>
<th>Wounds</th>
</tr>
</thead>
<tbody>
{models.map((model, index) => (
<tr key={model.id || index}>
<td>{model.name}</td>
<td>
<input
type="radio"
name="selectedModel"
checked={model.selected || false}
onChange={(e) => handleModelStatUpdate(index, 'selected', e.target.checked)}
/>
</td>
<td>
<input
type="number"
name={`model_${index}_bodies`}
value={model.bodies || 1}
onChange={(e) => handleModelStatUpdate(index, 'bodies', e.target.value)}
min="1"
/>
</td>
<td>
<input
type="number"
name={`model_${index}_toughness`}
value={model.toughness || 4}
onChange={(e) => handleModelStatUpdate(index, 'toughness', e.target.value)}
min="1"
max="12"
/>
</td>
<td>
<input
type="number"
name={`model_${index}_normalSave`}
value={model.normalSave || 3}
onChange={(e) => handleModelStatUpdate(index, 'normalSave', e.target.value)}
min="2"
max="6"
/>
</td>
<td>
<input
type="number"
name={`model_${index}_invulnerableSave`}
value={model.invulnerableSave || 7}
onChange={(e) => handleModelStatUpdate(index, 'invulnerableSave', e.target.value)}
min="2"
max="7"
/>
</td>
<td>
<input
type="number"
name={`model_${index}_wounds`}
value={model.wounds || 1}
onChange={(e) => handleModelStatUpdate(index, 'wounds', e.target.value)}
min="1"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
})
ModelStats.displayName = 'ModelStats'
export default ModelStats

View file

@ -0,0 +1,35 @@
import React from 'react'
import { useAppSelector, useAppDispatch } from '../../store/hooks'
import { toggleKeyword } from './DefencePanelSlice'
import { getDisplayName } from '../../utils/formatUtils'
const UnitKeywords: React.FC = () => {
const dispatch = useAppDispatch()
const { keywords } = useAppSelector((state) => state.defencePanel)
const { bootstrapData } = useAppSelector((state) => state.app)
if (!bootstrapData) {
return null
}
return (
<div className="unit-keywords">
<h3>Unit Keywords</h3>
<div className="keywords-grid">
{bootstrapData.unitKeywords?.filter(keyword => keyword.toLowerCase() !== 'all').map(keyword => (
<label key={keyword}>
<input
type="checkbox"
name={`keyword_${keyword}`}
checked={keywords.includes(keyword)}
onChange={() => dispatch(toggleKeyword(keyword))}
/>
{getDisplayName(keyword)}
</label>
)) || []}
</div>
</div>
)
}
export default UnitKeywords

View file

@ -0,0 +1,71 @@
import React from 'react'
import { useAppSelector, useAppDispatch } from '../../store/hooks'
import { setFactionFilter, selectUnit } from './DefencePanelSlice'
import SearchableSelect, { SearchableSelectOption } from '../SearchableSelect'
const UnitSelection: React.FC = () => {
const dispatch = useAppDispatch()
const { factionFilter, selectedUnit } = useAppSelector((state) => state.defencePanel)
const { bootstrapData } = useAppSelector((state) => state.app)
if (!bootstrapData) {
return <div>Loading...</div>
}
const filteredUnits = bootstrapData.unitProfiles?.filter(unit =>
!factionFilter || unit.faction === factionFilter
) || []
const factions = [...new Set(bootstrapData.unitProfiles?.map(unit => unit.faction) || [])].sort()
const factionOptions: SearchableSelectOption[] = [
{ value: '', label: 'All Factions' },
...factions.map(faction => ({ value: faction, label: faction }))
]
const unitOptions: SearchableSelectOption[] = [
{ value: '', label: 'Select Unit' },
...filteredUnits.sort((a, b) => a.name.localeCompare(b.name)).map(unit => ({
value: unit.id,
label: unit.name
}))
]
const handleUnitSelect = (unitId: string) => {
const unit = filteredUnits.find(u => u.id === unitId)
if (unit) {
dispatch(selectUnit(unit))
}
}
return (
<div className="unit-selection">
<h3>Defending Unit</h3>
<div className="filters">
<label>
Faction:
<SearchableSelect
options={factionOptions}
value={factionFilter}
onChange={(value) => dispatch(setFactionFilter(value))}
placeholder="Search factions..."
/>
</label>
</div>
<div className="unit-select">
<label>
Unit:
<SearchableSelect
options={unitOptions}
value={selectedUnit?.id || ''}
onChange={handleUnitSelect}
placeholder="Search units..."
/>
</label>
</div>
</div>
)
}
export default UnitSelection

View file

@ -1,5 +1,7 @@
import React from 'react'
import Modal from './Modal'
import Modal from '../Modal'
import { useAppSelector, useAppDispatch } from '../../store/hooks'
import { toggleFutureFeaturesModal } from './OptionsPanelSlice'
interface FutureFeature {
title: string
@ -10,20 +12,21 @@ interface FutureFeaturesData {
features: FutureFeature[]
}
interface FutureFeaturesModalProps {
isOpen: boolean
onClose: () => void
}
const FutureFeaturesModal: React.FC<FutureFeaturesModalProps> = ({ isOpen, onClose }) => {
const FutureFeaturesModal: React.FC = () => {
const futureFeaturesData: FutureFeaturesData = import.meta.env.VITE_FUTURE_FEATURES
const isOpen = useAppSelector((state)=>state.optionsPanel.futureFeaturesModalOpened)
const dispatch = useAppDispatch()
const onClose = () => {
dispatch(toggleFutureFeaturesModal(false))
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Future Features"
message=""
widthPercentage={80}
heightPercentage={80}
>

View file

@ -1,5 +1,7 @@
import React from 'react'
import Modal from './Modal'
import Modal from '../Modal'
import { useAppSelector, useAppDispatch } from '../../store/hooks'
import { toggleKnownBugsModal } from './OptionsPanelSlice'
interface KnownBug {
title: string
@ -10,20 +12,20 @@ interface KnownBugsData {
knownBugs: KnownBug[]
}
interface KnownBugsModalProps {
isOpen: boolean
onClose: () => void
}
const KnownBugsModal: React.FC = () => {
const knownBugsData: KnownBugsData = import.meta.env.VITE_KNOWN_BUGS
const isOpen = useAppSelector((state)=>state.optionsPanel.knownBugsModalOpened)
const dispatch = useAppDispatch()
const KnownBugsModal: React.FC<KnownBugsModalProps> = ({ isOpen, onClose }) => {
const knownBugsData: KnownBugsData = import.meta.env.VITE_KNOWN_BUGS;
const onClose = () => {
dispatch(toggleKnownBugsModal(false))
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Known Bugs"
message=""
widthPercentage={80}
heightPercentage={80}
>
@ -47,4 +49,4 @@ const KnownBugsModal: React.FC<KnownBugsModalProps> = ({ isOpen, onClose }) => {
)
}
export default KnownBugsModal;
export default KnownBugsModal

View file

@ -0,0 +1,35 @@
import React from 'react'
import { useAppDispatch } from '../../store/hooks'
import { toggleFutureFeaturesModal, toggleReleaseNotesModal, toggleKnownBugsModal } from './OptionsPanelSlice'
import ThemeSwitcher from './ThemeSwitcher'
import ReleaseNotesModal from './ReleaseNotesModal'
import FutureFeaturesModal from './FutureFeaturesModal'
import KnownBugsModal from './KnownBugsModal'
const OptionsPanel: React.FC = () => {
const dispatch = useAppDispatch()
return (
<>
<div className="options-content">
<div className="options-left">
<ThemeSwitcher />
</div>
<div className="options-right">
<div className="metadata-section">
<button className="metadata-btn" onClick={() => dispatch(toggleKnownBugsModal(true))}>Known Bugs</button>
<button className="metadata-btn" onClick={() => dispatch(toggleReleaseNotesModal(true))}>Release Notes</button>
<button className="metadata-btn" onClick={() => dispatch(toggleFutureFeaturesModal(true))}>Future Features</button>
</div>
</div>
</div>
<ReleaseNotesModal />
<FutureFeaturesModal />
<KnownBugsModal />
</>
)
}
export default OptionsPanel

View file

@ -0,0 +1,54 @@
import { Middleware, createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '../../store/store'
export type ThemeType = 'light' | 'cadian' | 'necrons' | 'bloodangels' | 'orks' | 'blacktemplars' | 'tyranids'
interface OptionsPanelState {
selectedTheme: ThemeType,
futureFeaturesModalOpened: boolean,
knownBugsModalOpened: boolean,
releaseNotesModalOpened: boolean,
}
const initialState: OptionsPanelState = {
selectedTheme: (localStorage.getItem('theme') as ThemeType) || 'light',
futureFeaturesModalOpened: false,
knownBugsModalOpened: false,
releaseNotesModalOpened: false
}
export const optionsPanelSlice = createSlice({
name: 'optionsPanel',
initialState,
reducers: {
switchTheme: (state, action: PayloadAction<ThemeType>) => {
state.selectedTheme = action.payload
},
toggleFutureFeaturesModal: (state, action: PayloadAction<boolean>) => {
state.futureFeaturesModalOpened = action.payload
},
toggleReleaseNotesModal: (state, action: PayloadAction<boolean>) => {
state.releaseNotesModalOpened = action.payload
},
toggleKnownBugsModal: (state, action: PayloadAction<boolean>) => {
state.knownBugsModalOpened = action.payload
},
}
})
export const { switchTheme, toggleFutureFeaturesModal, toggleReleaseNotesModal, toggleKnownBugsModal } = optionsPanelSlice.actions
export const themeMiddleware: Middleware<Record<string, never>, RootState> = () => (next) => (action) => {
const result = next(action)
if (switchTheme.match(action)) {
const themeAction = action as ReturnType<typeof switchTheme>
document.body.classList = themeAction.payload
localStorage.setItem('theme', themeAction.payload)
}
return result
};
export default optionsPanelSlice.reducer

View file

@ -1,5 +1,7 @@
import React from 'react'
import Modal from './Modal'
import Modal from '../Modal'
import { useAppSelector, useAppDispatch } from '../../store/hooks'
import { toggleReleaseNotesModal } from './OptionsPanelSlice'
interface ReleaseNote {
type: 'feature' | 'enhancement' | 'bugfix'
@ -17,30 +19,31 @@ interface ReleaseNotesData {
releases: Release[]
}
interface ReleaseNotesModalProps {
isOpen: boolean
onClose: () => void
}
const ReleaseNotesModal: React.FC = () => {
const releaseNotesData: ReleaseNotesData = import.meta.env.VITE_RELEASE_NOTES
const isOpen = useAppSelector((state)=>state.optionsPanel.releaseNotesModalOpened)
const dispatch = useAppDispatch()
const ReleaseNotesModal: React.FC<ReleaseNotesModalProps> = ({ isOpen, onClose }) => {
const releaseNotesData: ReleaseNotesData = import.meta.env.VITE_RELEASE_NOTES
const onClose = () => {
dispatch(toggleReleaseNotesModal(false))
}
const getTypeIcon = (type: string) => {
switch (type) {
case 'feature': return '✨'
case 'enhancement': return '⚡'
case 'bugfix': return '🐛'
default: return '📝'
}
}
const getTypeIcon = (type: string) => {
switch (type) {
case 'feature': return '✨'
case 'enhancement': return '⚡'
case 'bugfix': return '🐛'
default: return '📝'
}
}
const getCategoryColor = (category: string) => {
switch (category) {
case 'frontend': return '#4CAF50'
case 'backend': return '#2196F3'
default: return '#757575'
}
}
const getCategoryColor = (category: string) => {
switch (category) {
case 'frontend': return '#4CAF50'
case 'backend': return '#2196F3'
default: return '#757575'
}
}
return (
<Modal

View file

@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { useAppSelector, useAppDispatch } from '../../store/hooks'
import { RootState } from '../../store/store'
import { switchTheme, ThemeType } from './OptionsPanelSlice'
const ThemeSwitcher: React.FC = () => {
const dispatch = useAppDispatch()
const selectedTheme = useAppSelector((state: RootState) => state.optionsPanel.selectedTheme)
useEffect(() => {
document.body.className = selectedTheme;
}, [selectedTheme]);
return (
<div className="theme-section">
<label htmlFor="theme-select">Theme:</label>
<select
id="theme-select"
value={selectedTheme || 'light'}
onChange={(e) => dispatch(switchTheme(e.target.value as ThemeType))}
>
<option value="cadian">Astra Militarum</option>
<option value="blacktemplars">Black Templars</option>
<option value="bloodangels">Blood Angels</option>
<option value="light">Light</option>
<option value="necrons">Necrons</option>
<option value="orks">Orks</option>
<option value="tyranids">Tyranids</option>
</select>
</div>
)
}
export default ThemeSwitcher

View file

@ -8,7 +8,7 @@ interface AttackPipelineCardProps {
index: number
}
const AttackPipelineCard: React.FC<AttackPipelineCardProps> = ({ weaponStat, index }) => {
const AttackPipelineCard: React.FC<AttackPipelineCardProps> = ({ weaponStat }) => {
const [activeBreakdown, setActiveBreakdown] = useState<string>('attacks')
const pipelineStages = [
{

View file

@ -0,0 +1,147 @@
import React from 'react'
import { useAppSelector, useAppDispatch } from '../../store/hooks'
import { calculateDamage } from '../../services/backendApi'
import { AttackScenario, AttackModifiers, DiceExpression } from '../../types'
import { parseDiceExpression } from '../../utils/diceExpressionUtils'
import SummaryHeader from './SummaryHeader'
import AttackPipelineCard from './AttackPipelineCard'
import {
startCalculation,
calculationSuccess,
calculationFailure,
} from './ResultsPanelSlice'
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ResultsPanelProps {}
const ResultsPanel: React.FC<ResultsPanelProps> = () => {
const dispatch = useAppDispatch()
const { results, loading, error } = useAppSelector((state) => state.resultsPanel)
const attackState = useAppSelector((state) => state.attackPanel)
const defenceState = useAppSelector((state) => state.defencePanel)
const { validationErrors } = attackState
// Helper function to parse dice expressions from strings or pass through objects
const parseDiceExpressionValue = (value: string | DiceExpression) => {
if (typeof value !== 'string' || !value || value.trim() === '') return value
return parseDiceExpression(value) || value
}
const handleCalculate = async () => {
dispatch(startCalculation())
try {
if (!defenceState.selectedUnit) {
throw new Error('No defending unit selected')
}
// Create attack modifiers from Redux state
const attackModifiers: AttackModifiers = {
...attackState.modifiers,
}
// Filter target unit to only include the selected model and remove UI-only properties
const selectedModel = defenceState.models?.find(model => model.selected)
const cleanModel = selectedModel || defenceState.selectedUnit.models?.[0]
// Remove UI-only properties that backend doesn't expect
const { selected, ...cleanModelData } = cleanModel || {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _ = selected // Keep for destructuring but mark as intentionally unused
// Create defence modifiers from Redux state
const defenceModifiers = {
...defenceState.modifiers,
}
const targetUnit = {
...defenceState.selectedUnit,
models: cleanModelData ? [cleanModelData] : [],
defenceModifiers: defenceModifiers
}
// Process weapons to convert string attacks and damage to DiceExpression objects
const processedWeapons = (attackState.weapons || []).map(weaponCount => ({
...weaponCount,
weapon: {
...weaponCount.weapon,
attacks: parseDiceExpressionValue(weaponCount.weapon.attacks),
damage: parseDiceExpressionValue(weaponCount.weapon.damage)
}
}))
// Create attack scenario matching backend DTO structure
const attackScenario: AttackScenario = {
weapons: processedWeapons,
target: targetUnit,
attackModifiers: attackModifiers
}
const result = await calculateDamage(attackScenario)
dispatch(calculationSuccess(result))
} catch (err) {
dispatch(calculationFailure(err instanceof Error ? err.message : 'An error occurred'))
}
}
const canCalculate = () => {
const hasRequiredData = defenceState.selectedUnit &&
(attackState.weapons?.length || 0) > 0
const hasValidationErrors = validationErrors && validationErrors.size > 0
return hasRequiredData && !hasValidationErrors
}
return (
<div className="panel-content">
<div className="calculate-section">
<button
onClick={handleCalculate}
disabled={!canCalculate() || loading}
className="calculate-btn"
>
{loading ? 'Calculating...' : 'Calculate'}
</button>
{!canCalculate() && (
<div className="warning">
{validationErrors && validationErrors.size > 0 ? (
<p>Invalid dice expression in: {Array.from(validationErrors).join(', ')}</p>
) : (
<p>Please add at least one weapon and select defending unit</p>
)}
</div>
)}
</div>
{error && (
<div className="error-section">
<h3>Error</h3>
<p className="error-message">{error}</p>
</div>
)}
{results && (
<div className="results-section">
<SummaryHeader results={results} />
{results.weaponAttacksStats && results.weaponAttacksStats.length > 0 && (
<div className="weapons-pipeline">
<h4>Weapon Combat Resolution</h4>
<div className="pipeline-cards">
{results.weaponAttacksStats.map((weaponStat, index) => (
<AttackPipelineCard
key={index}
weaponStat={weaponStat}
index={index}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
export default ResultsPanel

View file

@ -0,0 +1,60 @@
import { createSlice } from '@reduxjs/toolkit'
import { AttackResolution } from '../../types'
import type { PayloadAction } from '@reduxjs/toolkit'
export interface ResultsPanelState {
results: AttackResolution | null
loading: boolean
error: string | null
}
const initialState: ResultsPanelState = {
results: null,
loading: false,
error: null
}
export const resultsPanelSlice = createSlice({
name: 'resultsPanel',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload
},
setResults: (state, action: PayloadAction<AttackResolution | null>) => {
state.results = action.payload
},
clearResults: (state) => {
state.results = null
state.error = null
},
startCalculation: (state) => {
state.loading = true
state.error = null
},
calculationSuccess: (state, action: PayloadAction<AttackResolution>) => {
state.loading = false
state.results = action.payload
state.error = null
},
calculationFailure: (state, action: PayloadAction<string>) => {
state.loading = false
state.error = action.payload
}
}
})
export const {
setLoading,
setError,
setResults,
clearResults,
startCalculation,
calculationSuccess,
calculationFailure,
} = resultsPanelSlice.actions
export default resultsPanelSlice.reducer

View file

@ -1,9 +1,5 @@
{
"features": [
{
"title": "Correctly parse Astra Militarum data",
"description": "We do not currently parse any data from the Astra Militarum faction due to how different the BS Data Structure is for this catalogue"
},
{
"title": "Correctly parse unit effects and abilities relevant to the calculation engine",
"description": "Special effects that grant units abilities such as rerolls, invulnerability saves, lethal hits or such other effects against specific units, are not correctly parsed due to the difficulty of parsing natural language that describe them. Fix that with an LLM at catalogue parsing time on the backend."

View file

@ -1,17 +1,9 @@
{
"knownBugs": [
{
"title": "Astra Militarum Catalogue not parseable",
"description": "Astra Militarum Battlescribe catalogue is not parseable by the current backend, and it is not available until it becomes so."
},
{
"title": "Tyranid units incorrectly parsed",
"description": "The following tyranid units have not been correctly parsed: Termagants"
},
{
"title": "Adepta Sororitas Catalogue not parseable",
"description": "Adepta Sororitas Battlescribe catalogue is not parseable by the current backend, and it is not available until it becomes so."
},
{
"title": "Mechanicus Catalogue not parseable",
"description": "Mechanicus Battlescribe catalogue is not parseable by the current backend, and it is not available until it becomes so."

View file

@ -1,5 +1,27 @@
{
"releases": [
{
"version": "1.8.1",
"date": "2025-09-01",
"changes": [
{
"type": "bugfix",
"category": "backend",
"description": "Fixed catalogue for Astra Militarum and Adepta Sororitas. Those catalogues are now usable to search for units and weapons"
}
]
},
{
"version": "1.8.0",
"date": "2025-08-26",
"changes": [
{
"type": "feature",
"category": "backend",
"description": "Greatly improved backend and frontend stability and maintainability by switching to react redux for state management. This doesn't affect directly user features, but makes development of new ones easier and faster"
}
]
},
{
"version": "1.7.1",
"date": "2025-08-23",

View file

@ -0,0 +1,32 @@
import { useCallback } from 'react'
import { useAppSelector, useAppDispatch } from '../store/hooks'
import { updateDefenceConfiguration } from '../components/defencePanel/DefencePanelSlice'
export const useDefenceModifierState = () => {
const dispatch = useAppDispatch()
const defenceState = useAppSelector((state) => state.defencePanel)
const { bootstrapData } = useAppSelector((state) => state.app)
const handleUpdateConfiguration = useCallback((updates: Parameters<typeof updateDefenceConfiguration>[0]['payload']) => {
dispatch(updateDefenceConfiguration(updates))
}, [dispatch])
const handleUpdateModifier = useCallback((field: string, value: string | number | boolean | undefined) => {
dispatch(updateDefenceConfiguration({
modifiers: { [field]: value }
}))
}, [dispatch])
return {
defenceState,
bootstrapData,
handleUpdateConfiguration,
handleUpdateModifier,
// Destructure commonly used fields for convenience
modifiers: defenceState.modifiers,
models: defenceState.models,
selectedUnit: defenceState.selectedUnit,
factionFilter: defenceState.factionFilter,
keywords: defenceState.keywords
}
}

View file

@ -0,0 +1,15 @@
import { useCallback } from 'react'
import { useAppSelector, useAppDispatch } from '../store/hooks'
import { updateModifier } from '../components/attackPanel/AttackPanelSlice'
export const useModifierState = () => {
const dispatch = useAppDispatch()
const { modifiers } = useAppSelector((state) => state.attackPanel)
const { bootstrapData } = useAppSelector((state) => state.app)
const handleUpdateModifier = useCallback((field: string, value: string | number | boolean | undefined) => {
dispatch(updateModifier({ field, value }))
}, [dispatch])
return { modifiers, bootstrapData, handleUpdateModifier }
}

View file

@ -1,5 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './store/store'
import App from './App'
const rootElement = document.getElementById('root')
@ -7,6 +10,8 @@ if (!rootElement) throw new Error('Root element not found')
createRoot(rootElement).render(
<StrictMode>
<App />
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
)

View file

@ -0,0 +1,64 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { BootstrapData } from '../types'
import { loadBootstrapData } from '../services/backendApi'
import type { PayloadAction } from '@reduxjs/toolkit'
export interface AppState {
bootstrapData: BootstrapData | null
loading: boolean
error: string | null
}
const initialState: AppState = {
bootstrapData: null,
loading: false,
error: null
}
// Async thunk for loading bootstrap data
export const loadBootstrapDataThunk = createAsyncThunk(
'app/loadBootstrapData',
async () => {
const data = await loadBootstrapData()
return data
}
)
export const appSlice = createSlice({
name: 'app',
initialState,
reducers: {
setBootstrapData: (state, action: PayloadAction<BootstrapData>) => {
state.bootstrapData = action.payload
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload
}
},
extraReducers: (builder) => {
builder
.addCase(loadBootstrapDataThunk.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(loadBootstrapDataThunk.fulfilled, (state, action) => {
state.loading = false
state.bootstrapData = action.payload
})
.addCase(loadBootstrapDataThunk.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || 'Failed to load bootstrap data'
})
}
})
export const {
setBootstrapData,
setLoading,
setError,
} = appSlice.actions
export default appSlice.reducer

View file

@ -0,0 +1,5 @@
import { useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

View file

@ -0,0 +1,22 @@
import { configureStore } from '@reduxjs/toolkit'
import optionsPanelReducer, { themeMiddleware } from '../components/optionsPanel/OptionsPanelSlice'
import defencePanelReducer from '../components/defencePanel/DefencePanelSlice'
import attackPanelReducer from '../components/attackPanel/AttackPanelSlice'
import resultsPanelReducer from '../components/resultsPanel/ResultsPanelSlice'
import appReducer from './AppSlice'
export const store = configureStore({
reducer: {
app: appReducer,
optionsPanel: optionsPanelReducer,
defencePanel: defencePanelReducer,
attackPanel: attackPanelReducer,
resultsPanel: resultsPanelReducer
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(themeMiddleware),
})
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch

View file

@ -1,5 +1,6 @@
import { QuantityRange, UnitTypeKeyword } from './core';
import { WeaponAmount } from './weapon';
import { QuantityRange, UnitTypeKeyword } from './core'
import { WeaponAmount } from './weapon'
import { AttackModifiers, DefenceModifiers} from './modifiers'
export interface ModelProfile {
id: string;
@ -22,6 +23,6 @@ export interface UnitProfile {
faction: string;
models: ModelProfile[];
unitKeywords: UnitTypeKeyword[];
attackModifiers: import('./modifiers').AttackModifiers;
defenceModifiers: import('./modifiers').DefenceModifiers;
attackModifiers: AttackModifiers;
defenceModifiers: DefenceModifiers;
}

View file

@ -1,23 +1,7 @@
import { AttackData, DefenceData, OptionsData, FormData } from './form-data';
import { BootstrapData } from './bootstrap';
export interface PanelProps<T = any> {
data: T;
updateData: (data: Partial<T>) => void;
}
export interface AttackPanelProps extends PanelProps<AttackData> {
bootstrapData: BootstrapData;
onValidationChange?: (errors: Set<string>) => void;
}
export interface DefencePanelProps extends PanelProps<DefenceData> {
bootstrapData: BootstrapData;
}
export interface OptionsPanelProps extends PanelProps<OptionsData> {}
// Legacy interfaces - kept for potential future use but currently unused
// Consider removing if not needed after Redux migration is complete
export interface ResultsPanelProps {
formData: FormData;
validationErrors?: Set<string>;
}

View file

@ -0,0 +1,60 @@
import { DiceExpression } from '../types'
/**
* Parses a dice expression string into a DiceExpression object
* Supports formats like: "d6", "2d3", "2d3+1", "3"
* @param value - The dice expression string to parse
* @returns DiceExpression object or undefined if parsing fails
*/
export const parseDiceExpression = (value: string): DiceExpression | undefined => {
if (!value || value.trim() === '') return undefined
const pattern = /^(\d*)?[dD](\d+)(\+(\d+))?$|^(\d+)$/
const match = pattern.exec(value.trim())
if (!match) return undefined
if (match[5]) {
// Only flat value like "3"
return { flatValue: parseInt(match[5]) }
} else {
// Dice expression like "d6", "2d3", "2d3+1"
const numberOfDice = match[1] ? parseInt(match[1]) : 1
const diceType = parseInt(match[2])
const flatValue = match[4] ? parseInt(match[4]) : undefined
return {
numberOfDice,
diceType,
...(flatValue && { flatValue })
}
}
}
/**
* Converts a DiceExpression object back to a string format
* @param dice - The DiceExpression object to format
* @returns Formatted dice expression string
*/
export const formatDiceExpression = (dice: DiceExpression): string => {
if (dice.numberOfDice && dice.diceType) {
let result = `${dice.numberOfDice}D${dice.diceType}`
if (dice.flatValue) {
result += `+${dice.flatValue}`
}
return result
} else if (dice.flatValue) {
return dice.flatValue.toString()
}
return ''
}
/**
* Validates if a dice expression string is valid
* @param value - The dice expression string to validate
* @returns True if valid, false otherwise
*/
export const isValidDiceExpression = (value: string): boolean => {
if (!value || value.trim() === '') return true // Empty is considered valid
return parseDiceExpression(value) !== undefined
}

View file

@ -0,0 +1,29 @@
/**
* Converts snake_case or SCREAMING_SNAKE_CASE to Title Case
* @param value - The string to format
* @returns Formatted display name
*/
export const getDisplayName = (value: string): string => {
return value.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ')
}
/**
* Formats a number as a modifier string with sign
* @param value - The numeric value
* @returns Formatted modifier string like "+2" or "-1"
*/
export const formatModifier = (value: number): string => {
if (value === 0) return '0'
return value > 0 ? `${value}+` : value.toString()
}
/**
* Capitalizes the first letter of a string
* @param value - The string to capitalize
* @returns Capitalized string
*/
export const capitalize = (value: string): string => {
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
}