diff --git a/fission/src/systems/match_mode/DefaultMatchModeConfigs.ts b/fission/src/systems/match_mode/DefaultMatchModeConfigs.ts
index 3619774e6c..43cc173a24 100644
--- a/fission/src/systems/match_mode/DefaultMatchModeConfigs.ts
+++ b/fission/src/systems/match_mode/DefaultMatchModeConfigs.ts
@@ -12,7 +12,7 @@ class DefaultMatchModeConfigs {
teleopTime: 135,
endgameTime: 20,
ignoreRotation: true,
- maxHeight: Infinity,
+ maxHeight: Number.MAX_SAFE_INTEGER,
heightLimitPenalty: 0,
sideMaxExtension: convertFeetToMeters(1.5),
sideExtensionPenalty: 0,
@@ -60,9 +60,9 @@ class DefaultMatchModeConfigs {
teleopTime: 15,
endgameTime: 5,
ignoreRotation: true,
- maxHeight: Infinity,
+ maxHeight: Number.MAX_SAFE_INTEGER,
heightLimitPenalty: 0,
- sideMaxExtension: Infinity,
+ sideMaxExtension: Number.MAX_SAFE_INTEGER,
sideExtensionPenalty: 0,
}
}
@@ -76,9 +76,9 @@ class DefaultMatchModeConfigs {
teleopTime: 135,
endgameTime: 20,
ignoreRotation: true,
- maxHeight: Infinity,
+ maxHeight: Number.MAX_SAFE_INTEGER,
heightLimitPenalty: 2,
- sideMaxExtension: Infinity,
+ sideMaxExtension: Number.MAX_SAFE_INTEGER,
sideExtensionPenalty: 2,
}
}
diff --git a/fission/src/ui/components/StyledComponents.tsx b/fission/src/ui/components/StyledComponents.tsx
index 1280851435..80da91eff9 100644
--- a/fission/src/ui/components/StyledComponents.tsx
+++ b/fission/src/ui/components/StyledComponents.tsx
@@ -37,7 +37,7 @@ import {
import { GiSteeringWheel } from "react-icons/gi"
import { GrConnect } from "react-icons/gr"
import { HiDownload } from "react-icons/hi"
-import { IoCheckmark, IoPencil, IoPeople, IoTrashBin } from "react-icons/io5"
+import { IoCheckmark, IoPencil, IoPeople, IoPlayOutline, IoTrashBin } from "react-icons/io5"
import Label from "./Label"
export class SynthesisIcons {
@@ -63,6 +63,7 @@ export class SynthesisIcons {
public static readonly CONNECT =
public static readonly INFO =
public static readonly BUG =
+ public static readonly PLAY =
/** Large icons: used for icon buttons */
public static readonly DELETE_LARGE =
@@ -75,6 +76,7 @@ export class SynthesisIcons {
public static readonly LEFT_ARROW_LARGE =
public static readonly BUG_LARGE =
public static readonly XMARK_LARGE =
+ public static readonly PLAY_LARGE =
public static readonly OPEN_HUD_ICON = (
boolean
+ message: string
+}
+
+interface FormField {
+ value: T
+ error: boolean
+ errorText: string
+ rules: ValidationRule[]
+}
+
+type FormState = Record
+
+type FieldConfig = {
+ defaultValue: string | number | boolean
+ rules: ValidationRule[]
+ type?: "text" | "number" | "decimal" | "checkbox"
+}
+
+// Validation rules
+const VALIDATION_RULES = {
+ required: (message = "This field is required"): ValidationRule => ({
+ validate: (value: unknown) => {
+ if (typeof value === "string") return value.trim() !== ""
+ return value != null
+ },
+ message,
+ }),
+
+ nonNegativeInteger: (message = "Must be a non-negative whole number"): ValidationRule => ({
+ validate: (value: unknown) => {
+ if (typeof value !== "string") return false
+ const num = parseInt(value, 10)
+ return !isNaN(num) && num >= 0 && Number.isInteger(num) && value === num.toString()
+ },
+ message,
+ }),
+
+ nonNegativeNumber: (message = "Must be a non-negative number"): ValidationRule => ({
+ validate: (value: unknown) => {
+ if (typeof value !== "string") return false
+ const num = parseFloat(value)
+ return !isNaN(num) && num >= 0
+ },
+ message,
+ }),
+}
+
+const fallbackConfig = DefaultMatchModeConfigs.fallbackValues()
+
+// Field configurations
+const FIELD_CONFIGS: Record = {
+ name: {
+ defaultValue: "Input Config Name",
+ rules: [VALIDATION_RULES.required("Name is required")],
+ type: "text",
+ },
+ autonomousTime: {
+ defaultValue: fallbackConfig.autonomousTime,
+ rules: [VALIDATION_RULES.nonNegativeInteger("Autonomous time must be a non-negative whole number")],
+ type: "number",
+ },
+ teleopTime: {
+ defaultValue: fallbackConfig.teleopTime,
+ rules: [VALIDATION_RULES.nonNegativeInteger("Teleop time must be a non-negative whole number")],
+ type: "number",
+ },
+ endgameTime: {
+ defaultValue: fallbackConfig.endgameTime,
+ rules: [VALIDATION_RULES.nonNegativeInteger("Endgame time must be a non-negative whole number")],
+ type: "number",
+ },
+ ignoreRotation: {
+ defaultValue: fallbackConfig.ignoreRotation,
+ rules: [],
+ type: "checkbox",
+ },
+ enableHeightPenalty: {
+ defaultValue: false,
+ rules: [],
+ type: "checkbox",
+ },
+ maxHeight: {
+ defaultValue: fallbackConfig.maxHeight === Number.MAX_SAFE_INTEGER ? 1.2 : fallbackConfig.maxHeight,
+ rules: [VALIDATION_RULES.nonNegativeNumber("Max height must be a non-negative number")],
+ type: "decimal",
+ },
+ heightLimitPenalty: {
+ defaultValue: fallbackConfig.heightLimitPenalty === 0 ? 2 : fallbackConfig.heightLimitPenalty,
+ rules: [VALIDATION_RULES.nonNegativeInteger("Height penalty must be a non-negative whole number")],
+ type: "number",
+ },
+ enableSideExtensionPenalty: {
+ defaultValue: false,
+ rules: [],
+ type: "checkbox",
+ },
+ sideMaxExtension: {
+ defaultValue:
+ fallbackConfig.sideMaxExtension === Number.MAX_SAFE_INTEGER ? 0.5 : fallbackConfig.sideMaxExtension,
+ rules: [VALIDATION_RULES.nonNegativeNumber("Side max extension must be a non-negative number")],
+ type: "decimal",
+ },
+ sideExtensionPenalty: {
+ defaultValue: fallbackConfig.sideExtensionPenalty === 0 ? 2 : fallbackConfig.sideExtensionPenalty,
+ rules: [VALIDATION_RULES.nonNegativeInteger("Side extension penalty must be a non-negative whole number")],
+ type: "number",
+ },
+}
+
+// Initial form state factory
+const createInitialFormState = (): FormState => {
+ const formState: FormState = {}
+
+ Object.entries(FIELD_CONFIGS).forEach(([fieldName, config]) => {
+ // Preserve boolean values for checkbox fields, convert others to strings
+ let value: unknown
+ if (config.type === "checkbox") {
+ value = config.defaultValue
+ } else {
+ value = typeof config.defaultValue === "string" ? config.defaultValue : config.defaultValue.toString()
+ }
+
+ formState[fieldName] = {
+ value,
+ error: false,
+ errorText: "",
+ rules: config.rules,
+ }
+ })
+
+ return formState
+}
+
+const CreateNewMatchModeConfigPanel: React.FC> = ({ panel }) => {
+ const { configureScreen, openPanel, closePanel } = useUIContext()
+ const [formState, setFormState] = useState(createInitialFormState)
+
+ const validateField = useCallback((field: FormField, value: unknown): { error: boolean; errorText: string } => {
+ for (const rule of field.rules) {
+ if (!rule.validate(value)) {
+ return { error: true, errorText: rule.message }
+ }
+ }
+ return { error: false, errorText: "" }
+ }, [])
+
+ const updateField = useCallback(
+ (fieldName: string, value: unknown) => {
+ setFormState(prev => {
+ const field = prev[fieldName]
+ const validation = validateField(field, value)
+
+ return {
+ ...prev,
+ [fieldName]: {
+ ...field,
+ value,
+ error: validation.error,
+ errorText: validation.errorText,
+ },
+ }
+ })
+ },
+ [validateField]
+ )
+
+ const handleFieldChange = useCallback(
+ (fieldName: string, isCheckbox = false) =>
+ (event: React.ChangeEvent) => {
+ const value = isCheckbox ? event.target.checked : event.target.value
+ updateField(fieldName, value)
+ },
+ [updateField]
+ )
+
+ // Prevent non-integer input for number fields
+ const preventNonIntegerKeys = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === "." || e.key === "-" || e.key === "+" || e.key === "e" || e.key === "E") {
+ e.preventDefault()
+ }
+ }, [])
+
+ const isFormValid = useCallback(() => {
+ return Object.values(formState).every(field => !field.error)
+ }, [formState])
+
+ const renderField = useCallback(
+ (fieldName: string, label: string, helperText?: string, conditionalOn?: string) => {
+ const field = formState[fieldName]
+ const config = FIELD_CONFIGS[fieldName]
+
+ // Check if this field should be conditionally rendered
+ if (conditionalOn && !formState[conditionalOn]?.value) {
+ return null
+ }
+
+ if (config.type === "checkbox") {
+ return (
+ updateField(fieldName, value)}
+ label={""}
+ />
+ }
+ label={label}
+ />
+ )
+ }
+
+ const isNumber = config.type === "number"
+ const isDecimal = config.type === "decimal"
+ const isNumericInput = isNumber || isDecimal
+
+ return (
+
+ )
+ },
+ [formState, handleFieldChange, preventNonIntegerKeys, updateField]
+ )
+
+ const createConfigFromForm = useCallback((): MatchModeConfig => {
+ return {
+ id: crypto.randomUUID(),
+ name: (formState.name.value as string).trim(),
+ isDefault: false,
+ autonomousTime: parseInt(formState.autonomousTime.value as string, 10),
+ teleopTime: parseInt(formState.teleopTime.value as string, 10),
+ endgameTime: parseInt(formState.endgameTime.value as string, 10),
+ ignoreRotation: formState.ignoreRotation.value as boolean,
+ maxHeight: formState.enableHeightPenalty.value
+ ? parseFloat(formState.maxHeight.value as string)
+ : Number.MAX_SAFE_INTEGER,
+ heightLimitPenalty: formState.enableHeightPenalty.value
+ ? parseFloat(formState.heightLimitPenalty.value as string)
+ : 0,
+ sideMaxExtension: formState.enableSideExtensionPenalty.value
+ ? parseFloat(formState.sideMaxExtension.value as string)
+ : Number.MAX_SAFE_INTEGER,
+ sideExtensionPenalty: formState.enableSideExtensionPenalty.value
+ ? parseFloat(formState.sideExtensionPenalty.value as string)
+ : 0,
+ }
+ }, [formState])
+
+ const downloadConfig = useCallback(() => {
+ const config = createConfigFromForm()
+
+ // Create a blob with the JSON data
+ const jsonString = JSON.stringify(config, null, 2)
+ const blob = new Blob([jsonString], { type: "application/json" })
+
+ // Create a download link
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `${config.name.replace(/[^a-z0-9]/gi, "_").toLowerCase()}_match_mode_config.json`
+ document.body.appendChild(link)
+ link.click()
+
+ // Cleanup
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+ }, [createConfigFromForm])
+
+ useEffect(() => {
+ configureScreen(
+ panel!,
+ {
+ title: "Create New Match Mode Config",
+ cancelText: "Cancel",
+ acceptText: "Save",
+ hideAccept: !isFormValid(),
+ },
+ {
+ onBeforeAccept: () => {
+ const config = createConfigFromForm()
+ const validatedConfig = validateAndNormalizeMatchModeConfig(config)
+
+ if (!validatedConfig) {
+ return "Validation failed"
+ }
+
+ const customConfigs = window.localStorage.getItem("match-mode-configs")
+ if (customConfigs) {
+ const customConfigsArray = JSON.parse(customConfigs)
+ customConfigsArray.push(validatedConfig)
+ window.localStorage.setItem("match-mode-configs", JSON.stringify(customConfigsArray))
+ } else {
+ window.localStorage.setItem("match-mode-configs", JSON.stringify([validatedConfig]))
+ }
+
+ setTimeout(async () => {
+ const { default: MatchModeConfigPanelComponent } = await import("./MatchModeConfigPanel")
+ openPanel(MatchModeConfigPanelComponent, undefined)
+ closePanel(panel!.id, CloseType.Overwrite)
+ }, 0)
+
+ return validatedConfig
+ },
+ }
+ )
+ }, [isFormValid, createConfigFromForm, configureScreen, panel, openPanel, closePanel])
+
+ // Field groups for organized rendering
+ const fieldGroups: Array<{
+ title: string
+ fields: Array<{
+ name: string
+ label: string
+ helperText?: string
+ conditionalOn?: string
+ }>
+ }> = [
+ {
+ title: "Basic Configuration",
+ fields: [{ name: "name", label: "Configuration Name" }],
+ },
+ {
+ title: "Timing Configuration",
+ fields: [
+ { name: "autonomousTime", label: "Autonomous Time (seconds)" },
+ { name: "teleopTime", label: "Teleop Time (seconds)" },
+ { name: "endgameTime", label: "Endgame Time (seconds)" },
+ ],
+ },
+ {
+ title: "Robot Constraints",
+ fields: [
+ { name: "ignoreRotation", label: "Ignore Robot Rotation for Height Calculations" },
+ { name: "enableHeightPenalty", label: "Enable Height Penalty" },
+ {
+ name: "maxHeight",
+ label: "Maximum Height (meters)",
+ helperText: "Height limit for the robot",
+ conditionalOn: "enableHeightPenalty",
+ },
+ {
+ name: "heightLimitPenalty",
+ label: "Height Penalty (points)",
+ conditionalOn: "enableHeightPenalty",
+ },
+ { name: "enableSideExtensionPenalty", label: "Enable Side Extension Penalty" },
+ {
+ name: "sideMaxExtension",
+ label: "Side Max Extension (meters)",
+ helperText: "Maximum side extension limit for the robot",
+ conditionalOn: "enableSideExtensionPenalty",
+ },
+ {
+ name: "sideExtensionPenalty",
+ label: "Side Extension Penalty (points)",
+ conditionalOn: "enableSideExtensionPenalty",
+ },
+ ],
+ },
+ ]
+
+ return (
+
+
+ {fieldGroups.map((group, groupIndex) => (
+
+
+
+ {group.title}
+
+ {group.fields.map(field =>
+ renderField(field.name, field.label, field.helperText, field.conditionalOn)
+ )}
+
+ {groupIndex < fieldGroups.length - 1 && }
+
+ ))}
+
+
+
+ {/* Download Button */}
+
+
+
+
+
+ )
+}
+
+export default CreateNewMatchModeConfigPanel
diff --git a/fission/src/ui/panels/configuring/MatchModeConfigPanel.tsx b/fission/src/ui/panels/configuring/MatchModeConfigPanel.tsx
index 9c14cee51d..255980707b 100644
--- a/fission/src/ui/panels/configuring/MatchModeConfigPanel.tsx
+++ b/fission/src/ui/panels/configuring/MatchModeConfigPanel.tsx
@@ -13,6 +13,7 @@ import Label from "@/ui/components/Label"
import type { PanelImplProps } from "@/ui/components/Panel"
import { NegativeButton, PositiveButton, SynthesisIcons } from "@/ui/components/StyledComponents"
import { CloseType, useUIContext } from "@/ui/helpers/UIProviderHelpers"
+import CreateNewMatchModeConfigPanel from "./CreateNewMatchModeConfigPanel"
/**
* Configuration for match mode rules and timing.
@@ -85,6 +86,57 @@ const props: Readonly<{ id: keyof MatchModeConfig; expectedType: string; require
{ id: "sideExtensionPenalty", expectedType: "number", required: false },
]
+export const validateAndNormalizeMatchModeConfig = (config: unknown): MatchModeConfig | null => {
+ // Type guard to check if config is an object
+ if (typeof config !== "object" || config === null) {
+ console.error("Match mode config validation failed: config must be an object")
+ globalAddToast("error", "Invalid Match Mode Config", "Configuration must be an object")
+ return null
+ }
+ const configObj = config as Record
+
+ const typeError = (id: string, expectedType?: string) => {
+ const errorMessage = expectedType ? `must be a ${expectedType}` : "is required"
+ console.error(`Match mode config validation failed: the '${id}' field ${errorMessage}`)
+ globalAddToast("error", "Invalid Match Mode Config", `The '${id}' field ${errorMessage}`)
+ }
+
+ function checkValidity(configObj: Record): configObj is Partial {
+ for (const prop of props) {
+ if (configObj[prop.id] == undefined) {
+ if (prop.required) {
+ typeError(prop.id)
+ return false
+ }
+ } else if (typeof configObj[prop.id] != prop.expectedType) {
+ if (prop.required) {
+ typeError(prop.id, prop.expectedType)
+ return false
+ } else {
+ globalAddToast(
+ "warning",
+ "Invalid Match Mode Config",
+ `The '${prop.id}' field must be a ${prop.expectedType}, ignoring ${prop.id} field`
+ )
+ }
+ }
+ }
+ return true
+ }
+
+ if (!checkValidity(configObj)) {
+ return null
+ }
+
+ // If validation passes, use the default values in any missing fields
+ const normalizedConfig = {
+ ...DefaultMatchModeConfigs.fallbackValues(),
+ ...configObj,
+ }
+ normalizedConfig.isDefault = false
+ return normalizedConfig
+}
+
interface ItemCardProps {
id: string
name: string
@@ -108,14 +160,14 @@ const ItemCard: React.FC = ({ id, name, primaryOnClick, secondary
{secondaryOnClick && (
{SynthesisIcons.DELETE_LARGE}
)}
- {SynthesisIcons.SELECT_LARGE}
+ {SynthesisIcons.PLAY_LARGE}
)
}
const MatchModeConfigPanel: React.FC> = ({ panel }) => {
- const { closePanel, openModal, configureScreen } = useUIContext()
+ const { openPanel, closePanel, openModal, configureScreen } = useUIContext()
const [matchModeConfigs, setMatchModeConfigs] = useState([])
const [useSpawnPositions, setUseSpawnPositions] = useState(false)
@@ -187,11 +239,6 @@ const MatchModeConfigPanel: React.FC> = ({ panel }) =
// Only save custom configs to local storage
const customConfigs = updatedConfigs.filter(c => !c.isDefault)
window.localStorage.setItem("match-mode-configs", JSON.stringify(customConfigs))
- globalAddToast(
- "info",
- "Match Mode Config Deleted",
- `Successfully deleted "${config.name}"`
- )
}
: undefined
}
@@ -209,57 +256,6 @@ const MatchModeConfigPanel: React.FC> = ({ panel }) =
}
}
- const validateAndNormalizeMatchModeConfig = (config: unknown): MatchModeConfig | null => {
- // Type guard to check if config is an object
- if (typeof config !== "object" || config === null) {
- console.error("Match mode config validation failed: config must be an object")
- globalAddToast("error", "Invalid Match Mode Config", "Configuration must be an object")
- return null
- }
- const configObj = config as Record
-
- const typeError = (id: string, expectedType?: string) => {
- const errorMessage = expectedType ? `must be a ${expectedType}` : "is required"
- console.error(`Match mode config validation failed: the '${id}' field ${errorMessage}`)
- globalAddToast("error", "Invalid Match Mode Config", `The '${id}' field ${errorMessage}`)
- }
-
- function checkValidity(configObj: Record): configObj is Partial {
- for (const prop of props) {
- if (configObj[prop.id] == undefined) {
- if (prop.required) {
- typeError(prop.id)
- return false
- }
- } else if (typeof configObj[prop.id] != prop.expectedType) {
- if (prop.required) {
- typeError(prop.id, prop.expectedType)
- return false
- } else {
- globalAddToast(
- "warning",
- "Invalid Match Mode Config",
- `The '${prop.id}' field must be a ${prop.expectedType}, ignoring ${prop.id} field`
- )
- }
- }
- }
- return true
- }
-
- if (!checkValidity(configObj)) {
- return null
- }
-
- // If validation passes, use the default values in any missing fields
- const normalizedConfig = {
- ...DefaultMatchModeConfigs.fallbackValues(),
- ...configObj,
- }
- normalizedConfig.isDefault = false
- return normalizedConfig
- }
-
const handleFileUpload = async (file: File) => {
// Check if it's a JSON file
if (!file.name.toLowerCase().endsWith(".json")) {
@@ -314,6 +310,12 @@ const MatchModeConfigPanel: React.FC> = ({ panel }) =
// Reset the input value so the same file can be selected again
e.target.value = ""
}
+
+ const createNewMatchModeConfig = () => {
+ openPanel(CreateNewMatchModeConfigPanel, undefined)
+ closePanel(panel!.id, CloseType.Overwrite)
+ }
+
return (
<>