diff --git a/BeaverBuddies/BeaverBuddies.csproj b/BeaverBuddies/BeaverBuddies.csproj index decf7edf..e4976cc5 100644 --- a/BeaverBuddies/BeaverBuddies.csproj +++ b/BeaverBuddies/BeaverBuddies.csproj @@ -17,7 +17,7 @@ netstandard2.1 BeaverBuddies TimberModTest - 1.4.0-rc31 + 1.4.0-rc32 false 1.1 diff --git a/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/MultiColonyPowerExport.Folktails.blueprint.json b/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/MultiColonyPowerExport.Folktails.blueprint.json new file mode 100644 index 00000000..06f2487c --- /dev/null +++ b/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/MultiColonyPowerExport.Folktails.blueprint.json @@ -0,0 +1,280 @@ +{ + "BuildingSpec": { + "SelectionSoundName": "Shaft", + "LoopingSoundName": "", + "BuildingCost": [ + { + "Id": "Gear", + "Amount": 10 + }, + { + "Id": "Plank", + "Amount": 10 + }, + { + "Id": "Log", + "Amount": 10 + } + ], + "ScienceCost": 200, + "PlaceFinished": false, + "FinishableWithBeaversOnSite": false, + "DrawRangeBoundsOnIt": false + }, + "BuildingModelSpec": { + "FinishedModelName": "#Finished", + "UnfinishedModelName": "#Unfinished", + "FinishedUncoveredModelName": "", + "UndergroundModelName": "", + "ConstructionModeModel": "Finished", + "UndergroundModelDepth": 0 + }, + "TemplateSpec": { + "TemplateName": "MultiColonyPowerExport.Folktails", + "BackwardCompatibleTemplateNames": [], + "RequiredFeatureToggle": "", + "DisablingFeatureToggle": "" + }, + "BlockObjectSpec": { + "Size": { + "X": 3, + "Y": 1, + "Z": 2 + }, + "Blocks": [ + { + "MatterBelow": "GroundOrStackable", + "Occupations": "Bottom, Top, Corners, Path, Middle", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + }, + { + "MatterBelow": "GroundOrStackable", + "Occupations": "Bottom, Top, Corners, Path, Middle", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + }, + { + "MatterBelow": "GroundOrStackable", + "Occupations": "Bottom, Top, Corners, Path, Middle", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + }, + { + "MatterBelow": "Any", + "Occupations": "All", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + }, + { + "MatterBelow": "Any", + "Occupations": "All", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + }, + { + "MatterBelow": "Any", + "Occupations": "All", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + } + ], + "Entrance": { + "HasEntrance": true, + "Coordinates": { + "X": 1, + "Y": -1, + "Z": 0 + } + }, + "BaseZ": 0, + "Overridable": false, + "Flippable": false + }, + "PlaceableBlockObjectSpec": { + "ToolGroupId": "Power", + "ToolOrder": 95, + "ToolShape": "Square", + "Layout": "Half", + "CustomPivot": { + "HasCustomPivot": true, + "Coordinates": { + "X": 1.5, + "Y": 1.0, + "Z": 0.0 + } + }, + "CanBeAttachedToTerrainSide": false, + "DevModeTool": false + }, + "LabeledEntitySpec": { + "DisplayNameLocKey": "BeaverBuddies.Building.PowerExport.DisplayName", + "DescriptionLocKey": "BeaverBuddies.Building.PowerExport.Description", + "FlavorDescriptionLocKey": "BeaverBuddies.Building.PowerExport.FlavorDescription", + "Icon": "Buildings/Power/MultiColonyPowerExport/PowerExportIcon" + }, + "BuildingAccessibleSpec": { + "LocalAccess": { + "X": 1.5, + "Y": 0.0, + "Z": 0.1 + }, + "ForceOneFinalAccess": false + }, + "WorkplaceSpec": { + "MaxWorkers": 1, + "DefaultWorkers": 1, + "DefaultWorkerType": "Beaver", + "DisallowOtherWorkerTypes": false, + "WorkerTypeUnlockCosts": [ + { + "WorkerType": "Bot", + "ScienceCost": 250 + } + ] + }, + "EnterableSpec": { + "OperatingState": "Finished", + "LimitedCapacityFinished": false, + "CapacityFinished": 0, + "LimitedCapacityUnfinished": false, + "CapacityUnfinished": 0 + }, + "WorkshopSpec": {}, + "MultiColonyPowerExportSpec": {}, + "LinkedBuildingSpec": {}, + "WorkplaceIlluminatorSpec": {}, + "MechanicalNodeSpec": { + "PowerOutput": 1, + "PowerInput": 1, + "IsShaft": false + }, + "TransputProviderSpec": { + "Transputs": [ + { + "Coordinates": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "Directions": "Left", + "ReverseRotation": false + } + ], + "IgnoreRotation": true + }, + "TransformSlotInitializerSpec": { + "Slots": [ + { + "SlotKeyword": "Entrance", + "Animation": "Sitting", + "Inanimate": false, + "RandomizeYRotation": false, + "WaterSlot": false + } + ] + }, + "WorkplaceSlotManagerSpec": {}, + "ConstructionSiteProgressVisualizerSpec": { + "ProgressThresholds": [ + 0.0 + ] + }, + "Children": { + "#Finished": { + "TimbermeshSpec": { + "Model": "Buildings/DistrictManagement/DistrictCrossing/DistrictCrossing.Folktails.Model" + }, + "CollidersSpec": { + "BoxColliders": [ + { + "Center": { + "X": 1.50582361, + "Y": 0.496590853, + "Z": 0.58214134 + }, + "Size": { + "X": 2.76765823, + "Y": 1.00564241, + "Z": 0.751245141 + } + }, + { + "Center": { + "X": 1.49376357, + "Y": 0.6928351, + "Z": 0.581762 + }, + "Size": { + "X": 1.72682786, + "Y": 1.39813089, + "Z": 0.7520038 + } + }, + { + "Center": { + "X": 1.49376345, + "Y": 0.02666691, + "Z": 0.48527962 + }, + "Size": { + "X": 3.00085449, + "Y": 0.06579453, + "Z": 0.9449686 + } + } + ], + "SphereColliders": [], + "CapsuleColliders": [] + } + }, + "#Unfinished": { + "Children": { + "ConstructionBase3x2Half#nested": { + "BlueprintPath": "ConstructionBases/ConstructionBase3x2Half/ConstructionBase3x2Half.blueprint" + }, + "ConstructionStage0": { + "TimbermeshSpec": { + "Model": "Buildings/DistrictManagement/DistrictCrossing/DistrictCrossing.Folktails.ConstructionStage0.Model" + }, + "CollidersSpec": { + "BoxColliders": [ + { + "Center": { + "X": 1.5, + "Y": 0.4955544, + "Z": 0.6149061 + }, + "Size": { + "X": 2.7535162, + "Y": 0.9911088, + "Z": 0.770187855 + } + }, + { + "Center": { + "X": 1.5, + "Y": 1.31363821, + "Z": 0.879893661 + }, + "Size": { + "X": 1.75455, + "Y": 0.644045353, + "Z": 0.240212739 + } + } + ], + "SphereColliders": [], + "CapsuleColliders": [] + } + } + } + } + } +} diff --git a/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/MultiColonyPowerExport.IronTeeth.blueprint.json b/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/MultiColonyPowerExport.IronTeeth.blueprint.json new file mode 100644 index 00000000..19aad1dc --- /dev/null +++ b/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/MultiColonyPowerExport.IronTeeth.blueprint.json @@ -0,0 +1,280 @@ +{ + "BuildingSpec": { + "SelectionSoundName": "Shaft", + "LoopingSoundName": "", + "BuildingCost": [ + { + "Id": "Gear", + "Amount": 10 + }, + { + "Id": "Plank", + "Amount": 10 + }, + { + "Id": "Log", + "Amount": 10 + } + ], + "ScienceCost": 200, + "PlaceFinished": false, + "FinishableWithBeaversOnSite": false, + "DrawRangeBoundsOnIt": false + }, + "BuildingModelSpec": { + "FinishedModelName": "#Finished", + "UnfinishedModelName": "#Unfinished", + "FinishedUncoveredModelName": "", + "UndergroundModelName": "", + "ConstructionModeModel": "Finished", + "UndergroundModelDepth": 0 + }, + "TemplateSpec": { + "TemplateName": "MultiColonyPowerExport.IronTeeth", + "BackwardCompatibleTemplateNames": [], + "RequiredFeatureToggle": "", + "DisablingFeatureToggle": "" + }, + "BlockObjectSpec": { + "Size": { + "X": 3, + "Y": 1, + "Z": 2 + }, + "Blocks": [ + { + "MatterBelow": "GroundOrStackable", + "Occupations": "Bottom, Top, Corners, Path, Middle", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + }, + { + "MatterBelow": "GroundOrStackable", + "Occupations": "Bottom, Top, Corners, Path, Middle", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + }, + { + "MatterBelow": "GroundOrStackable", + "Occupations": "Bottom, Top, Corners, Path, Middle", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + }, + { + "MatterBelow": "Any", + "Occupations": "All", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + }, + { + "MatterBelow": "Any", + "Occupations": "All", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + }, + { + "MatterBelow": "Any", + "Occupations": "All", + "Stackable": "None", + "OccupyAllBelow": false, + "Underground": false + } + ], + "Entrance": { + "HasEntrance": true, + "Coordinates": { + "X": 1, + "Y": -1, + "Z": 0 + } + }, + "BaseZ": 0, + "Overridable": false, + "Flippable": false + }, + "PlaceableBlockObjectSpec": { + "ToolGroupId": "Power", + "ToolOrder": 95, + "ToolShape": "Square", + "Layout": "Half", + "CustomPivot": { + "HasCustomPivot": true, + "Coordinates": { + "X": 1.5, + "Y": 1.0, + "Z": 0.0 + } + }, + "CanBeAttachedToTerrainSide": false, + "DevModeTool": false + }, + "LabeledEntitySpec": { + "DisplayNameLocKey": "BeaverBuddies.Building.PowerExport.DisplayName", + "DescriptionLocKey": "BeaverBuddies.Building.PowerExport.Description", + "FlavorDescriptionLocKey": "BeaverBuddies.Building.PowerExport.FlavorDescription", + "Icon": "Buildings/Power/MultiColonyPowerExport/PowerExportIcon" + }, + "BuildingAccessibleSpec": { + "LocalAccess": { + "X": 1.5, + "Y": 0.0, + "Z": 0.1 + }, + "ForceOneFinalAccess": false + }, + "WorkplaceSpec": { + "MaxWorkers": 1, + "DefaultWorkers": 1, + "DefaultWorkerType": "Beaver", + "DisallowOtherWorkerTypes": false, + "WorkerTypeUnlockCosts": [ + { + "WorkerType": "Bot", + "ScienceCost": 250 + } + ] + }, + "EnterableSpec": { + "OperatingState": "Finished", + "LimitedCapacityFinished": false, + "CapacityFinished": 0, + "LimitedCapacityUnfinished": false, + "CapacityUnfinished": 0 + }, + "WorkshopSpec": {}, + "MultiColonyPowerExportSpec": {}, + "LinkedBuildingSpec": {}, + "WorkplaceIlluminatorSpec": {}, + "MechanicalNodeSpec": { + "PowerOutput": 1, + "PowerInput": 1, + "IsShaft": false + }, + "TransputProviderSpec": { + "Transputs": [ + { + "Coordinates": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "Directions": "Left", + "ReverseRotation": false + } + ], + "IgnoreRotation": true + }, + "TransformSlotInitializerSpec": { + "Slots": [ + { + "SlotKeyword": "Entrance", + "Animation": "Sitting", + "Inanimate": false, + "RandomizeYRotation": false, + "WaterSlot": false + } + ] + }, + "WorkplaceSlotManagerSpec": {}, + "ConstructionSiteProgressVisualizerSpec": { + "ProgressThresholds": [ + 0.0 + ] + }, + "Children": { + "#Finished": { + "TimbermeshSpec": { + "Model": "Buildings/DistrictManagement/DistrictCrossing/DistrictCrossing.IronTeeth.Model" + }, + "CollidersSpec": { + "BoxColliders": [ + { + "Center": { + "X": 1.50582361, + "Y": 0.496590853, + "Z": 0.58214134 + }, + "Size": { + "X": 2.76765823, + "Y": 1.00564241, + "Z": 0.751245141 + } + }, + { + "Center": { + "X": 1.49376357, + "Y": 0.6928351, + "Z": 0.581762 + }, + "Size": { + "X": 1.72682786, + "Y": 1.39813089, + "Z": 0.7520038 + } + }, + { + "Center": { + "X": 1.49376345, + "Y": 0.02666691, + "Z": 0.48527962 + }, + "Size": { + "X": 3.00085449, + "Y": 0.06579453, + "Z": 0.9449686 + } + } + ], + "SphereColliders": [], + "CapsuleColliders": [] + } + }, + "#Unfinished": { + "Children": { + "ConstructionBase3x2Half#nested": { + "BlueprintPath": "ConstructionBases/ConstructionBase3x2Half/ConstructionBase3x2Half.blueprint" + }, + "ConstructionStage0": { + "TimbermeshSpec": { + "Model": "Buildings/DistrictManagement/DistrictCrossing/DistrictCrossing.IronTeeth.ConstructionStage0.Model" + }, + "CollidersSpec": { + "BoxColliders": [ + { + "Center": { + "X": 1.5, + "Y": 0.4955544, + "Z": 0.6149061 + }, + "Size": { + "X": 2.7535162, + "Y": 0.9911088, + "Z": 0.770187855 + } + }, + { + "Center": { + "X": 1.5, + "Y": 1.31363821, + "Z": 0.879893661 + }, + "Size": { + "X": 1.75455, + "Y": 0.644045353, + "Z": 0.240212739 + } + } + ], + "SphereColliders": [], + "CapsuleColliders": [] + } + } + } + } + } +} diff --git a/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/PowerExportIcon.png b/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/PowerExportIcon.png new file mode 100644 index 00000000..52290370 Binary files /dev/null and b/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/PowerExportIcon.png differ diff --git a/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/PowerExportIcon.png.meta.json b/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/PowerExportIcon.png.meta.json new file mode 100644 index 00000000..c77e5f3a --- /dev/null +++ b/BeaverBuddies/Buildings/Power/MultiColonyPowerExport/PowerExportIcon.png.meta.json @@ -0,0 +1,3 @@ +{ + "isSprite": true +} diff --git a/BeaverBuddies/Colonies/ColonyConfigurator.cs b/BeaverBuddies/Colonies/ColonyConfigurator.cs index d5b0d1c0..3965aa73 100644 --- a/BeaverBuddies/Colonies/ColonyConfigurator.cs +++ b/BeaverBuddies/Colonies/ColonyConfigurator.cs @@ -12,6 +12,7 @@ using Timberborn.TemplateInstantiation; using Timberborn.TimbermeshMaterials; using Timberborn.ToolSystem; +using Timberborn.Workshops; namespace BeaverBuddies.Colonies { @@ -27,6 +28,9 @@ public TemplateModule Get() builder.AddDecorator(); builder.AddDecorator(); builder.AddDecorator(); + // A Power Export Facility half: its settings, and a worker who works at it as at a Power Wheel. + builder.AddDecorator(); + builder.AddDecorator(); // A beaver's faction in a mixed game (both factions' beavers share one template). builder.AddDecorator(); return builder.Build(); @@ -38,16 +42,19 @@ public TemplateModule Get() private class EntityPanelModuleProvider : IProvider { private readonly TradingPostFragment _tradingPostFragment; + private readonly PowerExportFragment _powerExportFragment; - public EntityPanelModuleProvider(TradingPostFragment tradingPostFragment) + public EntityPanelModuleProvider(TradingPostFragment tradingPostFragment, PowerExportFragment powerExportFragment) { _tradingPostFragment = tradingPostFragment; + _powerExportFragment = powerExportFragment; } public EntityPanelModule Get() { EntityPanelModule.Builder builder = new EntityPanelModule.Builder(); builder.AddBottomFragment(_tradingPostFragment); + builder.AddBottomFragment(_powerExportFragment); return builder.Build(); } } @@ -62,6 +69,7 @@ public static void Configure(IContainerDefinition containerDefinition) containerDefinition.Bind().AsTransient(); containerDefinition.Bind().AsTransient(); containerDefinition.Bind().AsTransient(); + containerDefinition.Bind().AsTransient(); containerDefinition.MultiBind().ToProvider().AsSingleton(); containerDefinition.Bind().AsSingleton(); containerDefinition.Bind().AsSingleton(); @@ -97,6 +105,11 @@ public static void Configure(IContainerDefinition containerDefinition) containerDefinition.Bind().AsSingleton(); containerDefinition.Bind().AsSingleton(); containerDefinition.Bind().AsSingleton(); + // The Power Export Facility: power moved every tick, its panel, the Power window (H) and the power view (Ctrl+P). + containerDefinition.Bind().AsSingleton(); + containerDefinition.Bind().AsSingleton(); + containerDefinition.Bind().AsSingleton(); + containerDefinition.Bind().AsSingleton(); containerDefinition.MultiBind().ToProvider().AsSingleton(); containerDefinition.MultiBind().To().AsSingleton(); containerDefinition.MultiBind().To().AsSingleton(); diff --git a/BeaverBuddies/Colonies/ColonyDiagnostics.cs b/BeaverBuddies/Colonies/ColonyDiagnostics.cs index 98def449..aba382e5 100644 --- a/BeaverBuddies/Colonies/ColonyDiagnostics.cs +++ b/BeaverBuddies/Colonies/ColonyDiagnostics.cs @@ -250,6 +250,7 @@ long DistrictHash(DistrictCenter center) + $"marks=[{ColonyMarks.Instance?.Fingerprint()}] science=[{ColonyScienceService.Instance?.Fingerprint()}] " + $"hours=[{ColonyWorkingHours.Instance?.Fingerprint()}] away=[{ColonyLifecycle.Instance?.Fingerprint()}] " + $"stewards=[{ColonyStewards.Instance?.Fingerprint()}] wishes=[{ColonyWishlist.Instance?.Fingerprint()}] " + + $"power=[{PowerExportService.Instance?.Fingerprint()}] " + $"flags={flags} phases={phases} digest={ColonyDigest.Describe()}"; } diff --git a/BeaverBuddies/Colonies/ColonyGameWorld.cs b/BeaverBuddies/Colonies/ColonyGameWorld.cs index 874e16ba..f2b5757f 100644 --- a/BeaverBuddies/Colonies/ColonyGameWorld.cs +++ b/BeaverBuddies/Colonies/ColonyGameWorld.cs @@ -7,6 +7,7 @@ using Timberborn.DistributionSystem; using Timberborn.EntitySystem; using Timberborn.GameDistricts; +using Timberborn.MechanicalSystem; using Timberborn.Navigation; using Timberborn.PathSystem; using UnityEngine; @@ -19,17 +20,20 @@ namespace BeaverBuddies.Colonies /// or fills a cache the simulation reads (district roads are read from the game's instant map, the one its /// placement tools use), so the host can judge an action without changing anything. /// - public class ColonyGameWorld : IColonyWorld, IColonyRoadMap + public class ColonyGameWorld : IColonyWorld, IColonyRoadMap, IColonyPowerMap { private readonly EntityRegistry _entityRegistry; private readonly BuildingService _buildingService; private readonly IDistrictService _districtService; private readonly DistrictCenterRegistry _districtCenterRegistry; private readonly IBlockService _blockService; + private readonly TransputMap _transputMap; public ColonyGameWorld(EntityRegistry entityRegistry, BuildingService buildingService, - IDistrictService districtService, DistrictCenterRegistry districtCenterRegistry, IBlockService blockService) + IDistrictService districtService, DistrictCenterRegistry districtCenterRegistry, IBlockService blockService, + TransputMap transputMap) { + _transputMap = transputMap; _entityRegistry = entityRegistry; _buildingService = buildingService; _districtService = districtService; @@ -54,6 +58,13 @@ private EntityComponent Entity(string entityId) => /// public bool IsCrossingOf(int slot, string entityId) { + // A Power Export Facility between two colonies, the same way. + PowerExportHalf facility = Entity(entityId)?.GetComponent(); + if (facility != null) + { + int a = PowerExports.ColonyOf(facility), b = PowerExports.ColonyOf(facility.Partner); + return a >= 0 && b >= 0 && a != b && (a == slot || b == slot); + } DistrictCrossing half = Entity(entityId)?.GetComponent(); if (!TradingPosts.IsTradingPost(half)) return false; return DistrictOwner.OwnerOfDistrict(TradingPosts.DistrictOf(half)) == slot @@ -67,6 +78,20 @@ public bool IsTradingPostTemplate(string templateName) catch (Exception) { return false; } } + /// + /// Whether a template is where two colonies meet: the Trading Post or the Power Export Facility, each two halves + /// placed together, judged together, and allowed of either faction. + /// + public bool IsMeetingTemplate(string templateName) + { + try + { + BuildingSpec building = _buildingService.GetBuildingTemplate(templateName); + return TradingPosts.IsTradingPostTemplate(building) || PowerExports.IsFacilityTemplate(building); + } + catch (Exception) { return false; } + } + /// /// Whether two placements of a Trading Post are the two halves its placement tool puts down together, as the /// game lays them out for a building of that template's size (see ). @@ -121,12 +146,54 @@ public ColonyRefusal PlacementConflict(int slot, ColonyPlacement colonyPlacement if (spec == null) return ColonyRefusal.None; Placement placement = ToPlacement(colonyPlacement); List cells = spec.GetBlocks(placement).Select(block => Cell(block.Coordinates)).ToList(); - ColonyRefusal refusal = RoadConflict(slot, cells, EntranceOf(spec, placement), TradingPosts.IsTradingPostTemplate(building), + bool facility = PowerExports.IsFacilityTemplate(building); + ColonyRefusal refusal = RoadConflict(slot, cells, EntranceOf(spec, placement), TradingPosts.IsTradingPostTemplate(building) || facility, building.HasSpec(), out detail); + if (refusal == ColonyRefusal.None && !facility) + refusal = ColonyPowerRule.Conflict(slot, TransputsOf(building.GetSpec(), spec.GetBlocks(), placement), this, out detail); if (detail != null) detail = colonyPlacement.TemplateName + " " + detail; return refusal; } + /// + /// A building's power connections where it would stand: each connection's cell and the cell it faces (as the game + /// works them out for a placed node: Transput.Coordinates and Target). None for a building without power. + /// + public static List TransputsOf(TransputProviderSpec provider, Blocks blocks, Placement placement) + { + var result = new List(); + if (provider == null || provider.Transputs.IsDefaultOrEmpty) return result; + foreach (TransputSpec transput in provider.Transputs) + { + Vector3Int cell = blocks.Transform(transput.Coordinates, placement); + foreach (Direction3D direction in transput.Directions.GetEnumerator()) + { + Direction3D placed = placement.FlipMode.Transform(direction).RotateHorizontally(placement.Orientation); + result.Add(new ColonyTransput(Cell(cell), Cell(cell + placed.ToOffset()))); + } + } + return result; + } + + /// + /// The colony of the power connection on that faces back to , built + /// or being built. Nobody's for a Power Export Facility's half (it joins any network) and for a building nobody owns. + /// + public int? PowerOwnerFacing(ColonyCell cell, ColonyCell target) + { + Vector3Int from = Tile(cell), at = Tile(target); + foreach (Transput transput in _transputMap.GetTransputsAtCoordinates(at)) + { + if (transput.Target != from || transput.ParentNode == null) continue; + // A preview is nothing yet. + BlockObject blockObject = transput.ParentNode.GetComponent(); + if (blockObject && blockObject.IsPreview) continue; + int? owner = PowerExports.PowerOwnerOf(transput.ParentNode); + if (owner != null) return owner; + } + return null; + } + /// /// Where a building's road must be: the cell outside its door (the game's PositionedEntrance.Coordinates, the one /// its path connection checks; the game's "doorstep" is the building's own cell inside the door). Null for a @@ -185,7 +252,7 @@ public ColonyRefusal RoadConflict(int slot, IReadOnlyList footprint, /// /// The colony of a building, finished or not, whose entrance is this cell: its door faces the cell from beside. - /// A Trading Post's halves are left out: each half's entrance takes its own colony's road. + /// A Trading Post's halves are left out, and a Power Export Facility's: each half's entrance takes its own colony's road. /// public int? EntranceOwnerAt(ColonyCell cell) { @@ -196,7 +263,8 @@ public ColonyRefusal RoadConflict(int slot, IReadOnlyList footprint, { if (!blockObject || blockObject.IsPreview || !blockObject.HasEntrance) continue; if (blockObject.PositionedEntrance.Coordinates != tile) continue; - if (TradingPosts.IsTradingPostBuilding(blockObject.GetComponent())) continue; + EntityComponent entity = blockObject.GetComponent(); + if (TradingPosts.IsTradingPostBuilding(entity) || PowerExports.IsFacilityBuilding(entity)) continue; int? owner = DistrictOwner.OwnerOf(blockObject); if (owner != null) return owner; } diff --git a/BeaverBuddies/Colonies/ColonyPlacementValidator.cs b/BeaverBuddies/Colonies/ColonyPlacementValidator.cs index 34e19d5e..676f8ea6 100644 --- a/BeaverBuddies/Colonies/ColonyPlacementValidator.cs +++ b/BeaverBuddies/Colonies/ColonyPlacementValidator.cs @@ -63,8 +63,13 @@ private static ColonyVerdict Judge(BlockObject blockObject) // The cell outside the door, where its road must be (the game's "doorstep" is the building's own cell). Vector3Int? entrance = blockObject.HasEntrance ? blockObject.PositionedEntrance.Coordinates : (Vector3Int?)null; var cells = blockObject.PositionedBlocks.GetAllCoordinates().Select(ColonyGameWorld.Cell).ToList(); - ColonyRefusal refusal = world.RoadConflict(slot, cells, entrance, tradingPost: TradingPosts.IsTradingPostBuilding(blockObject), + bool facility = PowerExports.IsFacilityBuilding(blockObject); + ColonyRefusal refusal = world.RoadConflict(slot, cells, entrance, tradingPost: TradingPosts.IsTradingPostBuilding(blockObject) || facility, pathLike: blockObject.HasComponent(), out string detail); + // Its power connections, where the preview stands (a Power Export Facility's half joins any network). + if (refusal == ColonyRefusal.None && !facility) + refusal = ColonyPowerRule.Conflict(slot, ColonyGameWorld.TransputsOf(blockObject.GetComponent(), + blockObject.Blocks, blockObject.Placement), world, out detail); return refusal == ColonyRefusal.None ? ColonyVerdict.Allow : ColonyVerdict.Refuse(refusal, detail); } } diff --git a/BeaverBuddies/Colonies/ColonyPowerOverlay.cs b/BeaverBuddies/Colonies/ColonyPowerOverlay.cs new file mode 100644 index 00000000..73c2bf92 --- /dev/null +++ b/BeaverBuddies/Colonies/ColonyPowerOverlay.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using Timberborn.BlockObjectTools; +using Timberborn.BlockSystem; +using Timberborn.EntitySystem; +using Timberborn.InputSystem; +using Timberborn.MapStateSystem; +using Timberborn.MechanicalSystem; +using Timberborn.Rendering; +using Timberborn.SingletonSystem; +using Timberborn.TerrainSystem; +using Timberborn.ToolSystem; +using UnityEngine; + +namespace BeaverBuddies.Colonies +{ + /// + /// Draws every colony's power networks on the map in its colour, as the road view (Ctrl+L) draws roads: every shaft, + /// gearbox, generator, battery and powered building, built or being built, as the same filled square, in the same + /// strong colony colours. A Power Export Facility's half takes the colour of the network it joins (else its road's). + /// Two colonies' power networks never join except through a Power Export Facility, so this shows whose power runs + /// where. Shown at any time with its key (Ctrl+P), while the Power window is open, and while a power piece (a shaft, + /// gearbox, generator, battery or the facility) is in hand, when the road view makes way for it. Display only: it + /// reads the placed buildings and their owners and never touches the simulation. The squares are built into a mesh + /// when something changes, not drawn again every frame. + /// + public class ColonyPowerOverlay : ILoadableSingleton, IPostLoadableSingleton, IUpdatableSingleton, IInputProcessor + { + public const string ToggleKeyBindingId = "BeaverBuddies.KeyBind.ToggleColonyPower"; + + private const float RedrawInterval = 0.5f; + private const float RefreshInterval = 3f; + private const float AbovePaths = 0.12f; + private static readonly int ColorProperty = Shader.PropertyToID("_BaseColor"); + + private readonly AreaTileDrawerFactory _areaTileDrawerFactory; + private readonly MarkerDrawerFactory _markerDrawerFactory; + private readonly MapSize _mapSize; + private readonly InputService _inputService; + private readonly ToolService _toolService; + private readonly EventBus _eventBus; + private readonly EntityRegistry _entityRegistry; + + private GameObject root; + private readonly List drawers = new List(); + private bool failed, toggledOn, shown, dirty = true; + private float nextRedraw, nextRefresh; + + public ColonyPowerOverlay(AreaTileDrawerFactory areaTileDrawerFactory, MarkerDrawerFactory markerDrawerFactory, MapSize mapSize, + InputService inputService, ToolService toolService, EventBus eventBus, EntityRegistry entityRegistry) + { + _areaTileDrawerFactory = areaTileDrawerFactory; + _markerDrawerFactory = markerDrawerFactory; + _mapSize = mapSize; + _inputService = inputService; + _toolService = toolService; + _eventBus = eventBus; + _entityRegistry = entityRegistry; + } + + public void Load() => _eventBus.Register(this); + + public void PostLoad() => _inputService.AddInputProcessor(this); + + [OnEvent] + public void OnEntityInitialized(EntityInitializedEvent entityInitializedEvent) + { + if (entityInitializedEvent.Entity.HasComponent()) dirty = true; + } + + [OnEvent] + public void OnEntityDeleted(EntityDeletedEvent entityDeletedEvent) + { + if (entityDeletedEvent.Entity.HasComponent()) dirty = true; + } + + public bool ProcessInput() + { + if (_inputService.IsKeyDown(ToggleKeyBindingId)) toggledOn = !toggledOn; + return false; + } + + /// + /// A power piece is in hand (a shaft, gearbox, generator, battery, or the Power Export Facility: a mechanical + /// building that is not a consumer): where power runs matters more than where roads run for it. + /// + public static bool IsPowerPiece(ITool tool) + { + if (!(tool is BlockObjectTool blockObjectTool) || blockObjectTool.Template == null) return false; + if (blockObjectTool.Template.HasSpec()) return true; + MechanicalNodeSpec node = blockObjectTool.Template.GetSpec(); + return node != null && node.PowerInput <= 0; + } + + private static readonly ColonyProfiler.Spot PowerDrawing = ColonyProfiler.Declare("Power overlay drawing"); + + public void UpdateSingleton() + { + try + { + bool wanted = ColonyModeService.IsSeparateColonies + && (toggledOn || PowerOverviewPanel.Instance?.IsOpen == true || IsPowerPiece(_toolService.ActiveTool)); + if (!wanted) + { + if (shown) root?.SetActive(false); + shown = false; + return; + } + if (!EnsureDrawers()) return; + if (!shown) + { + root.SetActive(true); + shown = true; + dirty = true; + nextRedraw = 0; + } + float now = Time.unscaledTime; + if (!(dirty && now >= nextRedraw) && now < nextRefresh) return; + nextRedraw = now + RedrawInterval; + nextRefresh = now + RefreshInterval; + dirty = false; + long started = ColonyProfiler.Start(); + Redraw(); + ColonyProfiler.Stop(PowerDrawing, started); + } + catch (Exception error) + { + // Seeing the power networks is a help, not a requirement: previews and refusals still explain themselves. + if (!failed) Plugin.LogError("[Colony] Could not draw colony power: " + error); + failed = true; + root?.SetActive(false); + } + } + + private bool EnsureDrawers() + { + if (root != null) return true; + if (failed) return false; + root = new GameObject("BeaverBuddies_ColonyPower"); + for (int slot = 0; slot < ColonySlotTable.MaxSlots; slot++) + { + var holder = new GameObject("Colony" + (slot + 1)); + holder.transform.parent = root.transform; + drawers.Add(FilledDrawer(ColorOf(slot), holder) ?? _areaTileDrawerFactory.Create(ColorOf(slot), holder)); + } + root.SetActive(false); + return true; + } + + /// The road view's filled squares (the game's tile marker, a mesh built once per change). + private AreaTileDrawer FilledDrawer(Color color, GameObject holder) + { + try + { + var spec = _markerDrawerFactory._markerDrawerFactorySpec; + Mesh mesh = spec?.TileMesh.Asset; + Material source = spec?.TileMaterial.Asset; + if (mesh == null || source == null) return null; + var material = new Material(source); + material.SetColor(ColorProperty, color); + Vector2Int tileCount = WorldTiling.TileCount2D(_mapSize.TerrainSize.x, _mapSize.TerrainSize.y); + var parent = new GameObject(holder.name + "Tiles"); + parent.transform.parent = holder.transform; + parent.transform.localPosition = new Vector3(0f, AbovePaths, 0f); + var drawer = new AreaTileDrawer(mesh, material, tileCount, parent); + foreach (Transform part in parent.GetComponentsInChildren(true)) part.gameObject.layer = Layers.UILayer; + return drawer; + } + catch (Exception error) + { + Plugin.LogWarning("[Colony] Colony power is drawn as outlines: " + error.Message); + return null; + } + } + + private void Redraw() + { + var tiles = new List[drawers.Count]; + for (int slot = 0; slot < tiles.Length; slot++) tiles[slot] = new List(); + foreach (EntityComponent entity in _entityRegistry.Entities) + { + if (!entity.HasComponent()) continue; + BlockObject blockObject = entity.GetComponent(); + if (!blockObject || blockObject.IsPreview || !blockObject.Positioned) continue; + int? owner = OwnerOf(entity); + if (owner == null || owner.Value < 0 || owner.Value >= tiles.Length) continue; + // Its bottom level only: a building would otherwise get squares floating inside it. + int bottom = int.MaxValue; + foreach (Vector3Int tile in blockObject.PositionedBlocks.GetAllCoordinates()) bottom = Math.Min(bottom, tile.z); + foreach (Vector3Int tile in blockObject.PositionedBlocks.GetAllCoordinates()) + { + if (tile.z == bottom) tiles[owner.Value].Add(tile); + } + } + for (int slot = 0; slot < drawers.Count; slot++) drawers[slot].UpdateArea(tiles[slot]); + } + + /// Whose power a piece is: a facility half's network's colony (else its road's), anything else its owner. + private static int? OwnerOf(EntityComponent entity) + { + PowerExportHalf half = entity.GetComponent(); + if (half != null) + { + int network = PowerExports.NetworkColonyOf(half); + if (network >= 0) return network; + int road = PowerExports.ColonyOf(half); + return road >= 0 ? road : (int?)null; + } + return DistrictOwner.OwnerOf(entity); + } + + private static Color ColorOf(int slot) + { + Color color = slot >= 0 && slot < ColonyRoadOverlay.Palette.Roads.Length ? ColonyRoadOverlay.Palette.Roads[slot] : Color.white; + color.a = 0.7f; + return color; + } + } +} diff --git a/BeaverBuddies/Colonies/ColonyPowerRule.cs b/BeaverBuddies/Colonies/ColonyPowerRule.cs new file mode 100644 index 00000000..2a0ae79d --- /dev/null +++ b/BeaverBuddies/Colonies/ColonyPowerRule.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; + +namespace BeaverBuddies.Colonies +{ + /// A power connection of a building being placed: the cell it is on and the cell it faces. + public readonly struct ColonyTransput + { + public readonly ColonyCell Cell; + public readonly ColonyCell Target; + + public ColonyTransput(ColonyCell cell, ColonyCell target) + { + Cell = cell; + Target = target; + } + } + + /// What the power rule reads of the map. Implemented against the game in ColonyGameWorld, and by fakes in tests. + public interface IColonyPowerMap + { + /// + /// The colony whose power connection on faces back to (built or + /// being built), where the two would join. Null for none, or for one that joins any network (a Power Export + /// Facility's half, or a building nobody owns). + /// + int? PowerOwnerFacing(ColonyCell cell, ColonyCell target); + } + + /// + /// The power rule of separate colonies, like the road rule: two colonies' power networks never join, except through a + /// Power Export Facility. A mechanical building (a shaft, a gearbox, a generator, a battery, a workshop) may not be + /// placed where one of its power connections would meet another colony's. A Power Export Facility's halves join + /// whichever network reaches them: each has one connection, so a half can never join two networks. + /// + /// Reads only, and only what is the same on every computer, so a verdict is too. + /// + public static class ColonyPowerRule + { + /// Whether two nodes of these owners may be joined (nobody's joins anything). + public static bool MayJoin(int? a, int? b) => a == null || b == null || a.Value == b.Value; + + /// Why 's colony may not place a building with these power connections, or None. + public static ColonyRefusal Conflict(int slot, IReadOnlyList transputs, IColonyPowerMap map, out string detail) + { + detail = null; + for (int i = 0; i < transputs.Count; i++) + { + int? owner = map.PowerOwnerFacing(transputs[i].Cell, transputs[i].Target); + if (MayJoin(slot, owner)) continue; + detail = $"power at {transputs[i].Cell} would join slot {owner}'s at {transputs[i].Target}"; + return ColonyRefusal.TouchesOtherPower; + } + return ColonyRefusal.None; + } + } +} diff --git a/BeaverBuddies/Colonies/ColonyRoadOverlay.cs b/BeaverBuddies/Colonies/ColonyRoadOverlay.cs index 42da5c73..d8f7becc 100644 --- a/BeaverBuddies/Colonies/ColonyRoadOverlay.cs +++ b/BeaverBuddies/Colonies/ColonyRoadOverlay.cs @@ -129,9 +129,12 @@ public void UpdateSingleton() } } - /// A building's or the founding tool is in hand: where roads run matters for what is being placed. + /// + /// A building's or the founding tool is in hand: where roads run matters for what is being placed. Not a power + /// piece (a shaft, a generator...): the power view shows then instead (ColonyPowerOverlay). + /// private bool PlacingSomething() => - _toolService.ActiveTool is BlockObjectTool + (_toolService.ActiveTool is BlockObjectTool && !ColonyPowerOverlay.IsPowerPiece(_toolService.ActiveTool)) || SingletonManager.GetSingleton()?.FoundingToolActive == true; private static readonly int ColorProperty = Shader.PropertyToID("_BaseColor"); diff --git a/BeaverBuddies/Colonies/ColonyRules.cs b/BeaverBuddies/Colonies/ColonyRules.cs index 61116b72..19775ce9 100644 --- a/BeaverBuddies/Colonies/ColonyRules.cs +++ b/BeaverBuddies/Colonies/ColonyRules.cs @@ -166,6 +166,8 @@ public enum ColonyRefusal FactionSwitchNotAllowed, /// A mixed-factions game: another faction's building (each colony builds its own faction's). OtherFactionBuilding, + /// The building's power connection would join another colony's power network. + TouchesOtherPower, } public readonly struct ColonyVerdict diff --git a/BeaverBuddies/Colonies/ColonyRulesService.cs b/BeaverBuddies/Colonies/ColonyRulesService.cs index 506e977a..2165069e 100644 --- a/BeaverBuddies/Colonies/ColonyRulesService.cs +++ b/BeaverBuddies/Colonies/ColonyRulesService.cs @@ -28,9 +28,9 @@ public class ColonyRulesService : RegisteredSingleton, ILoadableSingleton public ColonyRulesService(EntityRegistry entityRegistry, QuickNotificationService quickNotificationService, BuildingService buildingService, IDistrictService districtService, DistrictCenterRegistry districtCenterRegistry, - DevModeManager devModeManager, IBlockService blockService) + DevModeManager devModeManager, IBlockService blockService, Timberborn.MechanicalSystem.TransputMap transputMap) { - world = new ColonyGameWorld(entityRegistry, buildingService, districtService, districtCenterRegistry, blockService); + world = new ColonyGameWorld(entityRegistry, buildingService, districtService, districtCenterRegistry, blockService, transputMap); _quickNotificationService = quickNotificationService; _devModeManager = devModeManager; } @@ -290,7 +290,8 @@ private static bool JudgeFactions(ReplayEvent replayEvent, out ColonyRefusal ref { var service = SingletonManager.GetSingleton(); string templateFaction = BeaverBuddies.Factions.FactionCatalog.Instance?.FactionOfTemplate(placed.prefabName); - bool tradingPost = service != null && service.world.IsTradingPostTemplate(placed.prefabName); + // A Trading Post or Power Export Facility of either faction: each is two colonies' meeting point. + bool tradingPost = service != null && service.world.IsMeetingTemplate(placed.prefabName); if (!BeaverBuddies.Factions.FactionRules.MayPlace(true, templateFaction, BeaverBuddies.Factions.ColonyFactionService.FactionOfSlot(replayEvent.slot), tradingPost)) { @@ -321,7 +322,8 @@ public static void JudgePairs(List events) var halves = new List(); foreach (ReplayEvent e in events) { - if (e is BuildingPlacedEvent placed && service.world.IsTradingPostTemplate(placed.prefabName)) halves.Add(placed); + // A Trading Post's, or a Power Export Facility's: both are two linked halves. + if (e is BuildingPlacedEvent placed && service.world.IsMeetingTemplate(placed.prefabName)) halves.Add(placed); } var paired = new HashSet(); for (int i = 0; i < halves.Count; i++) @@ -465,6 +467,7 @@ public void ShowNotice(string text, bool warning) ColonyRefusal.FoundingConflict => "BeaverBuddies.Colony.Refused.FoundingConflict", ColonyRefusal.NotEnoughScience => "BeaverBuddies.Colony.Refused.NotEnoughScience", ColonyRefusal.TouchesOtherColony => "BeaverBuddies.Colony.Refused.TouchesOtherColony", + ColonyRefusal.TouchesOtherPower => "BeaverBuddies.Colony.Refused.TouchesOtherPower", ColonyRefusal.DevModeOff => "BeaverBuddies.Colony.Refused.DevModeOff", ColonyRefusal.HostRefused => "BeaverBuddies.Colony.Refused.HostRefused", ColonyRefusal.FactionUnavailable => "BeaverBuddies.Colony.Refused.FactionUnavailable", diff --git a/BeaverBuddies/Colonies/ColonyStamps.cs b/BeaverBuddies/Colonies/ColonyStamps.cs index 5f8975e4..0d265968 100644 --- a/BeaverBuddies/Colonies/ColonyStamps.cs +++ b/BeaverBuddies/Colonies/ColonyStamps.cs @@ -211,7 +211,8 @@ private static ColonyStamp PlacedBuilding(EntityComponent entity) if (stamp == null) return null; BlockObject blockObject = entity.GetComponent(); if (blockObject == null || blockObject.IsPreview || !blockObject.Positioned) return null; - if (TradingPosts.IsTradingPostBuilding(entity)) return null; + // A Trading Post's or Power Export Facility's half is nobody's: each half is its road's colony's. + if (TradingPosts.IsTradingPostBuilding(entity) || PowerExports.IsFacilityBuilding(entity)) return null; return stamp; } diff --git a/BeaverBuddies/Colonies/ConstructionVisibility.cs b/BeaverBuddies/Colonies/ConstructionVisibility.cs index 26adb79b..9d8a5f4c 100644 --- a/BeaverBuddies/Colonies/ConstructionVisibility.cs +++ b/BeaverBuddies/Colonies/ConstructionVisibility.cs @@ -101,7 +101,8 @@ private void Apply() { EntityComponent entity = pair.Key; int? owner = entity ? DistrictOwner.OwnerOf(entity) : null; - if (!entity || owner == null || !hiddenSlots.Contains(owner.Value) || TradingPosts.IsTradingPostBuilding(entity) || IsFinished(entity)) + if (!entity || owner == null || !hiddenSlots.Contains(owner.Value) || TradingPosts.IsTradingPostBuilding(entity) + || PowerExports.IsFacilityBuilding(entity) || IsFinished(entity)) scratch.Add(entity); } foreach (EntityComponent entity in scratch) Release(entity); @@ -124,7 +125,7 @@ private void Apply() // Only what is still being built: a finished building stays drawn. EntityComponent entity = blockObject ? blockObject.GetComponent() : null; if (!entity) { unfinished.Remove(blockObject); continue; } - if (blockObject.IsFinished || TradingPosts.IsTradingPostBuilding(entity)) continue; + if (blockObject.IsFinished || TradingPosts.IsTradingPostBuilding(entity) || PowerExports.IsFacilityBuilding(entity)) continue; int? owner = DistrictOwner.OwnerOf(entity); if (owner == null || owner.Value == me || !hiddenSlots.Contains(owner.Value)) continue; // Drawn parts the game switches on again (a finished building's new model) are caught on the next pass. diff --git a/BeaverBuddies/Colonies/PowerExport.cs b/BeaverBuddies/Colonies/PowerExport.cs new file mode 100644 index 00000000..f89888e0 --- /dev/null +++ b/BeaverBuddies/Colonies/PowerExport.cs @@ -0,0 +1,287 @@ +using HarmonyLib; +using System.Collections.Generic; +using System.Collections.Immutable; +using Timberborn.BaseComponentSystem; +using Timberborn.BlockSystem; +using Timberborn.BlueprintSystem; +using Timberborn.Buildings; +using Timberborn.EntitySystem; +using Timberborn.GameDistricts; +using Timberborn.LinkedBuildingSystem; +using Timberborn.MechanicalSystem; +using Timberborn.Persistence; +using Timberborn.Workshops; +using Timberborn.WorldPersistence; + +namespace BeaverBuddies.Colonies +{ + /// + /// Marks the Power Export Facility (Buildings/Power/MultiColonyPowerExport): two linked halves, as a District + /// Crossing's, each with one worker and one power connection, that send spare power from one colony's network to + /// another's. The game finds spec types by their class name across every loaded assembly, so the name is this mod's own. + /// + public record MultiColonyPowerExportSpec : ComponentSpec + { + } + + /// Why a Power Export Facility half moves no power now (display only; worked out every tick). + public enum PowerExportStatus + { + /// Power crosses now (see ). + Sending, + Receiving, + /// Neither half's colony sends. + NotSending, + /// A half is still being built. + Unfinished, + /// A half has no colony's road at its door. + NoRoad, + /// Both halves are in the same colony. + SameColony, + /// A half's power connection reaches no network. + NoPower, + /// A half's road and its power network belong to different colonies. + Mismatched, + /// A half has no worker at work (none assigned, off shift, or paused). + NoWorker, + /// The link breaks a rule (one way, one to one, no circles): another link was there first. + LinkRefused, + /// The receiving side can use nothing now. + PartnerNeedsNone, + /// The sending side has nothing to spare now. + NothingToSpare, + } + + /// + /// A half of a Power Export Facility: its colony's settings (saved with it, set by an action the host judges) and + /// what it moves (worked out every tick by , the same on every computer). Its power + /// node is a consumer and a generator of 1 hp each, scaled to what crosses: the sending half draws it from its + /// network, the receiving half gives it to its own. + /// + public class PowerExportHalf : BaseComponent, IAwakableComponent, IPersistentEntity, IFinishedStateListener, IRegisteredComponent + { + private static readonly ComponentKey HalfKey = new ComponentKey("BeaverBuddies.PowerExportHalf"); + private static readonly PropertyKey SendingKey = new PropertyKey("Sending"); + private static readonly PropertyKey ChargeFirstKey = new PropertyKey("ChargeFirst"); + private static readonly PropertyKey UseBatteriesKey = new PropertyKey("UseBatteries"); + + private MechanicalNode node; + private Workshop workshop; + private LinkedBuilding linked; + private BlockObject blockObject; + private int appliedInput = -1, appliedOutput = -1; + + /// This half's colony sends power to the other half's. + public bool Sending { get; private set; } + /// Its colony's batteries fill before anything is sent. + public bool ChargeFirst { get; private set; } = true; + /// Its colony's stored power may be sent too. + public bool UseBatteries { get; private set; } + + // Worked out every tick (not saved; the same on every computer). + /// Power crossing through this half now, in hp: drawn from its network when sending, given when receiving. + public int Flow { get; internal set; } + public PowerExportStatus Status { get; internal set; } = PowerExportStatus.NotSending; + /// The last tick its worker was at work (a worker stops for a tick between shifts of work). + internal long LastWorkingTick = long.MinValue; + + public MechanicalNode Node => node; + public bool IsFinished => blockObject && blockObject.IsFinished; + public bool WorkingNow => workshop && workshop.CurrentlyWorking; + + /// The other half (linked when both are placed), or null. + public PowerExportHalf Partner => linked && linked._linked ? linked._linked.GetComponent() : null; + + public void Awake() + { + node = GetComponent(); + workshop = GetComponent(); + linked = GetComponent(); + blockObject = GetComponent(); + } + + public void OnEnterFinishedState() => SetPower(0, 0); + + public void OnExitFinishedState() + { + SetPower(0, 0); + Flow = 0; + } + + /// What the half's node draws from and gives to its network, in hp (every tick, every computer). + internal void SetPower(int input, int output) + { + if (node == null) return; + if (appliedInput != input) + { + appliedInput = input; + node.SetInputMultiplier(input); + } + if (appliedOutput != output) + { + appliedOutput = output; + node.SetOutputMultiplier(output); + } + } + + /// What the half's node draws from its network now (what the game counts in it). + public int PowerDrawn => node != null ? node.Actuals.PowerInput : 0; + + /// What the half's node gives to its network now. + public int PowerGiven => node != null ? node.Actuals.PowerOutput : 0; + + internal void Set(PowerExportSetting setting, bool value) + { + switch (setting) + { + case PowerExportSetting.Sending: Sending = value; break; + case PowerExportSetting.ChargeFirst: ChargeFirst = value; break; + case PowerExportSetting.UseBatteries: UseBatteries = value; break; + } + } + + public void Save(IEntitySaver entitySaver) + { + // Separate colonies only: in a shared game the facility is not offered, and nothing is saved. + if (!ColonyModeService.IsSeparateColonies) return; + IObjectSaver saver = entitySaver.GetComponent(HalfKey); + saver.Set(SendingKey, Sending); + saver.Set(ChargeFirstKey, ChargeFirst); + saver.Set(UseBatteriesKey, UseBatteries); + } + + public void Load(IEntityLoader entityLoader) + { + if (!entityLoader.TryGetComponent(HalfKey, out IObjectLoader loader)) return; + if (loader.Has(SendingKey)) Sending = loader.Get(SendingKey); + if (loader.Has(ChargeFirstKey)) ChargeFirst = loader.Get(ChargeFirstKey); + if (loader.Has(UseBatteriesKey)) UseBatteries = loader.Get(UseBatteriesKey); + } + } + + public enum PowerExportSetting + { + Sending, + ChargeFirst, + UseBatteries, + } + + /// Who is who at a Power Export Facility, and whose power network is whose. + public static class PowerExports + { + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable isFacilityTemplate = + new System.Runtime.CompilerServices.ConditionalWeakTable(); + private static readonly object IsFacility = new object(), IsNotFacility = new object(); + + /// An entity, or a building's preview, is a Power Export Facility half. + public static bool IsFacilityBuilding(BaseComponent entity) => + entity && entity.GetComponent() != null; + + /// A building template is the Power Export Facility (either faction's). + public static bool IsFacilityTemplate(BuildingSpec spec) + { + if (spec == null) return false; + return isFacilityTemplate.GetValue(spec, s => s.HasSpec() ? IsFacility : IsNotFacility) == IsFacility; + } + + /// The colony whose district the half's door opens onto (its worker's), or -1. + public static int ColonyOf(PowerExportHalf half) + { + if (!half) return -1; + DistrictBuilding building = half.GetComponent(); + return DistrictOwner.OwnerOfDistrict(building ? building.District : null) ?? -1; + } + + /// + /// Whose power a node is, for the power rule: its district's colony, else the colony that placed it. A Power + /// Export Facility half is nobody's (it joins whichever network reaches it), and so is a building nobody placed. + /// Simulation code: never the construction district, which the game works out from its instant map. + /// + public static int? PowerOwnerOf(MechanicalNode node) + { + if (!node || node.GetComponent() != null) return null; + return DistrictOwner.OwnerOf(node, useConstructionDistrict: false); + } + + /// + /// The colony a half's network belongs to: its neighbour's across its one connection, else (a neighbour nobody + /// owns) the first owned node found in the network. -1 for a half reaching no network, or a network of nobody's. + /// + public static int NetworkColonyOf(PowerExportHalf half) + { + MechanicalNode node = half ? half.Node : null; + if (node == null || node.Graph == null) return -1; + ImmutableArray transputs = node.Transputs; + if (transputs.IsDefault) return -1; + bool connected = false; + foreach (Transput transput in transputs) + { + if (!transput.Connected) continue; + connected = true; + int? owner = PowerOwnerOf(transput.ConnectedNode); + if (owner != null) return owner.Value; + } + if (!connected) return -1; + // All of a network is one colony's (the power rule). Every node is looked at, so the answer never depends on + // the order the game keeps them in (which may differ between computers); two colonies' would be nobody's. + int found = -1; + foreach (MechanicalNode other in node.Graph.Nodes) + { + int? owner = PowerOwnerOf(other); + if (owner == null) continue; + if (found >= 0 && found != owner.Value) return -1; + found = owner.Value; + } + return found; + } + } + + /* + * 9/29/2026 (Timberborn 1.1.2.4): a node joins every network whose connection faces its own, as it enters the finished + * state (MechanicalGraphManager.AddNode): + _mechanicalGraphFactory.Create().AddNode(mechanicalNode); + HashSet hashSet = new HashSet { mechanicalNode.Graph }; + foreach (Transput current in mechanicalNode.Transputs) + { + Transput facingTransput = _transputMap.GetFacingTransput(current); + if (facingTransput != null && facingTransput.IsFinished) + { + MechanicalGraph graph = facingTransput.ParentNode.Graph; + if (graph != null) { current.Connect(facingTransput); facingTransput.Connect(current); hashSet.Add(graph); } + } + } + if (hashSet.Count > 1) _mechanicalGraphFactory.Join(hashSet); + * A network split later (MechanicalGraphReorganizer) follows only the connections made here. + */ + // Separate colonies: two colonies' nodes are never connected, so their networks never join, whatever the placement + // check let through (two placements at once, a road that changed a building's colony). A Power Export Facility's half + // is nobody's and connects to anything. Shared games and each colony's own nodes are the game's. + [HarmonyPatch(typeof(MechanicalGraphManager), nameof(MechanicalGraphManager.AddNode))] + static class PowerNetworkSeparationPatcher + { + static bool Prefix(MechanicalGraphManager __instance, MechanicalNode mechanicalNode) + { + if (!ColonyModeService.IsSeparateColonies) return true; + __instance._mechanicalGraphFactory.Create().AddNode(mechanicalNode); + var graphs = new HashSet { mechanicalNode.Graph }; + int? mine = PowerExports.PowerOwnerOf(mechanicalNode); + foreach (Transput current in mechanicalNode.Transputs) + { + Transput facing = __instance._transputMap.GetFacingTransput(current); + if (facing == null || !facing.IsFinished) continue; + MechanicalGraph graph = facing.ParentNode.Graph; + if (graph == null) continue; + int? theirs = PowerExports.PowerOwnerOf(facing.ParentNode); + if (!ColonyPowerRule.MayJoin(mine, theirs)) continue; + // A node nobody owns (a facility half, a building nobody placed) joins one colony's power only: the first + // it meets, in the order of its connections (the same on every computer). + if (mine == null && theirs != null) mine = theirs; + current.Connect(facing); + facing.Connect(current); + graphs.Add(graph); + } + if (graphs.Count > 1) __instance._mechanicalGraphFactory.Join(graphs); + return false; + } + } +} diff --git a/BeaverBuddies/Colonies/PowerExportEvents.cs b/BeaverBuddies/Colonies/PowerExportEvents.cs new file mode 100644 index 00000000..6167d2cc --- /dev/null +++ b/BeaverBuddies/Colonies/PowerExportEvents.cs @@ -0,0 +1,156 @@ +using BeaverBuddies.Editor; +using BeaverBuddies.Events; +using BeaverBuddies.Panel; +using BeaverBuddies.Util; +using System; +using UnityEngine; + +namespace BeaverBuddies.Colonies +{ + /// + /// A colony's player changes a setting of their half of a Power Export Facility: sending power to the other colony, + /// charging their batteries first, or using their batteries for the other colony. Judged by the host as a change to + /// the half (its colony only, or whoever looks after it), and checked again as it is played, on every computer: + /// sending must obey the link rules (). + /// + [Serializable] + public class PowerExportSettingEvent : ReplayEvent + { + /// The half (named entityID so the colony rules judge it by its owner). + public string entityID; + public int setting; + public bool value; + + public override ColonyScope GetColonyScope() => ColonyScope.Entities(entityID); + + public override void Replay(IReplayContext context) + { + PowerExportHalf half = GetComponent(context, entityID); + if (half == null) return; + PowerExportSetting which = (PowerExportSetting)setting; + int colony = PowerExports.ColonyOf(half); + if (slot >= 0 && colony != slot) + { + Plugin.Log($"[Colony] Power export: colony {slot + 1} may not change a half of colony {colony + 1}"); + return; + } + PowerExportHalf partner = half.Partner; + int partnerColony = PowerExports.ColonyOf(partner); + if (which == PowerExportSetting.Sending && value && !half.Sending) + { + PowerLinkRefusal refusal = PowerExportMath.Check(colony, partnerColony, + PowerExportService.Instance?.LinksExcept(half) ?? new System.Collections.Generic.List<(int, int)>()); + if (refusal != PowerLinkRefusal.None) + { + Plugin.Log($"[Colony] Power export: colony {colony + 1} may not send to colony {partnerColony + 1}: {refusal}"); + if (player == ColonySession.LocalPlayer) + SingletonManager.GetSingleton()?.ShowNotice(PowerExportText.Refusal(refusal, partnerColony)); + return; + } + } + bool was = which switch + { + PowerExportSetting.Sending => half.Sending, + PowerExportSetting.ChargeFirst => half.ChargeFirst, + _ => half.UseBatteries, + }; + if (was == value) return; + half.Set(which, value); + ColonyDigest.Note("power", colony, setting, value ? 1 : 0); + Plugin.Log($"[Colony] Power export: colony {colony + 1} set {which} to {value}"); + // The other colony hears that power starts or stops coming (display only, on its computers). + if (which == PowerExportSetting.Sending && partnerColony >= 0 && partnerColony == ColonySession.LocalSlot) + SingletonManager.GetSingleton()?.ShowNotice(string.Format(RegisteredLocalizationService.T(value + ? "BeaverBuddies.Colony.Power.Notice.Started" : "BeaverBuddies.Colony.Power.Notice.Stopped"), + NativeNames.Plain(colony)), warning: false); + } + + public override string ToActionString() => $"Power export {(PowerExportSetting)setting} {(value ? "on" : "off")}"; + } + + /// What the facility's panel and the Power window do with a half's check boxes. + internal static class PowerExportActions + { + public static bool Current(PowerExportHalf half, PowerExportSetting setting) => setting switch + { + PowerExportSetting.Sending => half.Sending, + PowerExportSetting.ChargeFirst => half.ChargeFirst, + _ => half.UseBatteries, + }; + + /// Why the half's colony can't start sending now (a sentence for the greyed box), or null. + public static string SendBlocked(PowerExportHalf half) + { + if (!half || half.Sending) return null; + int me = PowerExports.ColonyOf(half), them = PowerExports.ColonyOf(half.Partner); + PowerLinkRefusal refusal = PowerExportMath.Check(me, them, + PowerExportService.Instance?.LinksExcept(half) ?? new System.Collections.Generic.List<(int, int)>()); + return refusal == PowerLinkRefusal.None ? null : PowerExportText.Refusal(refusal, them); + } + + /// Sends the change through the host (power exports work in a hosted game, as trading does). + public static void Change(PowerExportHalf half, PowerExportSetting setting, bool value) + { + string id = half ? GetEntityID(half) : null; + if (id == null) return; + if (ReplayEvent.DoPrefix(() => new PowerExportSettingEvent { entityID = id, setting = (int)setting, value = value })) + SingletonManager.GetSingleton()?.ShowNotice(PowerExportText.T("BeaverBuddies.Colony.Power.HostFirst")); + } + + private static string GetEntityID(PowerExportHalf half) => ReplayEvent.GetEntityID(half); + } + + /// A colony's name for plain text (no markup). + internal static class NativeNames + { + public static string Plain(int slot) => Util.NativeElements.Plain(ColonyExchangeService.ColonyName(slot)); + } + + /// The Power Export Facility's texts shared by its panel, the Power window and the refusals. + internal static class PowerExportText + { + public static string T(string key) => RegisteredLocalizationService.T(key); + + /// Why sending to can't start, in a sentence. + public static string Refusal(PowerLinkRefusal refusal, int partner) => refusal switch + { + PowerLinkRefusal.NotTwoColonies => T("BeaverBuddies.Colony.Power.Refused.NotTwoColonies"), + PowerLinkRefusal.PartnerSending => string.Format(T("BeaverBuddies.Colony.Power.Refused.PartnerSending"), NativeNames.Plain(partner)), + PowerLinkRefusal.SendingElsewhere => T("BeaverBuddies.Colony.Power.Refused.SendingElsewhere"), + PowerLinkRefusal.PartnerReceivingElsewhere => string.Format(T("BeaverBuddies.Colony.Power.Refused.PartnerReceivingElsewhere"), NativeNames.Plain(partner)), + PowerLinkRefusal.MakesCircle => T("BeaverBuddies.Colony.Power.Refused.MakesCircle"), + _ => "", + }; + + /// A colony's name in bold in its player's colour, lightened where it would be hard to read. + public static string ColoredName(int slot) + { + string name = NativeNames.Plain(slot); + if (slot < 0 || slot >= StartingLocationPlayer.PLAYER_COLORS.Length) return "" + name + ""; + string hex = ChatFormat.ReadableHex(ColorUtility.ToHtmlStringRGB(StartingLocationPlayer.PLAYER_COLORS[slot])); + return $"{name}"; + } + + /// A half's status line: power from 's colony to 's. + public static string Status(PowerExportHalf half, int sender, int receiver) + { + string from = NativeNames.Plain(sender), to = NativeNames.Plain(receiver); + return half.Status switch + { + PowerExportStatus.Sending => string.Format(T("BeaverBuddies.Colony.Power.Status.Flowing"), from, to, half.Flow), + PowerExportStatus.Receiving => string.Format(T("BeaverBuddies.Colony.Power.Status.Flowing"), from, to, half.Flow), + PowerExportStatus.NotSending => T("BeaverBuddies.Colony.Power.Status.NotSending"), + PowerExportStatus.Unfinished => T("BeaverBuddies.Colony.Power.Status.Unfinished"), + PowerExportStatus.NoRoad => T("BeaverBuddies.Colony.Power.Status.NoRoad"), + PowerExportStatus.SameColony => T("BeaverBuddies.Colony.Power.Status.SameColony"), + PowerExportStatus.NoPower => T("BeaverBuddies.Colony.Power.Status.NoPower"), + PowerExportStatus.Mismatched => T("BeaverBuddies.Colony.Power.Status.Mismatched"), + PowerExportStatus.NoWorker => T("BeaverBuddies.Colony.Power.Status.NoWorker"), + PowerExportStatus.LinkRefused => T("BeaverBuddies.Colony.Power.Status.LinkRefused"), + PowerExportStatus.PartnerNeedsNone => string.Format(T("BeaverBuddies.Colony.Power.Status.PartnerNeedsNone"), to), + PowerExportStatus.NothingToSpare => string.Format(T("BeaverBuddies.Colony.Power.Status.NothingToSpare"), from), + _ => "", + }; + } + } +} diff --git a/BeaverBuddies/Colonies/PowerExportFragment.cs b/BeaverBuddies/Colonies/PowerExportFragment.cs new file mode 100644 index 00000000..e5b94e90 --- /dev/null +++ b/BeaverBuddies/Colonies/PowerExportFragment.cs @@ -0,0 +1,260 @@ +using BeaverBuddies.Editor; +using BeaverBuddies.Events; +using BeaverBuddies.Panel; +using BeaverBuddies.Util; +using System; +using Timberborn.BaseComponentSystem; +using Timberborn.CoreUI; +using Timberborn.EntityPanelSystem; +using Timberborn.SelectionSystem; +using Timberborn.TooltipSystem; +using UnityEngine; +using UnityEngine.UIElements; + +namespace BeaverBuddies.Colonies +{ + /// + /// The Power Export Facility's panel, built from the game's own panel pieces as the Trading Post's is, below the + /// game's own (the worker, and the half's power network). On the player's own half: whom it links to, the power + /// crossing now or why none does, the half's three settings (send power, charge my batteries first, use my batteries), + /// and what the other colony's half is set to. On the other colony's half it says whose half it is and leads to the + /// player's own. Display and check boxes only; each change is an ordinary action that every computer plays. + /// + public class PowerExportFragment : IEntityPanelFragment + { + private const float RefreshInterval = 0.5f; + + private readonly VisualElementInitializer _visualElementInitializer; + private readonly ITooltipRegistrar _tooltipRegistrar; + private readonly EntitySelectionService _entitySelectionService; + + private NineSliceVisualElement root; + private Label headerText, flowText, statusText, partnerText; + private Button windowButton, myHalfButton; + private VisualElement mySettings; + private Toggle sendToggle, chargeToggle, batteryToggle; + private PowerExportHalf half; + private float nextRefresh; + // Why sending can't be turned on now (the tooltip of the greyed box), or null. + private string sendBlocked; + private int partnerSlot = -1; + + public PowerExportFragment(VisualElementInitializer visualElementInitializer, ITooltipRegistrar tooltipRegistrar, + EntitySelectionService entitySelectionService) + { + _visualElementInitializer = visualElementInitializer; + _tooltipRegistrar = tooltipRegistrar; + _entitySelectionService = entitySelectionService; + } + + public VisualElement InitializeFragment() + { + root = NativeElements.Section(); + root.style.flexShrink = 0; + + VisualElement header = NativeElements.Row(); + header.style.minHeight = 30; + header.style.marginBottom = 4; + headerText = RichText(13); + headerText.style.flexGrow = 1; + headerText.style.flexShrink = 1; + header.Add(headerText); + windowButton = SmallButton(T("BeaverBuddies.Colony.Power.WindowShort"), () => PowerOverviewPanel.Instance?.Toggle()); + windowButton.style.marginLeft = 6; + _tooltipRegistrar.RegisterWithKeyBinding(windowButton, T("BeaverBuddies.Colony.Power.WindowTooltip"), PowerOverviewPanel.KeyBindingId); + header.Add(windowButton); + root.Add(header); + + flowText = RichText(13); + root.Add(flowText); + statusText = NativeElements.MutedText("", 12); + statusText.style.marginTop = 2; + root.Add(statusText); + + mySettings = new VisualElement(); + mySettings.style.marginTop = 6; + sendToggle = NativeElements.CheckBox(""); + sendToggle.RegisterValueChangedCallback(change => Change(PowerExportSetting.Sending, change.newValue, sendToggle)); + _tooltipRegistrar.Register(sendToggle, () => sendBlocked ?? string.Format(T("BeaverBuddies.Colony.Power.SendTooltip"), Plain(partnerSlot))); + mySettings.Add(sendToggle); + chargeToggle = NativeElements.CheckBox(T("BeaverBuddies.Colony.Power.ChargeFirst")); + chargeToggle.RegisterValueChangedCallback(change => Change(PowerExportSetting.ChargeFirst, change.newValue, chargeToggle)); + _tooltipRegistrar.Register(chargeToggle, () => half && half.UseBatteries + ? string.Format(T("BeaverBuddies.Colony.Power.ChargeFirstOffTooltip"), Plain(partnerSlot)) + : T("BeaverBuddies.Colony.Power.ChargeFirstTooltip")); + mySettings.Add(chargeToggle); + batteryToggle = NativeElements.CheckBox(""); + batteryToggle.RegisterValueChangedCallback(change => Change(PowerExportSetting.UseBatteries, change.newValue, batteryToggle)); + _tooltipRegistrar.Register(batteryToggle, () => string.Format(T("BeaverBuddies.Colony.Power.UseBatteriesTooltip"), Plain(partnerSlot))); + mySettings.Add(batteryToggle); + root.Add(mySettings); + + partnerText = NativeElements.MutedText("", 12); + partnerText.style.marginTop = 6; + root.Add(partnerText); + + myHalfButton = NativeElements.WoodenButton(T("BeaverBuddies.Colony.Trade.SelectMyHalf"), SelectMyHalf); + myHalfButton.style.marginTop = 6; + root.Add(myHalfButton); + + _visualElementInitializer.InitializeVisualElement(root); + root.style.display = DisplayStyle.None; + return root; + } + + public void ShowFragment(BaseComponent entity) + { + half = entity.GetComponent(); + nextRefresh = 0; + Refresh(); + } + + public void ClearFragment() + { + half = null; + if (root != null) root.style.display = DisplayStyle.None; + } + + public void UpdateFragment() + { + if (!half) return; + if (Time.unscaledTime < nextRefresh) return; + nextRefresh = Time.unscaledTime + RefreshInterval; + Refresh(); + } + + private void Refresh() + { + try + { + RefreshUnsafe(); + } + catch (Exception error) + { + // A panel must never break the game; hide it and say why once in the log. + Plugin.LogWarning("[Colony] Power export panel: " + error); + root.style.display = DisplayStyle.None; + half = null; + } + } + + private void RefreshUnsafe() + { + if (!half || !ColonyModeService.IsSeparateColonies) + { + root.style.display = DisplayStyle.None; + return; + } + NativeElements.Show(root, true); + PowerExportHalf partner = half.Partner; + int me = PowerExports.ColonyOf(half), them = PowerExports.ColonyOf(partner), local = ColonySession.LocalSlot; + partnerSlot = them; + bool mine = local >= 0 && me == local; + bool linked = me >= 0 && them >= 0 && me != them; + + NativeElements.Show(windowButton, PowerOverviewPanel.Instance != null); + NativeElements.Show(myHalfButton, false); + if (!mine || !linked) + { + NativeElements.Show(mySettings, false); + NativeElements.Show(partnerText, false); + NativeElements.SetText(statusText, ""); + if (linked && local >= 0 && them == local) + { + // The other colony's half of a facility the player shares. + NativeElements.SetText(headerText, string.Format(T("BeaverBuddies.Colony.Power.TheirHalfTitle"), PowerExportText.ColoredName(me))); + NativeElements.SetText(flowText, FlowLine(half, partner, me, them)); + NativeElements.SetText(statusText, string.Format(T("BeaverBuddies.Colony.Power.TheirHalf"), Plain(me))); + NativeElements.Show(myHalfButton, true); + } + else if (linked) + { + NativeElements.SetText(headerText, string.Format(T("BeaverBuddies.Colony.Power.Between"), + PowerExportText.ColoredName(me), PowerExportText.ColoredName(them))); + NativeElements.SetText(flowText, FlowLine(half, partner, me, them)); + } + else + { + NativeElements.SetText(headerText, T("BeaverBuddies.Colony.Power.NotLinkedTitle")); + NativeElements.SetText(flowText, T(me >= 0 && me == them + ? "BeaverBuddies.Colony.Power.Status.SameColony" : "BeaverBuddies.Colony.Power.NotLinked")); + } + return; + } + + NativeElements.SetText(headerText, string.Format(T("BeaverBuddies.Colony.Power.LinkedWith"), PowerExportText.ColoredName(them))); + NativeElements.SetText(flowText, FlowLine(half, partner, me, them)); + PowerExportHalf sender = half.Sending ? half : partner != null && partner.Sending ? partner : null; + string status = sender == null || sender.Status == PowerExportStatus.Sending ? "" + : PowerExportText.Status(sender, PowerExports.ColonyOf(sender), PowerExports.ColonyOf(sender.Partner)); + NativeElements.SetText(statusText, status); + NativeElements.Show(statusText, status.Length > 0); + + NativeElements.Show(mySettings, true); + sendToggle.text = string.Format(T("BeaverBuddies.Colony.Power.Send"), Plain(them)); + batteryToggle.text = string.Format(T("BeaverBuddies.Colony.Power.UseBatteries"), Plain(them)); + sendToggle.SetValueWithoutNotify(half.Sending); + chargeToggle.SetValueWithoutNotify(half.ChargeFirst); + batteryToggle.SetValueWithoutNotify(half.UseBatteries); + sendBlocked = PowerExportActions.SendBlocked(half); + sendToggle.SetEnabled(sendBlocked == null); + // Only the sending side's batteries count; while its batteries feed the other colony they don't charge first. + chargeToggle.SetEnabled(half.Sending && !half.UseBatteries); + batteryToggle.SetEnabled(half.Sending); + + NativeElements.Show(partnerText, partner != null); + if (partner != null) + NativeElements.SetText(partnerText, string.Format(T(partner.Sending + ? "BeaverBuddies.Colony.Power.PartnerSends" : "BeaverBuddies.Colony.Power.PartnerDoesNotSend"), Plain(them))); + } + + /// "Player 1 → Player 2: 140 hp" while power crosses, else "No power crosses". + private static string FlowLine(PowerExportHalf half, PowerExportHalf partner, int me, int them) + { + PowerExportHalf sender = half.Sending ? half : partner != null && partner.Sending ? partner : null; + if (sender == null) return T("BeaverBuddies.Colony.Power.Status.NotSending"); + int from = sender == half ? me : them, to = sender == half ? them : me; + return string.Format(T("BeaverBuddies.Colony.Power.FlowLine"), PowerExportText.ColoredName(from), PowerExportText.ColoredName(to), sender.Flow); + } + + private void Change(PowerExportSetting setting, bool value, Toggle toggle) + { + if (!half) return; + // Shown as it is until the action comes back and is played. + toggle.SetValueWithoutNotify(PowerExportActions.Current(half, setting)); + PowerExportActions.Change(half, setting, value); + nextRefresh = 0; + } + + private void SelectMyHalf() + { + PowerExportHalf partner = half ? half.Partner : null; + if (!partner) return; + try { _entitySelectionService.SelectAndFocusOn(partner); } + catch (Exception error) { Plugin.LogWarning("[Colony] Could not select the other half: " + error.Message); } + } + + private static Label RichText(int size) + { + Label label = NativeElements.Text("", size); + label.enableRichText = true; + return label; + } + + private static Button SmallButton(string text, Action onClick) + { + Button button = NativeElements.WoodenButton(text, onClick); + button.style.minHeight = 24; + button.style.height = 24; + button.style.paddingTop = 0; + button.style.paddingBottom = 0; + button.style.fontSize = 12; + button.style.flexShrink = 0; + return button; + } + + private static string Plain(int slot) => NativeNames.Plain(slot); + + private static string T(string key) => RegisteredLocalizationService.T(key); + } +} diff --git a/BeaverBuddies/Colonies/PowerExportMath.cs b/BeaverBuddies/Colonies/PowerExportMath.cs new file mode 100644 index 00000000..ba7a1aa6 --- /dev/null +++ b/BeaverBuddies/Colonies/PowerExportMath.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace BeaverBuddies.Colonies +{ + /// Why a colony may not start sending power to another (see ). + public enum PowerLinkRefusal + { + None, + /// Both halves are in the same colony, or a half is in none. + NotTwoColonies, + /// The other colony already sends power to this one: power goes one way between two colonies. + PartnerSending, + /// This colony already sends power to a third colony (each colony sends to one). + SendingElsewhere, + /// The other colony already gets power from a third colony (each colony receives from one). + PartnerReceivingElsewhere, + /// Power would come back round to this colony through others. + MakesCircle, + } + + /// + /// The rules and the arithmetic of the Power Export Facility, with nothing of the game in them (checked headless in + /// StabilityTests). + /// + /// Links: a colony sends power to at most one colony and receives from at most one; between two colonies it goes one + /// way; and it never comes back round (A → B → C → A). Links therefore make chains, each with a start and an end. + /// + /// Flow, on those chains, every tick: a network sends only what the next can use (its consumers' shortfall, room in + /// its batteries, and what it passes on in turn), and only what it has to spare after its own consumers. With + /// "charge my batteries first" its batteries fill before anything is sent; with "use my batteries" its stored power + /// may be sent too, as far as its batteries can give after its own consumers. Power a network gets is spare power it + /// may pass on, so B can pass A's leftover on to C. + /// + public static class PowerExportMath + { + // ---- links between colonies ---- + + /// + /// Whether may start sending to , given the links already on (from + /// every other sending half, in any order; the same link twice is fine). + /// + public static PowerLinkRefusal Check(int from, int to, IEnumerable<(int from, int to)> existing) + { + if (from < 0 || to < 0 || from == to) return PowerLinkRefusal.NotTwoColonies; + var links = existing.Where(l => l.from >= 0 && l.to >= 0 && l.from != l.to).Distinct().ToList(); + if (links.Any(l => l.from == to && l.to == from)) return PowerLinkRefusal.PartnerSending; + if (links.Any(l => l.from == from && l.to != to)) return PowerLinkRefusal.SendingElsewhere; + if (links.Any(l => l.to == to && l.from != from)) return PowerLinkRefusal.PartnerReceivingElsewhere; + if (links.Contains((from, to))) return PowerLinkRefusal.None; + // Follow the power on from `to`: each colony sends to one at most, so this is a walk, bounded by the colonies. + int at = to; + for (int step = 0; step <= links.Count; step++) + { + int next = -1; + foreach (var link in links) + { + if (link.from == at) { next = link.to; break; } + } + if (next < 0) return PowerLinkRefusal.None; + if (next == from) return PowerLinkRefusal.MakesCircle; + at = next; + } + return PowerLinkRefusal.MakesCircle; + } + + /// + /// The links that obey the rules, taken in the order given (the first wins): what is let through every tick, so a + /// link that stopped obeying them (a road now reaches a half from another colony) is left out the same way on + /// every computer. + /// + public static HashSet<(int from, int to)> Admit(IEnumerable<(int from, int to)> requested) + { + var admitted = new List<(int from, int to)>(); + foreach (var link in requested) + { + if (Check(link.from, link.to, admitted) == PowerLinkRefusal.None && !admitted.Contains(link)) admitted.Add(link); + } + return new HashSet<(int from, int to)>(admitted); + } + + /// Each colony's place along its chain: 0 for a colony nothing sends to, then 1, 2... (links admitted). + public static Dictionary Depths(IEnumerable<(int from, int to)> admitted) + { + var links = admitted.ToList(); + var depth = new Dictionary(); + foreach (var link in links) + { + if (!depth.ContainsKey(link.from)) depth[link.from] = 0; + if (!depth.ContainsKey(link.to)) depth[link.to] = 0; + } + // At most one link into each colony and no circles, so this settles within as many passes as there are links. + for (int pass = 0; pass <= links.Count; pass++) + { + bool changed = false; + foreach (var link in links) + { + int want = depth[link.from] + 1; + if (depth[link.to] < want && want <= links.Count) { depth[link.to] = want; changed = true; } + } + if (!changed) break; + } + return depth; + } + + // ---- flow ---- + + /// A power network on a chain, in hp, without what Power Export Facilities send or bring. + public sealed class Network + { + /// Its own generators' output. + public int Supply; + /// Its own consumers' demand. + public int Demand; + /// The power its batteries could take in (0 when full or it has none). + public int BatteryRoom; + /// The power its batteries could give this tick (0 when empty or it has none). + public int BatteryPower; + /// The sending half's "Charge my batteries first" (on: its batteries fill before anything is sent). + public bool ChargeFirst = true; + /// The sending half's "Use my batteries" (on: its stored power may be sent too). + public bool UseBatteries; + } + + /// + /// How much crosses each edge (a sending half's network to its partner's), in the order the edges are given. + /// Every edge must go from a network earlier in than the one it feeds (the caller sorts + /// networks by their colony's place along the chain); an edge that does not is given nothing. + /// + public static int[] Flow(IReadOnlyList networks, IReadOnlyList<(int from, int to)> edges) + { + int n = networks.Count; + var flows = new int[edges.Count]; + var valid = new bool[edges.Count]; + for (int e = 0; e < edges.Count; e++) + valid[e] = edges[e].from >= 0 && edges[e].to >= 0 && edges[e].from < n && edges[e].to < n && edges[e].from < edges[e].to; + + // What each network can use, from the end of the chains back: its shortfall, its batteries' room, and what + // the networks it feeds can use. And what its buildings need (the same without any batteries' room): stored + // power is sent only for that, never from one colony's batteries into another's. + var want = new long[n]; + var need = new long[n]; + for (int i = n - 1; i >= 0; i--) + { + Network net = networks[i]; + long use = Math.Max(0, net.Demand - net.Supply) + Math.Max(0, net.BatteryRoom); + long short_ = Math.Max(0, net.Demand - net.Supply); + for (int e = 0; e < edges.Count; e++) + { + if (!valid[e] || edges[e].from != i) continue; + use += want[edges[e].to]; + short_ += need[edges[e].to]; + } + want[i] = Math.Min(use, int.MaxValue); + need[i] = Math.Min(short_, int.MaxValue); + } + + // What each network has, from the start of the chains on, given out to the networks it feeds in order. Two + // networks feeding one (a colony's two networks, each at its own facility) share what it can use. + var incoming = new long[n]; + var wantLeft = (long[])want.Clone(); + var needLeft = (long[])need.Clone(); + for (int i = 0; i < n; i++) + { + Network net = networks[i]; + long live = net.Supply + incoming[i] - (long)net.Demand; + // Spare power now: with "use my batteries" the other colony comes before this one's batteries (they don't + // charge first); else with "charge first" its batteries fill before anything is sent. + long spare = net.UseBatteries || !net.ChargeFirst ? Math.Max(0, live) : Math.Max(0, live - Math.Max(0, net.BatteryRoom)); + // Stored power, with "use my batteries": what its batteries give beyond its own shortfall. + long stored = net.UseBatteries ? Math.Max(0, net.BatteryPower - Math.Max(0, -live)) : 0; + for (int e = 0; e < edges.Count; e++) + { + if (!valid[e] || edges[e].from != i) continue; + int to = edges[e].to; + long fromSpare = Math.Min(spare, wantLeft[to]); + long fromStore = Math.Min(stored, Math.Min(Math.Max(0, needLeft[to] - fromSpare), wantLeft[to] - fromSpare)); + long give = fromSpare + fromStore; + if (give <= 0) continue; + flows[e] = (int)give; + spare -= fromSpare; + stored -= fromStore; + wantLeft[to] -= give; + needLeft[to] = Math.Max(0, needLeft[to] - give); + incoming[to] += give; + } + } + return flows; + } + } +} diff --git a/BeaverBuddies/Colonies/PowerExportService.cs b/BeaverBuddies/Colonies/PowerExportService.cs new file mode 100644 index 00000000..ee5a72a4 --- /dev/null +++ b/BeaverBuddies/Colonies/PowerExportService.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Timberborn.EntitySystem; +using Timberborn.MechanicalSystem; +using Timberborn.SingletonSystem; +using Timberborn.TickSystem; +using Timberborn.TimeSystem; +using UnityEngine; + +namespace BeaverBuddies.Colonies +{ + /// + /// Moves power through the Power Export Facilities, every tick on every computer, from saved state and the game's own + /// networks only (so every computer moves the same). A link from one colony to another is on while a half of that + /// colony sends and obeys the rules (, in the order of the halves' ids). A facility + /// carries its link while both halves are finished, each half's road and network belong to its own colony, and both + /// workers are at work. How much crosses is on the networks involved, figured from + /// the networks as they are, without the facilities' own share. + /// + public class PowerExportService : RegisteredSingleton, ILoadableSingleton, ITickableSingleton + { + // A worker between two shifts of work stops for a tick or two; the half counts as staffed through that. + private const int WorkerGraceTicks = 5; + + private readonly EntityComponentRegistry _entityComponentRegistry; + private readonly IDayNightCycle _dayNightCycle; + private long ticks; + private bool anyFlow; + private readonly List halves = new List(); + + /// Display: the links let through at the last tick (sender colony → receiver colony) and what each carried. + public IReadOnlyDictionary<(int from, int to), int> Links => links; + private readonly Dictionary<(int from, int to), int> links = new Dictionary<(int from, int to), int>(); + + public static PowerExportService Instance => SingletonManager.GetSingleton(); + + public PowerExportService(EntityComponentRegistry entityComponentRegistry, IDayNightCycle dayNightCycle) + { + _entityComponentRegistry = entityComponentRegistry; + _dayNightCycle = dayNightCycle; + } + + // Loadable only so the game builds it at load: it is found through SingletonManager, not injected. + public void Load() { } + + /// Every facility half, finished or not, in the order of their ids (the same on every computer). + public List AllHalves() + { + return _entityComponentRegistry.GetEnabled() + .Select(half => (id: Events.ReplayEvent.GetEntityID(half), half)) + .Where(p => p.id != null) + .OrderBy(p => p.id, StringComparer.Ordinal) + .Select(p => p.half).ToList(); + } + + /// + /// The links asked for now, by every sending half but (its partner included: it would + /// send the other way): what a colony's new link is checked against when its player turns sending on. + /// + public List<(int from, int to)> LinksExcept(PowerExportHalf except) + { + var result = new List<(int from, int to)>(); + foreach (PowerExportHalf half in AllHalves()) + { + if (half == except || !half.Sending) continue; + int from = PowerExports.ColonyOf(half), to = PowerExports.ColonyOf(half.Partner); + if (from >= 0 && to >= 0 && from != to) result.Add((from, to)); + } + return result; + } + + private static readonly ColonyProfiler.Spot Moving = ColonyProfiler.Declare("Power exports"); + + public void Tick() + { + ticks++; + if (!ColonyModeService.IsSeparateColonies) + { + if (anyFlow) StopAll(); + return; + } + long started = ColonyProfiler.Start(); + try + { + Move(); + } + finally + { + ColonyProfiler.Stop(Moving, started); + } + } + + private void StopAll() + { + foreach (PowerExportHalf half in AllHalves()) + { + half.SetPower(0, 0); + half.Flow = 0; + } + links.Clear(); + anyFlow = false; + } + + private void Move() + { + halves.Clear(); + halves.AddRange(AllHalves()); + links.Clear(); + if (halves.Count == 0) + { + anyFlow = false; + return; + } + + // Who is who, and which workers are at work. + var colony = new Dictionary(); + var network = new Dictionary(); + foreach (PowerExportHalf half in halves) + { + if (half.WorkingNow) half.LastWorkingTick = ticks; + colony[half] = PowerExports.ColonyOf(half); + network[half] = half.IsFinished ? PowerExports.NetworkColonyOf(half) : -1; + } + + // The links asked for, in the halves' order, and those that obey the rules. + var requested = new List<(int from, int to)>(); + foreach (PowerExportHalf half in halves) + { + PowerExportHalf partner = half.Partner; + if (!half.Sending || partner == null || !colony.ContainsKey(partner)) continue; + int from = colony[half], to = colony[partner]; + if (from >= 0 && to >= 0 && from != to) requested.Add((from, to)); + } + HashSet<(int from, int to)> admitted = PowerExportMath.Admit(requested); + Dictionary depth = PowerExportMath.Depths(admitted); + + // Each half's status, and the facilities that carry a link now (sending half first). + var carrying = new List<(PowerExportHalf sender, PowerExportHalf receiver)>(); + foreach (PowerExportHalf half in halves) + { + PowerExportHalf partner = half.Partner; + half.Status = StatusOf(half, partner, colony, network, admitted); + if (half.Status == PowerExportStatus.Sending) carrying.Add((half, partner)); + } + + // The networks involved, by their colony's place along its chain, then by the first half's id in them. + var graphs = new List(); + var graphIndex = new Dictionary(); + var graphDepth = new Dictionary(); + foreach (var (sender, receiver) in carrying) + { + foreach (PowerExportHalf half in new[] { sender, receiver }) + { + MechanicalGraph graph = half.Node.Graph; + if (graphIndex.ContainsKey(graph)) continue; + graphIndex[graph] = graphs.Count; + graphs.Add(graph); + graphDepth[graph] = depth.TryGetValue(colony[half], out int d) ? d : 0; + } + } + // A stable sort: the order found (the halves' ids) breaks ties. + List ordered = graphs.Select((g, i) => (g, i)).OrderBy(p => graphDepth[p.g]).ThenBy(p => p.i).Select(p => p.g).ToList(); + var position = new Dictionary(); + for (int i = 0; i < ordered.Count; i++) position[ordered[i]] = i; + + // Every facility half's own share, taken out of its network's figures. + var drawn = new Dictionary(); + var given = new Dictionary(); + foreach (PowerExportHalf half in halves) + { + MechanicalGraph graph = half.Node != null ? half.Node.Graph : null; + if (graph == null || !position.ContainsKey(graph)) continue; + drawn[graph] = (drawn.TryGetValue(graph, out int d) ? d : 0) + half.PowerDrawn; + given[graph] = (given.TryGetValue(graph, out int g) ? g : 0) + half.PowerGiven; + } + float hours = Mathf.Max(_dayNightCycle.FixedDeltaTimeInHours, 1e-6f); + var nets = new List(); + foreach (MechanicalGraph graph in ordered) + { + int room = graph.BatteryCapacity > graph.BatteryCharge + ? (int)Mathf.Min(Mathf.Ceil((graph.BatteryCapacity - graph.BatteryCharge) / hours), int.MaxValue / 4) : 0; + int battery = graph.BatteryCharge > 0 ? (int)Mathf.Min(Mathf.Ceil(graph.BatteryCharge / hours), int.MaxValue / 4) : 0; + nets.Add(new PowerExportMath.Network + { + Supply = graph.PowerSupply - (given.TryGetValue(graph, out int g) ? g : 0), + Demand = graph.PowerDemand - (drawn.TryGetValue(graph, out int d) ? d : 0), + BatteryRoom = room, + BatteryPower = battery, + }); + } + var edges = new List<(int from, int to)>(); + foreach (var (sender, receiver) in carrying) + { + int from = position[sender.Node.Graph], to = position[receiver.Node.Graph]; + edges.Add((from, to)); + // The sending half's settings are its network's (one colony's settings, the first half's if several). + PowerExportMath.Network net = nets[from]; + if (edges.Count(e => e.from == from) == 1) + { + net.ChargeFirst = sender.ChargeFirst; + net.UseBatteries = sender.UseBatteries; + } + } + int[] flows = PowerExportMath.Flow(nets, edges); + + // Apply: the sending half draws what crosses from its network, the receiving half gives it to its own. + var input = new Dictionary(); + var output = new Dictionary(); + for (int e = 0; e < carrying.Count; e++) + { + var (sender, receiver) = carrying[e]; + int flow = flows[e]; + input[sender] = flow; + output[receiver] = flow; + var key = (colony[sender], colony[receiver]); + links[key] = (links.TryGetValue(key, out int sum) ? sum : 0) + flow; + if (flow <= 0) + { + PowerExportMath.Network from = nets[edges[e].from]; + bool spare = from.Supply + (from.UseBatteries ? (long)from.BatteryPower : 0) > from.Demand; + sender.Status = receiver.Status = spare ? PowerExportStatus.PartnerNeedsNone : PowerExportStatus.NothingToSpare; + } + else receiver.Status = PowerExportStatus.Receiving; + } + foreach (var link in admitted) + { + if (!links.ContainsKey(link)) links[link] = 0; + } + anyFlow = false; + foreach (PowerExportHalf half in halves) + { + int take = input.TryGetValue(half, out int i) ? i : 0; + int give = output.TryGetValue(half, out int o) ? o : 0; + half.SetPower(take, give); + half.Flow = Math.Max(take, give); + if (take > 0 || give > 0) anyFlow = true; + } + } + + private PowerExportStatus StatusOf(PowerExportHalf half, PowerExportHalf partner, Dictionary colony, + Dictionary network, HashSet<(int from, int to)> admitted) + { + if (partner == null || !colony.ContainsKey(partner)) return PowerExportStatus.Unfinished; + bool mineSends = half.Sending, theirsSends = partner.Sending; + if (!mineSends && !theirsSends) return PowerExportStatus.NotSending; + if (!half.IsFinished || !partner.IsFinished) return PowerExportStatus.Unfinished; + int a = colony[half], b = colony[partner]; + if (a < 0 || b < 0) return PowerExportStatus.NoRoad; + if (a == b) return PowerExportStatus.SameColony; + int na = network[half], nb = network[partner]; + if (na < 0 || nb < 0) return PowerExportStatus.NoPower; + if (na != a || nb != b) return PowerExportStatus.Mismatched; + if (half.Node.Graph == null || partner.Node.Graph == null) return PowerExportStatus.NoPower; + // A paused (or otherwise blocked) half moves nothing at once: its node counts nothing in its network, so the + // other half must not give or take either (the workers' grace would otherwise make power from nothing). + if (!half.Node.Active || !partner.Node.Active || !Staffed(half) || !Staffed(partner)) return PowerExportStatus.NoWorker; + // Only the half whose colony sends carries the link; its partner's status follows once the flow is known. + PowerExportHalf sender = mineSends ? half : partner; + if (!admitted.Contains((colony[sender], colony[sender.Partner]))) return PowerExportStatus.LinkRefused; + return mineSends ? PowerExportStatus.Sending : PowerExportStatus.Receiving; + } + + private bool Staffed(PowerExportHalf half) => half.LastWorkingTick != long.MinValue && ticks - half.LastWorkingTick <= WorkerGraceTicks; + + /// Diagnostics: every half's settings and flow, in id order. + public string Fingerprint() => string.Join(" ", AllHalves().Select(h => + $"{(h.Sending ? "S" : "-")}{(h.ChargeFirst ? "C" : "-")}{(h.UseBatteries ? "B" : "-")}{h.Flow}")); + } +} diff --git a/BeaverBuddies/Colonies/PowerOverviewPanel.cs b/BeaverBuddies/Colonies/PowerOverviewPanel.cs new file mode 100644 index 00000000..9c50d234 --- /dev/null +++ b/BeaverBuddies/Colonies/PowerOverviewPanel.cs @@ -0,0 +1,641 @@ +using BeaverBuddies.Events; +using BeaverBuddies.IO; +using BeaverBuddies.Util; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Timberborn.AssetSystem; +using Timberborn.BaseComponentSystem; +using Timberborn.CoreUI; +using Timberborn.EntitySystem; +using Timberborn.InputSystem; +using Timberborn.MechanicalSystem; +using Timberborn.SelectionSystem; +using Timberborn.SingletonSystem; +using Timberborn.TooltipSystem; +using Timberborn.UILayoutSystem; +using UnityEngine; +using UnityEngine.UIElements; + +namespace BeaverBuddies.Colonies +{ + /// + /// The Power window, drawn as the Trading Posts and colonies window is (the game's framed box with a title badge and + /// a close button, dragged by its title or frame): where power goes between colonies (each chain on one line, with + /// what crosses each link), this player's colony's power networks (made, used, spare, batteries, and what comes in or + /// goes out, each with a button to go there), its Power Export Facilities (with their check boxes), and the other + /// colonies' power. It opens and closes with H (a key the player can change), the square Power button at the top + /// right, or "Power" on a facility, and closes with its close button or Esc. While it is open, every colony's power + /// networks show on the map (as Ctrl+P shows them). It does not pause the game. Display and check boxes only: each + /// change sends an ordinary action. + /// + public class PowerOverviewPanel : IPostLoadableSingleton, IUpdatableSingleton, IInputProcessor + { + public const string KeyBindingId = "BeaverBuddies.KeyBind.PowerOverview"; + private const string ToggleIconPath = "UI/Images/BeaverBuddies/square-toggle-power"; + private const float Top = 110, BottomMargin = 40, Width = 470; + + private readonly UILayout _uiLayout; + private readonly InputService _inputService; + private readonly VisualElementInitializer _visualElementInitializer; + private readonly VisualElementLoader _visualElementLoader; + private readonly IAssetLoader _assetLoader; + private readonly ITooltipRegistrar _tooltipRegistrar; + private readonly EntitySelectionService _entitySelectionService; + private readonly MechanicalGraphRegistry _mechanicalGraphRegistry; + + private sealed class FacilityRow + { + public PowerExportHalf Half; + public Label Title, Detail; + public Toggle Send, ChargeFirst, UseBatteries; + } + + private sealed class NetworkRow + { + public BaseComponent Target; + public Label Title, Detail; + } + + /// A network's figures, in hp (batteries in hph), and where to go to see it. + private sealed class NetworkFigures + { + public int Colony = -1, Made, Used, In, Out, Charge, Capacity; + public BaseComponent Target; + public string Key; + } + + private VisualElement window, chainList, networkList, facilityList, colonyList; + private NineSliceVisualElement box; + private Label chainEmpty, networkEmpty, facilityEmpty, colonyEmpty; + private VisualElement topButton; + private Toggle topToggle; + private bool open; + private float nextRefresh; + private string networksShape, facilitiesShape, coloniesShape, chainsShape; + private readonly List networkRows = new List(); + private readonly List facilityRows = new List(); + private readonly Dictionary colonyRows = new Dictionary(); + private readonly List - private static VisualElement TitleAndDetail(out Label title, out Label detail) + internal static VisualElement TitleAndDetail(out Label title, out Label detail) { var text = new VisualElement(); text.style.flexGrow = 1; @@ -1059,7 +1059,7 @@ private static Label Caption(string text = "") } /// A secondary line (who looks after a colony, "nothing yet"), in the mod's muted grey. - private static Label MutedLine(string text = "") + internal static Label MutedLine(string text = "") { Label label = NativeElements.GameText(text, NativeElements.TextSmall); label.style.color = NativeElements.Muted; @@ -1067,7 +1067,7 @@ private static Label MutedLine(string text = "") } /// A heading as the game's boxes write them (game-text-heading, bold). - private static Label Heading(string text = "") + internal static Label Heading(string text = "") { Label label = NativeElements.GameText(text, NativeElements.TextHeading); label.AddToClassList("text--bold"); @@ -1075,7 +1075,7 @@ private static Label Heading(string text = "") } /// The window's button: the game's wooden button (button-game) in its small text (game-text-small), 24 px high. - private static Button SmallButton(string text, Action onClick) + internal static Button SmallButton(string text, Action onClick) { Button button = NativeElements.WithTextClass(NativeElements.WoodenButton(text, onClick), NativeElements.TextSmall); button.style.minHeight = 24; diff --git a/BeaverBuddies/Colonies/TradingPostToolDisabler.cs b/BeaverBuddies/Colonies/TradingPostToolDisabler.cs index 50aec5ab..d3598a7a 100644 --- a/BeaverBuddies/Colonies/TradingPostToolDisabler.cs +++ b/BeaverBuddies/Colonies/TradingPostToolDisabler.cs @@ -14,8 +14,10 @@ public class TradingPostToolDisabler : IToolDisabler { public bool IsEnabled(ITool tool) => ColonyModeService.IsSeparateColonies || !IsTradingPostTool(tool); + /// The Trading Post's tool, or the Power Export Facility's: both belong to separate colonies only. internal static bool IsTradingPostTool(ITool tool) => - tool is BlockObjectTool blockObjectTool && blockObjectTool.Template?.HasSpec() == true; + tool is BlockObjectTool blockObjectTool && (blockObjectTool.Template?.HasSpec() == true + || blockObjectTool.Template?.HasSpec() == true); } /* diff --git a/BeaverBuddies/KeyBindings/BeaverBuddies.KeyBind.PowerOverview.blueprint.json b/BeaverBuddies/KeyBindings/BeaverBuddies.KeyBind.PowerOverview.blueprint.json new file mode 100644 index 00000000..315bb1a0 --- /dev/null +++ b/BeaverBuddies/KeyBindings/BeaverBuddies.KeyBind.PowerOverview.blueprint.json @@ -0,0 +1,15 @@ +{ + "KeyBindingSpec": { + "Id": "BeaverBuddies.KeyBind.PowerOverview", + "GroupId": "KeyBindingGroup.BeaverBuddies", + "LocKey": "BeaverBuddies.KeyBindings.PowerOverview", + "Order": 11, + "AllowOtherModifiers": false, + "DevModeOnly": false + }, + "PrimaryInputBindingSpec": { + "Path": "/Keyboard/h", + "InputModifiers": "None", + "Unchangeable": false + } +} diff --git a/BeaverBuddies/KeyBindings/BeaverBuddies.KeyBind.ToggleColonyPower.blueprint.json b/BeaverBuddies/KeyBindings/BeaverBuddies.KeyBind.ToggleColonyPower.blueprint.json new file mode 100644 index 00000000..10d8661c --- /dev/null +++ b/BeaverBuddies/KeyBindings/BeaverBuddies.KeyBind.ToggleColonyPower.blueprint.json @@ -0,0 +1,15 @@ +{ + "KeyBindingSpec": { + "Id": "BeaverBuddies.KeyBind.ToggleColonyPower", + "GroupId": "KeyBindingGroup.BeaverBuddies", + "LocKey": "BeaverBuddies.KeyBindings.ToggleColonyPower", + "Order": 10, + "AllowOtherModifiers": false, + "DevModeOnly": false + }, + "PrimaryInputBindingSpec": { + "Path": "/Keyboard/p", + "InputModifiers": "Ctrl", + "Unchangeable": false + } +} diff --git a/BeaverBuddies/Localizations/enUS_BeaverBuddie.csv b/BeaverBuddies/Localizations/enUS_BeaverBuddie.csv index 1e95ffa5..58532418 100644 --- a/BeaverBuddies/Localizations/enUS_BeaverBuddie.csv +++ b/BeaverBuddies/Localizations/enUS_BeaverBuddie.csv @@ -562,3 +562,69 @@ BeaverBuddies.Rejoin.Button,"Rejoin","" BeaverBuddies.Rejoin.Stay,"Stay here","" BeaverBuddies.Invite.InCoopGame,"{0} invited you to their co-op game. You're already in one: leave it from the game menu (Esc), then accept the invite again, or use Join co-op game in the main menu.","" BeaverBuddies.Invite.WhileHosting,"{0} invited you to their co-op game. You're hosting a waiting room: close it first, then accept the invite again.","" +BeaverBuddies.Building.PowerExport.DisplayName,"Power Export Facility","" +BeaverBuddies.Building.PowerExport.Description,"Sends spare power from one colony to another. It's the only place two colonies' power may meet. Build it between them: each half needs its colony's road at its door, a shaft at its end, and a worker.","" +BeaverBuddies.Building.PowerExport.FlavorDescription,"A turning wheel is a friendly wheel.","" +BeaverBuddies.KeyBindings.ToggleColonyPower,"Show colonies' power","" +BeaverBuddies.KeyBindings.PowerOverview,"Power window","" +BeaverBuddies.Colony.Refused.TouchesOtherPower,"That would join another colony's power. Colonies' power meets only at a Power Export Facility.","" +BeaverBuddies.Colony.Power.WindowShort,"Power","" +BeaverBuddies.Colony.Power.WindowTooltip,"Power window","" +BeaverBuddies.Colony.Power.LinkedWith,"Power link with {0}","" +BeaverBuddies.Colony.Power.TheirHalfTitle,"{0}'s half","" +BeaverBuddies.Colony.Power.TheirHalf,"This half is {0}'s. Your settings are on your own half.","" +BeaverBuddies.Colony.Power.Between,"Power link: {0} and {1}","" +BeaverBuddies.Colony.Power.NotLinkedTitle,"Power Export Facility","" +BeaverBuddies.Colony.Power.NotLinked,"Not linked yet. Each half needs a different colony's road at its door.","" +BeaverBuddies.Colony.Power.FlowLine,"{0} → {1}: {2} hp","" +BeaverBuddies.Colony.Power.Send,"Send power to {0}","" +BeaverBuddies.Colony.Power.SendTooltip,"Sends your spare power to {0}. Your own buildings are powered first.","" +BeaverBuddies.Colony.Power.ChargeFirst,"Charge my batteries first","" +BeaverBuddies.Colony.Power.ChargeFirstTooltip,"Your batteries fill before any power is sent.","" +BeaverBuddies.Colony.Power.ChargeFirstOffTooltip,"While your batteries feed {0}, they don't charge first.","" +BeaverBuddies.Colony.Power.UseBatteries,"Use my batteries for {0}","" +BeaverBuddies.Colony.Power.UseBatteriesTooltip,"Your stored power can run {0}'s buildings too, after your own. It never fills their batteries.","" +BeaverBuddies.Colony.Power.PartnerSends,"{0} sends power here.","" +BeaverBuddies.Colony.Power.PartnerDoesNotSend,"{0} doesn't send power here.","" +BeaverBuddies.Colony.Power.HostFirst,"Power links work in a co-op game: host the game first (even alone).","" +BeaverBuddies.Colony.Power.Hp,"{0} hp","" +BeaverBuddies.Colony.Power.Status.Flowing,"{0} sends {2} hp to {1}.","" +BeaverBuddies.Colony.Power.Status.NotSending,"No power is being sent.","" +BeaverBuddies.Colony.Power.Status.Unfinished,"Waiting for both halves to be built.","" +BeaverBuddies.Colony.Power.Status.NoRoad,"Each half needs a colony's road at its door.","" +BeaverBuddies.Colony.Power.Status.SameColony,"Both halves are in the same colony. Each half needs a different colony.","" +BeaverBuddies.Colony.Power.Status.NoPower,"Each half needs a shaft at its end.","" +BeaverBuddies.Colony.Power.Status.Mismatched,"A half's road and its shaft belong to different colonies.","" +BeaverBuddies.Colony.Power.Status.NoWorker,"Waiting for a worker at work on each side.","" +BeaverBuddies.Colony.Power.Status.LinkRefused,"Another power link was there first.","" +BeaverBuddies.Colony.Power.Status.PartnerNeedsNone,"{0} needs no power now.","" +BeaverBuddies.Colony.Power.Status.NothingToSpare,"{0} has no power to spare now.","" +BeaverBuddies.Colony.Power.Refused.NotTwoColonies,"Power goes only between two colonies.","" +BeaverBuddies.Colony.Power.Refused.PartnerSending,"{0} is sending power to you. They must stop first.","" +BeaverBuddies.Colony.Power.Refused.SendingElsewhere,"Your colony already sends power to another colony. A colony sends to one colony.","" +BeaverBuddies.Colony.Power.Refused.PartnerReceivingElsewhere,"{0} already gets power from another colony. A colony gets power from one colony.","" +BeaverBuddies.Colony.Power.Refused.MakesCircle,"That power would come back round to your colony.","" +BeaverBuddies.Colony.Power.Notice.Started,"{0} is sending you power.","" +BeaverBuddies.Colony.Power.Notice.Stopped,"{0} stopped sending you power.","" +BeaverBuddies.Colony.Power.Window.Title,"Power","" +BeaverBuddies.Colony.Power.Window.Button,"Power","" +BeaverBuddies.Colony.Power.Window.ButtonTooltip,"Power","" +BeaverBuddies.Colony.Power.Window.CloseTooltip,"Close","" +BeaverBuddies.Colony.Power.Window.Chains,"Power between colonies","" +BeaverBuddies.Colony.Power.Window.NoChains,"No power is being sent.","" +BeaverBuddies.Colony.Power.Window.ChainLink," → {1} ({0} hp)","" +BeaverBuddies.Colony.Power.Window.Networks,"Your power","" +BeaverBuddies.Colony.Power.Window.NoNetworks,"No generators or batteries yet.","" +BeaverBuddies.Colony.Power.Window.NetworkTitle,"Network {0}: {1} spare","" +BeaverBuddies.Colony.Power.Window.NetworkDetail,"Makes {0} · uses {1}","" +BeaverBuddies.Colony.Power.Window.Batteries,"batteries {0} / {1} hph","" +BeaverBuddies.Colony.Power.Window.In,"Gets {0} through a Power Export Facility","" +BeaverBuddies.Colony.Power.Window.Out,"Sends {0} through a Power Export Facility","" +BeaverBuddies.Colony.Power.Window.Facilities,"Power Export Facilities","" +BeaverBuddies.Colony.Power.Window.NoFacilities,"None yet. The Power Export Facility is in the Power tab.","" +BeaverBuddies.Colony.Power.Window.Colonies,"Other colonies","" +BeaverBuddies.Colony.Power.Window.NoColonies,"No other colonies yet.","" +BeaverBuddies.Colony.Power.Window.ColonyTitle,"{0}: {1} spare","" +BeaverBuddies.Colony.Power.Window.NoPower,"No generators or batteries yet.","" +BeaverBuddies.Colony.Power.Window.Hint,"Drag the title to move this window. +H or Esc closes it. Ctrl+P shows every colony's power on the map.","" diff --git a/BeaverBuddies/TemplateCollections/TemplateCollection.Buildings.Folktails.blueprint.json b/BeaverBuddies/TemplateCollections/TemplateCollection.Buildings.Folktails.blueprint.json index ced0852c..003799d4 100644 --- a/BeaverBuddies/TemplateCollections/TemplateCollection.Buildings.Folktails.blueprint.json +++ b/BeaverBuddies/TemplateCollections/TemplateCollection.Buildings.Folktails.blueprint.json @@ -2,7 +2,8 @@ "TemplateCollectionSpec": { "CollectionId": "Buildings.Folktails", "Blueprints#append": [ - "Buildings/DistrictManagement/MultiColonyTradingPost/MultiColonyTradingPost.Folktails.blueprint" + "Buildings/DistrictManagement/MultiColonyTradingPost/MultiColonyTradingPost.Folktails.blueprint", + "Buildings/Power/MultiColonyPowerExport/MultiColonyPowerExport.Folktails.blueprint" ] } } diff --git a/BeaverBuddies/TemplateCollections/TemplateCollection.Buildings.IronTeeth.blueprint.json b/BeaverBuddies/TemplateCollections/TemplateCollection.Buildings.IronTeeth.blueprint.json index e5287aee..7a43ce1b 100644 --- a/BeaverBuddies/TemplateCollections/TemplateCollection.Buildings.IronTeeth.blueprint.json +++ b/BeaverBuddies/TemplateCollections/TemplateCollection.Buildings.IronTeeth.blueprint.json @@ -2,7 +2,8 @@ "TemplateCollectionSpec": { "CollectionId": "Buildings.IronTeeth", "Blueprints#append": [ - "Buildings/DistrictManagement/MultiColonyTradingPost/MultiColonyTradingPost.IronTeeth.blueprint" + "Buildings/DistrictManagement/MultiColonyTradingPost/MultiColonyTradingPost.IronTeeth.blueprint", + "Buildings/Power/MultiColonyPowerExport/MultiColonyPowerExport.IronTeeth.blueprint" ] } } diff --git a/BeaverBuddies/UI/Images/BeaverBuddies/square-toggle-power.png b/BeaverBuddies/UI/Images/BeaverBuddies/square-toggle-power.png new file mode 100644 index 00000000..5f5e9077 Binary files /dev/null and b/BeaverBuddies/UI/Images/BeaverBuddies/square-toggle-power.png differ diff --git a/BeaverBuddies/UI/Images/BeaverBuddies/square-toggle-power.png.meta.json b/BeaverBuddies/UI/Images/BeaverBuddies/square-toggle-power.png.meta.json new file mode 100644 index 00000000..c77e5f3a --- /dev/null +++ b/BeaverBuddies/UI/Images/BeaverBuddies/square-toggle-power.png.meta.json @@ -0,0 +1,3 @@ +{ + "isSprite": true +} diff --git a/BeaverBuddies/changelog.txt b/BeaverBuddies/changelog.txt index 8790e238..06711378 100644 --- a/BeaverBuddies/changelog.txt +++ b/BeaverBuddies/changelog.txt @@ -1,3 +1,11 @@ +v1.4.0-rc32 (Timber Together) +* The Power Export Facility (Power tab) sends your spare power to another colony. Tick Send power to … on your half. +* Power goes one way between two colonies; a colony can pass on what it gets. Pay for it at a Trading Post. +* Two colonies' power never joins otherwise. Ctrl+P shows every colony's power. +* H opens the Power window: where power goes, your networks and facilities, and every colony's power. +* Both players need this version. +* Full details for every version: STABILITY-CHANGELOG.md in the mod folder. + v1.4.0-rc31 (Timber Together) * Type the whole trade, like 101 Logs for 2 Gears. It's never refused; the game carries exactly that in rounds. * The Rounds box shows how many rounds the trade takes; offers and messages say only the whole trade. diff --git a/BeaverBuddies/manifest.json b/BeaverBuddies/manifest.json index ff52e045..cf9f5318 100644 --- a/BeaverBuddies/manifest.json +++ b/BeaverBuddies/manifest.json @@ -1,6 +1,6 @@ { "Name": "Timber Together", - "Version": "1.4.0-rc31", + "Version": "1.4.0-rc32", "Id": "timbermods.TimberTogether", "MinimumGameVersion": "1.1.0.0", "Description": "Build apart. Thrive together. Co-op Timberborn with a colony each: every player runs their own districts, beavers and science on one map, and the colonies trade at Trading Posts. Built on BeaverBuddies by thomaswp and contributors (GPL-3.0), through the BeaverBuddies Stability Fork: see CREDITS.md. Don't enable another BeaverBuddies mod with it.", diff --git a/PRODUCT.md b/PRODUCT.md index f463dd11..e052a547 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -69,6 +69,10 @@ on one map, links them only through Trading Post barter, and still offers ordina - **Away players keep their colony:** by default a colony is never handed over while its player is away. A friend can look after it (Y), and automatic hand-over after a set number of days away is opt-in, for groups where someone may not come back. + - **Sharing power** (not played yet): the **Power Export Facility** (Power tab; 20 gears, 20 planks and 20 logs, + 10 of each a half; 200 science), two halves with a worker each, sends one colony's spare power to another (**Send power to …**, with + **Charge my batteries first** and **Use my batteries for …**). Power never joins between colonies otherwise; + Ctrl+P shows every colony's power and H opens the Power window. It trades nothing: payment is a Trading Post barter. - Inherited from the Stability Fork: Steam invites, the connection panel and chat, pings, player cursors, desync fixes. - Reporting: `%USERPROFILE%\AppData\LocalLow\Mechanistry\Timberborn\Player.log` from every player. In a @@ -88,18 +92,19 @@ on one map, links them only through Trading Post barter, and still offers ordina 5. **Every player has the same game version and the exact same download. Update together:** a different build cannot join (*Multiplayer build mismatch*). -**Status:** beta, current version **1.4.0-rc31** (a release candidate, GitHub's Latest; its changes are not played yet). The site names the newest published release, +**Status:** beta, current version **1.4.0-rc32** (a release candidate, GitHub's Latest; its changes are not played yet). The site names the newest published release, and `docs/assets/release.js` fills the version badges from GitHub. 1.4.0 has not been released yet. It is built for Timberborn **1.1.2.4**, and only the Steam version on Windows has been tested. It passes a large automated test -suite, and every feature has been played except mixed factions (12+ hours in one game with no desync). Players should play +suite, and every feature has been played except mixed factions and sharing power (12+ hours in one game with no desync). Players should play on a copy of their save and keep backups. Be honest about this without scaring people off. **Describe the mod as it is now, for a fresh game.** User-facing pages never say which version added or changed a feature ("since beta19", "rc2 made…"). They never mention earlier builds, older saves or save compatibility. The version history lives in the changelog only. -**Terminology:** Trading Posts (never "District Crossings"). No land, borders or territory: the only rule between -colonies is that their roads never join, except through a Trading Post. +**Terminology:** Trading Posts (never "District Crossings"), the Power Export Facility. No land, borders or territory: +the rules between colonies are that their roads never join, except through a Trading Post, and their power never +joins, except through a Power Export Facility. **Stack and hosting:** static site in `docs/` (plain HTML, CSS and small vanilla JS; no build step), served by GitHub Pages from `main:/docs` at https://timbermods.github.io/TimberTogether/. It must stay fast, lightweight diff --git a/README.md b/README.md index cf5d3c17..fd718cf0 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # Timber Together -***Tested for 12+ hours in a single multi colony game without any desyncs or issues. Every feature confirmed working in game besides mixed factions.*** +***Tested for 12+ hours in a single multi colony game without any desyncs or issues. Every feature confirmed working in game besides mixed factions and sharing power.*** ***Build apart. Thrive together.*** Co-op Timberborn where every player runs their own colony on one shared map: their own districts, beavers, stock, -science and working hours. The colonies meet only at **Trading Posts**, where they barter. Co-op, not a race. +science and working hours. The colonies meet only at **Trading Posts**, where they barter, and share power through a +**Power Export Facility**. Co-op, not a race. ![Timberborn 1.1.2.4](https://img.shields.io/badge/Timberborn-1.1.2.4-2a4034?labelColor=172620&style=flat-square) ![Status: beta](https://img.shields.io/badge/status-beta-e0812f?labelColor=172620&style=flat-square) [![GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-2a4034?labelColor=172620&style=flat-square)](License.txt) -[Install](#install) · [Host](#host-a-game) · [Join](#join-a-game) · [Your colony](#your-colony) · [Trading Posts](#trading-posts) · [Controls](#controls) · [Troubleshooting](#troubleshooting) · [Website](https://timbermods.github.io/TimberTogether/) +[Install](#install) · [Host](#host-a-game) · [Join](#join-a-game) · [Your colony](#your-colony) · [Trading Posts](#trading-posts) · [Sharing power](#sharing-power) · [Controls](#controls) · [Troubleshooting](#troubleshooting) · [Website](https://timbermods.github.io/TimberTogether/) > [!WARNING] -> **Beta: the release candidate for 1.4.0.** Automated checks cover everything here, and all but one feature has been played. +> **Beta: the release candidate for 1.4.0.** Automated checks cover everything here, and all but two features have been played. > Play on a copy of your save and keep backups. > - **Played:** hosting, joining over Steam and founding a second colony; two colonies building side by side, in step; > the Co-op Game room, from joining to Start; the **Separate colonies** checkbox and splitting a shared game; hosting @@ -19,7 +20,7 @@ science and working hours. The colonies meet only at **Trading Posts**, where th > automation, power, dynamite and tunnels, both Wonders, bots); looking after an away player's colony, and > hand-overs; and the Stability Fork's Steam invites, connection panel, cursors and desync fixes, over hours of > two-player play. -> - **Not played yet:** Folktails and Iron Teeth together. +> - **Not played yet:** Folktails and Iron Teeth together; sharing power through a Power Export Facility. Built on [BeaverBuddies](https://github.com/thomaswp/BeaverBuddies) by Thomas Price (thomaswp) and contributors, through the [BeaverBuddies Stability Fork](https://github.com/timbermods/BeaverBuddies-Stability-Fork) @@ -93,6 +94,8 @@ Steam ID, whoever hosts. and unlocks. Nobody else can change them, and your beavers work only for your colony. - **Build anywhere,** right up to a neighbor. The one rule: **your roads never join another colony's**, except through a Trading Post. **Ctrl+L** shows every colony's roads in its color. +- **Your power never joins another colony's,** except through a Power Export Facility. **Ctrl+P** shows every colony's + power in its color. - **Your screen is your colony's:** the top bar and its wellbeing window, alerts, status icons, batch lists and working hours. - **Shared by everyone:** the map (water, droughts, badwater, weather), speed, pause, pings, chat and saving. - **Up to four colonies.** More players help run the host's colony. @@ -129,6 +132,19 @@ already brought then go back to the colony that brought them. One exchange runs **Y** (or the **Trade** button, top right) lists your posts and every colony, with its food and water in days, its goods in stock, what it is **looking for**, and who looks after it. Set what your colony is looking for there. +## Sharing power + +Build a **Power Export Facility** (Power tab; 20 gears, 20 planks, 20 logs, 200 science) between two colonies, one half +for each. Each half needs its colony's road at its door, a shaft at its end, and one worker. + +Select your half and tick **Send power to …**. Your spare power goes to them, only as much as they can use. **Charge +my batteries first** fills yours before anything is sent; **Use my batteries for …** lets stored power go too. Power +goes one way between two colonies, and a colony sends to one colony, which can pass it on. It crosses while both +workers are at work. To pay for it, barter at a Trading Post. + +**H** (or the **Power** button, top right) shows where power goes, your networks and facilities, and every colony's +power. + ## One shared colony Untick **Separate colonies** on the New Game page and everyone builds one colony together, as in ordinary @@ -142,7 +158,9 @@ so far, and each earns its own science from then on. |---|---| | **Ctrl+K** | Found your colony | | **Ctrl+L** | Show every colony's roads in its color | +| **Ctrl+P** | Show every colony's power in its color | | **Y** | Trading Posts and colonies | +| **H** | Power window | | **Enter** | Type in the chat | | **Home** | Back to your colony (click a name in the connection panel to go to that player) | | **Ctrl+Shift+J** | Write a diagnostics report | @@ -179,6 +197,9 @@ More: [Steam invites](STEAM-INVITES.md), [the connection panel](CONNECTION-PANEL - **"That would join another colony's roads":** keep your roads a cell apart from theirs, or link them with a Trading Post. - **A Trading Post says *Not trading yet*:** each half needs a different colony's road at its door. +- **"That would join another colony's power":** keep your shafts a cell apart from theirs, or link them with a Power + Export Facility. +- **No power crosses a Power Export Facility:** select your half. Its panel says what's missing. - **Ctrl+K says this is one shared colony:** a player other than the host splits it with Esc → **Found your own colony**. diff --git a/RuntimeChecks/PowerExportBuildingChecks.cs b/RuntimeChecks/PowerExportBuildingChecks.cs new file mode 100644 index 00000000..27cfbd9e --- /dev/null +++ b/RuntimeChecks/PowerExportBuildingChecks.cs @@ -0,0 +1,145 @@ +using System.IO.Compression; +using System.Reflection; +using System.Text; +using System.Text.Json.Nodes; + +// The Power Export Facility is a building the mod adds with blueprints (BeaverBuddies/Buildings/Power): the game's +// District Crossing model and block layout as two linked halves, without the crossing's workings, with one worker, a power +// node and one power connection each. These read the built mod's blueprints next to its DLL and the game's own, and the +// game's assemblies for every member the facility's code relies on (PowerExport.cs, PowerExportService.cs). +internal static class PowerExportBuildingChecks +{ + const string Folder = "Buildings/Power/MultiColonyPowerExport"; + const string SpecName = "MultiColonyPowerExportSpec"; + static readonly string[] Factions = { "Folktails", "IronTeeth" }; + const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + + public static void Run(Assembly mod, string modDirectory, string managedDirectory, Action test) + { + string zipPath = Path.GetFullPath(Path.Combine(managedDirectory, "..", "StreamingAssets", "Modding", "Blueprints.zip")); + JsonNode GameBlueprint(string path) + { + using ZipArchive zip = ZipFile.OpenRead(zipPath); + ZipArchiveEntry entry = zip.GetEntry(path) ?? throw new Exception("the game has no " + path); + using var reader = new StreamReader(entry.Open(), Encoding.UTF8); + return JsonNode.Parse(reader.ReadToEnd())!; + } + JsonNode ModBlueprint(string path) + { + string file = Path.Combine(modDirectory, path.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(file)) throw new Exception("the built mod has no " + path); + return JsonNode.Parse(File.ReadAllText(file))!; + } + + foreach (string faction in Factions) + { + test($"Power Export Facility: the {faction} one has the game's District Crossing's model and blocks, and none of its workings", () => + { + JsonNode crossing = GameBlueprint($"Buildings/DistrictManagement/DistrictCrossing/DistrictCrossing.{faction}.blueprint.json"); + JsonNode facility = ModBlueprint($"{Folder}/MultiColonyPowerExport.{faction}.blueprint.json"); + Expect(JsonNode.DeepEquals(crossing["BlockObjectSpec"], facility["BlockObjectSpec"]), "its blocks and door are the crossing's"); + Expect(JsonNode.DeepEquals(crossing["Children"], facility["Children"]), "its models and colliders are the crossing's"); + Expect(JsonNode.DeepEquals(crossing["BuildingModelSpec"], facility["BuildingModelSpec"]), "its model names are the crossing's"); + Expect(JsonNode.DeepEquals(crossing["TransformSlotInitializerSpec"], facility["TransformSlotInitializerSpec"]), "its worker's place is the crossing's"); + Expect(facility["PlaceableBlockObjectSpec"]!["Layout"]!.GetValue() == crossing["PlaceableBlockObjectSpec"]!["Layout"]!.GetValue(), + "it is laid down as two halves, as the crossing is"); + Expect(facility["PlaceableBlockObjectSpec"]!["ToolGroupId"]!.GetValue() == "Power", "it sits in the Power toolbar group"); + Expect(facility["DistrictCrossingSpec"] == null && facility["DistrictObstacleSpec"] == null && facility["BlockObjectNavMeshSettingsSpec"] == null, + "nothing of the crossing's that would join two colonies' roads"); + Expect(facility["LinkedBuildingSpec"] != null && facility[SpecName] is JsonObject mark && mark.Count == 0, "two linked halves with the mod's mark"); + Expect(facility["LabeledEntitySpec"]!["Icon"]!.GetValue() == $"{Folder}/PowerExportIcon", "its own icon"); + // The power connection's direction is one the game reads. + Type directions = Assembly.Load("Timberborn.Coordinates").GetType("Timberborn.Coordinates.Directions3D", true)!; + foreach (JsonNode? transput in facility["TransputProviderSpec"]!["Transputs"]!.AsArray()) + Enum.Parse(directions, transput!["Directions"]!.GetValue()); + }); + } + + test("Power Export Facility: its icons are sprites in the built mod", () => + { + foreach (string icon in new[] { Path.Combine(modDirectory, Folder.Replace('/', Path.DirectorySeparatorChar), "PowerExportIcon.png"), + Path.Combine(modDirectory, "UI", "Images", "BeaverBuddies", "square-toggle-power.png") }) + { + Expect(File.Exists(icon) && File.ReadAllBytes(icon).Take(4).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47 }), "a PNG: " + icon); + JsonNode meta = JsonNode.Parse(File.ReadAllText(icon + ".meta.json"))!; + Expect(meta["isSprite"]!.GetValue(), "marked as a sprite: " + icon); + } + }); + + test("Power Export Facility: its mark is a spec the game finds by its name, which no game assembly uses", () => + { + Type spec = mod.GetType("BeaverBuddies.Colonies." + SpecName, true)!; + Type componentSpec = Assembly.Load("Timberborn.BlueprintSystem").GetType("Timberborn.BlueprintSystem.ComponentSpec", true)!; + Expect(componentSpec.IsAssignableFrom(spec) && !spec.IsAbstract && spec.IsPublic, "a public component spec"); + byte[] name = Encoding.UTF8.GetBytes(SpecName); + var clashes = Directory.GetFiles(managedDirectory, "*.dll").Where(dll => Contains(File.ReadAllBytes(dll), name)).Select(Path.GetFileName).ToList(); + Expect(clashes.Count == 0, "the name also appears in " + string.Join(", ", clashes)); + }); + + test("Power Export Facility: every game member its code relies on is still there", () => + { + Assembly mechanical = Assembly.Load("Timberborn.MechanicalSystem"); + Type manager = mechanical.GetType("Timberborn.MechanicalSystem.MechanicalGraphManager", true)!; + Type node = mechanical.GetType("Timberborn.MechanicalSystem.MechanicalNode", true)!; + Type graph = mechanical.GetType("Timberborn.MechanicalSystem.MechanicalGraph", true)!; + Type factory = mechanical.GetType("Timberborn.MechanicalSystem.MechanicalGraphFactory", true)!; + Type transputMap = mechanical.GetType("Timberborn.MechanicalSystem.TransputMap", true)!; + Type transput = mechanical.GetType("Timberborn.MechanicalSystem.Transput", true)!; + // The network join the power rule guards (PowerNetworkSeparationPatcher replaces it in separate colonies). + Expect(manager.GetMethod("AddNode", All, null, new[] { node }, null) != null, "MechanicalGraphManager.AddNode(MechanicalNode)"); + Expect(manager.GetField("_mechanicalGraphFactory", All)?.FieldType == factory, "MechanicalGraphManager._mechanicalGraphFactory"); + Expect(manager.GetField("_transputMap", All)?.FieldType == transputMap, "MechanicalGraphManager._transputMap"); + Expect(factory.GetMethod("Create", All) != null && factory.GetMethod("Join", All) != null, "MechanicalGraphFactory.Create and Join"); + Expect(graph.GetMethod("AddNode", All) != null, "MechanicalGraph.AddNode"); + Expect(transputMap.GetMethod("GetFacingTransput", All) != null && transputMap.GetMethod("GetTransputsAtCoordinates", All) != null, + "TransputMap.GetFacingTransput and GetTransputsAtCoordinates"); + foreach (string member in new[] { "Connect", "Target", "Coordinates", "ParentNode", "ConnectedNode", "Connected", "IsFinished" }) + Expect(transput.GetMember(member, All).Length > 0, "Transput." + member); + foreach (string member in new[] { "SetInputMultiplier", "SetOutputMultiplier", "Graph", "Transputs", "Actuals", "IsGenerator", "IsBattery" }) + Expect(node.GetMember(member, All).Length > 0, "MechanicalNode." + member); + foreach (string member in new[] { "PowerSupply", "PowerDemand", "BatteryCharge", "BatteryCapacity", "Nodes" }) + Expect(graph.GetMember(member, All).Length > 0, "MechanicalGraph." + member); + // The half's partner, and its worker at work. + Type linked = Assembly.Load("Timberborn.LinkedBuildingSystem").GetType("Timberborn.LinkedBuildingSystem.LinkedBuilding", true)!; + Expect(linked.GetField("_linked", All)?.FieldType == linked, "LinkedBuilding._linked"); + Assembly workshops = Assembly.Load("Timberborn.Workshops"); + Expect(workshops.GetType("Timberborn.Workshops.Workshop", true)!.GetProperty("CurrentlyWorking", All) != null, "Workshop.CurrentlyWorking"); + Expect(workshops.GetType("Timberborn.Workshops.WorkWorkplaceBehavior", false) != null, "WorkWorkplaceBehavior"); + Type dayNight = Assembly.Load("Timberborn.TimeSystem").GetType("Timberborn.TimeSystem.IDayNightCycle", true)!; + Expect(dayNight.GetProperty("FixedDeltaTimeInHours", All) != null, "IDayNightCycle.FixedDeltaTimeInHours"); + }); + + test("Power Export Facility: the game's own network join still connects facing connections and joins their networks", () => + { + // PowerNetworkSeparationPatcher is the game's AddNode with one check added: it must still be what the game does. + MethodInfo addNode = Assembly.Load("Timberborn.MechanicalSystem").GetType("Timberborn.MechanicalSystem.MechanicalGraphManager", true)! + .GetMethod("AddNode", All)!; + byte[] il = addNode.GetMethodBody()!.GetILAsByteArray()!; + var called = new HashSet(); + Module module = addNode.Module; + for (int i = 0; i + 4 < il.Length; i++) + { + if (il[i] != 0x28 && il[i] != 0x6F) continue; + try { called.Add(module.ResolveMethod(BitConverter.ToInt32(il, i + 1))!.Name); } catch { } + } + foreach (string name in new[] { "Create", "AddNode", "GetFacingTransput", "get_IsFinished", "Connect", "Join" }) + Expect(called.Contains(name), "the game's AddNode no longer calls " + name + " (it calls " + string.Join(", ", called.OrderBy(n => n)) + ")"); + }); + } + + static void Expect(bool condition, string what) + { + if (!condition) throw new Exception("wrong: " + what); + } + + static bool Contains(byte[] haystack, byte[] needle) + { + for (int i = 0; i + needle.Length <= haystack.Length; i++) + { + int j = 0; + while (j < needle.Length && haystack[i + j] == needle[j]) j++; + if (j == needle.Length) return true; + } + return false; + } +} diff --git a/RuntimeChecks/Program.cs b/RuntimeChecks/Program.cs index 26dd08db..a5d81fb3 100644 --- a/RuntimeChecks/Program.cs +++ b/RuntimeChecks/Program.cs @@ -111,6 +111,7 @@ void Test(string name, Action run) PerformanceRuntimeChecks.Run(assembly, Test); PlacementRandomChecks.Run(assembly, Test); TradingPostBuildingChecks.Run(assembly, Path.GetDirectoryName(modPath)!, Path.GetFullPath(args[1]), Test); +PowerExportBuildingChecks.Run(assembly, Path.GetDirectoryName(modPath)!, Path.GetFullPath(args[1]), Test); BindingChecks.Run(assembly, Path.GetFullPath(args[1]), args.Skip(2).Select(Path.GetFullPath), Test); DesyncCheckChecks.Run(assembly, Test); DesyncDialogChecks.Run(assembly, Test); diff --git a/RuntimeChecks/TradingPostBuildingChecks.cs b/RuntimeChecks/TradingPostBuildingChecks.cs index b4b26d3a..1d1da6bc 100644 --- a/RuntimeChecks/TradingPostBuildingChecks.cs +++ b/RuntimeChecks/TradingPostBuildingChecks.cs @@ -73,8 +73,11 @@ JsonNode ModBlueprint(string path) Expect(added["TemplateCollectionSpec"]!["CollectionId"]!.GetValue() == id, "the collection's id"); var appended = added["TemplateCollectionSpec"]!["Blueprints#append"]!.AsArray().Select(n => n!.GetValue()).ToList(); string blueprint = $"{Folder}/MultiColonyTradingPost.{faction}.blueprint"; - Expect(appended.SequenceEqual(new[] { blueprint }), "it appends exactly the Trading Post"); - Expect(File.Exists(Path.Combine(modDirectory, (blueprint + ".json").Replace('/', Path.DirectorySeparatorChar))), "the appended file is built"); + // The Trading Post, then the Power Export Facility (PowerExportBuildingChecks). + string facility = $"Buildings/Power/MultiColonyPowerExport/MultiColonyPowerExport.{faction}.blueprint"; + Expect(appended.SequenceEqual(new[] { blueprint, facility }), "it appends exactly the Trading Post and the Power Export Facility"); + foreach (string file in appended) + Expect(File.Exists(Path.Combine(modDirectory, (file + ".json").Replace('/', Path.DirectorySeparatorChar))), "the appended file is built: " + file); var listed = game["TemplateCollectionSpec"]!["Blueprints"]!.AsArray().Select(n => n!.GetValue()); Expect(listed.Contains($"Buildings/DistrictManagement/DistrictCrossing/DistrictCrossing.{faction}.blueprint"), "the game still has the District Crossing on this toolbar"); diff --git a/STABILITY-CHANGELOG.md b/STABILITY-CHANGELOG.md index 15cc9c7c..495a755d 100644 --- a/STABILITY-CHANGELOG.md +++ b/STABILITY-CHANGELOG.md @@ -5,6 +5,68 @@ Every change this fork makes relative to the original BeaverBuddies `v1.1` branc 1.1.2.4. For a plain-language summary, see the [README](README.md). Future releases add a new entry above the current one. +## 1.4.0-rc32 + +**The Power Export Facility: one colony sends its spare power to another; two colonies' power networks never join +otherwise; Ctrl+P shows every colony's power, and H opens a Power window** (design/POWER-EXPORT-PLAN.md). Built +against Timberborn 1.1.2.4: both configurations with 0 warnings; StabilityTests 558 and RuntimeChecks 508 pass. +**Not played.** Both players need this build. + +- **The building.** `Buildings/Power/MultiColonyPowerExport`, each faction's District Crossing model and block layout + as two linked halves (`LinkedBuildingSpec`, laid down as a pair, built together, removed together), in the Power + tab: 20 Gears, 20 Planks and 20 Logs for the facility (10 of each a half: each half is built and paid for on its + own), and 200 science to unlock. None of the + crossing's workings: no `DistrictCrossingSpec`, no district obstacle, no walkway between the halves, so it never joins + two colonies' roads. Each half has one worker (`WorkplaceSpec` 1/1, `WorkshopSpec`, and the game's + `WorkWorkplaceBehavior`, as a Power Wheel's walker), a power node of 1 hp in and 1 hp out, and **one** power + connection, on its end: a half can never join two networks. Like the Trading Post, it is neutral (never stamped; + each half is its road's colony's), its doors take either colony's road, its halves are judged together + (`ColonyRulesService.JudgePairs`), either partner may remove it (`ColonyGameWorld.IsCrossingOf`), either faction's + may be placed in a mixed game, and it is offered only in separate colonies (`TradingPostToolDisabler`). +- **Two colonies' power never joins, except through a facility.** It replaces "two colonies' shafts that touch make + one network". `ColonyPowerRule` (pure, checked headless like the road rule) refuses a mechanical building whose power + connection would face another colony's, built or being built, with *That would join another colony's power*: judged + by the host (`ColonyGameWorld.PlacementConflict`, from the template's `TransputProviderSpec` and the placement, as + the game places a node's transputs) and shown on the preview (`ColonyPlacementValidator`). A node's colony is its + district's, else its placer's; a facility half's is nobody's. And in separate colonies the game's network join + (`MechanicalGraphManager.AddNode`) is replaced by the same join with one check: two colonies' nodes are never + connected, and a node nobody owns joins one colony's only, so two placements at once can't join their networks + either (`PowerNetworkSeparationPatcher`; RuntimeChecks + reads the game's method and fails if it stops connecting facing transputs and joining networks as the copy does). +- **Sending power.** Each half has **Send power to …**, **Charge my batteries first** (on) and **Use my batteries for + …** (off), saved with the half and changed by `PowerExportSettingEvent`: judged by the host as a change to the half + (its colony or whoever looks after it), and checked again as it plays, on every computer. The link rules + (`PowerExportMath.Check`): power goes one way between two colonies (over every facility between them), a colony sends + to one colony and receives from one, and it never comes back round (A → B → C → A). The other colony hears when + power starts or stops coming. +- **The flow, every tick on every computer** (`PowerExportService`). The links asked for, in the order of the halves' + ids, are let through by the same rules (`PowerExportMath.Admit`, first wins, so a link a road change made wrong is + dropped the same way everywhere). A facility carries its link while both halves are finished, each half's road and + network are its own colony's, and both workers are at work (a worker between two shifts of work counts for 5 ticks). + `PowerExportMath.Flow` runs along the chains from start to end: a network sends only what the next can use (its + consumers' shortfall, its batteries' room, and what it passes on in turn), and only what it spares after its own + consumers; with charging first its batteries fill before anything is sent; with its batteries used, what they can + give beyond its own shortfall goes too (and they don't charge first), but only for the buildings down the chain, + never into another colony's batteries. A paused half moves nothing at once. Power a network gets is power it can pass on, + so B passes A's leftover to C. The sending half's node draws what crosses from its network and the receiving half's + gives it to its own (`MechanicalNode.SetInputMultiplier`/`SetOutputMultiplier` on a 1 hp node), so the game's own + batteries, efficiency and power panels count it. Each network's figures are read without the facilities' own share. + The daily colony check compares every half's settings and flow between computers (`power=[…]`). +- **The facility's panel,** below the game's (the worker, and the half's network), built as the Trading Post's: whom + it links to, the power crossing now or why none does (a missing road, shaft or worker, the other's link first, + nothing to spare, nothing needed), the half's three check boxes (sending greyed, with the reason, where the rules + refuse it), what the other half is set to, **Power** (the window) and **Select your half**. +- **The Power window (H,** or the square **Power** button at the top right, or **Power** on a facility): the game's + named box, as the colonies window. Where power goes between colonies (each chain on one line, with what crosses each + link); this player's colony's networks (made, used, spare, batteries, in and out, **Go to** its strongest generator); + its facilities with their check boxes; the other colonies' power. While it is open, the power view shows. +- **The power view (Ctrl+P).** `ColonyPowerOverlay`, the road view's filled squares in the same colony colours, for + every shaft, gearbox, generator, battery and powered building, built or being built; a facility half takes its + network's colour. Shown by its key, while the Power window is open, and while a shaft, gearbox, generator, battery + or the facility is in hand, when the road view makes way for it. Display only. +- New English strings; `TWO-COLONIES.md` (a Power Export Facilities section, the power rule), the README and the site. + The facility looks like a District Crossing, as the Trading Post does (known limit). + ## 1.4.0-rc31 **Trades typed whole and never refused, the Global history graphs your own colony's, and a design pass of the diff --git a/StabilityTests/ColonyChecks.cs b/StabilityTests/ColonyChecks.cs index 49479e94..1bf2fad5 100644 --- a/StabilityTests/ColonyChecks.cs +++ b/StabilityTests/ColonyChecks.cs @@ -1020,11 +1020,11 @@ TradeOfferForm.Verdict Judge(string giveItem, string giveText, string getItem, s .Select(match => match.Groups[1].Value)) .Where(key => !key.EndsWith(".")).Distinct().ToList(); Check(keys.Count > 60, "the trading post's texts were not found: " + keys.Count); - // The Trading Post building's own name, description and flavour line, named by its blueprints. + // The Trading Post's and the Power Export Facility's own names, descriptions and flavour lines, named by their blueprints. var blueprintKeys = Directory.GetFiles(Path.Combine(mod, "Buildings"), "*.blueprint.json", SearchOption.AllDirectories) .SelectMany(file => System.Text.RegularExpressions.Regex.Matches(File.ReadAllText(file), @"""[A-Za-z]*LocKey""\s*:\s*""([^""]+)""") .Select(match => match.Groups[1].Value)).Distinct().ToList(); - Check(blueprintKeys.Count == 3, "the Trading Post's blueprint texts were not found: " + blueprintKeys.Count); + Check(blueprintKeys.Count == 6, "the buildings' blueprint texts were not found: " + blueprintKeys.Count); keys.AddRange(blueprintKeys); var missing = keys.Where(key => !lines.Contains(key)).ToList(); Check(missing.Count == 0, "no English line for " + string.Join(", ", missing)); diff --git a/StabilityTests/PowerExportChecks.cs b/StabilityTests/PowerExportChecks.cs new file mode 100644 index 00000000..8e375ef1 --- /dev/null +++ b/StabilityTests/PowerExportChecks.cs @@ -0,0 +1,232 @@ +using BeaverBuddies.Colonies; +using System.Text.Json; + +/// +/// 1.4.0-rc32, the Power Export Facility (design/POWER-EXPORT-PLAN.md): the link rules, the flow arithmetic and the +/// power rule, headless; the building, its keys and its wiring read from the source. +/// +static class PowerExportChecks +{ + static void Check(bool value, string message = "assertion failed") { if (!value) throw new Exception(message); } + + static string Root() + { + string root = AppContext.BaseDirectory; + while (root != null && !File.Exists(Path.Combine(root, "BeaverBuddies.sln"))) root = Path.GetDirectoryName(root)!; + Check(root != null, "could not find the repository root"); + return root!; + } + + static string Source(params string[] parts) => + File.ReadAllText(Path.Combine(new[] { Root() }.Concat(parts).ToArray())).Replace("\r\n", "\n"); + + static JsonElement Json(params string[] parts) => JsonDocument.Parse(Source(parts).TrimStart('\uFEFF')).RootElement; + + static PowerExportMath.Network Net(int supply, int demand, int room = 0, int battery = 0, bool chargeFirst = true, bool useBatteries = false) => + new PowerExportMath.Network { Supply = supply, Demand = demand, BatteryRoom = room, BatteryPower = battery, ChargeFirst = chargeFirst, UseBatteries = useBatteries }; + + sealed class FakePower : IColonyPowerMap + { + public readonly Dictionary<(ColonyCell, ColonyCell), int?> Owners = new Dictionary<(ColonyCell, ColonyCell), int?>(); + public int? PowerOwnerFacing(ColonyCell cell, ColonyCell target) => Owners.TryGetValue((cell, target), out int? owner) ? owner : null; + } + + public static IEnumerable<(string Name, Action Run)> Tests() + { + yield return ("rc32: power goes one way between two colonies, one to one, and never round in a circle", () => + { + var none = new List<(int, int)>(); + Check(PowerExportMath.Check(0, 1, none) == PowerLinkRefusal.None, "a first link is refused"); + Check(PowerExportMath.Check(0, 0, none) == PowerLinkRefusal.NotTwoColonies, "a colony sends to itself"); + Check(PowerExportMath.Check(-1, 1, none) == PowerLinkRefusal.NotTwoColonies, "a half with no colony sends"); + Check(PowerExportMath.Check(0, 1, new[] { (1, 0) }) == PowerLinkRefusal.PartnerSending, "both ways at once"); + Check(PowerExportMath.Check(0, 1, new[] { (0, 1) }) == PowerLinkRefusal.None, "a second facility on the same link is refused"); + Check(PowerExportMath.Check(0, 2, new[] { (0, 1) }) == PowerLinkRefusal.SendingElsewhere, "a colony sends to two colonies"); + Check(PowerExportMath.Check(2, 1, new[] { (0, 1) }) == PowerLinkRefusal.PartnerReceivingElsewhere, "a colony receives from two colonies"); + // A → B → C: B passes on to C; C → A would close the circle. + Check(PowerExportMath.Check(1, 2, new[] { (0, 1) }) == PowerLinkRefusal.None, "a chain is refused"); + Check(PowerExportMath.Check(2, 0, new[] { (0, 1), (1, 2) }) == PowerLinkRefusal.MakesCircle, "a circle of three is let through"); + Check(PowerExportMath.Check(3, 0, new[] { (0, 1), (1, 2), (2, 3) }) == PowerLinkRefusal.MakesCircle, "a circle of four is let through"); + Check(PowerExportMath.Check(3, 0, new[] { (0, 1), (1, 2) }) == PowerLinkRefusal.None, "a colony joining the start of a chain is refused"); + }); + + yield return ("rc32: the links let through each tick are the first that obey the rules, the same in any run", () => + { + var admitted = PowerExportMath.Admit(new[] { (0, 1), (1, 0), (0, 2), (1, 2), (2, 0), (0, 1) }); + Check(admitted.SetEquals(new[] { (0, 1), (1, 2) }), "wrong links let through: " + string.Join(" ", admitted)); + var depth = PowerExportMath.Depths(new[] { (1, 2), (0, 1), (3, 4) }); + Check(depth[0] == 0 && depth[1] == 1 && depth[2] == 2 && depth[3] == 0 && depth[4] == 1, "wrong places along the chains"); + }); + + yield return ("rc32: a colony sends only what the other can use, after its own buildings", () => + { + // A makes 300 and uses 100; B makes nothing and needs 150. + int[] flow = PowerExportMath.Flow(new[] { Net(300, 100), Net(0, 150) }, new[] { (0, 1) }); + Check(flow[0] == 150, "sent " + flow[0]); + // B needs more than A can spare: A's own buildings keep theirs. + flow = PowerExportMath.Flow(new[] { Net(300, 250), Net(0, 150) }, new[] { (0, 1) }); + Check(flow[0] == 50, "sent " + flow[0]); + // Nothing to spare, or nothing needed. + Check(PowerExportMath.Flow(new[] { Net(100, 150), Net(0, 150) }, new[] { (0, 1) })[0] == 0, "sent while short itself"); + Check(PowerExportMath.Flow(new[] { Net(300, 100), Net(200, 150) }, new[] { (0, 1) })[0] == 0, "sent what was not needed"); + // B's own batteries' room counts as what it can use. + Check(PowerExportMath.Flow(new[] { Net(300, 100), Net(0, 50, room: 1000) }, new[] { (0, 1) })[0] == 200, "B's batteries got nothing"); + }); + + yield return ("rc32: charge my batteries first, and use my batteries", () => + { + // Charging first: A's batteries take its spare power before anything is sent. + Check(PowerExportMath.Flow(new[] { Net(300, 100, room: 150), Net(0, 150) }, new[] { (0, 1) })[0] == 50, "batteries did not charge first"); + Check(PowerExportMath.Flow(new[] { Net(300, 100, room: 1000), Net(0, 150) }, new[] { (0, 1) })[0] == 0, "sent before the batteries were full"); + // Not first: the other colony first, A's batteries take what is left. + Check(PowerExportMath.Flow(new[] { Net(300, 100, room: 1000, chargeFirst: false), Net(0, 150) }, new[] { (0, 1) })[0] == 150, "the other colony waited for the batteries"); + // Stored power is sent only with Use my batteries, and only what the batteries give beyond A's own shortfall. + Check(PowerExportMath.Flow(new[] { Net(100, 100, battery: 80), Net(0, 150) }, new[] { (0, 1) })[0] == 0, "stored power was sent without leave"); + Check(PowerExportMath.Flow(new[] { Net(100, 100, battery: 80, useBatteries: true), Net(0, 150) }, new[] { (0, 1) })[0] == 80, "stored power was not sent"); + Check(PowerExportMath.Flow(new[] { Net(50, 100, battery: 80, useBatteries: true), Net(0, 150) }, new[] { (0, 1) })[0] == 30, "A's own buildings lost power to the batteries' sending"); + Check(PowerExportMath.Flow(new[] { Net(300, 100, battery: 500, useBatteries: true), Net(0, 150) }, new[] { (0, 1) })[0] == 150, "more than was needed was sent"); + // Stored power feeds the other colony's buildings, never its batteries; spare power still fills them. + Check(PowerExportMath.Flow(new[] { Net(100, 100, battery: 500, useBatteries: true), Net(0, 0, room: 1000) }, new[] { (0, 1) })[0] == 0, "one colony's batteries were emptied into another's"); + Check(PowerExportMath.Flow(new[] { Net(160, 100, battery: 500, useBatteries: true), Net(0, 40, room: 1000) }, new[] { (0, 1) })[0] == 60, "spare power did not fill their batteries"); + Check(PowerExportMath.Flow(new[] { Net(100, 100, battery: 500, useBatteries: true), Net(0, 30), Net(0, 50, room: 1000) }, new[] { (0, 1), (1, 2) })[0] == 80, + "stored power did not reach the buildings down the chain"); + // With both on, the other colony comes before these batteries (the panel says so). + Check(PowerExportMath.Flow(new[] { Net(300, 100, room: 50, useBatteries: true), Net(0, 150) }, new[] { (0, 1) })[0] == 150, "both boxes on: the batteries charged first"); + }); + + yield return ("rc32: B passes A's leftover power on to C, and never makes power", () => + { + // A spares 200; B needs 50 of it; C needs 100. + int[] flow = PowerExportMath.Flow(new[] { Net(300, 100), Net(0, 50), Net(0, 100) }, new[] { (0, 1), (1, 2) }); + Check(flow[0] == 150 && flow[1] == 100, $"sent {flow[0]} then {flow[1]}"); + // A spares 80: B keeps 50 for itself and passes 30. + flow = PowerExportMath.Flow(new[] { Net(180, 100), Net(0, 50), Net(0, 100) }, new[] { (0, 1), (1, 2) }); + Check(flow[0] == 80 && flow[1] == 30, $"sent {flow[0]} then {flow[1]}"); + // Two of A's networks feeding one of B's share what it can use. + flow = PowerExportMath.Flow(new[] { Net(300, 0), Net(300, 0), Net(0, 200) }, new[] { (0, 2), (1, 2) }); + Check(flow[0] + flow[1] == 200, "B was sent more than it could use"); + // An edge backwards along the order gets nothing (the caller orders networks along their chains). + Check(PowerExportMath.Flow(new[] { Net(0, 100), Net(300, 0) }, new[] { (1, 0) })[0] == 0, "a backwards edge carried power"); + // Over many sizes: nothing crosses beyond what the sender has, nor beyond what the receivers can use. + var random = new Random(32); + for (int run = 0; run < 2000; run++) + { + var nets = Enumerable.Range(0, 3).Select(_ => Net(random.Next(0, 500), random.Next(0, 500), random.Next(0, 3) == 0 ? random.Next(0, 300) : 0, + random.Next(0, 300), random.Next(2) == 0, random.Next(2) == 0)).ToArray(); + flow = PowerExportMath.Flow(nets, new[] { (0, 1), (1, 2) }); + long have0 = nets[0].UseBatteries ? Math.Max(0, nets[0].Supply - nets[0].Demand) + nets[0].BatteryPower : Math.Max(0, nets[0].Supply - nets[0].Demand); + Check(flow[0] >= 0 && flow[1] >= 0 && flow[0] <= have0, "the first network sent more than it had"); + long have1 = nets[1].Supply + flow[0] - nets[1].Demand + (nets[1].UseBatteries ? nets[1].BatteryPower : 0); + Check(flow[1] <= Math.Max(0, have1), "the middle network passed on more than it had"); + Check(flow[1] <= Math.Max(0, nets[2].Demand - nets[2].Supply) + nets[2].BatteryRoom, "the last network got more than it could use"); + } + }); + + yield return ("rc32: the power rule refuses another colony's power connection, as the road rule refuses its roads", () => + { + var map = new FakePower(); + var here = new ColonyCell(5, 5, 1); + var there = here.Step(1, 0); + var transputs = new[] { new ColonyTransput(here, there) }; + Check(ColonyPowerRule.Conflict(0, transputs, map, out _) == ColonyRefusal.None, "nothing there was refused"); + map.Owners[(here, there)] = 0; + Check(ColonyPowerRule.Conflict(0, transputs, map, out _) == ColonyRefusal.None, "the colony's own power was refused"); + map.Owners[(here, there)] = 1; + Check(ColonyPowerRule.Conflict(0, transputs, map, out string detail) == ColonyRefusal.TouchesOtherPower && detail != null, "another colony's power was let through"); + Check(ColonyPowerRule.MayJoin(null, 1) && ColonyPowerRule.MayJoin(1, null) && ColonyPowerRule.MayJoin(2, 2) && !ColonyPowerRule.MayJoin(0, 1), + "nobody's joins anything; two colonies never"); + }); + + yield return ("rc32: the Power Export Facility's blueprints: two linked halves, one worker, one connection, its cost", () => + { + foreach (string faction in new[] { "Folktails", "IronTeeth" }) + { + JsonElement b = Json("BeaverBuddies", "Buildings", "Power", "MultiColonyPowerExport", $"MultiColonyPowerExport.{faction}.blueprint.json"); + JsonElement building = b.GetProperty("BuildingSpec"); + var cost = building.GetProperty("BuildingCost").EnumerateArray().ToDictionary(c => c.GetProperty("Id").GetString()!, c => c.GetProperty("Amount").GetInt32()); + Check(cost.Count == 3 && cost["Gear"] == 10 && cost["Plank"] == 10 && cost["Log"] == 10, faction + ": wrong cost (10 of each a half, 20 for the facility)"); + Check(building.GetProperty("ScienceCost").GetInt32() == 200, faction + ": wrong science cost"); + Check(b.GetProperty("TemplateSpec").GetProperty("TemplateName").GetString() == $"MultiColonyPowerExport.{faction}", faction + ": wrong template name"); + Check(b.GetProperty("WorkplaceSpec").GetProperty("MaxWorkers").GetInt32() == 1 && b.GetProperty("WorkplaceSpec").GetProperty("DefaultWorkers").GetInt32() == 1, faction + ": not one worker"); + foreach (string spec in new[] { "LinkedBuildingSpec", "MultiColonyPowerExportSpec", "WorkshopSpec", "EnterableSpec" }) + Check(b.TryGetProperty(spec, out _), $"{faction}: no {spec}"); + foreach (string spec in new[] { "DistrictCrossingSpec", "MultiColonyTradingPostSpec", "BlockObjectNavMeshSettingsSpec", "MechanicalBuildingSpec" }) + Check(!b.TryGetProperty(spec, out _), $"{faction}: {spec} (the halves must not join roads, nor switch power with workers)"); + JsonElement node = b.GetProperty("MechanicalNodeSpec"); + Check(node.GetProperty("PowerInput").GetInt32() == 1 && node.GetProperty("PowerOutput").GetInt32() == 1, faction + ": the node is not 1 hp each way"); + var transputs = b.GetProperty("TransputProviderSpec").GetProperty("Transputs").EnumerateArray().ToList(); + Check(transputs.Count == 1 && !transputs[0].GetProperty("Directions").GetString()!.Contains(','), faction + ": a half has more than one power connection"); + Check(b.GetProperty("PlaceableBlockObjectSpec").GetProperty("Layout").GetString() == "Half" + && b.GetProperty("PlaceableBlockObjectSpec").GetProperty("ToolGroupId").GetString() == "Power", faction + ": not placed as halves in the Power tab"); + Check(Source("BeaverBuddies", "TemplateCollections", $"TemplateCollection.Buildings.{faction}.blueprint.json") + .Contains($"Buildings/Power/MultiColonyPowerExport/MultiColonyPowerExport.{faction}.blueprint"), faction + ": not on the toolbar"); + } + Check(File.Exists(Path.Combine(Root(), "BeaverBuddies", "Buildings", "Power", "MultiColonyPowerExport", "PowerExportIcon.png")), "no icon"); + }); + + yield return ("rc32: Ctrl+P shows colonies' power and H opens the Power window, keys the game leaves free", () => + { + JsonElement power = Json("BeaverBuddies", "KeyBindings", "BeaverBuddies.KeyBind.ToggleColonyPower.blueprint.json").GetProperty("PrimaryInputBindingSpec"); + Check(power.GetProperty("Path").GetString() == "/Keyboard/p" && power.GetProperty("InputModifiers").GetString() == "Ctrl", "the power view is not Ctrl+P"); + JsonElement window = Json("BeaverBuddies", "KeyBindings", "BeaverBuddies.KeyBind.PowerOverview.blueprint.json").GetProperty("PrimaryInputBindingSpec"); + Check(window.GetProperty("Path").GetString() == "/Keyboard/h" && window.GetProperty("InputModifiers").GetString() == "None", "the Power window is not H"); + // No other binding of the mod's on either key. + foreach (string file in Directory.GetFiles(Path.Combine(Root(), "BeaverBuddies", "KeyBindings"), "*.json")) + { + if (file.Contains("ToggleColonyPower") || file.Contains("PowerOverview")) continue; + // A binding with no key of its own (the player sets one) takes none. + if (!JsonDocument.Parse(File.ReadAllText(file).TrimStart('\uFEFF')).RootElement.TryGetProperty("PrimaryInputBindingSpec", out JsonElement other)) continue; + string path = other.GetProperty("Path").GetString()!, modifiers = other.GetProperty("InputModifiers").GetString()!; + Check(!(path == "/Keyboard/p" && modifiers == "Ctrl") && !(path == "/Keyboard/h" && modifiers == "None"), Path.GetFileName(file) + " takes a power key"); + } + Check(Source("BeaverBuddies", "Colonies", "ColonyPowerOverlay.cs").Contains("\"BeaverBuddies.KeyBind.ToggleColonyPower\""), "the overlay listens for another key"); + Check(Source("BeaverBuddies", "Colonies", "PowerOverviewPanel.cs").Contains("\"BeaverBuddies.KeyBind.PowerOverview\""), "the window listens for another key"); + // The road view makes way while a power piece is in hand. + Check(Source("BeaverBuddies", "Colonies", "ColonyRoadOverlay.cs").Contains("!ColonyPowerOverlay.IsPowerPiece(_toolService.ActiveTool)"), "roads show over a power piece"); + }); + + yield return ("rc32: two colonies' power networks never join, and the facility is wired in like the Trading Post", () => + { + string export = Source("BeaverBuddies", "Colonies", "PowerExport.cs"); + Check(export.Contains("[HarmonyPatch(typeof(MechanicalGraphManager), nameof(MechanicalGraphManager.AddNode))]"), "the network join is not guarded"); + Check(export.Contains("if (!ColonyPowerRule.MayJoin(mine, theirs)) continue;"), "two colonies' nodes connect"); + Check(export.Contains("if (mine == null && theirs != null) mine = theirs;"), "a node nobody owns can bridge two colonies' power"); + Check(Source("BeaverBuddies", "Colonies", "PowerExportService.cs").Contains("if (!half.Node.Active || !partner.Node.Active || !Staffed(half) || !Staffed(partner))"), + "a paused half still moves power"); + Check(export.Contains("if (!ColonyModeService.IsSeparateColonies) return true;"), "a shared game's power is changed"); + Check(export.Contains("DistrictOwner.OwnerOf(node, useConstructionDistrict: false)"), "the simulation asks the construction district"); + string configurator = Source("BeaverBuddies", "Colonies", "ColonyConfigurator.cs"); + foreach (string bind in new[] { "Bind().AsSingleton();", "Bind().AsSingleton();", "Bind().AsSingleton();", + "Bind().AsSingleton();", "AddDecorator();", "AddDecorator();", + "builder.AddBottomFragment(_powerExportFragment);" }) + Check(configurator.Contains(bind), "not bound: " + bind); + string world = Source("BeaverBuddies", "Colonies", "ColonyGameWorld.cs"); + Check(world.Contains("PowerExports.IsFacilityBuilding(entity)) continue;"), "a facility's doors take one colony's road"); + Check(world.Contains("ColonyPowerRule.Conflict(slot, TransputsOf("), "the host does not judge power connections"); + Check(Source("BeaverBuddies", "Colonies", "ColonyPlacementValidator.cs").Contains("ColonyPowerRule.Conflict(slot, ColonyGameWorld.TransputsOf("), "previews do not show the power rule"); + Check(Source("BeaverBuddies", "Colonies", "ColonyRulesService.cs").Contains("service.world.IsMeetingTemplate(placed.prefabName)) halves.Add(placed);"), "a facility's halves are judged apart"); + Check(Source("BeaverBuddies", "Colonies", "TradingPostToolDisabler.cs").Contains("HasSpec()"), "a shared game offers the facility"); + Check(Source("BeaverBuddies", "Colonies", "ColonyStamps.cs").Contains("PowerExports.IsFacilityBuilding(entity)) return null;"), "a facility half is stamped with its placer"); + string events = Source("BeaverBuddies", "Colonies", "PowerExportEvents.cs"); + Check(events.Contains("public override ColonyScope GetColonyScope() => ColonyScope.Entities(entityID);"), "a setting is not judged by its half's owner"); + Check(events.Contains("PowerExportMath.Check(colony, partnerColony,"), "sending is not checked as it is played"); + }); + + yield return ("rc32: every text the Power Export Facility shows is in the English strings", () => + { + string csv = Source("BeaverBuddies", "Localizations", "enUS_BeaverBuddie.csv"); + var have = new HashSet(csv.Split('\n').Select(line => line.Split(',')[0])); + var used = new HashSet(); + foreach (string file in new[] { "PowerExport.cs", "PowerExportEvents.cs", "PowerExportFragment.cs", "PowerOverviewPanel.cs", "PowerExportService.cs", "ColonyPowerOverlay.cs", "ColonyRulesService.cs" }) + foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(Source("BeaverBuddies", "Colonies", file), "\"(BeaverBuddies\\.(?:Colony\\.Power|Colony\\.Refused\\.TouchesOtherPower)[A-Za-z.]*)\"")) + used.Add(m.Groups[1].Value); + foreach (string key in new[] { "BeaverBuddies.Building.PowerExport.DisplayName", "BeaverBuddies.Building.PowerExport.Description", + "BeaverBuddies.Building.PowerExport.FlavorDescription", "BeaverBuddies.KeyBindings.ToggleColonyPower", "BeaverBuddies.KeyBindings.PowerOverview" }) + used.Add(key); + Check(used.Count > 40, "too few texts found: " + used.Count); + var missing = used.Where(key => !have.Contains(key)).ToList(); + Check(missing.Count == 0, "missing: " + string.Join(", ", missing)); + Check(csv.Contains("BeaverBuddies.Building.PowerExport.DisplayName,\"Power Export Facility\""), "the building's name is not Power Export Facility"); + }); + } +} diff --git a/StabilityTests/Program.cs b/StabilityTests/Program.cs index b33ad90f..ccd9b33d 100644 --- a/StabilityTests/Program.cs +++ b/StabilityTests/Program.cs @@ -61,7 +61,7 @@ finally { BeaverBuddies.IO.EventIO.IsNull = false; } }) }; -tests = tests.Concat(Preview5Checks.Tests()).Concat(PerformanceChecks.Tests()).Concat(ActivityTransportChecks.Tests()).Concat(CursorPreferencesChecks.Tests()).Concat(PlayerColorsChecks.Tests()).Concat(SteamLinkChecks.Tests()).Concat(PingCadenceChecks.Tests()).Concat(NetworkStatusChecks.Tests()).Concat(PanelModelChecks.Tests()).Concat(PanelLayoutChecks.Tests()).Concat(CatchUpSpeedChecks.Tests()).Concat(SpeedBoostChecks.Tests()).Concat(CoopDelayChecks.Tests()).Concat(HostPacingChecks.Tests()).Concat(FrameRatePacingChecks.Tests()).Concat(ModWarningChecks.Tests()).Concat(ChatChecks.Tests()).Concat(SessionEndChecks.Tests()).Concat(EntitySlotCacheChecks.Tests()).Concat(ColonyChecks.Tests()).Concat(FeatureChecks.Tests()).Concat(DesyncCheckChecks.Tests()).Concat(DirectTcpChecks.Tests()).Concat(DesyncDialogChecks.Tests()).Concat(DeterminismRuleChecks.Tests()).Concat(SendLaneChecks.Tests()).Concat(PaceChecks.Tests()).Concat(LobbyChecks.Tests()).Concat(FactionChecks.Tests()).Concat(JoinReviewChecks.Tests()).Concat(JoinFixChecks.Tests()).Concat(ReviewBeta21Checks.Tests()).Concat(JoinBoxChecks.Tests()).Concat(RcMainChecks.Tests()).Concat(RcLateGameChecks.Tests()).Concat(RcFactionChecks.Tests()).Concat(RcTradingChecks.Tests()).Concat(RcPerformanceChecks.Tests()).Concat(RcColonyChecks.Tests()).Concat(Rc5Checks.Tests()).Concat(Rc6Checks.Tests()).Concat(Rc7Checks.Tests()).Concat(Rc13Checks.Tests()).Concat(Rc14Checks.Tests()).Concat(Rc15Checks.Tests()).Concat(Rc22Checks.Tests()).Concat(Rc27Checks.Tests()).ToArray(); +tests = tests.Concat(Preview5Checks.Tests()).Concat(PerformanceChecks.Tests()).Concat(ActivityTransportChecks.Tests()).Concat(CursorPreferencesChecks.Tests()).Concat(PlayerColorsChecks.Tests()).Concat(SteamLinkChecks.Tests()).Concat(PingCadenceChecks.Tests()).Concat(NetworkStatusChecks.Tests()).Concat(PanelModelChecks.Tests()).Concat(PanelLayoutChecks.Tests()).Concat(CatchUpSpeedChecks.Tests()).Concat(SpeedBoostChecks.Tests()).Concat(CoopDelayChecks.Tests()).Concat(HostPacingChecks.Tests()).Concat(FrameRatePacingChecks.Tests()).Concat(ModWarningChecks.Tests()).Concat(ChatChecks.Tests()).Concat(SessionEndChecks.Tests()).Concat(EntitySlotCacheChecks.Tests()).Concat(ColonyChecks.Tests()).Concat(FeatureChecks.Tests()).Concat(DesyncCheckChecks.Tests()).Concat(DirectTcpChecks.Tests()).Concat(DesyncDialogChecks.Tests()).Concat(DeterminismRuleChecks.Tests()).Concat(SendLaneChecks.Tests()).Concat(PaceChecks.Tests()).Concat(LobbyChecks.Tests()).Concat(FactionChecks.Tests()).Concat(JoinReviewChecks.Tests()).Concat(JoinFixChecks.Tests()).Concat(ReviewBeta21Checks.Tests()).Concat(JoinBoxChecks.Tests()).Concat(RcMainChecks.Tests()).Concat(RcLateGameChecks.Tests()).Concat(RcFactionChecks.Tests()).Concat(RcTradingChecks.Tests()).Concat(RcPerformanceChecks.Tests()).Concat(RcColonyChecks.Tests()).Concat(Rc5Checks.Tests()).Concat(Rc6Checks.Tests()).Concat(Rc7Checks.Tests()).Concat(Rc13Checks.Tests()).Concat(Rc14Checks.Tests()).Concat(Rc15Checks.Tests()).Concat(Rc22Checks.Tests()).Concat(Rc27Checks.Tests()).Concat(PowerExportChecks.Tests()).ToArray(); // `-- --only ` runs only the checks whose name contains the text (for working on one part). string? only = args.SkipWhile(a => a != "--only").Skip(1).FirstOrDefault(); if (only != null) tests = tests.Where(t => t.Name.Contains(only, StringComparison.OrdinalIgnoreCase)).ToArray(); diff --git a/StabilityTests/StabilityTests.csproj b/StabilityTests/StabilityTests.csproj index 84e78dbc..84f3558b 100644 --- a/StabilityTests/StabilityTests.csproj +++ b/StabilityTests/StabilityTests.csproj @@ -41,6 +41,8 @@ + + diff --git a/TWO-COLONIES.md b/TWO-COLONIES.md index b53d23ba..d032e6bf 100644 --- a/TWO-COLONIES.md +++ b/TWO-COLONIES.md @@ -14,6 +14,7 @@ page is the full set of rules. - **Build, mark and plant anywhere**, right up to another colony's buildings and roads. - **Two colonies' roads never join**, except through a Trading Post. (A District Crossing links your own districts, as in the game.) +- **Two colonies' power never joins**, except through a Power Export Facility. - **You change only your own colony** (or one you look after), whether or not its neighbors are playing. - **Your beavers work only for your colony.** - **Shared by everyone:** game speed and pause (menus and dialogs don't pause), pings, chat, saving, and the map: water, droughts, badwater and @@ -213,6 +214,39 @@ see them there, under the header of a post you share, and in the goods grid when - Heavy goods take many trips: give busy posts more workers (up to 10). - Either of its two colonies may remove a post; no one else can. Its halves are placed as one. +## Power Export Facilities + +The **Power Export Facility** is in the Power tab. It **costs 20 gears, 20 planks and 20 logs** (10 of each a half), +needs **200 science** to unlock, and appears only in separate-colonies games. It's the only place two colonies' power meets. + +**Placing it.** Build it between two colonies, as a Trading Post: one half for each. Each half needs its colony's road +at its door, a shaft at its end (where the power marker shows while placing), and **one worker**. Either colony may +place it, and either of its two colonies may remove it. + +**Sending power.** Select your half and tick **Send power to …**. Your spare power goes to the other colony: what your +own buildings don't use, and only as much as theirs can use (their buildings, then their batteries). They get a message. + +- **Charge my batteries first** (on at first): your batteries fill before anything is sent. +- **Use my batteries for …** (off at first): your stored power can run their buildings too, after your own (it never + fills their batteries). While it's on, your batteries don't charge first. + +**One way, one to one.** Between two colonies, power goes one way: while one sends, the other can't. A colony sends to +one colony and gets power from one colony. Power it gets is spare power it can pass on: if A sends to B, B can send to +C. Power never comes back round: C can't then send to A. + +**Power moves only while both workers are at work.** Each colony keeps its own working hours, so power crosses in the +hours both are on shift. The panel says why nothing crosses: a missing road, shaft or worker, or nothing to spare or +needed. + +**Nothing is traded here.** To pay for power, barter at a Trading Post. + +**The Power window (H)** (also the **Power** button at the top right, or **Power** on a facility) lists: + +- where power goes between colonies, and how much crosses each link; +- your colony's power networks: what each makes and uses, its batteries, and what comes in or goes out, with **Go to**; +- your facilities, with their check boxes and **Go to**; +- the other colonies' power. + ## Looking after a colony Going away for a while? Ask a friend to look after your colony. @@ -258,7 +292,7 @@ With **Separate science and unlocks** ticked: | Work | Whose | |---|---| -| Building, demolishing | Only the owning colony (either partner can remove a Trading Post between them) | +| Building, demolishing | Only the owning colony (either partner can remove a Trading Post or Power Export Facility between them) | | Cutting trees | Only trees your colony marked | | Planting | Only on your colony's planting marks | | Harvesting, gathering, scavenging | What grows on your marks, and wild things nobody marked (first come) | @@ -279,7 +313,6 @@ A tile marked by one colony can't be marked or unmarked by another. Unmarking an - **Water:** one shared world. A dam upstream changes what flows downstream, and droughts and badwater reach everyone. Prefer maps with water near each start. - **Blasts** from dynamite and unstable cores destroy whatever they reach, any colony's. -- **Power:** two colonies' shafts that touch make one network. - **Area effects:** decorations, monuments and Wonders help any beaver in range (in a mixed game, only their own faction's). @@ -312,6 +345,15 @@ time with **Ctrl+L**. **Two placements at once** can still join two colonies' roads. Both players are warned (*Two districts' roads have been joined…*), and the game keeps running: remove the joining path or building soon. +## The power rule + +Two colonies' power never joins, except through a Power Export Facility. A shaft, gearbox, generator, battery or +powered building can't go where its power connection would meet another colony's: *That would join another colony's +power*. Two placements at once can't join them either: their networks stay apart. + +**Seeing the power.** Every colony's power shows as bright squares in its color while you place a shaft, gearbox, +generator, battery or Power Export Facility, while the Power window is open, and any time with **Ctrl+P**. + ## Dev mode and testing alone **Dev mode** (Alt+Shift+Z) in co-op: only its instant unlock, *Finish now* and *Add 1000 Science* are shared. Its other @@ -329,7 +371,8 @@ act as the next colony (1 → 2 → 3 → 4 → 1). - In a mixed game, both halves of a Trading Post show the placing colony's faction, and achievements follow the host's faction. - The room has no chat or map preview. Anyone who can reach the direct-IP port can join it; the host can remove them. -- Trading needs a co-op session: host the game (even alone) to trade. +- Trading needs a co-op session: host the game (even alone) to trade. So does sending power. +- The Power Export Facility looks like a District Crossing: its halves use the crossing's model. - A post's reserve counts your stock in that half's district, not your whole colony's. - The days of food and water in the colonies window (Y) are an estimate from yesterday's use. - **Automation** runs on the game's ticks in co-op: up to a tick later than alone. A spring-return lever gives a diff --git a/design/POWER-EXPORT-PLAN.md b/design/POWER-EXPORT-PLAN.md index 2b2d2ca0..f2604136 100644 --- a/design/POWER-EXPORT-PLAN.md +++ b/design/POWER-EXPORT-PLAN.md @@ -5,6 +5,30 @@ fully before writing code. Anything marked **VERIFY** is a belief about Timberbo against the decompiled game: confirm it before building on it. Written against the rc31 source (the Trading Post form without rounds). +## As built (1.4.0-rc32) + +What the build settled that the plan left open (the VERIFY items, checked against the decompiled game): + +- **Two linked buildings, not one.** A building has one power node, and a node sits in one network, so each half is its + own building (the game's `LinkedBuilding`, as a District Crossing's halves): laid down as a pair, built together, + removed together. Each half has its own node, worker and door. +- **The node** is 1 hp in and 1 hp out, scaled every tick with the game's own `SetInputMultiplier` / + `SetOutputMultiplier` (VERIFY 1: output and input can change at runtime; the graph's totals follow through the + actuals' change events). +- **The network join** is `MechanicalGraphManager.AddNode` (VERIFY 2); `MechanicalConnectorActivator` only draws + connectors. A split follows only the connections made there, so guarding the join is enough. +- **Batteries** (VERIFY 3): the game's `BatteryService` charges with the graph's surplus and discharges its shortfall, + so a sending half's draw counts against its colony's surplus, and a receiving half's output towards its partner's. +- **Connection** (VERIFY 4): one transput per half, on its left end at ground level (`Directions: Left`). +- **Workers** (VERIFY 5, option B): not the crossing's workings (they would join roads). Each half is a one-worker + workshop whose worker works as a Power Wheel's walker does (`WorkWorkplaceBehavior`), in quarter-hour stretches, so + `Workshop.CurrentlyWorking` drops for a tick between them: a half counts as staffed for 5 ticks after its worker + last worked (VERIFY 6: off shift, paused or unassigned reads as unstaffed). +- **Cost.** 20 of each for the facility: 10 of each a half, since each half is built and paid for on its own. +- **Look.** The District Crossing's model, as the Trading Post's. +- **Keys** checked against the game's bindings: Ctrl+P is free (plain P is `ToggleBuildingPause`, no other modifiers), + H is free (only Ctrl+H, `ToggleGUI`, no other modifiers). + ## 1. Goal Let one colony send spare power to another. Power only: the Power Export Facility trades nothing. Players who want to pay for diff --git a/docs/index.html b/docs/index.html index e18f61c7..5cb925d3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -322,6 +322,11 @@

Folktails and Iron Teeth together Not played yetThe host ticks Mixed factions on the New Game page, and each player picks a faction in the room. Your colony gets your faction's toolbar, beavers, bots and buildings. Between factions, Trading Posts carry the goods both use, and science, but not beavers.

Needs every faction unlocked on the host's computer, and no other faction mod; otherwise the box is greyed and says why.

+
+

Sharing power Not played yet

+

Build a Power Export Facility (Power tab) between two colonies. Each half needs its colony's road, a shaft at its end, and a worker. Tick Send power to … on your half, and your spare power goes to them.

+

Power goes one way between two colonies, and each colony can pass it on. H opens the Power window. To pay for it, trade at a Trading Post.

+

Away for a while? Your colony waits

Your colony is kept for you. To keep it running, press Y → Let … look after it. Your friend presses Run this colony to play it, and Back to your colony when done. Take it back any time.

@@ -334,6 +339,7 @@

Your colony, your rules

  • Yours alone

    Your districts, beavers, stock, working hours and marks. Nobody else can change your buildings, and your beavers work only for you.

  • Build anywhere

    No land and no borders: build right up to a neighbor. The one rule: your roads never join theirs. Ctrl+L shows every colony's roads.

  • +
  • Your own power

    Your power never joins a neighbor's, except through a Power Export Facility. Ctrl+P shows every colony's power.

  • Your own science

    With Separate science and unlocks ticked (the default), each colony earns its own science and unlocks its own buildings.

  • Remembered by the save

    The save knows you by your Steam ID, so you get the same colony whoever hosts.

  • Shared by everyone

    Speed, pause, pings, chat, saving, and the map itself: water, droughts, badwater and weather.

  • @@ -451,7 +457,7 @@

    Also included

    Beta: what's been played

    -

    The release candidate for 1.4.0. Automated checks cover everything here, and all but one feature has been played. Play on a copy of your save and keep backups.

    +

    The release candidate for 1.4.0. Automated checks cover everything here, and all but two features have been played. Play on a copy of your save and keep backups.

    @@ -473,6 +479,7 @@

    Played in real games

    Not played yet

    • Folktails and Iron Teeth together.
    • +
    • Sharing power through a Power Export Facility.
    @@ -560,13 +567,13 @@

    What's in 1.4.0-rc31

    All releases · Changelog

    -
    -

    Type the whole trade

    -

    Offer 101 logs for 2 gears, or 500 for 500. Nothing is refused. The game carries exactly that, up to 100 of each a round, and Rounds shows how many.

    -

    Your own colony, everywhere you look

    -

    The Global history graphs, the wellbeing window and a good's stock chart show only your colony.

    -

    A cleaner colonies window

    -

    The Trading Posts and colonies window (Y) matches the game's own windows, with readable text.

    +
    +

    Share power with a neighbor

    +

    The Power Export Facility sends your spare power to another colony. Tick Send power to … on your half: they get only what they can use, after your own buildings.

    +

    Your power stays yours

    +

    Two colonies' power never joins, except through a facility. Ctrl+P shows every colony's power.

    +

    The Power window

    +

    Press H to see where power goes, your networks and facilities, and every colony's power.

    diff --git a/docs/install.html b/docs/install.html index 435bbea3..ae3afbaa 100644 --- a/docs/install.html +++ b/docs/install.html @@ -260,7 +260,9 @@

    Keys

    Ctrl+KFound your colony, if you have none. Ctrl+LShow every colony's roads in its color. + Ctrl+PShow every colony's power in its color. YYour Trading Posts and every colony. + HThe Power window. EnterType in the chat. HomeBack to your colony. Click a name in the connection panel to go to that player. Ctrl+Shift+JWrite a diagnostics report. diff --git a/docs/troubleshooting.html b/docs/troubleshooting.html index 21a0e128..52a5b450 100644 --- a/docs/troubleshooting.html +++ b/docs/troubleshooting.html @@ -224,6 +224,20 @@

    Colonies and trading

    +
    + "That would join another colony's power" +
    +

    A shaft or powered building of yours would connect to theirs. Keep your shafts a cell apart; to share power, put a Power Export Facility between them. Ctrl+P shows whose power is whose.

    +
    +
    + +
    + No power crosses a Power Export Facility +
    +

    Select your half: its panel says what's missing. Each half needs its colony's road at its door, a shaft at its end, and a worker at work. Power crosses only when one side has some to spare and the other can use it.

    +
    +
    +
    Ctrl+K says this is one shared colony