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:
parent
af03a4dc44
commit
4f5bd1f851
61 changed files with 2365 additions and 1725 deletions
|
@ -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>
|
||||
|
|
|
@ -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 + "']");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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.
|
119
web-client-frontend/package-lock.json
generated
119
web-client-frontend/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
|
@ -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>
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
116
web-client-frontend/src/components/defencePanel/ModelStats.tsx
Normal file
116
web-client-frontend/src/components/defencePanel/ModelStats.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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}
|
||||
>
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 = [
|
||||
{
|
147
web-client-frontend/src/components/resultsPanel/ResultsPanel.tsx
Normal file
147
web-client-frontend/src/components/resultsPanel/ResultsPanel.tsx
Normal 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
|
|
@ -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
|
|
@ -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."
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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",
|
||||
|
|
32
web-client-frontend/src/hooks/useDefenceModifierState.ts
Normal file
32
web-client-frontend/src/hooks/useDefenceModifierState.ts
Normal 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
|
||||
}
|
||||
}
|
15
web-client-frontend/src/hooks/useModifierState.ts
Normal file
15
web-client-frontend/src/hooks/useModifierState.ts
Normal 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 }
|
||||
}
|
|
@ -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>,
|
||||
)
|
||||
|
|
64
web-client-frontend/src/store/AppSlice.ts
Normal file
64
web-client-frontend/src/store/AppSlice.ts
Normal 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
|
5
web-client-frontend/src/store/hooks.ts
Normal file
5
web-client-frontend/src/store/hooks.ts
Normal 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>()
|
22
web-client-frontend/src/store/store.ts
Normal file
22
web-client-frontend/src/store/store.ts
Normal 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
|
|
@ -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;
|
||||
}
|
|
@ -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>;
|
||||
}
|
60
web-client-frontend/src/utils/diceExpressionUtils.ts
Normal file
60
web-client-frontend/src/utils/diceExpressionUtils.ts
Normal 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
|
||||
}
|
29
web-client-frontend/src/utils/formatUtils.ts
Normal file
29
web-client-frontend/src/utils/formatUtils.ts
Normal 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()
|
||||
}
|
Loading…
Add table
Reference in a new issue