Compare commits

..

3 Commits

Author SHA1 Message Date
a2f66b0dca feat(ai): improving agents planning and memory 2026-03-20 02:12:29 -04:00
f5bf7d8e3f bug(ui): GmWindow was not holding its size after moving it 2026-03-20 02:11:50 -04:00
892d069b92 feat(viewer): add GM Ops Console window replacing ops strip
Introduces a floating, draggable, resizable Game Master console as the
first of a planned series of GM/debug windows. Replaces the horizontal
ops-strip card layout with proper data tables using TanStack Table v8.

- GmWindow.vue: reusable draggable+resizable floating window base;
  snapshots offsetWidth/Height on drag start so resize is preserved
- GmOpsWindow.vue: Ships / Stations / Factions tabs with global filter,
  column sorting, and drag-to-reorder columns (useColumnOrder composable)
- gmStore.ts: Pinia store fed from ViewerWorldLifecycle.rebuildFactions
  with raw world arrays (ships, stations, factions)
- Removes opsStripEl binding (was stored but never read by controller)
- GM Console toggle button replaces the bottom ops strip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 00:24:32 -04:00
21 changed files with 2986 additions and 459 deletions

View File

@@ -6,8 +6,9 @@ internal sealed class CommanderPlanningService
{
private const float FactionCommanderReplanInterval = 10f;
private const float ShipCommanderReplanInterval = 5f;
private readonly FactionObjectivePlanner _objectivePlanner = new();
private readonly FactionObjectiveExecutor _objectiveExecutor = new();
private static readonly GoapPlanner<FactionPlanningState> _factionPlanner = new(s => s.Clone());
private static readonly GoapPlanner<ShipPlanningState> _shipPlanner = new(s => s.Clone());
private static readonly IReadOnlyList<GoapGoal<FactionPlanningState>> _factionGoals =
@@ -76,12 +77,9 @@ internal sealed class CommanderPlanningService
commander.ReplanTimer = FactionCommanderReplanInterval;
commander.NeedsReplan = false;
commander.PlanningCycle += 1;
var state = BuildFactionPlanningState(world, commander.FactionId);
var actions = BuildFactionActions(world);
// Clear stale directives — actions will re-assert what is still needed.
commander.ActiveDirectives.Clear();
var rankedGoals = _factionGoals
.Select(g => (goal: g, priority: g.ComputePriority(state, world, commander)))
@@ -89,28 +87,15 @@ internal sealed class CommanderPlanningService
.OrderByDescending(x => x.priority)
.ToList();
commander.LastPlanningState = state;
commander.LastGoalPriorities = rankedGoals.Select(x => (x.goal.Name, x.priority)).ToList();
// Execute the first action of each active goal's plan (top 3 to avoid conflicts).
foreach (var (goal, _) in rankedGoals.Take(3))
{
var plan = _factionPlanner.Plan(state, goal, actions);
plan?.CurrentAction?.Execute(engine, world, commander);
}
if (FactionIndustryPlanner.GetActiveExpansionProject(world, commander.FactionId) is null)
{
if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-war-industry", StringComparison.Ordinal)))
{
TryQueueFactionExpansionProject(world, commander, SelectGoalDrivenWarIndustryProject(world, state, commander.FactionId));
}
else if (rankedGoals.Any(entry => string.Equals(entry.goal.Name, "ensure-water-security", StringComparison.Ordinal)))
{
TryQueueFactionExpansionProject(world, commander, FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, "water"));
}
}
commander.LastStrategicAssessment = state;
commander.LastStrategicPriorities = rankedGoals.Select(x => (x.goal.Name, x.priority)).ToList();
_objectivePlanner.UpdateBlackboard(world, commander, state);
_objectivePlanner.RefreshObjectives(
world,
commander,
state,
rankedGoals.Select(entry => (entry.goal.Name, entry.priority)).ToList());
_objectiveExecutor.Execute(engine, world, commander, state);
}
private void UpdateShipCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
@@ -172,18 +157,30 @@ internal sealed class CommanderPlanningService
TargetSystemCount = Math.Max(1, Math.Min(StationSimulationService.StrategicControlTargetSystems, world.Systems.Count)),
HasShipFactory = stations.Any(s => s.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
OreStockpile = economy.GetCommodity("ore").OnHand,
RefinedMetalsStockpile = refinedMetals.OnHand,
RefinedMetalsProductionRate = refinedMetals.ProjectedProductionRatePerSecond,
RefinedMetalsShortageHorizonSeconds = refinedMetals.ProjectedShortageHorizonSeconds,
HullpartsStockpile = hullparts.OnHand,
HullpartsProductionRate = hullparts.ProjectedProductionRatePerSecond,
HullpartsShortageHorizonSeconds = hullparts.ProjectedShortageHorizonSeconds,
ClaytronicsStockpile = claytronics.OnHand,
ClaytronicsProductionRate = claytronics.ProjectedProductionRatePerSecond,
ClaytronicsShortageHorizonSeconds = claytronics.ProjectedShortageHorizonSeconds,
WaterStockpile = water.OnHand,
WaterProductionRate = water.ProjectedProductionRatePerSecond,
WaterShortageHorizonSeconds = water.ProjectedShortageHorizonSeconds,
RefinedMetalsAvailableStock = refinedMetals.AvailableStock,
RefinedMetalsUsageRate = refinedMetals.OperationalUsageRatePerSecond,
RefinedMetalsProjectedProductionRate = refinedMetals.ProjectedProductionRatePerSecond,
RefinedMetalsProjectedNetRate = refinedMetals.ProjectedNetRatePerSecond,
RefinedMetalsLevelSeconds = refinedMetals.LevelSeconds,
RefinedMetalsLevel = refinedMetals.Level.ToString().ToLowerInvariant(),
HullpartsAvailableStock = hullparts.AvailableStock,
HullpartsUsageRate = hullparts.OperationalUsageRatePerSecond,
HullpartsProjectedProductionRate = hullparts.ProjectedProductionRatePerSecond,
HullpartsProjectedNetRate = hullparts.ProjectedNetRatePerSecond,
HullpartsLevelSeconds = hullparts.LevelSeconds,
HullpartsLevel = hullparts.Level.ToString().ToLowerInvariant(),
ClaytronicsAvailableStock = claytronics.AvailableStock,
ClaytronicsUsageRate = claytronics.OperationalUsageRatePerSecond,
ClaytronicsProjectedProductionRate = claytronics.ProjectedProductionRatePerSecond,
ClaytronicsProjectedNetRate = claytronics.ProjectedNetRatePerSecond,
ClaytronicsLevelSeconds = claytronics.LevelSeconds,
ClaytronicsLevel = claytronics.Level.ToString().ToLowerInvariant(),
WaterAvailableStock = water.AvailableStock,
WaterUsageRate = water.OperationalUsageRatePerSecond,
WaterProjectedProductionRate = water.ProjectedProductionRatePerSecond,
WaterProjectedNetRate = water.ProjectedNetRatePerSecond,
WaterLevelSeconds = water.LevelSeconds,
WaterLevel = water.Level.ToString().ToLowerInvariant(),
};
}
@@ -192,16 +189,23 @@ internal sealed class CommanderPlanningService
ShipRuntime ship,
CommanderRuntime commander)
{
var factionCommander = world.Commanders.FirstOrDefault(c =>
c.FactionId == commander.FactionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
var factionCommander = FindFactionCommander(world, commander.FactionId);
var enemyTarget = SelectEnemyTarget(world, ship);
var tradeRoute = SelectTradeRoute(world, ship.FactionId);
var expansionTask = GetHighestPriorityIssuedTask(factionCommander, FactionIssuedTaskKind.ExpandIndustry);
var attackTask = GetHighestPriorityIssuedTask(factionCommander, FactionIssuedTaskKind.AttackFactionAssets);
var shipyardExpansionTask = factionCommander?.IssuedTasks
.Where(task =>
task.Kind == FactionIssuedTaskKind.ExpandIndustry
&& task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active
&& string.Equals(task.ModuleId, "module_gen_build_l_01", StringComparison.Ordinal))
.OrderByDescending(task => task.Priority)
.FirstOrDefault();
var expansionProject = FactionIndustryPlanner.GetActiveExpansionProject(world, ship.FactionId);
if (commander.ActiveBehavior is not null)
{
commander.ActiveBehavior.AreaSystemId = enemyTarget?.SystemId;
commander.ActiveBehavior.AreaSystemId = attackTask?.TargetSystemId ?? expansionTask?.TargetSystemId ?? enemyTarget?.SystemId;
commander.ActiveBehavior.TargetEntityId = enemyTarget?.EntityId;
if (string.Equals(ship.Definition.Kind, "transport", StringComparison.Ordinal))
{
@@ -209,12 +213,12 @@ internal sealed class CommanderPlanningService
commander.ActiveBehavior.StationId = tradeRoute?.SourceStationId;
commander.ActiveBehavior.TargetEntityId = tradeRoute?.DestinationStationId;
}
else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal) && expansionProject is not null)
else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal) && (expansionTask is not null || expansionProject is not null))
{
commander.ActiveBehavior.StationId = expansionProject.SupportStationId;
commander.ActiveBehavior.TargetEntityId = expansionProject.SiteId;
commander.ActiveBehavior.ModuleId = expansionProject.ModuleId;
commander.ActiveBehavior.AreaSystemId = expansionProject.SystemId;
commander.ActiveBehavior.StationId = expansionProject?.SupportStationId;
commander.ActiveBehavior.TargetEntityId = expansionTask?.TargetSiteId ?? expansionProject?.SiteId;
commander.ActiveBehavior.ModuleId = expansionTask?.ModuleId ?? expansionProject?.ModuleId;
commander.ActiveBehavior.AreaSystemId = expansionTask?.TargetSystemId ?? expansionProject?.SystemId;
}
else if (string.Equals(ship.Definition.Kind, "construction", StringComparison.Ordinal))
{
@@ -229,15 +233,13 @@ internal sealed class CommanderPlanningService
ShipKind = ship.Definition.Kind,
HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"),
FactionWantsOre = true,
FactionWantsCombat = factionCommander?.ActiveDirectives.Contains("attack-rival", StringComparer.Ordinal) ?? false,
FactionWantsExpansion = factionCommander?.ActiveDirectives
.Contains("expand-territory", StringComparer.Ordinal) ?? false,
FactionNeedsShipyard = !(factionCommander?.ActiveDirectives.Contains("bootstrap-war-industry", StringComparer.Ordinal) ?? false)
? false
: !world.Stations.Any(station =>
FactionWantsCombat = attackTask is not null,
FactionWantsExpansion = expansionTask is not null,
FactionNeedsShipyard = shipyardExpansionTask is not null
&& !world.Stations.Any(station =>
string.Equals(station.FactionId, ship.FactionId, StringComparison.Ordinal)
&& station.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
TargetEnemySystemId = enemyTarget?.SystemId,
TargetEnemySystemId = attackTask?.TargetSystemId ?? enemyTarget?.SystemId,
TargetEnemyEntityId = enemyTarget?.EntityId,
TradeItemId = tradeRoute?.ItemId,
TradeSourceStationId = tradeRoute?.SourceStationId,
@@ -245,70 +247,33 @@ internal sealed class CommanderPlanningService
};
}
private static IReadOnlyList<GoapAction<FactionPlanningState>> BuildFactionActions(SimulationWorld world)
{
var actions = new List<GoapAction<FactionPlanningState>>();
actions.Add(new PlanWarIndustryAction());
actions.Add(new PlanCommoditySupplyAction("water"));
foreach (var (shipId, def) in world.ShipDefinitions)
{
actions.Add(new OrderShipProductionAction(def.Kind, shipId));
}
actions.Add(new LaunchExterminationCampaignAction());
actions.Add(new ExpandToSystemAction());
return actions;
}
internal static bool FactionCommanderHasDirective(SimulationWorld world, string factionId, string directive) =>
internal static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) =>
world.Commanders.FirstOrDefault(c =>
c.FactionId == factionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal))
?.ActiveDirectives.Contains(directive, StringComparer.Ordinal) ?? false;
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
private static void TryQueueFactionExpansionProject(
internal static bool FactionCommanderHasIssuedTask(
SimulationWorld world,
CommanderRuntime commander,
IndustryExpansionProject? project)
{
if (project is null)
{
return;
}
string factionId,
FactionIssuedTaskKind kind,
string? shipRole = null) =>
FindFactionCommander(world, factionId)?
.IssuedTasks.Any(task =>
task.Kind == kind
&& task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active or FactionIssuedTaskState.Blocked
&& (shipRole is null || string.Equals(task.ShipRole, shipRole, StringComparison.Ordinal))) ?? false;
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
}
private static IndustryExpansionProject? SelectGoalDrivenWarIndustryProject(
SimulationWorld world,
FactionPlanningState state,
string factionId)
{
if (!state.HasRefinedMetalsProduction || state.RefinedMetalsShortageHorizonSeconds < 240f)
{
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "refinedmetals");
}
if (!state.HasHullpartsProduction || state.HullpartsShortageHorizonSeconds < 240f)
{
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "hullparts");
}
if (!state.HasClaytronicsProduction || state.ClaytronicsShortageHorizonSeconds < 240f)
{
return FactionIndustryPlanner.AnalyzeCommodityNeed(world, factionId, "claytronics");
}
if (!state.HasShipFactory)
{
return FactionIndustryPlanner.CreateShipyardFoundationProject(world, factionId);
}
return null;
}
internal static FactionIssuedTaskRuntime? GetHighestPriorityIssuedTask(
CommanderRuntime? factionCommander,
FactionIssuedTaskKind kind,
string? shipRole = null) =>
factionCommander?.IssuedTasks
.Where(task =>
task.Kind == kind
&& task.State is FactionIssuedTaskState.Planned or FactionIssuedTaskState.Active or FactionIssuedTaskState.Blocked
&& (shipRole is null || string.Equals(task.ShipRole, shipRole, StringComparison.Ordinal)))
.OrderByDescending(task => task.Priority)
.FirstOrDefault();
private static (string EntityId, string SystemId)? SelectEnemyTarget(SimulationWorld world, ShipRuntime ship)
{

View File

@@ -16,26 +16,40 @@ public sealed class FactionPlanningState
public int EnemyShipCount { get; set; }
public int EnemyStationCount { get; set; }
public float OreStockpile { get; set; }
public float RefinedMetalsStockpile { get; set; }
public float RefinedMetalsProductionRate { get; set; }
public float RefinedMetalsShortageHorizonSeconds { get; set; }
public float HullpartsStockpile { get; set; }
public float HullpartsProductionRate { get; set; }
public float HullpartsShortageHorizonSeconds { get; set; }
public float ClaytronicsStockpile { get; set; }
public float ClaytronicsProductionRate { get; set; }
public float ClaytronicsShortageHorizonSeconds { get; set; }
public float WaterStockpile { get; set; }
public float WaterProductionRate { get; set; }
public float WaterShortageHorizonSeconds { get; set; }
public float RefinedMetalsAvailableStock { get; set; }
public float RefinedMetalsUsageRate { get; set; }
public float RefinedMetalsProjectedProductionRate { get; set; }
public float RefinedMetalsProjectedNetRate { get; set; }
public float RefinedMetalsLevelSeconds { get; set; }
public string RefinedMetalsLevel { get; set; } = "unknown";
public float HullpartsAvailableStock { get; set; }
public float HullpartsUsageRate { get; set; }
public float HullpartsProjectedProductionRate { get; set; }
public float HullpartsProjectedNetRate { get; set; }
public float HullpartsLevelSeconds { get; set; }
public string HullpartsLevel { get; set; } = "unknown";
public float ClaytronicsAvailableStock { get; set; }
public float ClaytronicsUsageRate { get; set; }
public float ClaytronicsProjectedProductionRate { get; set; }
public float ClaytronicsProjectedNetRate { get; set; }
public float ClaytronicsLevelSeconds { get; set; }
public string ClaytronicsLevel { get; set; } = "unknown";
public float WaterAvailableStock { get; set; }
public float WaterUsageRate { get; set; }
public float WaterProjectedProductionRate { get; set; }
public float WaterProjectedNetRate { get; set; }
public float WaterLevelSeconds { get; set; }
public string WaterLevel { get; set; } = "unknown";
public bool HasRefinedMetalsProduction => RefinedMetalsProductionRate > 0.01f;
public bool HasHullpartsProduction => HullpartsProductionRate > 0.01f;
public bool HasClaytronicsProduction => ClaytronicsProductionRate > 0.01f;
public bool HasWaterProduction => WaterProductionRate > 0.01f;
public bool HasRefinedMetalsProduction => RefinedMetalsProjectedProductionRate > 0.01f;
public bool HasHullpartsProduction => HullpartsProjectedProductionRate > 0.01f;
public bool HasClaytronicsProduction => ClaytronicsProjectedProductionRate > 0.01f;
public bool HasWaterProduction => WaterProjectedProductionRate > 0.01f;
public bool HasWarIndustrySupplyChain =>
HasRefinedMetalsProduction && HasHullpartsProduction && HasClaytronicsProduction;
IsCommodityOperational(RefinedMetalsProjectedProductionRate, RefinedMetalsProjectedNetRate, RefinedMetalsLevelSeconds, RefinedMetalsLevel, 240f)
&& IsCommodityOperational(HullpartsProjectedProductionRate, HullpartsProjectedNetRate, HullpartsLevelSeconds, HullpartsLevel, 240f)
&& IsCommodityOperational(ClaytronicsProjectedProductionRate, ClaytronicsProjectedNetRate, ClaytronicsLevelSeconds, ClaytronicsLevel, 240f);
public FactionPlanningState Clone() => (FactionPlanningState)MemberwiseClone();
@@ -44,6 +58,39 @@ public sealed class FactionPlanningState
var expansionDeficit = Math.Max(0, state.TargetSystemCount - state.ControlledSystemCount);
return Math.Max(3, (state.ControlledSystemCount * 2) + (expansionDeficit * 3) + Math.Min(4, state.EnemyFactionCount + state.EnemyStationCount));
}
internal static bool IsCommodityOperational(
float projectedProductionRate,
float projectedNetRate,
float levelSeconds,
string level,
float targetLevelSeconds) =>
projectedProductionRate > 0.01f
&& projectedNetRate >= -0.01f
&& levelSeconds >= targetLevelSeconds
&& (string.Equals(level, "stable", StringComparison.OrdinalIgnoreCase)
|| string.Equals(level, "surplus", StringComparison.OrdinalIgnoreCase));
internal static float ComputeCommodityNeed(
float projectedProductionRate,
float usageRate,
float projectedNetRate,
float levelSeconds,
string level,
float targetLevelSeconds)
{
var levelWeight = level switch
{
"critical" => 140f,
"low" => 80f,
"stable" => 20f,
_ => 0f,
};
var rateDeficit = MathF.Max(0f, usageRate - projectedProductionRate);
var levelDeficit = MathF.Max(0f, targetLevelSeconds - levelSeconds) / MathF.Max(targetLevelSeconds, 1f);
var instability = projectedNetRate < 0f ? MathF.Abs(projectedNetRate) * 80f : 0f;
return levelWeight + (rateDeficit * 140f) + (levelDeficit * 120f) + instability;
}
}
// ─── Goals ─────────────────────────────────────────────────────────────────────
@@ -63,12 +110,16 @@ public sealed class EnsureWarIndustryGoal : GoapGoal<FactionPlanningState>
}
var missingStages =
(state.HasRefinedMetalsProduction ? 0 : 1) +
(state.HasHullpartsProduction ? 0 : 1) +
(state.HasClaytronicsProduction ? 0 : 1) +
(FactionPlanningState.IsCommodityOperational(state.RefinedMetalsProjectedProductionRate, state.RefinedMetalsProjectedNetRate, state.RefinedMetalsLevelSeconds, state.RefinedMetalsLevel, 240f) ? 0 : 1) +
(FactionPlanningState.IsCommodityOperational(state.HullpartsProjectedProductionRate, state.HullpartsProjectedNetRate, state.HullpartsLevelSeconds, state.HullpartsLevel, 240f) ? 0 : 1) +
(FactionPlanningState.IsCommodityOperational(state.ClaytronicsProjectedProductionRate, state.ClaytronicsProjectedNetRate, state.ClaytronicsLevelSeconds, state.ClaytronicsLevel, 240f) ? 0 : 1) +
(state.HasShipFactory ? 0 : 1);
var supplyNeed =
FactionPlanningState.ComputeCommodityNeed(state.RefinedMetalsProjectedProductionRate, state.RefinedMetalsUsageRate, state.RefinedMetalsProjectedNetRate, state.RefinedMetalsLevelSeconds, state.RefinedMetalsLevel, 240f)
+ FactionPlanningState.ComputeCommodityNeed(state.HullpartsProjectedProductionRate, state.HullpartsUsageRate, state.HullpartsProjectedNetRate, state.HullpartsLevelSeconds, state.HullpartsLevel, 240f)
+ FactionPlanningState.ComputeCommodityNeed(state.ClaytronicsProjectedProductionRate, state.ClaytronicsUsageRate, state.ClaytronicsProjectedNetRate, state.ClaytronicsLevelSeconds, state.ClaytronicsLevel, 240f);
return missingStages <= 0 ? 0f : 125f + (missingStages * 18f);
return missingStages <= 0 && supplyNeed <= 0.01f ? 0f : 110f + (missingStages * 22f) + (supplyNeed * 0.18f);
}
}
@@ -77,21 +128,22 @@ public sealed class EnsureWaterSecurityGoal : GoapGoal<FactionPlanningState>
public override string Name => "ensure-water-security";
public override bool IsSatisfied(FactionPlanningState state) =>
state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f;
FactionPlanningState.IsCommodityOperational(state.WaterProjectedProductionRate, state.WaterProjectedNetRate, state.WaterLevelSeconds, state.WaterLevel, 300f);
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
if (state.HasWaterProduction && state.WaterShortageHorizonSeconds >= 300f)
if (FactionPlanningState.IsCommodityOperational(state.WaterProjectedProductionRate, state.WaterProjectedNetRate, state.WaterLevelSeconds, state.WaterLevel, 300f))
{
return 0f;
}
if (float.IsPositiveInfinity(state.WaterShortageHorizonSeconds))
{
return state.HasWaterProduction ? 0f : 85f;
}
return 55f + MathF.Max(0f, 300f - state.WaterShortageHorizonSeconds) * 0.2f;
return 55f + FactionPlanningState.ComputeCommodityNeed(
state.WaterProjectedProductionRate,
state.WaterUsageRate,
state.WaterProjectedNetRate,
state.WaterLevelSeconds,
state.WaterLevel,
300f) * 0.25f;
}
}
@@ -170,159 +222,3 @@ public sealed class EnsureConstructionCapacityGoal : GoapGoal<FactionPlanningSta
return deficit <= 0 ? 0f : 60f + (deficit * 10f);
}
}
// ─── Actions ───────────────────────────────────────────────────────────────────
public sealed class OrderShipProductionAction : GoapAction<FactionPlanningState>
{
private readonly string shipKind;
private readonly string shipId;
public OrderShipProductionAction(string shipKind, string shipId)
{
this.shipKind = shipKind;
this.shipId = shipId;
}
public override string Name => $"order-{shipId}-production";
public override float Cost => 1f;
public override bool CheckPreconditions(FactionPlanningState state) =>
state.HasShipFactory && state.HasWarIndustrySupplyChain;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
switch (shipKind)
{
case "military": state.MilitaryShipCount++; break;
case "mining": state.MinerShipCount++; break;
case "transport": state.TransportShipCount++; break;
case "construction": state.ConstructorShipCount++; break;
}
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add($"produce-{shipKind}-ships");
}
}
public sealed class PlanWarIndustryAction : GoapAction<FactionPlanningState>
{
public override string Name => "plan-war-industry";
public override float Cost => 2f;
public override bool CheckPreconditions(FactionPlanningState state) =>
state.EnemyFactionCount > 0 && (!state.HasWarIndustrySupplyChain || !state.HasShipFactory);
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
state.RefinedMetalsProductionRate = MathF.Max(state.RefinedMetalsProductionRate, 1f);
state.HullpartsProductionRate = MathF.Max(state.HullpartsProductionRate, 1f);
state.ClaytronicsProductionRate = MathF.Max(state.ClaytronicsProductionRate, 1f);
state.HasShipFactory = true;
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add("bootstrap-war-industry");
if (FactionIndustryPlanner.AnalyzeShipyardNeed(world, commander.FactionId) is not { } project)
{
return;
}
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
}
}
public sealed class PlanCommoditySupplyAction : GoapAction<FactionPlanningState>
{
private readonly string commodityId;
public PlanCommoditySupplyAction(string commodityId)
{
this.commodityId = commodityId;
}
public override string Name => $"plan-{commodityId}-supply";
public override float Cost => 2f;
public override bool CheckPreconditions(FactionPlanningState state) =>
commodityId switch
{
"water" => !state.HasWaterProduction || state.WaterShortageHorizonSeconds < 300f,
_ => false,
};
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
if (string.Equals(commodityId, "water", StringComparison.Ordinal))
{
state.WaterProductionRate = MathF.Max(state.WaterProductionRate, 1f);
state.WaterShortageHorizonSeconds = MathF.Max(state.WaterShortageHorizonSeconds, 600f);
}
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
if (FactionIndustryPlanner.AnalyzeCommodityNeed(world, commander.FactionId, commodityId) is not { } project)
{
return;
}
FactionIndustryPlanner.EnsureExpansionSite(world, commander.FactionId, project);
commander.ActiveDirectives.Add($"expand-industry:{project.CommodityId}:{project.SystemId}:{project.CelestialId}");
}
}
public sealed class ExpandToSystemAction : GoapAction<FactionPlanningState>
{
public override string Name => "expand-to-system";
public override float Cost => 3f;
public override bool CheckPreconditions(FactionPlanningState state) =>
state.ConstructorShipCount > 0 && state.MilitaryShipCount >= 2 && state.HasWarIndustrySupplyChain;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
state.ControlledSystemCount++;
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add("expand-territory");
}
}
public sealed class LaunchExterminationCampaignAction : GoapAction<FactionPlanningState>
{
public override string Name => "launch-extermination-campaign";
public override float Cost => 1f;
public override bool CheckPreconditions(FactionPlanningState state) =>
state.EnemyFactionCount > 0
&& state.HasShipFactory
&& state.MilitaryShipCount >= Math.Max(2, FactionPlanningState.ComputeTargetWarships(state) / 2);
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
state.EnemyShipCount = 0;
state.EnemyStationCount = 0;
state.EnemyFactionCount = 0;
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add("attack-rival");
commander.ActiveDirectives.Add("produce-military-ships");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
namespace SpaceGame.Api.Factions.Contracts;
public sealed record FactionGoapStateSnapshot(
public sealed record FactionPlanningStateSnapshot(
int MilitaryShipCount,
int MinerShipCount,
int TransportShipCount,
@@ -9,17 +9,134 @@ public sealed record FactionGoapStateSnapshot(
int TargetSystemCount,
bool HasShipFactory,
float OreStockpile,
float RefinedMetalsStockpile,
float RefinedMetalsProductionRate,
float HullpartsStockpile,
float HullpartsProductionRate,
float ClaytronicsStockpile,
float ClaytronicsProductionRate,
float WaterStockpile,
float WaterProductionRate,
float WaterShortageHorizonSeconds);
float RefinedMetalsAvailableStock,
float RefinedMetalsUsageRate,
float RefinedMetalsProjectedProductionRate,
float RefinedMetalsProjectedNetRate,
float RefinedMetalsLevelSeconds,
string RefinedMetalsLevel,
float HullpartsAvailableStock,
float HullpartsUsageRate,
float HullpartsProjectedProductionRate,
float HullpartsProjectedNetRate,
float HullpartsLevelSeconds,
string HullpartsLevel,
float ClaytronicsAvailableStock,
float ClaytronicsUsageRate,
float ClaytronicsProjectedProductionRate,
float ClaytronicsProjectedNetRate,
float ClaytronicsLevelSeconds,
string ClaytronicsLevel,
float WaterAvailableStock,
float WaterUsageRate,
float WaterProjectedProductionRate,
float WaterProjectedNetRate,
float WaterLevelSeconds,
string WaterLevel);
public sealed record FactionGoapPrioritySnapshot(string GoalName, float Priority);
public sealed record FactionStrategicPrioritySnapshot(string GoalName, float Priority);
public sealed record FactionCommoditySignalSnapshot(
string ItemId,
float AvailableStock,
float OnHand,
float ProductionRatePerSecond,
float CommittedProductionRatePerSecond,
float UsageRatePerSecond,
float NetRatePerSecond,
float ProjectedNetRatePerSecond,
float LevelSeconds,
string Level,
float ProjectedProductionRatePerSecond,
float BuyBacklog,
float ReservedForConstruction);
public sealed record FactionThreatSignalSnapshot(
string ScopeId,
string ScopeKind,
int EnemyShipCount,
int EnemyStationCount);
public sealed record FactionBlackboardSnapshot(
int PlanCycle,
DateTimeOffset UpdatedAtUtc,
int TargetWarshipCount,
bool HasWarIndustrySupplyChain,
bool HasShipyard,
bool HasActiveExpansionProject,
string? ActiveExpansionCommodityId,
string? ActiveExpansionModuleId,
string? ActiveExpansionSiteId,
string? ActiveExpansionSystemId,
int EnemyFactionCount,
int EnemyShipCount,
int EnemyStationCount,
int MilitaryShipCount,
int MinerShipCount,
int TransportShipCount,
int ConstructorShipCount,
int ControlledSystemCount,
IReadOnlyList<FactionCommoditySignalSnapshot> CommoditySignals,
IReadOnlyList<FactionThreatSignalSnapshot> ThreatSignals);
public sealed record FactionPlanStepSnapshot(
string Id,
string Kind,
string Status,
float Priority,
string? CommodityId,
string? ModuleId,
string? TargetFactionId,
string? TargetSiteId,
string? BlockingReason,
string? Notes,
int LastEvaluatedCycle,
IReadOnlyList<string> DependencyStepIds,
IReadOnlyList<string> RequiredFacts,
IReadOnlyList<string> ProducedFacts,
IReadOnlyList<string> AssignedAssets,
IReadOnlyList<string> IssuedTaskIds);
public sealed record FactionIssuedTaskSnapshot(
string Id,
string Kind,
string State,
string ObjectiveId,
string StepId,
float Priority,
string? ShipRole,
string? CommodityId,
string? ModuleId,
string? TargetFactionId,
string? TargetSystemId,
string? TargetSiteId,
int CreatedAtCycle,
int UpdatedAtCycle,
string? BlockingReason,
string? Notes,
IReadOnlyList<string> AssignedAssets);
public sealed record FactionObjectiveSnapshot(
string Id,
string Kind,
string State,
float Priority,
string? ParentObjectiveId,
string? TargetFactionId,
string? TargetSystemId,
string? TargetSiteId,
string? TargetRegionId,
string? CommodityId,
string? ModuleId,
int BudgetWeight,
int SlotCost,
int CreatedAtCycle,
int UpdatedAtCycle,
string? InvalidationReason,
string? BlockingReason,
IReadOnlyList<string> PrerequisiteObjectiveIds,
IReadOnlyList<string> AssignedAssets,
IReadOnlyList<FactionPlanStepSnapshot> Steps);
public sealed record FactionSnapshot(
string Id,
@@ -32,8 +149,11 @@ public sealed record FactionSnapshot(
int ShipsBuilt,
int ShipsLost,
string? DefaultPolicySetId,
FactionGoapStateSnapshot? GoapState,
IReadOnlyList<FactionGoapPrioritySnapshot>? GoapPriorities);
FactionPlanningStateSnapshot? StrategicAssessment,
IReadOnlyList<FactionStrategicPrioritySnapshot>? StrategicPriorities,
FactionBlackboardSnapshot? Blackboard,
IReadOnlyList<FactionObjectiveSnapshot>? Objectives,
IReadOnlyList<FactionIssuedTaskSnapshot>? IssuedTasks);
public sealed record FactionDelta(
string Id,
@@ -46,5 +166,8 @@ public sealed record FactionDelta(
int ShipsBuilt,
int ShipsLost,
string? DefaultPolicySetId,
FactionGoapStateSnapshot? GoapState,
IReadOnlyList<FactionGoapPrioritySnapshot>? GoapPriorities);
FactionPlanningStateSnapshot? StrategicAssessment,
IReadOnlyList<FactionStrategicPrioritySnapshot>? StrategicPriorities,
FactionBlackboardSnapshot? Blackboard,
IReadOnlyList<FactionObjectiveSnapshot>? Objectives,
IReadOnlyList<FactionIssuedTaskSnapshot>? IssuedTasks);

View File

@@ -27,7 +27,6 @@ public sealed class CommanderRuntime
public string? PolicySetId { get; set; }
public string? Doctrine { get; set; }
public List<string> Goals { get; } = [];
public HashSet<string> ActiveDirectives { get; } = new(StringComparer.Ordinal);
public string? ActiveGoalName { get; set; }
public string? ActiveActionName { get; set; }
public float ReplanTimer { get; set; }
@@ -37,8 +36,194 @@ public sealed class CommanderRuntime
public CommanderTaskRuntime? ActiveTask { get; set; }
public HashSet<string> SubordinateCommanderIds { get; } = new(StringComparer.Ordinal);
public bool IsAlive { get; set; } = true;
public FactionPlanningState? LastPlanningState { get; set; }
public IReadOnlyList<(string Name, float Priority)>? LastGoalPriorities { get; set; }
public FactionPlanningState? LastStrategicAssessment { get; set; }
public IReadOnlyList<(string Name, float Priority)>? LastStrategicPriorities { get; set; }
public FactionBlackboardRuntime? FactionBlackboard { get; set; }
public List<FactionObjectiveRuntime> Objectives { get; } = [];
public List<FactionIssuedTaskRuntime> IssuedTasks { get; } = [];
public int PlanningCycle { get; set; }
}
public enum FactionObjectiveKind
{
DestroyFaction,
BootstrapWarIndustry,
BuildShipyard,
BuildAttackFleet,
EnsureCommoditySupply,
EnsureWaterSecurity,
EnsureMiningCapacity,
EnsureConstructionCapacity,
EnsureTransportCapacity,
}
public enum FactionObjectiveState
{
Planned,
Active,
Blocked,
Complete,
Failed,
Cancelled,
}
public enum FactionPlanStepKind
{
EnsureCommodityProduction,
EnsureShipyardSite,
ProduceFleet,
AttackFactionAssets,
EnsureWaterSupply,
EnsureMiningCapacity,
EnsureConstructionCapacity,
EnsureTransportCapacity,
MonitorExpansionProject,
}
public enum FactionPlanStepStatus
{
Planned,
Ready,
Running,
Blocked,
Complete,
Failed,
Cancelled,
}
public enum FactionIssuedTaskKind
{
ExpandIndustry,
ProduceShips,
AttackFactionAssets,
SustainWarIndustry,
}
public enum FactionIssuedTaskState
{
Planned,
Active,
Blocked,
Complete,
Cancelled,
}
public sealed class FactionObjectiveRuntime
{
public required string Id { get; init; }
public required string MergeKey { get; init; }
public required FactionObjectiveKind Kind { get; init; }
public FactionObjectiveState State { get; set; } = FactionObjectiveState.Planned;
public float Priority { get; set; }
public string? ParentObjectiveId { get; set; }
public string? TargetFactionId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetSiteId { get; set; }
public string? TargetRegionId { get; set; }
public string? CommodityId { get; set; }
public string? ModuleId { get; set; }
public int BudgetWeight { get; set; }
public int SlotCost { get; set; } = 1;
public int CreatedAtCycle { get; init; }
public int UpdatedAtCycle { get; set; }
public string? InvalidationReason { get; set; }
public string? BlockingReason { get; set; }
public HashSet<string> PrerequisiteObjectiveIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> AssignedAssetIds { get; } = new(StringComparer.Ordinal);
public List<FactionPlanStepRuntime> Steps { get; } = [];
}
public sealed class FactionPlanStepRuntime
{
public required string Id { get; init; }
public required string ObjectiveId { get; init; }
public required FactionPlanStepKind Kind { get; init; }
public FactionPlanStepStatus Status { get; set; } = FactionPlanStepStatus.Planned;
public float Priority { get; set; }
public string? CommodityId { get; set; }
public string? ModuleId { get; set; }
public string? TargetFactionId { get; set; }
public string? TargetSiteId { get; set; }
public string? BlockingReason { get; set; }
public string? Notes { get; set; }
public int LastEvaluatedCycle { get; set; }
public HashSet<string> DependencyStepIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> RequiredFacts { get; } = new(StringComparer.Ordinal);
public HashSet<string> ProducedFacts { get; } = new(StringComparer.Ordinal);
public HashSet<string> AssignedAssetIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> IssuedTaskIds { get; } = new(StringComparer.Ordinal);
}
public sealed class FactionIssuedTaskRuntime
{
public required string Id { get; init; }
public required string MergeKey { get; init; }
public required FactionIssuedTaskKind Kind { get; init; }
public required string ObjectiveId { get; init; }
public required string StepId { get; init; }
public FactionIssuedTaskState State { get; set; } = FactionIssuedTaskState.Planned;
public float Priority { get; set; }
public string? ShipRole { get; set; }
public string? CommodityId { get; set; }
public string? ModuleId { get; set; }
public string? TargetFactionId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetSiteId { get; set; }
public int CreatedAtCycle { get; init; }
public int UpdatedAtCycle { get; set; }
public string? BlockingReason { get; set; }
public string? Notes { get; set; }
public HashSet<string> AssignedAssetIds { get; } = new(StringComparer.Ordinal);
}
public sealed class FactionBlackboardRuntime
{
public int PlanCycle { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public int TargetWarshipCount { get; set; }
public bool HasWarIndustrySupplyChain { get; set; }
public bool HasShipyard { get; set; }
public bool HasActiveExpansionProject { get; set; }
public string? ActiveExpansionCommodityId { get; set; }
public string? ActiveExpansionModuleId { get; set; }
public string? ActiveExpansionSiteId { get; set; }
public string? ActiveExpansionSystemId { get; set; }
public int EnemyFactionCount { get; set; }
public int EnemyShipCount { get; set; }
public int EnemyStationCount { get; set; }
public int MilitaryShipCount { get; set; }
public int MinerShipCount { get; set; }
public int TransportShipCount { get; set; }
public int ConstructorShipCount { get; set; }
public int ControlledSystemCount { get; set; }
public List<FactionCommoditySignalRuntime> CommoditySignals { get; } = [];
public List<FactionThreatSignalRuntime> ThreatSignals { get; } = [];
public HashSet<string> AvailableShipIds { get; } = new(StringComparer.Ordinal);
}
public sealed class FactionCommoditySignalRuntime
{
public required string ItemId { get; init; }
public float AvailableStock { get; set; }
public float OnHand { get; set; }
public float ProductionRatePerSecond { get; set; }
public float CommittedProductionRatePerSecond { get; set; }
public float UsageRatePerSecond { get; set; }
public float NetRatePerSecond { get; set; }
public float ProjectedNetRatePerSecond { get; set; }
public float LevelSeconds { get; set; }
public string Level { get; set; } = "unknown";
public float ProjectedProductionRatePerSecond { get; set; }
public float BuyBacklog { get; set; }
public float ReservedForConstruction { get; set; }
}
public sealed class FactionThreatSignalRuntime
{
public required string ScopeId { get; init; }
public required string ScopeKind { get; init; }
public int EnemyShipCount { get; set; }
public int EnemyStationCount { get; set; }
}
public sealed class CommanderBehaviorRuntime

View File

@@ -0,0 +1,54 @@
namespace SpaceGame.Api.Industry.Planning;
internal static class CommodityOperationalSignal
{
internal static float ComputeNeedScore(FactionCommoditySnapshot commodity, float targetLevelSeconds)
{
var productionDeficit = MathF.Max(0f, commodity.ConsumptionRatePerSecond - commodity.ProjectedProductionRatePerSecond);
var levelDeficit = MathF.Max(0f, targetLevelSeconds - commodity.LevelSeconds) / MathF.Max(targetLevelSeconds, 1f);
var backlogPressure = MathF.Max(0f, commodity.BuyBacklog + commodity.ReservedForConstruction - commodity.AvailableStock);
var levelWeight = commodity.Level switch
{
CommodityLevelKind.Critical => 140f,
CommodityLevelKind.Low => 80f,
CommodityLevelKind.Stable => 20f,
_ => 0f,
};
return levelWeight
+ (productionDeficit * 140f)
+ (levelDeficit * 120f)
+ backlogPressure;
}
internal static bool IsOperational(FactionCommoditySnapshot commodity, float targetLevelSeconds) =>
commodity.ProjectedProductionRatePerSecond > 0.01f
&& commodity.ProjectedNetRatePerSecond >= -0.01f
&& commodity.LevelSeconds >= targetLevelSeconds
&& commodity.Level is CommodityLevelKind.Stable or CommodityLevelKind.Surplus;
internal static bool IsStrained(FactionCommoditySnapshot commodity, float targetLevelSeconds) =>
!IsOperational(commodity, targetLevelSeconds)
|| commodity.Level is CommodityLevelKind.Critical or CommodityLevelKind.Low;
internal static float ComputeFeasibilityFactor(FactionCommoditySnapshot commodity, float targetLevelSeconds)
{
if (commodity.AvailableStock <= 0.01f && commodity.ProjectedProductionRatePerSecond <= 0.01f)
{
return 0.65f;
}
if (commodity.Level is CommodityLevelKind.Critical)
{
return 0.72f;
}
if (commodity.Level is CommodityLevelKind.Low || commodity.LevelSeconds < targetLevelSeconds)
{
return 0.84f;
}
return 1f;
}
}

View File

@@ -41,50 +41,27 @@ internal sealed class FactionCommoditySnapshot
internal float NetRatePerSecond => ProductionRatePerSecond - ConsumptionRatePerSecond;
internal float ProjectedProductionRatePerSecond => ProductionRatePerSecond + CommittedProductionRatePerSecond;
internal float ProjectedNetRatePerSecond => ProjectedProductionRatePerSecond - ConsumptionRatePerSecond;
internal float OperationalUsageRatePerSecond => MathF.Max(ConsumptionRatePerSecond, BuyBacklog / 180f);
internal float LevelSeconds => AvailableStock <= 0.01f
? 0f
: AvailableStock / MathF.Max(OperationalUsageRatePerSecond, 0.01f);
internal float ShortageHorizonSeconds
internal CommodityLevelKind Level =>
LevelSeconds switch
{
get
{
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
{
return float.PositiveInfinity;
<= 60f => CommodityLevelKind.Critical,
<= 180f => CommodityLevelKind.Low,
<= 480f => CommodityLevelKind.Stable,
_ => CommodityLevelKind.Surplus,
};
}
if (NetRatePerSecond >= -0.01f)
internal enum CommodityLevelKind
{
return float.PositiveInfinity;
}
return AvailableStock / MathF.Max(0.01f, -NetRatePerSecond);
}
}
internal float ProjectedShortageHorizonSeconds
{
get
{
if (ConsumptionRatePerSecond <= 0.01f && BuyBacklog <= 0.01f)
{
return float.PositiveInfinity;
}
if (ProjectedNetRatePerSecond >= -0.01f)
{
return float.PositiveInfinity;
}
return AvailableStock / MathF.Max(0.01f, -ProjectedNetRatePerSecond);
}
}
internal float PressureScore =>
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
+ MathF.Max(0f, ConsumptionRatePerSecond - ProductionRatePerSecond) * 120f;
internal float ProjectedPressureScore =>
MathF.Max(0f, (BuyBacklog + ReservedForConstruction) - (OnHand + Inbound))
+ MathF.Max(0f, ConsumptionRatePerSecond - ProjectedProductionRatePerSecond) * 120f;
Critical,
Low,
Stable,
Surplus,
}
internal static class FactionEconomyAnalyzer

View File

@@ -4,6 +4,9 @@ using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
internal static class FactionIndustryPlanner
{
private const float CommodityTargetLevelSeconds = 240f;
private const float WaterTargetLevelSeconds = 300f;
internal static IndustryExpansionProject? AnalyzeCommodityNeed(SimulationWorld world, string factionId, string commodityId)
{
if (HasActiveExpansionProject(world, factionId))
@@ -64,14 +67,13 @@ internal static class FactionIndustryPlanner
.Select(itemId => new
{
ItemId = itemId,
HasProducer = FactionHasProducerForCommodity(world, factionId, itemId),
Pressure = GetCommodityPressure(world, factionId, itemId),
Stockpile = GetCommodityStockpile(world, factionId, itemId),
Commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId),
})
.Where(entry => !entry.HasProducer || entry.Pressure > 0.01f || entry.Stockpile < 120f)
.OrderByDescending(entry => !entry.HasProducer ? 1 : 0)
.ThenByDescending(entry => entry.Pressure)
.ThenBy(entry => entry.Stockpile)
.Where(entry => entry.Commodity.ProjectedProductionRatePerSecond <= 0.01f
|| CommodityOperationalSignal.IsStrained(entry.Commodity, GetTargetLevelSeconds(entry.ItemId)))
.OrderByDescending(entry => entry.Commodity.ProjectedProductionRatePerSecond <= 0.01f ? 1 : 0)
.ThenByDescending(entry => CommodityOperationalSignal.ComputeNeedScore(entry.Commodity, GetTargetLevelSeconds(entry.ItemId)))
.ThenBy(entry => entry.Commodity.AvailableStock)
.Select(entry => entry.ItemId)
.FirstOrDefault();
@@ -301,14 +303,20 @@ internal static class FactionIndustryPlanner
.GroupBy(order => order.ItemId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Sum(order => order.RemainingAmount), StringComparer.Ordinal);
if (CommanderPlanningService.FactionCommanderHasDirective(world, factionId, "produce-military-ships"))
if (CommanderPlanningService.FactionCommanderHasIssuedTask(world, factionId, FactionIssuedTaskKind.ProduceShips, "military"))
{
demandByItem["hullparts"] = demandByItem.GetValueOrDefault("hullparts") + 120f;
demandByItem["claytronics"] = demandByItem.GetValueOrDefault("claytronics") + 90f;
}
return demandByItem
.Select(entry => (ItemId: ResolveBottleneckCommodity(world, factionId, entry.Key), Score: entry.Value))
.Select(entry =>
{
var itemId = ResolveBottleneckCommodity(world, factionId, entry.Key);
var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId);
var score = entry.Value + CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(itemId));
return (ItemId: itemId, Score: score);
})
.Where(entry => entry.ItemId is not null)
.GroupBy(entry => entry.ItemId!, StringComparer.Ordinal)
.Select(group => (ItemId: group.Key, Score: group.Sum(entry => entry.Score)))
@@ -358,7 +366,11 @@ internal static class FactionIndustryPlanner
var weakestUnproducedInput = world.ProductionGraph.GetImmediateInputs(itemId)
.Where(inputId => !FactionHasProducerForCommodity(world, factionId, inputId))
.Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId), Stockpile: GetCommodityStockpile(world, factionId, inputId)))
.Select(inputId =>
{
var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(inputId);
return (ItemId: inputId, Score: CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(inputId)), Stockpile: commodity.AvailableStock);
})
.OrderByDescending(entry => entry.Score)
.ThenBy(entry => entry.Stockpile)
.FirstOrDefault();
@@ -370,11 +382,15 @@ internal static class FactionIndustryPlanner
}
var weakestInput = world.ProductionGraph.GetImmediateInputs(itemId)
.Select(inputId => (ItemId: inputId, Score: GetCommodityPressure(world, factionId, inputId)))
.Select(inputId =>
{
var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(inputId);
return (ItemId: inputId, Score: CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(inputId)));
})
.OrderByDescending(entry => entry.Score)
.FirstOrDefault();
return weakestInput.Score > GetCommodityPressure(world, factionId, itemId) * 0.6f
return weakestInput.Score > GetCommodityNeedScore(world, factionId, itemId) * 0.6f
? ResolveBottleneckCommodity(world, factionId, weakestInput.ItemId, visited)
: itemId;
}
@@ -419,13 +435,14 @@ internal static class FactionIndustryPlanner
&& string.Equals(site.TargetKind, "station-foundation", StringComparison.Ordinal)
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
private static float GetCommodityPressure(SimulationWorld world, string factionId, string itemId)
private static float GetCommodityNeedScore(SimulationWorld world, string factionId, string itemId)
{
return FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).ProjectedPressureScore;
var commodity = FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId);
return CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(itemId));
}
private static float GetCommodityStockpile(SimulationWorld world, string factionId, string itemId) =>
FactionEconomyAnalyzer.Build(world, factionId).GetCommodity(itemId).AvailableStock;
private static float GetTargetLevelSeconds(string itemId) =>
string.Equals(itemId, "water", StringComparison.Ordinal) ? WaterTargetLevelSeconds : CommodityTargetLevelSeconds;
private static CelestialRuntime? SelectFoundationCelestial(SimulationWorld world, string factionId, string commodityId)
{

View File

@@ -207,8 +207,11 @@ internal sealed class SimulationProjectionService
faction.ShipsBuilt,
faction.ShipsLost,
faction.DefaultPolicySetId,
faction.GoapState,
faction.GoapPriorities)).ToList());
faction.StrategicAssessment,
faction.StrategicPriorities,
faction.Blackboard,
faction.Objectives,
faction.IssuedTasks)).ToList());
}
public void PrimeDeltaBaseline(SimulationWorld world)
@@ -515,10 +518,18 @@ internal sealed class SimulationProjectionService
private static string BuildFactionSignature(FactionRuntime faction, CommanderRuntime? commander)
{
var goapSig = commander?.LastGoalPriorities is { } prios
var prioritySig = commander?.LastStrategicPriorities is { } prios
? string.Join(",", prios.Select(p => $"{p.Name}:{p.Priority:0.##}"))
: string.Empty;
return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{goapSig}";
var objectiveSig = commander?.Objectives is { Count: > 0 } objectives
? string.Join(",", objectives.Select(objective =>
$"{objective.Kind}:{objective.State}:{objective.Priority:0.##}:{objective.BlockingReason}:{objective.InvalidationReason}"))
: string.Empty;
var taskSig = commander?.IssuedTasks is { Count: > 0 } tasks
? string.Join(",", tasks.Select(task =>
$"{task.Kind}:{task.State}:{task.Priority:0.##}:{task.ShipRole}:{task.CommodityId}:{task.TargetFactionId}:{task.TargetSiteId}"))
: string.Empty;
return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{prioritySig}|{objectiveSig}|{taskSig}";
}
private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new(
@@ -759,12 +770,15 @@ internal sealed class SimulationProjectionService
private static FactionDelta ToFactionDelta(FactionRuntime faction, CommanderRuntime? commander)
{
FactionGoapStateSnapshot? goapState = null;
IReadOnlyList<FactionGoapPrioritySnapshot>? goapPriorities = null;
FactionPlanningStateSnapshot? strategicAssessment = null;
IReadOnlyList<FactionStrategicPrioritySnapshot>? strategicPriorities = null;
FactionBlackboardSnapshot? blackboard = null;
IReadOnlyList<FactionObjectiveSnapshot>? objectives = null;
IReadOnlyList<FactionIssuedTaskSnapshot>? issuedTasks = null;
if (commander?.LastPlanningState is { } ps)
if (commander?.LastStrategicAssessment is { } ps)
{
goapState = new FactionGoapStateSnapshot(
strategicAssessment = new FactionPlanningStateSnapshot(
ps.MilitaryShipCount,
ps.MinerShipCount,
ps.TransportShipCount,
@@ -773,20 +787,149 @@ internal sealed class SimulationProjectionService
ps.TargetSystemCount,
ps.HasShipFactory,
NormalizeFiniteFloat(ps.OreStockpile),
NormalizeFiniteFloat(ps.RefinedMetalsStockpile),
NormalizeFiniteFloat(ps.RefinedMetalsProductionRate),
NormalizeFiniteFloat(ps.HullpartsStockpile),
NormalizeFiniteFloat(ps.HullpartsProductionRate),
NormalizeFiniteFloat(ps.ClaytronicsStockpile),
NormalizeFiniteFloat(ps.ClaytronicsProductionRate),
NormalizeFiniteFloat(ps.WaterStockpile),
NormalizeFiniteFloat(ps.WaterProductionRate),
NormalizeFiniteFloat(ps.WaterShortageHorizonSeconds));
NormalizeFiniteFloat(ps.RefinedMetalsAvailableStock),
NormalizeFiniteFloat(ps.RefinedMetalsUsageRate),
NormalizeFiniteFloat(ps.RefinedMetalsProjectedProductionRate),
NormalizeFiniteFloat(ps.RefinedMetalsProjectedNetRate),
NormalizeFiniteFloat(ps.RefinedMetalsLevelSeconds),
ps.RefinedMetalsLevel,
NormalizeFiniteFloat(ps.HullpartsAvailableStock),
NormalizeFiniteFloat(ps.HullpartsUsageRate),
NormalizeFiniteFloat(ps.HullpartsProjectedProductionRate),
NormalizeFiniteFloat(ps.HullpartsProjectedNetRate),
NormalizeFiniteFloat(ps.HullpartsLevelSeconds),
ps.HullpartsLevel,
NormalizeFiniteFloat(ps.ClaytronicsAvailableStock),
NormalizeFiniteFloat(ps.ClaytronicsUsageRate),
NormalizeFiniteFloat(ps.ClaytronicsProjectedProductionRate),
NormalizeFiniteFloat(ps.ClaytronicsProjectedNetRate),
NormalizeFiniteFloat(ps.ClaytronicsLevelSeconds),
ps.ClaytronicsLevel,
NormalizeFiniteFloat(ps.WaterAvailableStock),
NormalizeFiniteFloat(ps.WaterUsageRate),
NormalizeFiniteFloat(ps.WaterProjectedProductionRate),
NormalizeFiniteFloat(ps.WaterProjectedNetRate),
NormalizeFiniteFloat(ps.WaterLevelSeconds),
ps.WaterLevel);
}
if (commander?.LastGoalPriorities is { } prios)
if (commander?.LastStrategicPriorities is { } prios)
{
goapPriorities = prios.Select(p => new FactionGoapPrioritySnapshot(p.Name, p.Priority)).ToList();
strategicPriorities = prios.Select(p => new FactionStrategicPrioritySnapshot(p.Name, p.Priority)).ToList();
}
if (commander?.FactionBlackboard is { } bb)
{
blackboard = new FactionBlackboardSnapshot(
bb.PlanCycle,
bb.UpdatedAtUtc,
bb.TargetWarshipCount,
bb.HasWarIndustrySupplyChain,
bb.HasShipyard,
bb.HasActiveExpansionProject,
bb.ActiveExpansionCommodityId,
bb.ActiveExpansionModuleId,
bb.ActiveExpansionSiteId,
bb.ActiveExpansionSystemId,
bb.EnemyFactionCount,
bb.EnemyShipCount,
bb.EnemyStationCount,
bb.MilitaryShipCount,
bb.MinerShipCount,
bb.TransportShipCount,
bb.ConstructorShipCount,
bb.ControlledSystemCount,
bb.CommoditySignals.Select(signal => new FactionCommoditySignalSnapshot(
signal.ItemId,
NormalizeFiniteFloat(signal.AvailableStock),
NormalizeFiniteFloat(signal.OnHand),
NormalizeFiniteFloat(signal.ProductionRatePerSecond),
NormalizeFiniteFloat(signal.CommittedProductionRatePerSecond),
NormalizeFiniteFloat(signal.UsageRatePerSecond),
NormalizeFiniteFloat(signal.NetRatePerSecond),
NormalizeFiniteFloat(signal.ProjectedNetRatePerSecond),
NormalizeFiniteFloat(signal.LevelSeconds),
signal.Level,
NormalizeFiniteFloat(signal.ProjectedProductionRatePerSecond),
NormalizeFiniteFloat(signal.BuyBacklog),
NormalizeFiniteFloat(signal.ReservedForConstruction))).ToList(),
bb.ThreatSignals.Select(signal => new FactionThreatSignalSnapshot(
signal.ScopeId,
signal.ScopeKind,
signal.EnemyShipCount,
signal.EnemyStationCount)).ToList());
}
if (commander?.Objectives is { Count: > 0 } runtimeObjectives)
{
objectives = runtimeObjectives
.OrderByDescending(objective => objective.Priority)
.Select(objective => new FactionObjectiveSnapshot(
objective.Id,
objective.Kind.ToString(),
objective.State.ToString(),
objective.Priority,
objective.ParentObjectiveId,
objective.TargetFactionId,
objective.TargetSystemId,
objective.TargetSiteId,
objective.TargetRegionId,
objective.CommodityId,
objective.ModuleId,
objective.BudgetWeight,
objective.SlotCost,
objective.CreatedAtCycle,
objective.UpdatedAtCycle,
objective.InvalidationReason,
objective.BlockingReason,
objective.PrerequisiteObjectiveIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
objective.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
objective.Steps
.OrderByDescending(step => step.Priority)
.Select(step => new FactionPlanStepSnapshot(
step.Id,
step.Kind.ToString(),
step.Status.ToString(),
step.Priority,
step.CommodityId,
step.ModuleId,
step.TargetFactionId,
step.TargetSiteId,
step.BlockingReason,
step.Notes,
step.LastEvaluatedCycle,
step.DependencyStepIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
step.RequiredFacts.OrderBy(fact => fact, StringComparer.Ordinal).ToList(),
step.ProducedFacts.OrderBy(fact => fact, StringComparer.Ordinal).ToList(),
step.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
step.IssuedTaskIds.OrderBy(id => id, StringComparer.Ordinal).ToList()))
.ToList()))
.ToList();
}
if (commander?.IssuedTasks is { Count: > 0 } runtimeTasks)
{
issuedTasks = runtimeTasks
.OrderByDescending(task => task.Priority)
.Select(task => new FactionIssuedTaskSnapshot(
task.Id,
task.Kind.ToString(),
task.State.ToString(),
task.ObjectiveId,
task.StepId,
task.Priority,
task.ShipRole,
task.CommodityId,
task.ModuleId,
task.TargetFactionId,
task.TargetSystemId,
task.TargetSiteId,
task.CreatedAtCycle,
task.UpdatedAtCycle,
task.BlockingReason,
task.Notes,
task.AssignedAssetIds.OrderBy(id => id, StringComparer.Ordinal).ToList()))
.ToList();
}
return new FactionDelta(
@@ -800,8 +943,11 @@ internal sealed class SimulationProjectionService
faction.ShipsBuilt,
faction.ShipsLost,
faction.DefaultPolicySetId,
goapState,
goapPriorities);
strategicAssessment,
strategicPriorities,
blackboard,
objectives,
issuedTasks);
}
private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new(

View File

@@ -4,6 +4,9 @@ namespace SpaceGame.Api.Stations.Simulation;
internal sealed class InfrastructureSimulationService
{
private const float CommodityTargetLevelSeconds = 240f;
private const float EnergyTargetLevelSeconds = 240f;
internal void UpdateClaims(SimulationWorld world, ICollection<SimulationEventRecord> events)
{
foreach (var claim in world.Claims)
@@ -259,16 +262,19 @@ internal sealed class InfrastructureSimulationService
var currentCount = CountModules(station.InstalledModules, objectiveModuleId);
var marginalOutputRate = EstimateMarginalOutputRate(world, station, objectiveModuleId, objectiveCommodityId);
var constructionImpact = EstimateConstructionBottleneckImpact(world, objectiveModuleId, constructionDemandByItem);
var score = 90f + commodity.ProjectedPressureScore + (marginalOutputRate * 900f) + constructionImpact;
var score = 90f
+ CommodityOperationalSignal.ComputeNeedScore(commodity, GetTargetLevelSeconds(objectiveCommodityId))
+ (marginalOutputRate * 900f)
+ constructionImpact;
if (currentCount == 0)
{
score += 80f;
}
if (!float.IsPositiveInfinity(commodity.ProjectedShortageHorizonSeconds))
if (commodity.LevelSeconds < GetTargetLevelSeconds(objectiveCommodityId))
{
score += MathF.Max(0f, 300f - commodity.ProjectedShortageHorizonSeconds) * 0.3f;
score += MathF.Max(0f, GetTargetLevelSeconds(objectiveCommodityId) - commodity.LevelSeconds) * 0.3f;
}
score *= EstimateObjectiveExpansionFeasibility(world, station, economy, objectiveModuleId, objectiveCommodityId);
@@ -287,16 +293,19 @@ internal sealed class InfrastructureSimulationService
var currentCount = CountModules(station.InstalledModules, "module_gen_prod_energycells_01");
var constructionImpact = EstimateConstructionBottleneckImpact(world, "module_gen_prod_energycells_01", constructionDemandByItem);
var readinessUnlock = EstimateSupportUnlockScore(world, station, economy, "module_gen_prod_energycells_01");
var score = 40f + energy.ProjectedPressureScore * 0.5f + constructionImpact + readinessUnlock;
var score = 40f
+ CommodityOperationalSignal.ComputeNeedScore(energy, EnergyTargetLevelSeconds) * 0.5f
+ constructionImpact
+ readinessUnlock;
if (currentCount == 0)
{
score += 70f;
}
if (!float.IsPositiveInfinity(energy.ProjectedShortageHorizonSeconds))
if (energy.LevelSeconds < EnergyTargetLevelSeconds)
{
score += MathF.Max(0f, 240f - energy.ProjectedShortageHorizonSeconds) * 0.2f;
score += MathF.Max(0f, EnergyTargetLevelSeconds - energy.LevelSeconds) * 0.2f;
}
return score - (currentCount * 40f);
@@ -433,17 +442,9 @@ internal sealed class InfrastructureSimulationService
foreach (var input in recipe.Inputs)
{
var inputCommodity = economy.GetCommodity(input.ItemId);
if (inputCommodity.AvailableStock <= 0.01f && inputCommodity.ProjectedProductionRatePerSecond <= 0.01f)
{
feasibility *= 0.65f;
continue;
}
if (!float.IsPositiveInfinity(inputCommodity.ProjectedShortageHorizonSeconds)
&& inputCommodity.ProjectedShortageHorizonSeconds < 180f)
{
feasibility *= 0.82f;
}
feasibility *= CommodityOperationalSignal.ComputeFeasibilityFactor(
inputCommodity,
GetTargetLevelSeconds(input.ItemId));
}
}
@@ -505,7 +506,8 @@ internal sealed class InfrastructureSimulationService
{
inputFactor *= 0.95f + (availableStockRatio * 0.05f);
}
else if (commodity.ProjectedProductionRatePerSecond > 0.01f)
else if (commodity.ProjectedProductionRatePerSecond > 0.01f
&& commodity.Level is not CommodityLevelKind.Critical)
{
inputFactor *= 0.82f + (availableStockRatio * 0.08f);
}
@@ -719,6 +721,11 @@ internal sealed class InfrastructureSimulationService
private static bool ObjectiveNeedsEnergy(SimulationWorld world, string objectiveCommodityId) =>
world.ProductionGraph.GetImmediateInputs(objectiveCommodityId).Contains("energycells", StringComparer.Ordinal);
private static float GetTargetLevelSeconds(string commodityId) =>
string.Equals(commodityId, "energycells", StringComparison.Ordinal) ? EnergyTargetLevelSeconds :
string.Equals(commodityId, "water", StringComparison.Ordinal) ? 300f :
CommodityTargetLevelSeconds;
internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site)
{
var nextModuleId = GetNextStationModuleToBuild(station, world);

View File

@@ -44,7 +44,7 @@ internal sealed class StationSimulationService
var hullpartsReserve = MathF.Max(constructionHullpartsReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f);
var claytronicsReserve = MathF.Max(constructionClayReserve, HasStationModules(station, "module_gen_build_l_01") ? 120f : 0f);
var shipPartsReserve = HasStationModules(station, "module_gen_build_l_01")
&& FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships")
&& FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military")
? 90f
: 0f;
@@ -163,14 +163,33 @@ internal sealed class StationSimulationService
var priority = (float)recipe.Priority;
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
var fleetPressure = FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") ? 1f : 0f;
priority += GetStationRecipePriorityAdjustment(station, recipe, expansionPressure, fleetPressure);
var fleetPressure = FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, "military") ? 1f : 0f;
priority += GetStationRecipePriorityAdjustment(world, station, recipe, expansionPressure, fleetPressure);
return priority;
}
private static float GetStationRecipePriorityAdjustment(StationRuntime station, RecipeDefinition recipe, float expansionPressure, float fleetPressure)
private static float GetStationRecipePriorityAdjustment(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, float expansionPressure, float fleetPressure)
{
if (recipe.ShipOutputId is not null && world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition))
{
var shipPressure = GetShipProductionPressure(world, station.FactionId, shipDefinition.Kind);
return shipDefinition.Kind switch
{
"military" => recipe.Id switch
{
"frigate-construction" => 320f * shipPressure,
"destroyer-construction" => 200f * shipPressure,
"cruiser-construction" => 120f * shipPressure,
_ => 160f * shipPressure,
},
"construction" => 260f * shipPressure,
"mining" => 250f * shipPressure,
"transport" => 230f * shipPressure,
_ => 0f,
};
}
var outputItemIds = recipe.Outputs
.Select(output => output.ItemId)
.ToHashSet(StringComparer.Ordinal);
@@ -201,9 +220,6 @@ internal sealed class StationSimulationService
{
"command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly"
=> 220f * MathF.Max(expansionPressure, fleetPressure),
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
"destroyer-construction" => 200f * MathF.Max(expansionPressure, fleetPressure),
"cruiser-construction" => 120f * MathF.Max(expansionPressure, fleetPressure),
"ammo-fabrication" => -80f * expansionPressure,
"trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly"
=> -120f * expansionPressure,
@@ -228,8 +244,7 @@ internal sealed class StationSimulationService
return false;
}
if (!string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal)
|| !FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships"))
if (!FactionCommanderHasIssuedTask(world, station.FactionId, FactionIssuedTaskKind.ProduceShips, shipDefinition.Kind))
{
return false;
}
@@ -431,16 +446,17 @@ internal sealed class StationSimulationService
private static float ScaleReserveByEconomy(FactionEconomySnapshot economy, string itemId, float baseReserve)
{
var commodity = economy.GetCommodity(itemId);
if (float.IsPositiveInfinity(commodity.ShortageHorizonSeconds))
if (commodity.Level == CommodityLevelKind.Critical)
{
return MathF.Max(0f, baseReserve);
return baseReserve * 1.6f;
}
return commodity.ShortageHorizonSeconds < 180f
? baseReserve * 1.5f
: commodity.ShortageHorizonSeconds < 360f
? baseReserve * 1.2f
: baseReserve;
return commodity.Level switch
{
CommodityLevelKind.Low => baseReserve * 1.25f,
CommodityLevelKind.Stable when commodity.ProjectedNetRatePerSecond < -0.01f => baseReserve * 1.1f,
_ => MathF.Max(0f, baseReserve),
};
}
private static float ScaleSupplyTriggerByEconomy(FactionEconomySnapshot economy, string itemId, float baseTrigger)
@@ -452,26 +468,38 @@ internal sealed class StationSimulationService
private static float ScaleDemandValuation(FactionEconomySnapshot economy, string itemId, float baseValuation)
{
var commodity = economy.GetCommodity(itemId);
if (float.IsPositiveInfinity(commodity.ShortageHorizonSeconds))
return commodity.Level switch
{
return commodity.ProductionRatePerSecond > 0.01f ? baseValuation : baseValuation * 1.3f;
}
return commodity.ShortageHorizonSeconds < 180f
? baseValuation * 1.5f
: commodity.ShortageHorizonSeconds < 360f
? baseValuation * 1.25f
: baseValuation;
CommodityLevelKind.Critical => baseValuation * 1.6f,
CommodityLevelKind.Low => baseValuation * 1.3f,
CommodityLevelKind.Stable when commodity.ProjectedNetRatePerSecond < -0.01f => baseValuation * 1.15f,
CommodityLevelKind.Surplus when commodity.ProjectedNetRatePerSecond > 0.01f => baseValuation * 0.9f,
_ => commodity.ProductionRatePerSecond > 0.01f ? baseValuation : baseValuation * 1.15f,
};
}
private static float ScaleSupplyValuation(FactionEconomySnapshot economy, string itemId, float baseValuation)
{
var commodity = economy.GetCommodity(itemId);
return commodity.NetRatePerSecond > 0.01f && commodity.ShortageHorizonSeconds > 600f
return commodity.Level == CommodityLevelKind.Surplus && commodity.NetRatePerSecond > 0.01f
? baseValuation * 0.75f
: commodity.Level == CommodityLevelKind.Critical
? baseValuation * 1.15f
: baseValuation;
}
private static float GetShipProductionPressure(SimulationWorld world, string factionId, string shipKind)
{
var factionCommander = FindFactionCommander(world, factionId);
var task = GetHighestPriorityIssuedTask(factionCommander, FactionIssuedTaskKind.ProduceShips, shipKind);
if (task is null)
{
return 0f;
}
return task.State == FactionIssuedTaskState.Blocked ? 0.4f : 1f;
}
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
{
var totalLagrangePoints = world.Celestials.Count(node =>

View File

@@ -8,6 +8,7 @@
"name": "space-game-viewer",
"version": "0.1.0",
"dependencies": {
"@tanstack/vue-table": "^8.21.3",
"pinia": "^3.0.3",
"three": "^0.179.1",
"vue": "^3.5.21"
@@ -1118,6 +1119,36 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/vue-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz",
"integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"vue": ">=3.2"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/vue-table": "^8.21.3",
"pinia": "^3.0.3",
"three": "^0.179.1",
"vue": "^3.5.21"

View File

@@ -5,13 +5,12 @@ import { GameViewer } from "./GameViewer";
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import ViewerOpsStrip from "./components/ViewerOpsStrip.vue";
import GmOpsWindow from "./components/gm/GmOpsWindow.vue";
import { createViewerHudState } from "./viewerHudState";
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes";
const canvasHostEl = ref<HTMLDivElement | null>(null);
const opsStripHostEl = ref<HTMLDivElement | null>(null);
const historyLayerHostEl = ref<HTMLDivElement | null>(null);
const marqueeEl = ref<HTMLDivElement | null>(null);
const hoverLabelEl = ref<HTMLDivElement | null>(null);
@@ -22,11 +21,12 @@ const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
let viewer: GameViewer | undefined;
const gmOpsOpen = ref(false);
onMounted(async () => {
await nextTick();
if (
!canvasHostEl.value
|| !opsStripHostEl.value
|| !historyLayerHostEl.value
|| !marqueeEl.value
|| !hoverLabelEl.value
@@ -38,7 +38,6 @@ onMounted(async () => {
viewer = new GameViewer(canvasHostEl.value, {
state: hudState,
selectionStore,
opsStripEl: opsStripHostEl.value,
historyLayerEl: historyLayerHostEl.value,
marqueeEl: marqueeEl.value,
hoverLabelEl: hoverLabelEl.value,
@@ -146,13 +145,19 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
/>
</div>
<div ref="opsStripHostEl">
<ViewerOpsStrip
:state="hudState.opsStrip"
@history="onOpenHistory"
@focus="onFocusSelection"
<button
type="button"
class="gm-console-toggle"
@click="gmOpsOpen = !gmOpsOpen"
>
{{ gmOpsOpen ? "Close" : "GM Console" }}
</button>
<GmOpsWindow
v-if="gmOpsOpen"
@close="gmOpsOpen = false"
@focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')"
/>
</div>
<div
ref="marqueeEl"

View File

@@ -68,7 +68,6 @@ export class ViewerAppController {
readonly hudState: ViewerHudState;
readonly selectionStore: ViewerSelectionStore;
private readonly opsStripEl: HTMLDivElement;
private readonly historyLayerEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
@@ -122,7 +121,6 @@ export class ViewerAppController {
this.container = container;
this.hudState = hud.state;
this.selectionStore = hud.selectionStore;
this.opsStripEl = hud.opsStripEl;
this.historyLayerEl = hud.historyLayerEl;
this.marqueeEl = hud.marqueeEl;
this.hoverLabelEl = hud.hoverLabelEl;

View File

@@ -0,0 +1,617 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import {
useVueTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
createColumnHelper,
FlexRender,
type SortingState,
type ColumnOrderState,
type Updater,
} from "@tanstack/vue-table";
import { storeToRefs } from "pinia";
import GmWindow from "./GmWindow.vue";
import { useGmStore } from "../../ui/stores/gmStore";
import { useViewerSelectionStore } from "../../ui/stores/viewerSelection";
import type { ShipSnapshot } from "../../contractsShips";
import type { StationSnapshot } from "../../contractsInfrastructure";
import type { FactionSnapshot } from "../../contractsFactions";
// ── Column ordering composable ─────────────────────────────────────────────
function useColumnOrder(initialIds: string[]) {
const columnOrder = ref<ColumnOrderState>([...initialIds]);
const draggingId = ref<string | null>(null);
const overId = ref<string | null>(null);
function onColumnOrderChange(updater: Updater<ColumnOrderState>) {
columnOrder.value = typeof updater === "function" ? updater(columnOrder.value) : updater;
}
function onDragStart(e: DragEvent, id: string) {
draggingId.value = id;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", id);
}
}
function onDragOver(e: DragEvent, id: string) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
overId.value = id;
}
function onDragLeave() {
overId.value = null;
}
function onDrop(targetId: string) {
if (!draggingId.value || draggingId.value === targetId) return;
const order = [...columnOrder.value];
const from = order.indexOf(draggingId.value);
const to = order.indexOf(targetId);
order.splice(from, 1);
order.splice(to, 0, draggingId.value);
columnOrder.value = order;
draggingId.value = null;
overId.value = null;
}
function onDragEnd() {
draggingId.value = null;
overId.value = null;
}
return { columnOrder, draggingId, overId, onColumnOrderChange, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd };
}
const emit = defineEmits<{
close: [];
focus: [id: string, kind: "ship" | "station"];
}>();
type TabId = "ships" | "stations" | "factions";
const activeTab = ref<TabId>("ships");
const gmStore = useGmStore();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
// Faction name lookup
const factionMap = computed(() =>
new Map(gmStore.factions.map((f) => [f.id, f.label])),
);
// ── Ships table ────────────────────────────────────────────────────────────
type ShipRow = {
id: string;
label: string;
class: string;
faction: string;
system: string;
state: string;
behavior: string;
task: string;
cargo: number;
health: number;
};
const shipRows = computed<ShipRow[]>(() =>
gmStore.ships.map((s) => ({
id: s.id,
label: s.label,
class: s.class,
faction: factionMap.value.get(s.factionId) ?? s.factionId,
system: s.systemId,
state: s.state,
behavior: s.defaultBehaviorKind + (s.behaviorPhase ? ` · ${s.behaviorPhase}` : ""),
task: s.controllerTaskKind,
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
health: Math.round(s.health),
})),
);
const shipColumnHelper = createColumnHelper<ShipRow>();
const shipColumns = [
shipColumnHelper.accessor("label", { header: "Name" }),
shipColumnHelper.accessor("class", { header: "Class" }),
shipColumnHelper.accessor("faction", { header: "Faction" }),
shipColumnHelper.accessor("system", { header: "System" }),
shipColumnHelper.accessor("state", { header: "State" }),
shipColumnHelper.accessor("behavior", { header: "Behavior" }),
shipColumnHelper.accessor("task", { header: "Task" }),
shipColumnHelper.accessor("cargo", { header: "Cargo" }),
shipColumnHelper.accessor("health", { header: "HP" }),
];
const shipFilter = ref("");
const shipSorting = ref<SortingState>([]);
const shipOrder = useColumnOrder(["label", "class", "faction", "system", "state", "behavior", "task", "cargo", "health"]);
const shipTable = useVueTable({
get data() { return shipRows.value; },
columns: shipColumns,
state: {
get globalFilter() { return shipFilter.value; },
get sorting() { return shipSorting.value; },
get columnOrder() { return shipOrder.columnOrder.value; },
},
onGlobalFilterChange: (v) => { shipFilter.value = String(v); },
onSortingChange: (updater) => {
shipSorting.value = typeof updater === "function" ? updater(shipSorting.value) : updater;
},
onColumnOrderChange: shipOrder.onColumnOrderChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
// ── Stations table ─────────────────────────────────────────────────────────
type StationRow = {
id: string;
label: string;
category: string;
faction: string;
system: string;
docked: string;
cargo: number;
population: number;
modules: number;
};
const stationRows = computed<StationRow[]>(() =>
gmStore.stations.map((s) => ({
id: s.id,
label: s.label,
category: s.category,
faction: factionMap.value.get(s.factionId) ?? s.factionId,
system: s.systemId,
docked: `${s.dockedShips} / ${s.dockingPads}`,
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
population: Math.round(s.population),
modules: s.installedModules.length,
})),
);
const stationColumnHelper = createColumnHelper<StationRow>();
const stationColumns = [
stationColumnHelper.accessor("label", { header: "Name" }),
stationColumnHelper.accessor("category", { header: "Category" }),
stationColumnHelper.accessor("faction", { header: "Faction" }),
stationColumnHelper.accessor("system", { header: "System" }),
stationColumnHelper.accessor("docked", { header: "Docked" }),
stationColumnHelper.accessor("cargo", { header: "Cargo" }),
stationColumnHelper.accessor("population", { header: "Pop" }),
stationColumnHelper.accessor("modules", { header: "Modules" }),
];
const stationFilter = ref("");
const stationSorting = ref<SortingState>([]);
const stationOrder = useColumnOrder(["label", "category", "faction", "system", "docked", "cargo", "population", "modules"]);
const stationTable = useVueTable({
get data() { return stationRows.value; },
columns: stationColumns,
state: {
get globalFilter() { return stationFilter.value; },
get sorting() { return stationSorting.value; },
get columnOrder() { return stationOrder.columnOrder.value; },
},
onGlobalFilterChange: (v) => { stationFilter.value = String(v); },
onSortingChange: (updater) => {
stationSorting.value = typeof updater === "function" ? updater(stationSorting.value) : updater;
},
onColumnOrderChange: stationOrder.onColumnOrderChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
// ── Factions table ─────────────────────────────────────────────────────────
type FactionRow = {
id: string;
label: string;
credits: number;
population: number;
military: number;
miners: number;
transport: number;
constructors: number;
systems: string;
ore: number;
shipsBuilt: number;
shipsLost: number;
};
const factionRows = computed<FactionRow[]>(() =>
gmStore.factions.map((f) => {
const gs = f.goapState;
return {
id: f.id,
label: f.label,
credits: Math.round(f.credits),
population: Math.round(f.populationTotal),
military: gs?.militaryShipCount ?? 0,
miners: gs?.minerShipCount ?? 0,
transport: gs?.transportShipCount ?? 0,
constructors: gs?.constructorShipCount ?? 0,
systems: gs ? `${gs.controlledSystemCount} / ${gs.targetSystemCount}` : "—",
ore: gs ? Math.round(gs.oreStockpile) : 0,
shipsBuilt: f.shipsBuilt,
shipsLost: f.shipsLost,
};
}),
);
const factionColumnHelper = createColumnHelper<FactionRow>();
const factionColumns = [
factionColumnHelper.accessor("label", { header: "Faction" }),
factionColumnHelper.accessor("credits", { header: "Credits" }),
factionColumnHelper.accessor("population", { header: "Pop" }),
factionColumnHelper.accessor("military", { header: "Military" }),
factionColumnHelper.accessor("miners", { header: "Miners" }),
factionColumnHelper.accessor("transport", { header: "Transport" }),
factionColumnHelper.accessor("constructors", { header: "Constructors" }),
factionColumnHelper.accessor("systems", { header: "Systems" }),
factionColumnHelper.accessor("ore", { header: "Ore" }),
factionColumnHelper.accessor("shipsBuilt", { header: "Built" }),
factionColumnHelper.accessor("shipsLost", { header: "Lost" }),
];
const factionFilter = ref("");
const factionSorting = ref<SortingState>([]);
const factionOrder = useColumnOrder(["label", "credits", "population", "military", "miners", "transport", "constructors", "systems", "ore", "shipsBuilt", "shipsLost"]);
const factionTable = useVueTable({
get data() { return factionRows.value; },
columns: factionColumns,
state: {
get globalFilter() { return factionFilter.value; },
get sorting() { return factionSorting.value; },
get columnOrder() { return factionOrder.columnOrder.value; },
},
onGlobalFilterChange: (v) => { factionFilter.value = String(v); },
onSortingChange: (updater) => {
factionSorting.value = typeof updater === "function" ? updater(factionSorting.value) : updater;
},
onColumnOrderChange: factionOrder.onColumnOrderChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
// ── Row counts ─────────────────────────────────────────────────────────────
const tabs: { id: TabId; label: string }[] = [
{ id: "ships", label: "Ships" },
{ id: "stations", label: "Stations" },
{ id: "factions", label: "Factions" },
];
const activeFilter = computed({
get: () => {
if (activeTab.value === "ships") return shipFilter.value;
if (activeTab.value === "stations") return stationFilter.value;
return factionFilter.value;
},
set: (v: string) => {
if (activeTab.value === "ships") shipFilter.value = v;
else if (activeTab.value === "stations") stationFilter.value = v;
else factionFilter.value = v;
},
});
const activeRowCount = computed(() => {
if (activeTab.value === "ships") return shipTable.getFilteredRowModel().rows.length;
if (activeTab.value === "stations") return stationTable.getFilteredRowModel().rows.length;
return factionTable.getFilteredRowModel().rows.length;
});
const activeTotalCount = computed(() => {
if (activeTab.value === "ships") return gmStore.ships.length;
if (activeTab.value === "stations") return gmStore.stations.length;
return gmStore.factions.length;
});
// ── Row interaction ────────────────────────────────────────────────────────
function onShipClick(row: ShipRow) {
selectionStore.selectSelection({ id: row.id, kind: "ship", label: row.label }, "ui");
}
function onShipDblClick(row: ShipRow) {
selectionStore.selectSelection({ id: row.id, kind: "ship", label: row.label }, "ui");
emit("focus", row.id, "ship");
}
function onStationClick(row: StationRow) {
selectionStore.selectSelection({ id: row.id, kind: "station", label: row.label }, "ui");
}
function onStationDblClick(row: StationRow) {
selectionStore.selectSelection({ id: row.id, kind: "station", label: row.label }, "ui");
emit("focus", row.id, "station");
}
function isShipSelected(id: string) {
return selectedEntityKind.value === "ship" && selectedEntityId.value === id;
}
function isStationSelected(id: string) {
return selectedEntityKind.value === "station" && selectedEntityId.value === id;
}
</script>
<template>
<GmWindow
title="Ships"
:initial-width="980"
:initial-height="560"
:initial-x="80"
:initial-y="80"
@close="emit('close')"
>
<div class="flex h-full flex-col">
<!-- Tab bar + search -->
<div class="gm-toolbar flex shrink-0 items-center gap-3 px-3 py-2">
<div class="flex gap-1">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="gm-tab-btn rounded px-3 py-1 text-xs font-semibold uppercase tracking-widest transition"
:class="activeTab === tab.id ? 'gm-tab-btn--active' : ''"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="relative flex-1">
<input
v-model="activeFilter"
class="gm-search-input w-full rounded border py-1 pl-7 pr-7 text-xs"
placeholder="Filter…"
type="search"
/>
<svg class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 opacity-40" width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0" />
</svg>
<button
v-if="activeFilter"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] opacity-40 hover:opacity-80"
@click="activeFilter = ''"
>
</button>
</div>
<span class="gm-row-count shrink-0 font-mono text-xs tabular-nums opacity-60">
{{ activeRowCount }} / {{ activeTotalCount }}
</span>
</div>
<!-- Ships table -->
<div
v-show="activeTab === 'ships'"
class="gm-table-container min-h-0 flex-1 overflow-auto"
>
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 z-10">
<tr
v-for="headerGroup in shipTable.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
class="gm-th whitespace-nowrap px-3 py-2 text-left"
:class="[
header.column.getCanSort() ? 'cursor-pointer' : '',
shipOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
shipOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
]"
draggable="true"
@dragstart="shipOrder.onDragStart($event, header.column.id)"
@dragover="shipOrder.onDragOver($event, header.column.id)"
@dragleave="shipOrder.onDragLeave()"
@drop="shipOrder.onDrop(header.column.id)"
@dragend="shipOrder.onDragEnd()"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<span class="flex select-none items-center gap-1">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span class="text-[10px] opacity-60">
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
</span>
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in shipTable.getRowModel().rows"
:key="row.id"
class="gm-tr cursor-pointer"
:class="isShipSelected(row.original.id) ? 'gm-tr--selected' : ''"
@click="onShipClick(row.original)"
@dblclick="onShipDblClick(row.original)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="gm-td whitespace-nowrap px-3 py-1.5"
>
<span
v-if="cell.column.id === 'class'"
class="gm-badge rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</span>
<FlexRender
v-else
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
<tr v-if="shipTable.getRowModel().rows.length === 0">
<td :colspan="shipColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
No ships match the filter.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Stations table -->
<div
v-show="activeTab === 'stations'"
class="gm-table-container min-h-0 flex-1 overflow-auto"
>
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 z-10">
<tr
v-for="headerGroup in stationTable.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
class="gm-th whitespace-nowrap px-3 py-2 text-left"
:class="[
header.column.getCanSort() ? 'cursor-pointer' : '',
stationOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
stationOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
]"
draggable="true"
@dragstart="stationOrder.onDragStart($event, header.column.id)"
@dragover="stationOrder.onDragOver($event, header.column.id)"
@dragleave="stationOrder.onDragLeave()"
@drop="stationOrder.onDrop(header.column.id)"
@dragend="stationOrder.onDragEnd()"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<span class="flex select-none items-center gap-1">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span class="text-[10px] opacity-60">
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
</span>
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in stationTable.getRowModel().rows"
:key="row.id"
class="gm-tr cursor-pointer"
:class="isStationSelected(row.original.id) ? 'gm-tr--selected' : ''"
@click="onStationClick(row.original)"
@dblclick="onStationDblClick(row.original)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="gm-td whitespace-nowrap px-3 py-1.5"
>
<span
v-if="cell.column.id === 'category'"
class="gm-badge rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</span>
<FlexRender
v-else
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
<tr v-if="stationTable.getRowModel().rows.length === 0">
<td :colspan="stationColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
No stations match the filter.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Factions table -->
<div
v-show="activeTab === 'factions'"
class="gm-table-container min-h-0 flex-1 overflow-auto"
>
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 z-10">
<tr
v-for="headerGroup in factionTable.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
class="gm-th whitespace-nowrap px-3 py-2 text-left"
:class="[
header.column.getCanSort() ? 'cursor-pointer' : '',
factionOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
factionOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
]"
draggable="true"
@dragstart="factionOrder.onDragStart($event, header.column.id)"
@dragover="factionOrder.onDragOver($event, header.column.id)"
@dragleave="factionOrder.onDragLeave()"
@drop="factionOrder.onDrop(header.column.id)"
@dragend="factionOrder.onDragEnd()"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<span class="flex select-none items-center gap-1">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span class="text-[10px] opacity-60">
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
</span>
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in factionTable.getRowModel().rows"
:key="row.id"
class="gm-tr"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="gm-td whitespace-nowrap px-3 py-1.5"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
</tr>
<tr v-if="factionTable.getRowModel().rows.length === 0">
<td :colspan="factionColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
No factions match the filter.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</GmWindow>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const props = withDefaults(defineProps<{
title: string;
initialX?: number;
initialY?: number;
initialWidth?: number;
initialHeight?: number;
}>(), {
initialX: 80,
initialY: 80,
initialWidth: 960,
initialHeight: 580,
});
const emit = defineEmits<{
close: [];
}>();
const windowEl = ref<HTMLDivElement | null>(null);
const x = ref(props.initialX);
const y = ref(props.initialY);
const isDragging = ref(false);
let dragOffsetX = 0;
let dragOffsetY = 0;
function onTitleMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).closest("button")) return;
isDragging.value = true;
dragOffsetX = e.clientX - x.value;
dragOffsetY = e.clientY - y.value;
e.preventDefault();
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return;
x.value = e.clientX - dragOffsetX;
y.value = e.clientY - dragOffsetY;
}
function onMouseUp() {
isDragging.value = false;
}
onMounted(() => {
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
// Set initial size imperatively so Vue's reactive style binding never
// touches width/height — the browser's CSS resize handle owns them.
if (windowEl.value) {
windowEl.value.style.width = `${props.initialWidth}px`;
windowEl.value.style.height = `${props.initialHeight}px`;
}
});
onUnmounted(() => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
});
</script>
<template>
<div
ref="windowEl"
class="gm-window pointer-events-auto fixed flex flex-col overflow-hidden rounded-xl border"
:style="{
left: `${x}px`,
top: `${y}px`,
cursor: isDragging ? 'grabbing' : 'default',
zIndex: 200,
}"
>
<!-- Title bar -->
<div
class="gm-window-titlebar flex shrink-0 cursor-grab select-none items-center gap-2 px-4 py-2.5"
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
@mousedown="onTitleMouseDown"
>
<span class="gm-window-title-badge mr-1 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-widest">GM</span>
<h2 class="flex-1 font-[Space_Grotesk] text-sm font-semibold tracking-wide">{{ title }}</h2>
<button
type="button"
class="gm-window-close-btn flex h-6 w-6 items-center justify-center rounded text-xs opacity-60 transition hover:opacity-100"
aria-label="Close window"
@click="emit('close')"
>
</button>
</div>
<!-- Content -->
<div class="min-h-0 flex-1 overflow-hidden">
<slot />
</div>
</div>
</template>

View File

@@ -386,3 +386,181 @@ canvas {
min-height: 120px;
}
}
/* ── GM Windows ──────────────────────────────────────────────────────────── */
.gm-window {
backdrop-filter: blur(18px);
background: rgba(7, 14, 27, 0.92);
border-color: rgba(132, 196, 255, 0.18);
box-shadow:
0 0 0 1px rgba(127, 214, 255, 0.06) inset,
0 32px 72px rgba(0, 0, 0, 0.52);
color: var(--viewer-text);
resize: both;
min-width: 480px;
min-height: 240px;
overflow: hidden;
}
.gm-window-titlebar {
background: rgba(127, 214, 255, 0.04);
border-bottom: 1px solid rgba(132, 196, 255, 0.12);
}
.gm-window-title-badge {
background: rgba(127, 214, 255, 0.14);
color: var(--viewer-accent);
font-family: "IBM Plex Mono", monospace;
}
.gm-window-close-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--viewer-text);
font-family: inherit;
cursor: pointer;
}
.gm-window-close-btn:hover {
background: rgba(255, 80, 60, 0.18);
border-color: rgba(255, 80, 60, 0.3);
}
.gm-toolbar {
background: rgba(127, 214, 255, 0.03);
border-bottom: 1px solid rgba(132, 196, 255, 0.1);
}
.gm-tab-btn {
background: transparent;
border: 1px solid transparent;
color: var(--viewer-muted);
cursor: pointer;
font-family: "IBM Plex Mono", monospace;
}
.gm-tab-btn:hover {
color: var(--viewer-text);
background: rgba(127, 214, 255, 0.06);
}
.gm-tab-btn--active {
background: rgba(127, 214, 255, 0.12);
border-color: rgba(127, 214, 255, 0.22);
color: var(--viewer-accent);
}
.gm-search-input {
background: rgba(127, 214, 255, 0.05);
border-color: rgba(132, 196, 255, 0.18);
color: var(--viewer-text);
font-family: "IBM Plex Mono", monospace;
outline: none;
}
.gm-search-input:focus {
border-color: rgba(127, 214, 255, 0.4);
background: rgba(127, 214, 255, 0.08);
}
.gm-search-input::placeholder {
color: var(--viewer-muted);
}
.gm-row-count {
color: var(--viewer-muted);
font-family: "IBM Plex Mono", monospace;
}
.gm-table-container {
scrollbar-width: thin;
scrollbar-color: rgba(127, 214, 255, 0.2) transparent;
}
.gm-table {
font-family: "IBM Plex Mono", monospace;
}
.gm-th {
background: rgba(7, 14, 27, 0.98);
color: var(--viewer-accent);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
border-bottom: 1px solid rgba(132, 196, 255, 0.14);
}
.gm-th:hover {
color: var(--viewer-text);
}
.gm-tr {
border-bottom: 1px solid transparent;
transition: background 80ms ease;
}
.gm-tr:nth-child(even) .gm-td {
background: rgba(127, 214, 255, 0.025);
}
.gm-tr:hover .gm-td {
background: rgba(127, 214, 255, 0.07);
}
.gm-tr--selected .gm-td {
background: rgba(255, 191, 105, 0.1) !important;
border-bottom-color: rgba(255, 191, 105, 0.14);
}
.gm-td {
color: var(--viewer-muted);
font-size: 0.72rem;
border-bottom: 1px solid rgba(132, 196, 255, 0.06);
}
.gm-td:first-child {
color: var(--viewer-text);
font-weight: 500;
}
.gm-badge {
background: rgba(127, 214, 255, 0.1);
color: var(--viewer-accent);
}
.gm-th--dragging {
opacity: 0.35;
}
.gm-th--dragover {
background: rgba(127, 214, 255, 0.14);
box-shadow: inset 2px 0 0 var(--viewer-accent);
}
.gm-console-toggle {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
pointer-events: auto;
z-index: 100;
padding: 7px 18px;
border-radius: 999px;
background: rgba(127, 214, 255, 0.1);
border: 1px solid rgba(127, 214, 255, 0.24);
color: var(--viewer-accent);
font-family: "IBM Plex Mono", monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease;
backdrop-filter: blur(8px);
}
.gm-console-toggle:hover {
background: rgba(127, 214, 255, 0.18);
border-color: rgba(127, 214, 255, 0.4);
}

View File

@@ -0,0 +1,23 @@
import { defineStore } from "pinia";
import type { ShipSnapshot } from "../../contractsShips";
import type { StationSnapshot } from "../../contractsInfrastructure";
import type { FactionSnapshot } from "../../contractsFactions";
export const useGmStore = defineStore("gm", {
state: () => ({
ships: [] as ShipSnapshot[],
stations: [] as StationSnapshot[],
factions: [] as FactionSnapshot[],
}),
actions: {
updateWorld(
ships: ShipSnapshot[],
stations: StationSnapshot[],
factions: FactionSnapshot[],
) {
this.ships = ships;
this.stations = stations;
this.factions = factions;
},
},
});

View File

@@ -112,7 +112,6 @@ export interface ViewerHudState {
export interface ViewerHudBindings {
state: ViewerHudState;
selectionStore: ViewerSelectionStore;
opsStripEl: HTMLDivElement;
historyLayerEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
hoverLabelEl: HTMLDivElement;

View File

@@ -1,6 +1,8 @@
import { fetchWorldSnapshot, openWorldStream } from "./api";
import type { ViewerHudState } from "./viewerHudState";
import { buildOpsStripState } from "./viewerOpsStrip";
import { useGmStore } from "./ui/stores/gmStore";
import { viewerPinia } from "./ui/stores/pinia";
import { buildDetailPanelState } from "./viewerPanels";
import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
import type {
@@ -195,6 +197,15 @@ export class ViewerWorldLifecycle {
this.context.getPovLevel(),
this.context.getActiveSystemId(),
);
const world = this.context.getWorld();
if (world) {
useGmStore(viewerPinia).updateWorld(
[...world.ships.values()],
[...world.stations.values()],
[...world.factions.values()],
);
}
}
updatePanels() {