Ver Fonte

implement beacon

JAQUIN_C há 1 mês atrás
pai
commit
fc52d72792

+ 82 - 0
src/assets/components/BeaconConfigurator.tsx

@@ -0,0 +1,82 @@
+import data from "../../assets/data/2.0/data.json";
+import ModuleSlots from "./ModuleSlots";
+import type { Beacon, Module } from "../../../scripts/factorio-dump/process-data.models";
+import { useQualityScroller } from "../../hooks/useQualityScroller";
+import Icon from "../icon";
+
+const beaconsData = data.beacons as Beacon[];
+
+export type BeaconGroup = {
+  id: string;
+  beacon: Beacon;
+  qualityLevel: number;
+  count: number;
+  modules: { module: Module; qualityLevel: number }[];
+};
+
+export default function BeaconConfigurator({ groups, onChange }: { groups: BeaconGroup[], onChange: (g: BeaconGroup[]) => void }) {
+  const handleAddGroup = () => {
+    const defaultBeacon = beaconsData[0];
+    onChange([...groups, {
+      id: Date.now().toString(),
+      beacon: defaultBeacon,
+      qualityLevel: 0,
+      count: 1,
+      modules: []
+    }]);
+  };
+
+  const updateGroup = (id: string, patch: Partial<BeaconGroup>) => {
+    onChange(groups.map(g => g.id === id ? { ...g, ...patch } : g));
+  };
+
+  return (
+    <div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
+      {groups.map((g) => (
+        <div key={g.id} style={{ display: "flex", gap: "16px", alignItems: "center", background: "#242324", padding: "8px", borderRadius: "4px", border: "1px solid #3a3a3a" }}>
+          
+          {/* Beacon Count */}
+          <div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
+            <label style={{ fontSize: "11px", color: "#999" }}>Count</label>
+            <input 
+              type="number" min="1" max="50" 
+              value={g.count} 
+              onChange={e => updateGroup(g.id, { count: Number(e.target.value) || 1 })}
+              style={{ width: "50px", background: "#1a1a1a", color: "#ffe6c0", border: "1px solid #646464", padding: "4px", borderRadius: "4px" }}
+            />
+          </div>
+
+          {/* Beacon Type & Quality */}
+          <BeaconIconWrapper 
+            beacon={g.beacon} 
+            qualityLevel={g.qualityLevel} 
+            onChangeQuality={(q) => updateGroup(g.id, { qualityLevel: q })} 
+          />
+
+          {/* Beacon Modules */}
+          <div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
+             <label style={{ fontSize: "11px", color: "#999" }}>Modules ({g.beacon.module_slots})</label>
+             <ModuleSlots 
+               maxSlots={g.beacon.module_slots} 
+               allowedEffects={g.beacon.allowed_effects as string[]}
+               onChange={mods => updateGroup(g.id, { modules: mods })}
+             />
+          </div>
+
+          <button onClick={() => onChange(groups.filter(x => x.id !== g.id))} style={{ marginLeft: "auto", background: "none", border: "none", color: "#d9614f", cursor: "pointer", fontSize: "16px" }}>✕</button>
+        </div>
+      ))}
+      <button onClick={handleAddGroup} style={{ alignSelf: "flex-start", background: "none", border: "1px dashed #646464", color: "#999", padding: "6px 12px", borderRadius: "4px", cursor: "pointer" }}>+ Add Beacons</button>
+    </div>
+  );
+}
+
+function BeaconIconWrapper({ beacon, qualityLevel, onChangeQuality }: any) {
+  const { scrollRef, activeQuality } = useQualityScroller("normal", (q) => onChangeQuality(q.level));
+  return (
+    <div ref={scrollRef} style={{ display: "flex", flexDirection: "column", alignItems: "center", cursor: "ns-resize", gap: "4px" }} title="Shift+Scroll to change beacon quality">
+       <label style={{ fontSize: "11px", color: "#999" }}>Beacon</label>
+       <Icon iconName={beacon.icon ?? ""} size={44} qualityLevel={activeQuality.level} />
+    </div>
+  );
+}

+ 93 - 56
src/assets/components/ClockWizard.tsx

@@ -1,5 +1,4 @@
-import { useState } from "react";
-import InputConfigurator from "./InputConfigurator";
+import { useMemo, useState } from "react";
 import ModuleSlots from "./ModuleSlots";
 import styles from "./ClockWizard.module.css"; // (Create a simple flex-col layout for this)
 import { useClockStore } from "../../store/useClockStore";
@@ -10,92 +9,130 @@ import type {
 } from "../../../scripts/factorio-dump/process-data.models";
 import {
   calculateOptimalBatch,
+  computeMachineStats,
   generateAdvancedClock,
 } from "../../engine/factorioEngine";
 import MachineSelector from "../MachineSelector";
+import BeaconConfigurator, { type BeaconGroup } from "./BeaconConfigurator";
 
 export default function ClockWizard() {
   const loadState = useClockStore((s) => s.loadState);
 
   const [recipe, setRecipe] = useState<Recipe | null>(null);
   const [machine, setMachine] = useState<Machine | null>(null);
-
-  // Track inputs and modules
+  const [machineQuality, setMachineQuality] = useState<number>(0);
+  
+  const [machineModules, setMachineModules] = useState<{ module: Module, qualityLevel: number }[]>([]);
+  const [beaconGroups, setBeaconGroups] = useState<BeaconGroup[]>([]);
   const [inputConfigs, setInputConfigs] = useState<Record<string, any>>({});
-  const [machineModules, setMachineModules] = useState<
-    { module: Module; qualityLevel: number }[]
-  >([]);
 
-  // Called when the user clicks a recipe/machine in your existing MachineSelector
-  const handleMachineSelect = (
-    selectedMachine: Machine,
-    selectedRecipe: Recipe,
-  ) => {
-    setMachine(selectedMachine);
-    setRecipe(selectedRecipe);
-  };
+  // --- LIVE STATS CALCULATION ---
+  const stats = useMemo(() => {
+    if (!machine || !recipe) return null;
+    return computeMachineStats({
+      machine,
+      machineQualityLevel: machineQuality,
+      machineModules: machineModules,
+      beacons: beaconGroups.map(bg => ({
+        beacon: bg.beacon,
+        beaconQualityLevel: bg.qualityLevel,
+        count: bg.count,
+        modules: bg.modules
+      }))
+    }, recipe);
+  }, [machine, recipe, machineQuality, machineModules, beaconGroups]);
+
 
   const handleGenerate = () => {
-    if (!recipe || !machine) return;
+    if (!recipe || !machine || !stats) return;
 
-    //  Run the math engine (assuming basic timings for now, you can hook up the exact module math here)
-    const batch = calculateOptimalBatch(
-      recipe,
-      0, // productivity bonus (calculate from machineModules)
-      machine.crafting_speed, // actual speed (calculate from machineModules)
-      16,
-    );
+    // 1. Math
+    const batch = calculateOptimalBatch(recipe, stats, 16);
 
-    // Generate the timeline blocks
+    // 2. Blueprint / Timeline Blocks
     const clockData = generateAdvancedClock(batch, {
       stackSize: 16,
-      //inputConfigs // Pass the user's mixed-belt config to the generator
+      //inputConfigs // (Assuming inputConfigs logic is mapped inside generator)
     });
 
-    // 3. Inject it straight into the Zustand store to update the UI
+    // 3. Update Store
     loadState({
       duration: clockData.duration,
-      rows: Object.fromEntries(clockData.rows.map((r) => [r.id, r])),
-      rowOrder: clockData.rows.map((r) => r.id),
-      blocks: Object.fromEntries(clockData.blocks.map((b) => [b.id, b])),
-      selectedBlockIds: new Set(),
+      rows: Object.fromEntries(clockData.rows.map(r => [r.id, r])),
+      rowOrder: clockData.rows.map(r => r.id),
+      blocks: Object.fromEntries(clockData.blocks.map(b => [b.id, b])),
+      selectedBlockIds: new Set()
     });
   };
 
   return (
-    <div className={styles.wrap}>
-      <h2>1. Choose Recipe & Machine</h2>
-      <MachineSelector
-        onChange={(m) => {
-          handleMachineSelect(m.machine,m.recipe);
-        }}
-        // You'll need to expose the selected recipe from MachineSelector or lift its state up
-      />
+    <div className={styles.wrap} style={{ display: "flex", flexDirection: "column", gap: "24px", padding: "20px", background: "#313031", color: "#ffe6c0", border: "1px solid #646464", borderRadius: "8px" }}>
+      
+      {/* 1. Recipe & Machine */}
+      <div style={{ display: "flex", gap: "20px", alignItems: "flex-start" }}>
+        <div style={{ flex: 1 }}>
+          <h2 style={{ fontSize: "16px", color: "#f1be64", margin: "0 0 12px 0" }}>1. Setup Machine</h2>
+          <MachineSelector 
+            onChange={(res) => { 
+              setMachine(res.machine); 
+              setMachineQuality(res.qualityLevel); 
+              setRecipe(res.recipe); 
+            }} 
+          />
+        </div>
 
-      {machine && recipe && (
-        <>
-          <div className={styles.section}>
-            <h2>2. Machine Modules</h2>
-            <ModuleSlots
-              maxSlots={machine.module_slots || 0}
-              allowedEffects={machine.allowed_effects as string[]}
-              onChange={setMachineModules}
-            />
+        {/* --- STATS PREVIEW DASHBOARD --- */}
+        {stats && (
+          <div style={{ flex: 1, background: "#1a1a1a", border: "1px dashed #f1be64", borderRadius: "6px", padding: "12px", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" }}>
+            <div style={{ gridColumn: "span 2" }}>
+               <h3 style={{ margin: 0, fontSize: "12px", color: "#999", textTransform: "uppercase" }}>Live Capabilities</h3>
+            </div>
+            <div>
+              <div style={{ fontSize: "11px", color: "#999" }}>Crafting Speed</div>
+              <div style={{ fontSize: "18px", color: "#7fc98a", fontWeight: "bold" }}>{stats.actualCraftingSpeed.toFixed(2)}</div>
+            </div>
+            <div>
+              <div style={{ fontSize: "11px", color: "#999" }}>Productivity</div>
+              <div style={{ fontSize: "18px", color: "#7fc98a", fontWeight: "bold" }}>+{Math.round(stats.productivityBonus * 100)}%</div>
+            </div>
+            <div>
+              <div style={{ fontSize: "11px", color: "#999" }}>Craft Time (Ticks)</div>
+              <div style={{ fontSize: "14px", color: "#ffe6c0" }}>{stats.singleCraftTicks.toFixed(1)}t</div>
+            </div>
+            <div>
+              <div style={{ fontSize: "11px", color: "#999" }}>Overload Limit (Multiplier)</div>
+              <div style={{ fontSize: "14px", color: "#ffe6c0" }}>{stats.overloadMultiplier}x</div>
+            </div>
           </div>
+        )}
+      </div>
 
-          <div className={styles.section}>
-            <h2>3. Configure Inputs (Belts vs Chests)</h2>
-            <InputConfigurator
-              recipe={recipe}
-              onConfigChange={setInputConfigs} // Assuming you pass the config up
-            />
+      {machine && recipe && (
+        <>
+          {/* 2. Modules & Beacons */}
+          <div style={{ display: "flex", gap: "40px" }}>
+            <div style={{ flex: 1 }}>
+              <h2 style={{ fontSize: "14px", color: "#f1be64", margin: "0 0 12px 0" }}>2. Machine Modules</h2>
+              <ModuleSlots 
+                maxSlots={machine.module_slots || 0} 
+                allowedEffects={machine.allowed_effects as string[]}
+                onChange={setMachineModules} 
+              />
+            </div>
+            <div style={{ flex: 2 }}>
+              <h2 style={{ fontSize: "14px", color: "#f1be64", margin: "0 0 12px 0" }}>3. Beacons</h2>
+              <BeaconConfigurator groups={beaconGroups} onChange={setBeaconGroups} />
+            </div>
           </div>
 
-          <button className={styles.generateBtn} onClick={handleGenerate}>
-            Generate Optimal Timeline
+          <button 
+            onClick={handleGenerate}
+            style={{ padding: "10px 16px", background: "#f1be64", color: "#1a1300", fontWeight: "bold", border: "none", borderRadius: "4px", cursor: "pointer", alignSelf: "flex-start" }}
+          >
+            Generate Optimized Timeline
           </button>
         </>
       )}
     </div>
   );
-}
+}

+ 13 - 1
src/assets/components/ModuleSlots.module.css

@@ -37,4 +37,16 @@
   justify-content: center;
   align-items: center;
   cursor: ns-resize; /* Indicates scrolling is possible */
-}
+}
+.fillBtn {
+  background: #373737;
+  border: 1px solid #646464;
+  color: #ffe6c0;
+  border-radius: 4px;
+  padding: 6px 10px;
+  cursor: pointer;
+  font-size: 11px;
+}
+.fillBtn:hover {
+  border-color: #f1be64;
+}

+ 37 - 73
src/assets/components/ModuleSlots.tsx

@@ -18,21 +18,11 @@ type ModuleSlotsProps = {
   onChange: (modules: { module: Module, qualityLevel: number }[]) => void;
 };
 
-// Sub-component to bind the Quality Scroller to individual occupied slots safely
-function OccupiedSlot({ 
-  module, 
-  initialQualityLevel, 
-  onChangeQuality 
-}: { 
-  module: Module, 
-  initialQualityLevel: number, 
-  onChangeQuality: (level: number) => void 
-}) {
+function OccupiedSlot({ module, initialQualityLevel, onChangeQuality }: any) {
   const initialQualityName = orderedQualities.find(q => q.level === initialQualityLevel)?.name || "normal";
-  
   const { scrollRef, activeQuality } = useQualityScroller(initialQualityName, (newQuality) => {
     onChangeQuality(newQuality.level);
-  });
+  }, "factorio-module-quality"); // Persist module quality separately!
 
   return (
     <div ref={scrollRef} className={styles.occupiedSlot}>
@@ -46,48 +36,28 @@ export default function ModuleSlots({ maxSlots, allowedEffects, onChange }: Modu
   const [activeSlot, setActiveSlot] = useState<number | null>(null);
   const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
 
-  // Sync state length when the machine changes (e.g., swapping from Assembler 2 to 3)
   useEffect(() => {
     setSlots(prev => {
       const newSlots = Array(maxSlots).fill(null);
-      for (let i = 0; i < Math.min(prev.length, maxSlots); i++) {
-        newSlots[i] = prev[i];
-      }
+      for (let i = 0; i < Math.min(prev.length, maxSlots); i++) newSlots[i] = prev[i];
       return newSlots;
     });
   }, [maxSlots]);
 
-  // Dynamically filter available modules based on the machine's allowed effects
   const moduleCategories = useMemo(() => {
     let validModules = data.modules as Module[];
     if (allowedEffects && allowedEffects.length > 0) {
       validModules = validModules.filter(m => {
         if (!m.effect) return true;
-        // A module is allowed if EVERY effect it provides is supported by the machine
-        const effects = Object.keys(m.effect);
-        return effects.every(e => allowedEffects.includes(e));
+        return Object.keys(m.effect).every(e => {
+          const effectValue = m.effect![e as keyof typeof m.effect] ?? 0;
+          return effectValue <= 0 || allowedEffects.includes(e);
+        });
       });
     }
-    
-    return [{
-      ...prod,
-      subGroup: [{ ...moduleSubGroup, children: validModules as unknown as MenuItem[] }]
-    }];
+    return [{ ...prod, subGroup: [{ ...moduleSubGroup, children: validModules as unknown as MenuItem[] }] }];
   }, [allowedEffects]);
 
-  const handleOpenMenu = (e: React.MouseEvent<HTMLElement>, index: number) => {
-    setAnchorEl(e.currentTarget);
-    setActiveSlot(index);
-  };
-
-  const handleClearSlot = (e: React.MouseEvent, index: number) => {
-    e.preventDefault();
-    const newSlots = [...slots];
-    newSlots[index] = null;
-    setSlots(newSlots);
-    onChange(newSlots.filter(s => s !== null) as { module: Module, qualityLevel: number }[]);
-  };
-
   const handleSelectModule = (moduleName: string, qualityLevel: number) => {
     const selectedModule = data.modules.find((m: any) => m.name === moduleName) as Module;
     if (!selectedModule || activeSlot === null) return;
@@ -96,50 +66,44 @@ export default function ModuleSlots({ maxSlots, allowedEffects, onChange }: Modu
     newSlots[activeSlot] = { module: selectedModule, qualityLevel };
     setSlots(newSlots);
     setActiveSlot(null);
-    
-    onChange(newSlots.filter(s => s !== null) as { module: Module, qualityLevel: number }[]);
+    onChange(newSlots.filter(s => s !== null) as any);
   };
 
-  const handleSlotQualityChange = (index: number, newLevel: number) => {
-    const newSlots = [...slots];
-    if (newSlots[index]) {
-      newSlots[index]!.qualityLevel = newLevel;
-      setSlots(newSlots);
-      onChange(newSlots.filter(s => s !== null) as { module: Module, qualityLevel: number }[]);
-    }
+  const handleFillAll = () => {
+    const template = slots.find(s => s !== null);
+    if (!template) return;
+    const newSlots = slots.map(s => s || template);
+    setSlots(newSlots);
+    onChange(newSlots as any);
   };
 
   if (maxSlots === 0) return null;
 
   return (
-    <div className={styles.slotsContainer}>
-      {slots.map((slot, i) => (
-        <div 
-          key={i} 
-          className={styles.slot} 
-          onClick={(e) => handleOpenMenu(e, i)}
-          onContextMenu={(e) => handleClearSlot(e, i)}
-          title={slot ? "Click to replace.\nRight-click to remove.\nShift+Scroll to change quality." : "Click to add module"}
-        >
-          {slot ? (
-            <OccupiedSlot 
-              module={slot.module} 
-              initialQualityLevel={slot.qualityLevel} 
-              onChangeQuality={(lvl) => handleSlotQualityChange(i, lvl)} 
-            />
-          ) : (
-            <span className={styles.emptySlot}>+</span>
-          )}
-        </div>
-      ))}
+    <div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
+      <div className={styles.slotsContainer}>
+        {slots.map((slot, i) => (
+          <div 
+            key={i} 
+            className={styles.slot} 
+            onClick={(e) => { setAnchorEl(e.currentTarget); setActiveSlot(i); }}
+            onContextMenu={(e) => { e.preventDefault(); const n = [...slots]; n[i] = null; setSlots(n); onChange(n.filter(s=>s) as any); }}
+          >
+            {slot ? (
+              <OccupiedSlot module={slot.module} initialQualityLevel={slot.qualityLevel} onChangeQuality={(lvl: number) => { const n = [...slots]; n[i]!.qualityLevel = lvl; setSlots(n); onChange(n.filter(s=>s) as any); }} />
+            ) : <span className={styles.emptySlot}>+</span>}
+          </div>
+        ))}
+      </div>
       
+      {slots.some(s => s !== null) && slots.some(s => s === null) && (
+        <button onClick={handleFillAll} className={styles.fillBtn} title="Fill empty slots with the first module">
+          Fill All
+        </button>
+      )}
+
       <Popper open={activeSlot !== null} anchorEl={anchorEl} placement="bottom-start" style={{ zIndex: 1300 }}>
-         <SelectMenu 
-           title="Select Module" 
-           categories={moduleCategories} 
-           onSelectItem={handleSelectModule}
-           onClose={() => setActiveSlot(null)}
-         />
+         <SelectMenu title="Select Module" categories={moduleCategories} onSelectItem={handleSelectModule} onClose={() => setActiveSlot(null)} />
       </Popper>
     </div>
   );

+ 84 - 0
src/engine/factorioEngine.ts

@@ -1,6 +1,7 @@
 
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
 import type { ItemProductPrototype } from "../../scripts/factorio-dump/lua-api/models";
+import type { Beacon, Machine, Module } from "../../scripts/factorio-dump/process-data.models";
 
 import { defaultClockSignal } from "../store/useClockStore";
 // --- Math Helpers ---
@@ -29,7 +30,90 @@ export interface ClockConfig {
   outputPreset?: string;
   swingTicks?: number; // e.g., 8 for chest_to_chest, 12 for chest_to_belt
 }
+export interface MachineSetup {
+  machine: Machine;
+  machineQualityLevel?: number; // 0 = Normal, 1 = Uncommon, 2 = Rare, 3 = Epic, 4 = Legendary
+  machineModules: Array<{ module: Module; qualityLevel: number }>;
+  beacons: Array<{
+    beacon: Beacon;
+    beaconQualityLevel?: number;
+    count: number;
+    modules: Array<{ module: Module; qualityLevel: number }>; 
+  }>;
+}
+export interface CalculatedTimings {
+  actualCraftingSpeed: number;
+  productivityBonus: number;
+  singleCraftTicks: number;
+  overloadMultiplier: number;
+}
+
+// Factorio 2.0 Quality Multipliers (Normal = 1x, Uncommon = 1.3x, Rare = 1.6x, Epic = 1.9x, Legendary = 2.5x)
+function getQualityMultiplier(level: number): number {
+  if (level === 4) return 2.5; // Legendary gets a bigger bump
+  return 1 + (level * 0.3);
+}
+
+export function computeMachineStats(setup: MachineSetup, recipe: Recipe): CalculatedTimings {
+  let speedBonus = 0;
+  let productivityBonus = 0;
+
+  // 1. Machine Quality (e.g. Legendary machine has +150% base speed)
+  const machineQualityMultiplier = getQualityMultiplier(setup.machineQualityLevel ?? 0);
+  const baseSpeed = setup.machine.crafting_speed * machineQualityMultiplier;
+
+  // 2. Machine Modules (Unpacking the wrapper and applying quality)
+  setup.machineModules.forEach(({ module, qualityLevel }) => {
+    const modQualityMultiplier = getQualityMultiplier(qualityLevel);
+    
+    if (module.effect?.speed) {
+      speedBonus += module.effect.speed * modQualityMultiplier;
+    }
+    if (module.effect?.productivity) {
+      productivityBonus += module.effect.productivity * modQualityMultiplier;
+    }
+  });
+
+  // 3. Beacons (Unpacking wrapper, applying beacon quality AND module quality)
+  setup.beacons.forEach(b => {
+    const beaconQualityLevel = b.beaconQualityLevel ?? 0;
+    const effectivityBonus = (b.beacon.distribution_effectivity_bonus_per_quality_level ?? 0) * beaconQualityLevel;
+    const distributionEffectivity = b.beacon.distribution_effectivity + effectivityBonus;
+
+    let beaconSpeed = 0;
+    let beaconProd = 0;
 
+    b.modules.forEach(({ module, qualityLevel }) => {
+      const modQualityMultiplier = getQualityMultiplier(qualityLevel);
+      if (module.effect?.speed) beaconSpeed += module.effect.speed * modQualityMultiplier;
+      if (module.effect?.productivity) beaconProd += module.effect.productivity * modQualityMultiplier;
+    });
+
+    speedBonus += beaconSpeed * distributionEffectivity * b.count;
+    productivityBonus += beaconProd * distributionEffectivity * b.count;
+  });
+
+  // Minimum speed multiplier in Factorio is 0.2 (-80%)
+  const effectiveSpeedMultiplier = Math.max(0.2, 1 + speedBonus);
+  const actualCraftingSpeed = baseSpeed * effectiveSpeedMultiplier;
+
+  // 4. Timing & Insertion Limits
+  const energyRequired = recipe.energy_required ?? 0.5;
+  const craftTimeSeconds = energyRequired / actualCraftingSpeed;
+  const singleCraftTicks = craftTimeSeconds * 60;
+
+  let overloadMultiplier = recipe.overload_multiplier;
+  if (!overloadMultiplier || overloadMultiplier === 0) {
+    overloadMultiplier = Math.max(2, Math.min(100, Math.ceil(1.166 / craftTimeSeconds)));
+  }
+
+  return {
+    actualCraftingSpeed,
+    productivityBonus,
+    singleCraftTicks,
+    overloadMultiplier
+  };
+}
 
 // ---  Batch Calculator ---
 export function calculateOptimalBatch(