From 6e48f0df224210f4ed92a0641b816c47a25dd570 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Sun, 15 Mar 2026 15:08:37 -0400 Subject: [PATCH 01/32] Add submodules for OPResource + OPDB --- .gitmodules | 6 ++++++ asset | 1 + db | 1 + 3 files changed, 8 insertions(+) create mode 100644 .gitmodules create mode 160000 asset create mode 160000 db diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..f5d71500 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "db"] + path = db + url = https://github.com/OpenPerpetuum/OPDB.git +[submodule "asset"] + path = asset + url = https://github.com/OpenPerpetuum/OPResource.git diff --git a/asset b/asset new file mode 160000 index 00000000..19b6b7f2 --- /dev/null +++ b/asset @@ -0,0 +1 @@ +Subproject commit 19b6b7f2c4455f8cd6b6fbac5f1b130683d23f45 diff --git a/db b/db new file mode 160000 index 00000000..008632ea --- /dev/null +++ b/db @@ -0,0 +1 @@ +Subproject commit 008632ea4b20af45068e77c469e2ad5a3a4c0253 From 5af73813dae264f6f654b98aba57b3b5b8d0ce4c Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Sun, 15 Mar 2026 15:34:50 -0400 Subject: [PATCH 02/32] Add container support using linux containers, add migration script for linux, add template for perpetuum.ini, add Makefile for some helper commands, update server to take DistributedTransactions as argument to be able to disable it for linux --- .env.local | 38 ++++++ Makefile | 36 ++++++ compose.yml | 85 ++++++++++++ docker/Dockerfile.asset.dev | 11 ++ docker/Dockerfile.migration.dev | 24 ++++ docker/Dockerfile.server.dev | 27 ++++ script/compose.sh | 8 ++ script/migration.sh | 122 ++++++++++++++++++ .../PerpetuumBootstrapper.cs | 4 +- .../PerpetuumServerService2.cs | 3 +- template/perpetuum.ini | 18 +++ template/restore_DB_to_original_state.sql | 17 +++ 12 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 .env.local create mode 100644 Makefile create mode 100644 compose.yml create mode 100644 docker/Dockerfile.asset.dev create mode 100644 docker/Dockerfile.migration.dev create mode 100644 docker/Dockerfile.server.dev create mode 100755 script/compose.sh create mode 100755 script/migration.sh create mode 100644 template/perpetuum.ini create mode 100644 template/restore_DB_to_original_state.sql diff --git a/.env.local b/.env.local new file mode 100644 index 00000000..0473f0da --- /dev/null +++ b/.env.local @@ -0,0 +1,38 @@ +# Environment for local development. +# Compatible with podman/docker with linux containers. + +# Database settings +DB_PASSWORD="l3 /migration/Patches/$1/$1.sql +} + +# Create perperuumsa database if it does not exist +echo "IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'perpetuumsa') CREATE DATABASE perpetuumsa" > /work/create-database.sql +/opt/mssql-tools/bin/sqlcmd -S db -C -U sa -P "${DB_PASSWORD}" -I -i "/work/create-database.sql" +rm /work/create-database.sql + +# echo "CREATE LOGIN sa WITH PASSWORD = '${DB_PASSWORD}'" > /work/create-user.sql +# runSqlCmd + +# Restore DB original state +runSqlCmd "/work/restore_DB_to_original_state.sql" + +# Apply patches +applyPatch Pre_Alpha_0 prealpha_patch_0.sql +applyPatch Pre_Alpha_1 prealpha_patch_1.sql Server +applyPatch Pre_Alpha_2 prealpha_patch_2.sql Server +applyPatch Pre_Alpha_3 prealpha_patch_3.sql +applyPatch Pre_Alpha_4 prealpha_patch_4.sql Server +applyPatch Pre_Alpha_5 prealpha_patch_5.sql +applyPatch Pre_Alpha_6 prealpha_patch_6.sql +applyPatch Pre_Alpha_7_FInal FIX_robottemplaterelation_pinkarkhe.sql +applyPatch Pre_Alpha_7_FInal NPC_robottemplates_argano_GetsEcms__2018_04_12.sql +applyPatch Live_1 live_patch_1.sql +applyPatch Live_2 live_patch_2.sql +applyPatch Live_3 live_patch_3.sql +applyPatch Live_4 live_patch_4.sql +applyPatch Live_5 live_patch_5.sql +applyPatch Live_6 live_patch_6.sql +applyPatch Live_7 live_patch_7.sql +applyPatch Live_8 live_patch_8.sql +applyPatch Live_9 live_patch_9.sql +applyPatch Live_10 live_patch_10.sql Server +applyPatch Live_11 live_patch_11.sql Server +applyPatch Live_12 live_patch_12.sql +applyPatch Live_13 live_patch_13.sql Server +applyPatch Live_14 live_patch_14.sql +applyPatch Live_15 live_patch_15.sql Server +applyPatch Live_16 live_patch_16.sql Server +applyPatch Live_17 live_patch_17.sql Server +applyPatch Live_18 live_patch_18.sql Server +applyPatch Live_19 live_patch_19.sql Server +applyPatch Live_20 live_patch_20.sql Server +applyPatch Live_21 live_patch_21.sql Server +applyPatch Live_22 live_patch_22.sql Server +applyPatch Live_23 live_patch_23.sql +applyPatch Live_24 live_patch_24.sql Server +applyPatch Live_25 live_patch_25.sql Server +applyPatch Live_26 live_patch_26.sql Server +applyPatch Live_27 live_patch_27.sql Server +applyPatch Live_28 live_patch_28.sql Server +applyPatch Live_29 live_patch_29.sql +applyPatch Live_30 live_patch_30.sql Server +applyPatch Live_31 live_patch_31.sql Server +applyPatch Live_32 live_patch_32.sql Server +applyPatch Live_33 live_patch_33.sql Server +preparePatch Live_34 +applyPatch Live_34 Live_34.sql Server +preparePatch Live_35 +applyPatch Live_35 Live_35.sql Server + + +# Add test account (user: test, pass: test) +runSqlCmd "/migration/Tools/TOOL_test_account.sql" + +echo Patching complete + +touch /data/done \ No newline at end of file diff --git a/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs b/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs index 8717adee..d3fa67d2 100644 --- a/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs +++ b/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs @@ -128,7 +128,7 @@ public void WriteCommandsToFile(string path) File.WriteAllText(path, sb.ToString()); } - public void Init(string gameRoot) + public void Init(string gameRoot, bool distributedTransactions) { _builder = new ContainerBuilder(); InitContainer(gameRoot); @@ -144,7 +144,7 @@ public void Init(string gameRoot) Logger.Info($"GC Latency mode: {GCSettings.LatencyMode}"); Logger.Info($"Vector is hardware accelerated: {Vector.IsHardwareAccelerated}"); - TransactionManager.ImplicitDistributedTransactions = true; + TransactionManager.ImplicitDistributedTransactions = distributedTransactions; Db.DbQueryFactory = _container.Resolve>(); diff --git a/src/Perpetuum.ServerService2/PerpetuumServerService2.cs b/src/Perpetuum.ServerService2/PerpetuumServerService2.cs index 1d4dd3ed..9dc56306 100644 --- a/src/Perpetuum.ServerService2/PerpetuumServerService2.cs +++ b/src/Perpetuum.ServerService2/PerpetuumServerService2.cs @@ -23,11 +23,12 @@ public void ServerStart() { // assumes the server is in the default installation directory. string gameroot = _configuration.GetValue("GameRoot") ?? "C:\\PerpetuumServer\\data"; + bool distributedTransactions = _configuration.GetValue("DistributedTransactions", true); _logger.LogInformation("Perpetuum Dedicated Server v2 - starting"); try { - Bootstrapper.Init(gameroot); + Bootstrapper.Init(gameroot, distributedTransactions); } catch (Exception ex) { diff --git a/template/perpetuum.ini b/template/perpetuum.ini new file mode 100644 index 00000000..c4b180e9 --- /dev/null +++ b/template/perpetuum.ini @@ -0,0 +1,18 @@ +{ + "ListenerPort": {SERVER_PORT}, + "EnableUpnp": false, + "EnableDev": true, + + "PersonalConfig": "startup_standalone", + "ConnectionString": "{CONNECTION_STRING}", + + "Corporation":{ + "Price" : 250000, + "HangarPrice" : 50000, + "RentPeriod" : 7, + "LeavePeriod" : 1440, + "NumberOfHangarFolders" : 10, + "FoundingPrice" : 25000 + }, + "ResourceServerURL": "{ASSET_URL}" +} \ No newline at end of file diff --git a/template/restore_DB_to_original_state.sql b/template/restore_DB_to_original_state.sql new file mode 100644 index 00000000..8ec0c097 --- /dev/null +++ b/template/restore_DB_to_original_state.sql @@ -0,0 +1,17 @@ +-- Altered restore DB SQL script that can run inside a container +-- Assumes the 'perpetuumsa.bak' is available at the path '/data' + +USE [master] +GO + + +ALTER DATABASE perpetuumsa +SET SINGLE_USER WITH +ROLLBACK IMMEDIATE + +RESTORE DATABASE perpetuumsa FROM DISK = '/data/perpetuumsa.bak' WITH +MOVE 'perpetuumsa' TO '/data/psa.mdf', +MOVE 'perpetuumsa_log' TO '/data/psa_log.ldf', REPLACE + +ALTER DATABASE perpetuumsa SET MULTI_USER +GO \ No newline at end of file From 26512e64dbe14ddd31584ded04257519e31296f5 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Sun, 15 Mar 2026 21:26:01 -0400 Subject: [PATCH 03/32] Update mssql-tools to a more recent version (18) to fix migration, update server image to include GDI+ --- docker/Dockerfile.migration.dev | 22 ++++++++++++++++++++-- docker/Dockerfile.server.dev | 5 ++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile.migration.dev b/docker/Dockerfile.migration.dev index 74dc6879..bd6bd076 100644 --- a/docker/Dockerfile.migration.dev +++ b/docker/Dockerfile.migration.dev @@ -1,10 +1,28 @@ -# TODO check for specific tag instead of latest if possible -FROM mcr.microsoft.com/mssql-tools:latest +FROM ubuntu:22.04 ARG ASSET_URL ARG CONNECTION_STRING ARG SERVER_PORT +# Install dependencies for mssql-tools18 +RUN apt-get update && apt-get install -y \ + curl \ + gnupg \ + apt-transport-https \ + && rm -rf /var/lib/apt/lists/* + +# Add Microsoft repository +RUN curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - && \ + curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list | tee /etc/apt/sources.list.d/msprod.list + +# Install mssql-tools18 +RUN apt-get update && ACCEPT_EULA=Y apt-get install -y \ + mssql-tools18 \ + && rm -rf /var/lib/apt/lists/* + +# Add sqlcmd to PATH +ENV PATH="/opt/mssql-tools18/bin:${PATH}" + WORKDIR /work COPY script/migration.sh . diff --git a/docker/Dockerfile.server.dev b/docker/Dockerfile.server.dev index b8449e82..62d698ca 100644 --- a/docker/Dockerfile.server.dev +++ b/docker/Dockerfile.server.dev @@ -18,7 +18,10 @@ RUN dotnet publish --force src/Perpetuum.ServerService2/Perpetuum.ServerService2 FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 # Install the missing GSSAPI/Kerberos dependency used by the SQL client -RUN apt-get update && apt-get install -y libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y libgssapi-krb5-2 curl && rm -rf /var/lib/apt/lists/* + +# Install GDI+ +RUN curl https://raw.githubusercontent.com/stulzq/awesome-dotnetcore-image/master/install/ubuntu.sh|sh WORKDIR /runtime From 0689ed4505ef0233ce2c4a093042110c8cacba62 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Mon, 16 Mar 2026 19:05:55 -0400 Subject: [PATCH 04/32] Fix comment in .env.local, remove preparePatch from the migration script and fix migration when using a directory --- .env.local | 3 +-- script/migration.sh | 33 ++++++++++++++++----------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/.env.local b/.env.local index 0473f0da..b2618f88 100644 --- a/.env.local +++ b/.env.local @@ -27,9 +27,8 @@ PERPETUUM_DATA="./perpetuum-data" # Connection string: Specific changes made to work on linux: # - Added "sa" user, password to use SQL server authentication; # - Added TrustServerCertificate=True to trust the self-signed certificate -# - Changed Server to the name of the db container: "db" +# - Changed Server to the name of the database container: "db" # - Removed due to incompatibility on linux: "Connection Reset=True;" -# - Removed since client refuses self-signed certificate: Trusted_Connection=True; # - Removed Trusted_Connection since we don't want to use Windows Authentication (SPPI) CONNECTION_STRING="Server=db;Database=perpetuumsa;User Id=sa;Password=${DB_PASSWORD};TrustServerCertificate=True;Pooling=True;Connection Timeout=30;Connection Lifetime=260;Min Pool Size=20;Max Pool Size=60;" diff --git a/script/migration.sh b/script/migration.sh index 7e0015aa..cc945121 100755 --- a/script/migration.sh +++ b/script/migration.sh @@ -26,7 +26,9 @@ cp -rv /base-data/layers /data/ runSqlCmd () { set +x - /opt/mssql-tools/bin/sqlcmd -S db -d perpetuumsa -C -U sa -P "${DB_PASSWORD}" -I -i $1 + sqlcmd -S db -d perpetuumsa -C -U sa -P "${DB_PASSWORD}" -I -i $1 + # Comment the line above and uncomment the following line if you want to stop at any error during execution of a script. + # sqlcmd -S db -d perpetuumsa -b -C -U sa -P "${DB_PASSWORD}" -I -i $1 set -x } @@ -35,28 +37,27 @@ runSqlCmd () { # # Arguments: # - 1: Name of the directory of the patch to apply (Ex: Live_99) -# - 2: SQL File name to execute (Ex: some_patch.sql) +# - 2: SQL File name OR directory to execute (Ex: some_patch.sql or Raw_SQL) # - 3: (optional) Name of the directory containing the "data" folder (Ex: Server) applyPatch () { - runSqlCmd "/migration/Patches/$1/$2" + PATCH_PATH="/migration/Patches/$1/$2" + if [ -d "$PATCH_PATH" ]; then + for f in "$PATCH_PATH"/*.sql; do + [ -e "$f" ] || continue + runSqlCmd "$f" + done + else + runSqlCmd "$PATCH_PATH" + fi if [ $# -eq 3 ]; then cp -rv "/migration/Patches/$1/$3/data/" / fi } -# preparePatch concatenate all raw sql files into a single patch file. -# save the patch as "$1.sql" in the folder of the patch. -# -# Arguments: -# - 1: Name of the directory of the patch to prepare (Ex: Live_99) -preparePatch () { - cat /migration/Patches/$1/Raw_SQL/* > /migration/Patches/$1/$1.sql -} - # Create perperuumsa database if it does not exist echo "IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'perpetuumsa') CREATE DATABASE perpetuumsa" > /work/create-database.sql -/opt/mssql-tools/bin/sqlcmd -S db -C -U sa -P "${DB_PASSWORD}" -I -i "/work/create-database.sql" +sqlcmd -S db -C -U sa -P "${DB_PASSWORD}" -I -i "/work/create-database.sql" rm /work/create-database.sql # echo "CREATE LOGIN sa WITH PASSWORD = '${DB_PASSWORD}'" > /work/create-user.sql @@ -108,10 +109,8 @@ applyPatch Live_30 live_patch_30.sql Server applyPatch Live_31 live_patch_31.sql Server applyPatch Live_32 live_patch_32.sql Server applyPatch Live_33 live_patch_33.sql Server -preparePatch Live_34 -applyPatch Live_34 Live_34.sql Server -preparePatch Live_35 -applyPatch Live_35 Live_35.sql Server +applyPatch Live_34 Raw_SQL Server +applyPatch Live_35 Raw_SQL Server # Add test account (user: test, pass: test) From 1073ced618d551b25799dd9b886ad8c55b84ca4f Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Mon, 16 Mar 2026 20:03:34 -0400 Subject: [PATCH 05/32] Move db persistent storage into a docker volume --- compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/compose.yml b/compose.yml index 2ba73731..78f93c88 100644 --- a/compose.yml +++ b/compose.yml @@ -29,9 +29,8 @@ services: hostname: db volumes: # Note for linux/WSL2, it can cause permission denied, fix: - # mkdir mssql-data - # sudo chown -R 10001:10001 mssql-data - - ./mssql-data:/var/opt/mssql + # sudo chown -R 10001:10001 openperpetuum-db + - openperpetuum-db:/var/opt/mssql # Mount perpetuum database path that contains the perpetuumsa.bak - "${PERPETUUM_DATA}/database:/data" @@ -82,4 +81,5 @@ networks: driver: bridge volumes: - openperpetuum-data: \ No newline at end of file + openperpetuum-data: + openperpetuum-db: \ No newline at end of file From 0fa79c68c4fa8275d77112f71d690064727be94d Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Fri, 20 Mar 2026 20:11:39 -0400 Subject: [PATCH 06/32] Most fo the refactor from System.Drawing to SkiaSharp is done. --- .env.local | 10 +- .gitignore | 8 + Makefile | 2 +- compose.yml | 4 +- docker/Dockerfile.migration.dev | 4 +- docker/Dockerfile.server.dev | 5 +- script/migration.sh | 9 +- .../Modules/TerrainsModule.cs | 3 +- .../Perpetuum.RequestHandlers.csproj | 1 + src/Perpetuum.RequestHandlers/SetRobotTint.cs | 5 +- .../NpcPlaceSafeSpawnPoint.cs | 4 +- .../NpcSafeSpawnPointRequestHandler.cs | 5 +- .../NpcSetSafeSpawnPoint.cs | 4 +- .../Zone/StatsMapDrawing/DisplaySpots.cs | 6 +- .../StatsMapDrawing/DrawMissionTargetLog.cs | 114 +++++---- .../StatsMapDrawing/GenerateMissionSpots.cs | 113 ++++---- .../GenerateRandomPointsOnly.cs | 7 +- .../ValidateMissionObjectLocations.cs | 29 ++- .../Zone/StatsMapDrawing/WorstMissionSpots.cs | 27 +- .../Zone/StatsMapDrawing/ZoneDrawStatMap.cs | 241 ++++++++++-------- .../Zone/ZoneSetLayerWithBitMap.cs | 10 +- src/Perpetuum.Server/Program.cs | 3 +- src/Perpetuum/Area.cs | 20 +- src/Perpetuum/BinaryStream.cs | 12 +- src/Perpetuum/BitmapExtensions.cs | 11 +- .../Collections/Spatial/Grid.NonGeneric.cs | 7 +- src/Perpetuum/Collections/Spatial/Grid.cs | 10 +- src/Perpetuum/Collections/Spatial/QuadTree.cs | 5 +- src/Perpetuum/ColorExtensions.cs | 17 ++ src/Perpetuum/Commands.cs | 4 +- src/Perpetuum/EnumerableExtensions.cs | 3 - src/Perpetuum/GenXY/GenxyConverter.cs | 18 +- src/Perpetuum/GenXY/GenxyReader.cs | 10 +- src/Perpetuum/Items/Paint.cs | 9 +- src/Perpetuum/Modules/DrillerModule.cs | 4 +- src/Perpetuum/Modules/LargeHarvesterModule.cs | 2 +- src/Perpetuum/PathFinders/AStarFinder.cs | 17 +- src/Perpetuum/PathFinders/PathFinder.cs | 16 +- src/Perpetuum/Perpetuum.csproj | 3 +- src/Perpetuum/Players/PlayerMoveChecker.cs | 5 +- src/Perpetuum/PointExtensions.cs | 50 ++-- src/Perpetuum/Robots/Robot.Properties.cs | 16 +- .../MissionEngine/MissionSpotObjects.cs | 7 +- .../ZoneMissionTargetObjects.cs | 68 +++-- .../RelicManagers/AbstractRelicManager.cs | 14 +- .../RelicManagers/OutpostRelicManager.cs | 11 +- .../Relics/RelicManagers/ZoneRelicManager.cs | 4 +- .../Services/RiftSystem/RiftManager.cs | 15 +- src/Perpetuum/SizeExtensions.cs | 22 +- src/Perpetuum/StateMachines/IState.cs | 2 - src/Perpetuum/Zones/Artifacts/Artifact.cs | 4 +- .../Generators/PersistentArtifactGenerator.cs | 7 +- .../Artifacts/Scanners/ArtifactScanResult.cs | 4 +- .../Artifacts/Scanners/ArtifactScanner.cs | 2 - src/Perpetuum/Zones/Beams/BeamBuilder.cs | 5 +- .../RandomWalkableAroundPositionFinder.cs | 2 +- src/Perpetuum/Zones/IZone.cs | 6 +- src/Perpetuum/Zones/Movements/PathMovement.cs | 7 +- src/Perpetuum/Zones/NpcSystem/AI/BaseAI.cs | 3 - src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs | 20 +- .../AI/CombatDrones/CombatDroneAI.cs | 20 +- .../AI/CombatDrones/EscortCombatDroneAI.cs | 3 +- .../AI/CombatDrones/RetreatCombatDroneAI.cs | 4 +- src/Perpetuum/Zones/NpcSystem/AI/HomingAI.cs | 6 +- .../Zones/NpcSystem/AI/IndustrialAI.cs | 25 +- .../EscortIndustrialDroneAI.cs | 4 +- .../RetreatIndustrialDroneAI.cs | 4 +- src/Perpetuum/Zones/NpcSystem/AI/Node.cs | 7 +- .../NpcSystem/Presences/DynamicPresence.cs | 5 +- .../RandomSpawningExpiringPresence.cs | 5 +- .../InterzonePresences/InterzonePresence.cs | 7 +- .../PathFinders/FreeRoamingPathFinder.cs | 15 +- .../PathFinders/IRoamingPathFinder.cs | 6 +- .../PathFinders/NormalRoamingPathFinder.cs | 19 +- .../Presences/PathFinders/RoamingState.cs | 2 - .../Presences/PresenceConfiguration.cs | 2 +- .../NpcSystem/Presences/RoamingPresence.cs | 8 +- .../ISafeSpawnPointsRepository.cs | 2 - .../NpcSafeSpawnPointsRepository.cs | 6 +- .../SafeSpawnPoints/SafeSpawnPoint.cs | 5 +- .../Zones/PBS/EnergyWell/PBSEnergyWell.cs | 8 +- src/Perpetuum/Zones/Position.cs | 30 +-- .../Scanning/Scanners/Scanner.Artifact.cs | 4 +- .../Scanning/Scanners/Scanner.Directional.cs | 10 +- .../Scanning/Scanners/Scanner.Intrusion.cs | 4 +- .../Scanning/Scanners/Scanner.OneTile.cs | 7 +- src/Perpetuum/Zones/Terrains/BlockingInfo.cs | 2 - src/Perpetuum/Zones/Terrains/ITerrain.cs | 1 - .../Zones/Terrains/IUpdateableLayer.cs | 2 - .../Zones/Terrains/LayerExtensions.cs | 5 +- .../Generators/MineralNodeGeneratorBase.cs | 17 +- .../RandomWalkMineralNodeGenerator.cs | 18 +- .../Materials/Minerals/GravelLayer.cs | 14 +- .../Materials/Minerals/MineralExtractor.cs | 14 +- .../Materials/Minerals/MineralLayer.cs | 14 +- .../Materials/Minerals/MineralNode.cs | 17 +- .../Terrains/Materials/Plants/PlantHandler.cs | 6 +- .../Zones/Terrains/PassableMapBuilder.cs | 9 +- .../Operations/BlurTerraformingOperation.cs | 4 +- .../Terrains/Terraforming/TerraformHandler.cs | 9 +- src/Perpetuum/Zones/Terrains/Terrain.cs | 1 - .../Zones/Terrains/TerrainUpdateMonitor.cs | 11 +- .../Zones/Terrains/TerrainUpdateNotifier.cs | 6 +- src/Perpetuum/Zones/Zone.cs | 4 +- src/Perpetuum/Zones/ZoneConfiguration.cs | 11 +- src/Perpetuum/Zones/ZoneExtensions.Bitmap.cs | 25 +- src/Perpetuum/Zones/ZoneExtensions.Terrain.cs | 10 +- src/Perpetuum/Zones/ZoneExtensions.cs | 24 +- .../{perpetuum.ini => perpetuum.ini.template} | 0 109 files changed, 730 insertions(+), 768 deletions(-) create mode 100644 src/Perpetuum/ColorExtensions.cs rename template/{perpetuum.ini => perpetuum.ini.template} (100%) diff --git a/.env.local b/.env.local index b2618f88..97d026ba 100644 --- a/.env.local +++ b/.env.local @@ -21,9 +21,17 @@ ASSET_URL="http://localhost:${ASSET_PORT}" GAME_ROOT="/data" -# Directory from the official PerpetuumServer data +# Original data directory from Stream: Perpetuum Dedicated Server installer PERPETUUM_DATA="./perpetuum-data" +# User-provided additional map layers assets +# TODO: Move to a guide/readme +# Required if you want to load the server with Gamma Islands +# Download URL: https://drive.google.com/file/d/1qDjPHbTSdal_aKN6SvYC_EoGW-COkwhJ/view?usp=sharing +# sha256sum: +# f4d42d5dbf6a61d2d7730b2058f5d2bb80a63d0994bbc84b906f19441bb8e425 GAMMA_LAYERS_2021_10_17_P26.rar +CUSTOM_LAYERS="./custom-layers" + # Connection string: Specific changes made to work on linux: # - Added "sa" user, password to use SQL server authentication; # - Added TrustServerCertificate=True to trust the self-signed certificate diff --git a/.gitignore b/.gitignore index 48a955f7..8aca2a82 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,11 @@ src/Perpetuum.ServerService2/data/layers/ src/Perpetuum.ServerService2/data/logs/ bin/ Releases/ + +custom-assets/ + +# User-provided additional map layers assets +custom-layers/ + +# Original directory folder from Stream: Perpetuum Dedicated Server installer +perpetuum-data/ diff --git a/Makefile b/Makefile index 2a0a0769..47229890 100644 --- a/Makefile +++ b/Makefile @@ -33,4 +33,4 @@ log-db: log-server: ./script/compose.sh logs server -f -phonyx: help compose-start compose-stop compose-down log-asset log-db log-server +phonyx: help up start stop down log-asset log-db log-server diff --git a/compose.yml b/compose.yml index 78f93c88..97f3ea36 100644 --- a/compose.yml +++ b/compose.yml @@ -53,6 +53,8 @@ services: volumes: - ./db:/migration - "${PERPETUUM_DATA}:/base-data" + - "${CUSTOM_LAYERS}:/custom-layers" + - "./src/Perpetuum.ServerService2/data:/perpetuum-service-data" - openperpetuum-data:/data server: @@ -82,4 +84,4 @@ networks: volumes: openperpetuum-data: - openperpetuum-db: \ No newline at end of file + openperpetuum-db: diff --git a/docker/Dockerfile.migration.dev b/docker/Dockerfile.migration.dev index bd6bd076..12007a4b 100644 --- a/docker/Dockerfile.migration.dev +++ b/docker/Dockerfile.migration.dev @@ -26,9 +26,11 @@ ENV PATH="/opt/mssql-tools18/bin:${PATH}" WORKDIR /work COPY script/migration.sh . -COPY template/perpetuum.ini . +COPY template/perpetuum.ini.template . COPY template/restore_DB_to_original_state.sql . +RUN mv perpetuum.ini.template perpetuum.ini + RUN sed -i "s/{SERVER_PORT}/${SERVER_PORT}/" perpetuum.ini # Update the connection string but hides the command to hide the password RUN set +x && \ diff --git a/docker/Dockerfile.server.dev b/docker/Dockerfile.server.dev index 62d698ca..d2104611 100644 --- a/docker/Dockerfile.server.dev +++ b/docker/Dockerfile.server.dev @@ -9,7 +9,10 @@ WORKDIR /build COPY src ./src/ -# Build the projectt (restore + compile) +# Dedicated restore command to cache the layer for faster container boot time= +RUN dotnet restore src/Perpetuum.ServerService2/Perpetuum.ServerService2.csproj + +# Build the project # Provides a self-contained executable (no dotnet runtime required) RUN dotnet publish --force src/Perpetuum.ServerService2/Perpetuum.ServerService2.csproj --configuration Release --self-contained --runtime $RUNTIME_IDENTIFIER -o out diff --git a/script/migration.sh b/script/migration.sh index cc945121..d6da2880 100755 --- a/script/migration.sh +++ b/script/migration.sh @@ -19,11 +19,18 @@ set -eux # - Set initial state using backup # - Run migration for each patch +# Copy the generated perpetuum.ini cp -v /work/perpetuum.ini /data/ # Copy PerpetuumServer/data to the shared /data cp -rv /base-data/layers /data/ +# Copy the custom-layers to the shared /data/layers +cp -rv /custom-layers/* /data/layers + +# Copy the Perpetuum.ServerService data directory +cp -rv /perpetuum-service-data/* /data/ + runSqlCmd () { set +x sqlcmd -S db -d perpetuumsa -C -U sa -P "${DB_PASSWORD}" -I -i $1 @@ -118,4 +125,4 @@ runSqlCmd "/migration/Tools/TOOL_test_account.sql" echo Patching complete -touch /data/done \ No newline at end of file +touch /data/done diff --git a/src/Perpetuum.Bootstrapper/Modules/TerrainsModule.cs b/src/Perpetuum.Bootstrapper/Modules/TerrainsModule.cs index 531fb525..28b71a9c 100644 --- a/src/Perpetuum.Bootstrapper/Modules/TerrainsModule.cs +++ b/src/Perpetuum.Bootstrapper/Modules/TerrainsModule.cs @@ -11,6 +11,7 @@ using Perpetuum.Zones.Terrains.Materials.Minerals; using Perpetuum.Zones.Terrains.Materials.Minerals.Generators; using Perpetuum.Zones.Terrains.Materials.Plants; +using SkiaSharp; namespace Perpetuum.Bootstrapper.Modules { @@ -88,7 +89,7 @@ protected override void Load(ContainerBuilder builder) { Terrain terrain = ctx.Resolve(); - System.Drawing.Size size = zone.Configuration.Size; + SKSizeI size = zone.Configuration.Size; ILayerFileIO loader = ctx.Resolve(); diff --git a/src/Perpetuum.RequestHandlers/Perpetuum.RequestHandlers.csproj b/src/Perpetuum.RequestHandlers/Perpetuum.RequestHandlers.csproj index 47de1543..bca53b08 100644 --- a/src/Perpetuum.RequestHandlers/Perpetuum.RequestHandlers.csproj +++ b/src/Perpetuum.RequestHandlers/Perpetuum.RequestHandlers.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Perpetuum.RequestHandlers/SetRobotTint.cs b/src/Perpetuum.RequestHandlers/SetRobotTint.cs index 6e5046f6..cfe3345a 100644 --- a/src/Perpetuum.RequestHandlers/SetRobotTint.cs +++ b/src/Perpetuum.RequestHandlers/SetRobotTint.cs @@ -1,8 +1,7 @@ -using System.Collections.Generic; -using System.Drawing; using Perpetuum.Data; using Perpetuum.Host.Requests; using Perpetuum.Robots; +using SkiaSharp; namespace Perpetuum.RequestHandlers { @@ -13,7 +12,7 @@ public void HandleRequest(IRequest request) using (var scope = Db.CreateTransaction()) { var robotEid = request.Data.GetOrDefault(k.robotEID); - var tint = request.Data.GetOrDefault(k.tint); + var tint = request.Data.GetOrDefault(k.tint); var robot = Robot.GetOrThrow(robotEid); robot.Tint = tint; diff --git a/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcPlaceSafeSpawnPoint.cs b/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcPlaceSafeSpawnPoint.cs index fd4b3a2c..67924c03 100644 --- a/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcPlaceSafeSpawnPoint.cs +++ b/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcPlaceSafeSpawnPoint.cs @@ -1,6 +1,6 @@ -using System.Drawing; using Perpetuum.Data; using Perpetuum.Host.Requests; +using SkiaSharp; namespace Perpetuum.RequestHandlers.Zone.NpcSafeSpawnPoints { @@ -12,7 +12,7 @@ public override void HandleRequest(IZoneRequest request) { var x = request.Data.GetOrDefault(k.x); var y = request.Data.GetOrDefault(k.y); - AddSafeSpawnPoint(request, new Point(x, y)); + AddSafeSpawnPoint(request, new SKPointI(x, y)); scope.Complete(); } } diff --git a/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcSafeSpawnPointRequestHandler.cs b/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcSafeSpawnPointRequestHandler.cs index be954eb0..484b717c 100644 --- a/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcSafeSpawnPointRequestHandler.cs +++ b/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcSafeSpawnPointRequestHandler.cs @@ -1,9 +1,8 @@ -using System.Collections.Generic; -using System.Drawing; using System.Transactions; using Perpetuum.Data; using Perpetuum.Host.Requests; using Perpetuum.Zones.NpcSystem.SafeSpawnPoints; +using SkiaSharp; namespace Perpetuum.RequestHandlers.Zone.NpcSafeSpawnPoints { @@ -36,7 +35,7 @@ void Sender() } } - protected void AddSafeSpawnPoint(IZoneRequest request, Point location) + protected void AddSafeSpawnPoint(IZoneRequest request, SKPointI location) { var point = new SafeSpawnPoint { Location = location }; request.Zone.SafeSpawnPoints.Add(point); diff --git a/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcSetSafeSpawnPoint.cs b/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcSetSafeSpawnPoint.cs index 1030e747..386fe1fe 100644 --- a/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcSetSafeSpawnPoint.cs +++ b/src/Perpetuum.RequestHandlers/Zone/NpcSafeSpawnPoints/NpcSetSafeSpawnPoint.cs @@ -1,7 +1,7 @@ -using System.Drawing; using Perpetuum.Data; using Perpetuum.Host.Requests; using Perpetuum.Zones.NpcSystem.SafeSpawnPoints; +using SkiaSharp; namespace Perpetuum.RequestHandlers.Zone.NpcSafeSpawnPoints { @@ -18,7 +18,7 @@ public override void HandleRequest(IZoneRequest request) var point = new SafeSpawnPoint { Id = id, - Location = new Point(x, y) + Location = new SKPointI(x, y) }; request.Zone.SafeSpawnPoints.Update(point); diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DisplaySpots.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DisplaySpots.cs index 37cb22cf..851b6f8a 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DisplaySpots.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DisplaySpots.cs @@ -1,11 +1,11 @@ -using System.Drawing; -using Perpetuum.Services.MissionEngine; +using Perpetuum.Services.MissionEngine; +using SkiaSharp; namespace Perpetuum.RequestHandlers.Zone.StatsMapDrawing { public partial class ZoneDrawStatMap { - private Bitmap DisplaySpots() + private SKBitmap DisplaySpots() { var staticObjects = MissionSpot.GetStaticObjectsFromZone(_zone); diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs index 55a1440b..ed3fd365 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs @@ -1,17 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Drawing; -using System.Drawing.Drawing2D; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using System.Data; using Perpetuum.Data; using Perpetuum.Host.Requests; using Perpetuum.Log; using Perpetuum.Services.MissionEngine; using Perpetuum.Services.MissionEngine.MissionStructures; using Perpetuum.Zones; +using SkiaSharp; namespace Perpetuum.RequestHandlers.Zone.StatsMapDrawing { @@ -70,7 +64,7 @@ private void DrawMissionTargetLog(IRequest request) internal class MissionTargetSuccessLogEntry { public DateTime EventTime; - public Point point; + public SKPoint point; public MissionTargetType targetType; public Guid guid; public long locationEid; @@ -82,7 +76,7 @@ public static MissionTargetSuccessLogEntry FromRecord(IDataRecord record) var mtsle = new MissionTargetSuccessLogEntry() { EventTime = record.GetValue("eventtime"), - point = new Point(record.GetValue("x"), record.GetValue("y")), + point = new SKPointI(record.GetValue("x"), record.GetValue("y")), targetType = (MissionTargetType) record.GetValue("targettype"), guid = record.GetValue("guid"), locationEid = record.GetValue("locationeid"), @@ -121,8 +115,10 @@ private void DrawOneCategory(IRequest request,MissionLocation missionLocation, M var category1 = category; - bitmap.WithGraphics(gx => gx.DrawString(category1.ToString(), new Font("Tahoma", 15), new SolidBrush(Color.White), new PointF(20, 40))); - bitmap.WithGraphics(gx => gx.DrawString(littleText, new Font("Tahoma", 15), new SolidBrush(Color.White), new PointF(20, 60))); + var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 15); + var paint = new SKPaint { Color = SKColors.White }; + bitmap.WithCanvas(gx => gx.DrawText(category1.ToString(), 20, 40, font, paint)); + bitmap.WithCanvas(gx => gx.DrawText(littleText, 20, 60, font, paint)); var idString = $"{missionLocation.id:0000}"; @@ -131,35 +127,33 @@ private void DrawOneCategory(IRequest request,MissionLocation missionLocation, M _saveBitmapHelper.SaveBitmap(_zone,bitmap, fname); } - private void DrawEntriesOnBitmap(MissionTargetSuccessLogEntry[] entries, Bitmap background) + private void DrawEntriesOnBitmap(MissionTargetSuccessLogEntry[] entries, SKBitmap background) { var eventSeries = entries.GroupBy(t => t.guid); - var g = Graphics.FromImage(background); - var pen = new Pen(new SolidBrush(Color.FromArgb(25, Color.FromArgb(200, 200, 200))), 1.1f); - - var switchBrush = new SolidBrush(Color.FromArgb(25, _switchColor)); - var submitItemBrush = new SolidBrush(Color.FromArgb(25, _kioskColor)); - var itemSupplyBrush = new SolidBrush(Color.FromArgb(25, _itemSupplyColor)); - var findArtifactBrush = new SolidBrush(Color.FromArgb(50, _findArtifactColor)); - var popNpcBrush = new SolidBrush(Color.FromArgb(50, _popNpcColor)); - var lootBrush = new SolidBrush(Color.FromArgb(50, _lootColor)); - var fetchItemBrush = new SolidBrush(Color.FromArgb(25, _fetchItemColor)); - var killBrush = new SolidBrush(Color.FromArgb(30, _killColor)); - var scanMineralBrush = new SolidBrush(Color.FromArgb(50, _scanMineralColor)); - var drillMineralBrush = new SolidBrush(Color.FromArgb(50, _drillMineralColor)); - var harvestBrush = new SolidBrush(Color.FromArgb(50, _harvestColor)); + var c = new SKCanvas(background); + var pen = new SKPaint { Color = new SKColor(200, 200, 200, 25), Style = SKPaintStyle.Stroke, StrokeWidth = 1.1f, IsAntialias = true }; + + var switchBrush = new SKPaint { Color = new SKColor(_switchColor.Red, _switchColor.Green, _switchColor.Blue, 25), IsAntialias = true }; + var submitItemBrush = new SKPaint { Color = new SKColor(_kioskColor.Red, _kioskColor.Green, _kioskColor.Blue, 25), Style = SKPaintStyle.Fill, IsAntialias = true }; + var itemSupplyBrush = new SKPaint { Color = new SKColor(_itemSupplyColor.Red, _itemSupplyColor.Green, _itemSupplyColor.Blue, 25), Style = SKPaintStyle.Fill, IsAntialias = true }; + var findArtifactBrush = new SKPaint { Color = new SKColor(_findArtifactColor.Red, _findArtifactColor.Green, _findArtifactColor.Blue, 50), Style = SKPaintStyle.Fill, IsAntialias = true }; + var popNpcBrush = new SKPaint { Color = new SKColor(_popNpcColor.Red, _popNpcColor.Green, _popNpcColor.Blue, 50), Style = SKPaintStyle.Fill, IsAntialias = true }; + var lootBrush = new SKPaint { Color = new SKColor(_lootColor.Red, _lootColor.Green, _lootColor.Blue, 50), Style = SKPaintStyle.Stroke, IsAntialias = true }; + var fetchItemBrush = new SKPaint { Color = new SKColor(_fetchItemColor.Red, _fetchItemColor.Green, _fetchItemColor.Blue, 25), Style = SKPaintStyle.Fill, IsAntialias = true }; + var killBrush = new SKPaint { Color = new SKColor(_killColor.Red, _killColor.Green, _killColor.Blue, 30), Style = SKPaintStyle.Fill, IsAntialias = true }; + var scanMineralBrush = new SKPaint { Color = new SKColor(_scanMineralColor.Red, _scanMineralColor.Green, _scanMineralColor.Blue, 50), Style = SKPaintStyle.Fill, IsAntialias = true }; + var drillMineralBrush = new SKPaint { Color = new SKColor(_drillMineralColor.Red, _drillMineralColor.Green, _drillMineralColor.Blue, 50), Style = SKPaintStyle.Fill, IsAntialias = true }; + var harvestBrush = new SKPaint { Color = new SKColor(_harvestColor.Red, _harvestColor.Green, _harvestColor.Blue, 50), Style = SKPaintStyle.Stroke, IsAntialias = true }; var circle = 10.0f; - g.CompositingQuality = CompositingQuality.HighQuality; - g.SmoothingMode = SmoothingMode.AntiAlias; foreach (var series in eventSeries) { var points = series.OrderBy(v => v.EventTime).Select(v => v.point).ToArray(); - g.DrawLines(pen, points); + c.DrawPoints(SKPointMode.Polygon, points, pen); var eventsAtStructures = series.Where(s => ( s.targetType == MissionTargetType.use_switch || @@ -177,66 +171,76 @@ private void DrawEntriesOnBitmap(MissionTargetSuccessLogEntry[] entries, Bitmap foreach (var logEntry in eventsAtStructures) { - Brush p; - Pen pp; + SKPaint paint; switch (logEntry.targetType) { case MissionTargetType.submit_item: - p = submitItemBrush; - g.FillEllipse(p, logEntry.point.X - circle / 2.0f, logEntry.point.Y - circle / 2.0f, circle, circle); + paint = submitItemBrush; + SKRect rect = new(logEntry.point.X - circle / 2.0f, logEntry.point.Y - circle / 2.0f, circle, circle); + c.DrawOval(rect, paint); continue; case MissionTargetType.use_switch: - p = switchBrush; - g.FillEllipse(p, logEntry.point.X - circle / 2.0f, logEntry.point.Y - circle / 2.0f, circle, circle); + paint = switchBrush; + rect = new(logEntry.point.X - circle / 2.0f, logEntry.point.Y - circle / 2.0f, circle, circle); + c.DrawOval(rect, paint); continue; case MissionTargetType.use_itemsupply: - p = itemSupplyBrush; - g.FillEllipse(p, logEntry.point.X - circle / 2.0f, logEntry.point.Y - circle / 2.0f, circle, circle); + paint = itemSupplyBrush; + rect = new(logEntry.point.X - circle / 2.0f, logEntry.point.Y - circle / 2.0f, circle, circle); + c.DrawOval(rect, paint); continue; case MissionTargetType.find_artifact: - p = findArtifactBrush; - g.FillRectangle(p, logEntry.point.X - circle / 2.0f, logEntry.point.Y - circle / 2.0f, circle, circle); + paint = findArtifactBrush; + rect = new(logEntry.point.X - circle / 2.0f, logEntry.point.Y - circle / 2.0f, circle, circle); + c.DrawOval(rect, paint); continue; case MissionTargetType.pop_npc: - p = popNpcBrush; - g.FillRectangle(p, logEntry.point.X - circle / 2.0f, logEntry.point.Y - circle / 2.0f, circle, circle); + paint = popNpcBrush; + rect = new(logEntry.point.X - circle / 2.0f, logEntry.point.Y - circle / 2.0f, circle, circle); + c.DrawOval(rect, paint); continue; case MissionTargetType.loot_item: - pp = new Pen(lootBrush, 3); + paint = lootBrush; const int lootSize = 11; - g.DrawRectangle(pp, logEntry.point.X - lootSize / 2.0f, logEntry.point.Y - lootSize / 2.0f, lootSize, lootSize); + rect = new(logEntry.point.X - lootSize / 2.0f, logEntry.point.Y - lootSize / 2.0f, lootSize, lootSize); + c.DrawOval(rect, paint); continue; case MissionTargetType.fetch_item: - pp = new Pen(fetchItemBrush, 4); + paint = fetchItemBrush; const int fetchSize = 14; - g.DrawRectangle(pp, logEntry.point.X - fetchSize / 2.0f, logEntry.point.Y - fetchSize / 2.0f, fetchSize, fetchSize); + rect = new(logEntry.point.X - fetchSize / 2.0f, logEntry.point.Y - fetchSize / 2.0f, fetchSize, fetchSize); + c.DrawOval(rect, paint); continue; case MissionTargetType.kill_definition: const int tizenKetto = 12; - pp = new Pen(killBrush, 4); - g.DrawRectangle(pp, logEntry.point.X - tizenKetto / 2.0f, logEntry.point.Y - tizenKetto / 2.0f, tizenKetto, tizenKetto); + rect = new(logEntry.point.X - tizenKetto / 2.0f, logEntry.point.Y - tizenKetto / 2.0f, tizenKetto, tizenKetto); + paint = killBrush; + c.DrawRect(rect, paint); continue; case MissionTargetType.scan_mineral: - pp = new Pen(scanMineralBrush, 4); - g.DrawEllipse(pp, logEntry.point.X - tizenKetto / 2.0f, logEntry.point.Y - tizenKetto / 2.0f, tizenKetto, tizenKetto); + paint = scanMineralBrush; + rect = new(logEntry.point.X - tizenKetto / 2.0f, logEntry.point.Y - tizenKetto / 2.0f, tizenKetto, tizenKetto); + c.DrawOval(rect, paint); continue; case MissionTargetType.drill_mineral: - pp = new Pen(drillMineralBrush, 4); - g.DrawEllipse(pp, logEntry.point.X - tizenKetto / 2.0f, logEntry.point.Y - tizenKetto / 2.0f, tizenKetto, tizenKetto); + paint = drillMineralBrush; + rect = new(logEntry.point.X - tizenKetto / 2.0f, logEntry.point.Y - tizenKetto / 2.0f, tizenKetto, tizenKetto); + c.DrawOval(rect, paint); continue; case MissionTargetType.harvest_plant: - pp = new Pen(harvestBrush, 4); - g.DrawEllipse(pp, logEntry.point.X - tizenKetto / 2.0f, logEntry.point.Y - tizenKetto / 2.0f, tizenKetto, tizenKetto); + paint = harvestBrush; + rect = new(logEntry.point.X - tizenKetto / 2.0f, logEntry.point.Y - tizenKetto / 2.0f, tizenKetto, tizenKetto); + c.DrawOval(rect, paint ); continue; default: @@ -254,7 +258,7 @@ private void DrawEntriesOnBitmap(MissionTargetSuccessLogEntry[] entries, Bitmap - private Bitmap DrawAllTargetsOnZone() + private SKBitmap DrawAllTargetsOnZone() { const string query = "SELECT * FROM dbo.missiontargetslog WHERE zoneid=@zoneId"; diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs index cd380e5d..0ba7d622 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs @@ -1,15 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Drawing.Drawing2D; -using System.Linq; -using System.Threading.Tasks; -using Perpetuum.Data; +using Perpetuum.Data; using Perpetuum.Host.Requests; using Perpetuum.Log; using Perpetuum.Services.MissionEngine; using Perpetuum.Zones; using Perpetuum.Zones.Terrains; +using SkiaSharp; namespace Perpetuum.RequestHandlers.Zone.StatsMapDrawing { @@ -143,7 +138,7 @@ private class AccuracyInfo private const int structureToTerminals = 50; private const int randomPointToTerminals = 70; - private Bitmap GenerateMissionSpots(IRequest request) + private SKBitmap GenerateMissionSpots(IRequest request) { //-------- kick brute force fill in @@ -176,26 +171,26 @@ private Bitmap GenerateMissionSpots(IRequest request) return resultBitmap; } - private static readonly Color _passableColor = Color.FromArgb(255,16, 26, 26); - private static readonly Color _fieldTerminalColor = Color.White; - private static readonly Color _switchColor = Color.FromArgb(255,255, 82, 0); - private static readonly Color _kioskColor = Color.FromArgb(255,153, 206, 70); - private static readonly Color _itemSupplyColor = Color.FromArgb(255,48, 198, 249); - private static readonly Color _randomPointColor = Color.FromArgb(255,237, 144, 251); - private static readonly Color _dockingBaseColor = Color.FromArgb(255,21, 68, 29); - private static readonly Color _teleportColor = Color.FromArgb(255,74, 78, 6); - private static readonly Color _sapColor = Color.FromArgb(255,54, 29, 99); - private static readonly Color _islandColor = Color.FromArgb(255,0, 24, 59); - private static readonly Color _findArtifactColor = Color.FromArgb(255,255, 204, 77); - private static readonly Color _popNpcColor = Color.FromArgb(255, 105, 82, 0); - private static readonly Color _lootColor = Color.FromArgb(255, 0, 151, 208); - private static readonly Color _fetchItemColor = Color.FromArgb(255, 12, 137, 119); - private static readonly Color _killColor = Color.FromArgb(255, 152, 15, 15); - private static readonly Color _scanMineralColor = Color.FromArgb(255, 124, 164, 255); - private static readonly Color _drillMineralColor = Color.FromArgb(255, 214, 144, 126); - private static readonly Color _harvestColor = Color.FromArgb(255, 164, 231, 72); - - private Bitmap DrawResultOnBitmap(List spotInfos, Dictionary> staticObjects ) + private static readonly SKColor _passableColor = new(16, 26, 26); + private static readonly SKColor _fieldTerminalColor = SKColors.White; + private static readonly SKColor _switchColor = new(255, 82, 0); + private static readonly SKColor _kioskColor = new(153, 206, 70); + private static readonly SKColor _itemSupplyColor = new(48, 198, 249); + private static readonly SKColor _randomPointColor = new(237, 144, 251); + private static readonly SKColor _dockingBaseColor = new(21, 68, 29); + private static readonly SKColor _teleportColor = new(74, 78, 6); + private static readonly SKColor _sapColor = new(54, 29, 99); + private static readonly SKColor _islandColor = new(0, 24, 59); + private static readonly SKColor _findArtifactColor = new(255, 204, 77); + private static readonly SKColor _popNpcColor = new(105, 82, 0); + private static readonly SKColor _lootColor = new(0, 151, 208); + private static readonly SKColor _fetchItemColor = new(12, 137, 119); + private static readonly SKColor _killColor = new(152, 15, 15); + private static readonly SKColor _scanMineralColor = new(124, 164, 255); + private static readonly SKColor _drillMineralColor = new(214, 144, 126); + private static readonly SKColor _harvestColor = new(164, 231, 72); + + private SKBitmap DrawResultOnBitmap(List spotInfos, Dictionary> staticObjects ) { var b = _zone.CreatePassableBitmap(_passableColor); @@ -227,23 +222,23 @@ private Bitmap DrawResultOnBitmap(List spotInfos, Dictionary spotInfos, Dictionary g.DrawString(fttext, new Font("Tahoma", 15), new SolidBrush(_fieldTerminalColor), new PointF(20, 40))); - b.WithGraphics(g => g.DrawString(swtext, new Font("Tahoma", 15), new SolidBrush(_switchColor), new PointF(20, 60))); - b.WithGraphics(g => g.DrawString(kiotext, new Font("Tahoma", 15), new SolidBrush(_kioskColor), new PointF(20, 80))); - b.WithGraphics(g => g.DrawString(istext, new Font("Tahoma", 15), new SolidBrush(_itemSupplyColor), new PointF(20, 100))); - b.WithGraphics(g => g.DrawString(rptext, new Font("Tahoma", 15), new SolidBrush(_randomPointColor), new PointF(20, 120))); + var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 15); + var fieldTerminalColorPaint = new SKPaint { Color = _fieldTerminalColor }; + var switchPaint = new SKPaint { Color = _switchColor }; + var kioskPaint = new SKPaint { Color = _kioskColor }; + var itemSupplyPaint = new SKPaint { Color = _itemSupplyColor }; + var randomPointPaint = new SKPaint { Color = _randomPointColor }; + + b.WithCanvas(c => c.DrawText(fttext, 20, 40, font, fieldTerminalColorPaint)); + b.WithCanvas(c => c.DrawText(swtext, 20, 60, font, switchPaint)); + b.WithCanvas(c => c.DrawText(kiotext, 20, 80, font, kioskPaint)); + b.WithCanvas(c => c.DrawText(istext, 20, 100, font, itemSupplyPaint)); + b.WithCanvas(c => c.DrawText(rptext, 20, 120, font, randomPointPaint)); return b; } - private void FillEllipseOnPoint(Color color, int radius, Position position, Bitmap bitmap) + private void FillEllipseOnPoint(SKColor color, int radius, Position position, SKBitmap bitmap) { - var gfx = Graphics.FromImage(bitmap); - gfx.CompositingQuality = CompositingQuality.HighQuality; - gfx.SmoothingMode = SmoothingMode.AntiAlias; + var c = new SKCanvas(bitmap); var size = radius * 2; var x = position.intX - radius; var y = position.intY - radius; - gfx.FillEllipse(new SolidBrush(color),x,y,size,size ); + var paint = new SKPaint { Color = color, Style = SKPaintStyle.Fill, IsAntialias = true }; + var rect = new SKRect(x,y,size,size); + c.DrawOval(rect, paint); /* @@ -306,18 +308,17 @@ private void FillEllipseOnPoint(Color color, int radius, Position position, Bitm } - private void DrawEllipseOnPoint(Color color, int radius, Position position, Bitmap bitmap) + private void DrawEllipseOnPoint(SKColor color, int radius, Position position, SKBitmap bitmap) { - var gfx = Graphics.FromImage(bitmap); - gfx.CompositingQuality = CompositingQuality.HighQuality; - gfx.SmoothingMode = SmoothingMode.AntiAlias; + var c = new SKCanvas(bitmap); var size = radius * 2; var x = position.intX - radius; var y = position.intY - radius; - gfx.DrawEllipse( new Pen(color,3), x, y, size, size); - + var paint = new SKPaint { Color = color, Style = SKPaintStyle.Stroke, StrokeWidth = 3, IsAntialias = true }; + var rect = new SKRect(x, y, size, size); + c.DrawOval(rect, paint); } @@ -335,7 +336,7 @@ private void PlaceOneType(List spotInfos, MissionSpotType type, int var currentBorder = accuracyInfo.initialBorder; var foundTotal = 0; - var freePoints = new List(_zone.Configuration.Size.Width * _zone.Configuration.Size.Height); + var freePoints = new List(_zone.Configuration.Size.Width * _zone.Configuration.Size.Height); InitPoints(spotInfos, distanceInfos, staticObjects, freePoints); while (true) @@ -421,7 +422,7 @@ private static void SaveInfoAsync(MissionSpot si) Task.Run(() => { si.Save(); }); } - private void InitPoints(List spotInfos, Dictionary distanceInfos, Dictionary> staticObjects, List freePoints) + private void InitPoints(List spotInfos, Dictionary distanceInfos, Dictionary> staticObjects, List freePoints) { var zoneWidth = _zone.Size.Width; var zoneHeight = _zone.Size.Height; @@ -437,16 +438,16 @@ private void InitPoints(List spotInfos, Dictionary freePoints) + private static void CleanUpOneSpot(Position center, int distance, ref List freePoints) { - var goodKeys = new List(freePoints.Count); + var goodKeys = new List(freePoints.Count); foreach (var point in freePoints) { var pos = point.ToPosition(); @@ -583,9 +584,9 @@ private bool CheckConditionsAroundPosition(Position center, int blockRadius, int private int _counter; - private void MakeASnapshot(MissionSpotType spotType, List freePoints) + private void MakeASnapshot(MissionSpotType spotType, List freePoints) { - var pointsCopy = new List(freePoints); + var pointsCopy = new List(freePoints); _counter++; var fileName = spotType + "_freepoints." + $"{_counter:0000}"; @@ -596,7 +597,7 @@ private void MakeASnapshot(MissionSpotType spotType, List freePoints) foreach (var point in pointsCopy) { - bmp.SetPixel(point.X, point.Y, Color.White); + bmp.SetPixel(point.X, point.Y, SKColors.White); } _saveBitmapHelper.SaveBitmap(_zone,bmp, fileName); diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateRandomPointsOnly.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateRandomPointsOnly.cs index d49d6a00..7e4f9113 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateRandomPointsOnly.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateRandomPointsOnly.cs @@ -1,17 +1,16 @@ -using System.Drawing; -using System.Linq; -using Perpetuum.Data; +using Perpetuum.Data; using Perpetuum.Host.Requests; using Perpetuum.Log; using Perpetuum.Services.MissionEngine; using Perpetuum.Zones; +using SkiaSharp; namespace Perpetuum.RequestHandlers.Zone.StatsMapDrawing { public partial class ZoneDrawStatMap { - private Bitmap GenerateRandomPointsOnly(IRequest request) + private SKBitmap GenerateRandomPointsOnly(IRequest request) { //-------- kick brute force fill in const int randomPointTargetAmount = 2500; diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ValidateMissionObjectLocations.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ValidateMissionObjectLocations.cs index 07573987..facbd435 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ValidateMissionObjectLocations.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ValidateMissionObjectLocations.cs @@ -1,11 +1,10 @@ -using System.Drawing; -using System.Linq; -using Perpetuum.Log; +using Perpetuum.Log; using Perpetuum.Services.MissionEngine; using Perpetuum.Services.MissionEngine.MissionStructures; using Perpetuum.Units.DockingBases; using Perpetuum.Units.FieldTerminals; using Perpetuum.Zones; +using SkiaSharp; namespace Perpetuum.RequestHandlers.Zone.StatsMapDrawing { @@ -13,18 +12,18 @@ public partial class ZoneDrawStatMap { - public Bitmap ValidateMissionObjectLocations() + public SKBitmap ValidateMissionObjectLocations() { var b = _zone.CreatePassableBitmap(_passableColor); - var g = Graphics.FromImage(b); + var c = new SKCanvas(b); var circle = 10f; var randomPointTargets = _missionDataCache.GetAllMissionTargets.Where(t => t.ZoneId == _zone.Id && t.Type == MissionTargetType.rnd_point).ToList(); - var greebrush = new SolidBrush(Color.LawnGreen); - var redBrush = new SolidBrush(Color.OrangeRed); - var yellowBrush = new SolidBrush(Color.Yellow); - var redPen = new Pen(Color.Red, 4); + var greenbrush = new SKPaint { Color = SKColors.LawnGreen, Style = SKPaintStyle.Fill }; + var redBrush = new SKPaint { Color = SKColors.OrangeRed, Style = SKPaintStyle.Fill }; + var yellowBrush = new SKPaint { Color = SKColors.Yellow, Style = SKPaintStyle.Fill }; + var redPen = new SKPaint { Color = SKColors.Red, Style = SKPaintStyle.Stroke, StrokeWidth = 4 }; foreach (var randomPointTarget in randomPointTargets) { @@ -32,11 +31,13 @@ public Bitmap ValidateMissionObjectLocations() if (CheckConditionsAroundPosition(p, randomPointBlockRadius, randomPointIslandRadius, true)) { - g.FillEllipse(greebrush,(float)( randomPointTarget.targetPosition.X - circle ), (float)( randomPointTarget.targetPosition.Y - circle ), circle*2, circle*2); + var rect = new SKRect((float)(randomPointTarget.targetPosition.X - circle), (float)(randomPointTarget.targetPosition.Y - circle), circle * 2, circle * 2); + c.DrawOval(rect, greenbrush); } else { - g.FillEllipse(redBrush, (float)(randomPointTarget.targetPosition.X - circle ), (float)(randomPointTarget.targetPosition.Y - circle ), circle*2, circle*2); + var rect = new SKRect((float)(randomPointTarget.targetPosition.X - circle ), (float)(randomPointTarget.targetPosition.Y - circle ), circle*2, circle*2); + c.DrawOval(rect, redBrush); } } @@ -50,7 +51,8 @@ public Bitmap ValidateMissionObjectLocations() if (strucureTarget == null) { Logger.Error("no target was found for structure:" + structureUnit.Eid + " " + structureUnit.TargetType); - g.FillEllipse(yellowBrush, structureUnit.CurrentPosition.intX - circle, structureUnit.CurrentPosition.intY - circle, circle*2, circle*2); + var rect = new SKRect(structureUnit.CurrentPosition.intX - circle, structureUnit.CurrentPosition.intY - circle, circle*2, circle*2); + c.DrawOval(rect, yellowBrush); continue; } @@ -68,7 +70,8 @@ public Bitmap ValidateMissionObjectLocations() if (location == null) { - g.DrawEllipse(redPen, locationUnit.CurrentPosition.intX - circle, locationUnit.CurrentPosition.intY - circle, circle * 2, circle * 2); + var rect = new SKRect(locationUnit.CurrentPosition.intX - circle, locationUnit.CurrentPosition.intY - circle, circle * 2, circle * 2); + c.DrawOval(rect, redPen); Logger.Error("no location was found for " + locationUnit); continue; } diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs index 8f1b0635..f8d563f0 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs @@ -1,17 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using Perpetuum.Log; +using Perpetuum.Log; using Perpetuum.Services.MissionEngine; using Perpetuum.Zones; +using SkiaSharp; namespace Perpetuum.RequestHandlers.Zone.StatsMapDrawing { public partial class ZoneDrawStatMap { - private Bitmap DrawWorstSpotsMap() + private SKBitmap DrawWorstSpotsMap() { _addtoradius = 0; //init the mf var b = _zone.CreatePassableBitmap(_passableColor, _islandColor); @@ -35,11 +32,15 @@ private Bitmap DrawWorstSpotsMap() WriteReportByType(MissionSpotType.kiosk, spotStats,b); WriteReportByType(MissionSpotType.itemsupply, spotStats,b); - - b.WithGraphics(g => g.DrawString("switch", new Font("Tahoma", 15), new SolidBrush(_switchColor), new PointF(20, 60))); - b.WithGraphics(g => g.DrawString("item submit/kiosk", new Font("Tahoma", 15), new SolidBrush(_kioskColor), new PointF(20, 80))); - b.WithGraphics(g => g.DrawString("item supply", new Font("Tahoma", 15), new SolidBrush(_itemSupplyColor), new PointF(20, 100))); - b.WithGraphics(g => g.DrawString("random point", new Font("Tahoma", 15), new SolidBrush(_randomPointColor), new PointF(20, 120))); + var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 15); + var switchPaint = new SKPaint { Color = _switchColor }; + var kioskPaint = new SKPaint { Color = _kioskColor }; + var itemSupplyPaint = new SKPaint { Color = _itemSupplyColor }; + var randomPointPaint = new SKPaint { Color = _randomPointColor }; + b.WithCanvas(c => c.DrawText("switch", 20, 60, font, switchPaint)); + b.WithCanvas(c => c.DrawText("item submit/kiosk", 20, 80, font, kioskPaint)); + b.WithCanvas(c => c.DrawText("item supply", 20, 100, font, itemSupplyPaint)); + b.WithCanvas(c => c.DrawText("random point", 20, 120, font, randomPointPaint)); return b; @@ -47,7 +48,7 @@ private Bitmap DrawWorstSpotsMap() } private int _addtoradius; - private void WriteReportByType(MissionSpotType missionSpotType, List spotStats, Bitmap bitmap) + private void WriteReportByType(MissionSpotType missionSpotType, List spotStats, SKBitmap bitmap) { var lines = new List(spotStats.Count); var ordered = spotStats.OrderBy(s => s.GetAmountByType(missionSpotType)).ToArray(); @@ -72,7 +73,7 @@ private void WriteReportByType(MissionSpotType missionSpotType, List _zone.CreatePassableBitmap(Color.White)); + RegisterCreator("passable", () => _zone.CreatePassableBitmap(SKColors.White)); RegisterCreator("islandmask", CreateIslandMaskMap); RegisterCreator("controlmap", CreateControlMap); RegisterCreator("TerraformProtected", CreateControlFlagMap(TerrainControlFlags.TerraformProtected)); @@ -74,31 +73,33 @@ public ZoneDrawStatMap(IFileSystem fileSystem, SaveBitmapHelper saveBitmapHelper RegisterCreator(k.groundType, CreateGroundTypeMap); } - private void RegisterCreator(string type, Func bitmapFactory) + private void RegisterCreator(string type, Func bitmapFactory) { _actions[type] = (r) => CreateAndSave(type, () => bitmapFactory(r)); } - private void RegisterCreator(string type, Func bitmapFactory) + private void RegisterCreator(string type, Func bitmapFactory) { _actions[type] = (r) => CreateAndSave(type, bitmapFactory); } - private void CreateAndSave(string postfix, Func bitmapFactory) + private void CreateAndSave(string postfix, Func bitmapFactory) { - Bitmap bmp = bitmapFactory(); + SKBitmap bmp = bitmapFactory(); if (bmp == null) { return; } - bmp.WithGraphics(g => g.DrawString(_zone.Configuration.Name, new Font("Tahoma", 20), Brushes.Red, new PointF(10, 10))); + var paint = new SKPaint { Color = SKColors.Red }; + var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 20); + bmp.WithCanvas(c => c.DrawText(_zone.Configuration.Name, 10, 10, font, paint)); string fileName = "stat_" + postfix; if (_sendtoclient) // send to client. { using MemoryStream ms = new(); - bmp.Save(ms, ImageFormat.Png); + bmp.Encode(ms, SKEncodedImageFormat.Png, 100); string Base64 = Convert.ToBase64String(ms.GetBuffer()); Message.Builder.FromRequest(_request).SetData("name", fileName).SetData("img", Base64).Send(); } @@ -251,17 +252,17 @@ public void HandleRequest(IZoneRequest request) Message.Builder.FromRequest(request).WithData(data).Send(); } - private Bitmap CreateAltitudeBitmap() + private SKBitmap CreateAltitudeBitmap() { return _zone.CreateBitmap().ForEach((bmp, x, y) => { byte altitudeValue = (byte)(_zone.Terrain.Altitude.GetAltitudeAsDouble(x, y) / 2048 * 612).Clamp(0, 255); - Color color = Color.FromArgb(altitudeValue, altitudeValue, altitudeValue); + SKColor color = new(altitudeValue, altitudeValue, altitudeValue); bmp.SetPixel(x, y, color); }); } - private Bitmap CreateSlopeBitmap() + private SKBitmap CreateSlopeBitmap() { const int threshold = 4 * 4; @@ -275,12 +276,12 @@ private Bitmap CreateSlopeBitmap() return; } - int c = 255 - (int)((double)slope / threshold * 255); - bmp.SetPixel(x, y, Color.FromArgb(c, c, c)); + byte c = (byte)(255 - (int)((double)slope / threshold * 255)); + bmp.SetPixel(x, y, new(c, c, c)); }); } - private Bitmap CreateBlockingMap() + private SKBitmap CreateBlockingMap() { return _zone.CreateBitmap().ForEach((bmp, x, y) => { @@ -290,7 +291,7 @@ private Bitmap CreateBlockingMap() return; } - bmp.SetPixel(x, y, Color.White); + bmp.SetPixel(x, y, SKColors.White); }); } @@ -300,15 +301,15 @@ private void GenerateNewFlagsMap() } - private Bitmap CreateNewFlagsMap() + private SKBitmap CreateNewFlagsMap() { return _zone.CreateBitmap().ForEach((bmp, x, y) => { TerrainControlInfo ci = _zone.Terrain.Controls.GetValue(x, y); - int r = 0; - int g = 0; - int b = 0; + byte r = 0; + byte g = 0; + byte b = 0; if (ci.PBSHighway) { @@ -325,51 +326,59 @@ private Bitmap CreateNewFlagsMap() b = 255; } - Color color = Color.FromArgb(255, r, g, b); + SKColor color = new(r, g, b); bmp.SetPixel(x, y, color); }); } - private Bitmap CreatePlayersMap() + private SKBitmap CreatePlayersMap() { - return CreateAltitudeBitmap().WithGraphics(g => + return CreateAltitudeBitmap().WithCanvas(c => { foreach (Accounting.Characters.Character unit in _zone.GetCharacters()) { int size = 12; - Pen pen = Pens.Red; int x = unit.GetPlayerRobotFromZone().CurrentPosition.intX - (size / 2); int y = unit.GetPlayerRobotFromZone().CurrentPosition.intY - (size / 2); - g.DrawEllipse(pen, x, y, size, size); + var pen = new SKPaint { Color = SKColors.Red, Style = SKPaintStyle.Stroke }; + var rect1 = new SKRectI(x, y, size, size); + c.DrawOval(rect1, pen); const int width = 4; - g.DrawEllipse(Pens.BlueViolet, x, y, width, width); - g.DrawString(unit.Nick, new Font("Tahoma", 12), Brushes.Red, x + 10, y + 10); + var pen2 = new SKPaint { Color = SKColors.BlueViolet, Style = SKPaintStyle.Stroke }; + var rect2 = new SKRectI(x, y, width, width); + c.DrawOval(rect2, pen2); + var textPaint = new SKPaint { Color = SKColors.Red }; + var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 12); + c.DrawText(unit.Nick, x + 10, y + 10, font, textPaint); } }); } - private Bitmap CreateNPCMap() + private SKBitmap CreateNPCMap() { - return CreateAltitudeBitmap().WithGraphics(g => + return CreateAltitudeBitmap().WithCanvas(g => { DrawNpcPresencesOnGraphic(g); DrawNpcFlocksOnGraphic(g); }); } - private void DrawNpcPresencesOnGraphic(Graphics graphics) + private void DrawNpcPresencesOnGraphic(SKCanvas canvas) { foreach (RoamingPresence presence in _zone.PresenceManager.GetPresences().OfType()) { - graphics.DrawRectangle(Pens.Blue, presence.Area.X1, presence.Area.Y1, presence.Area.Width, presence.Area.Height); - graphics.DrawString(presence.Configuration.Name, new Font("Tahoma", 8), Brushes.Red, presence.Area.X1, presence.Area.Y1); + var paint = new SKPaint { Color = SKColors.Blue, Style = SKPaintStyle.Stroke }; + canvas.DrawRect(presence.Area.X1, presence.Area.Y1, presence.Area.Width, presence.Area.Height, paint); + var textPaint = new SKPaint { Color = SKColors.Red }; + var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 8); + canvas.DrawText(presence.Configuration.Name, presence.Area.X1, presence.Area.Y1, font, textPaint); } } - private void DrawNpcFlocksOnGraphic(Graphics graphics) + private void DrawNpcFlocksOnGraphic(SKCanvas canvas) { foreach (Zones.NpcSystem.Flocks.Flock? flock in _zone.PresenceManager.GetPresences().OfType().SelectMany(p => p.Flocks)) { @@ -381,18 +390,24 @@ private void DrawNpcFlocksOnGraphic(Graphics graphics) int tyHomeRange = flock.Configuration.SpawnOrigin.intY - flock.HomeRange; int widthHome = flock.HomeRange * 2; - graphics.DrawEllipse(Pens.BlueViolet, txSpawnMax, tySpawnMax, widthSpawnMax, widthSpawnMax); - graphics.DrawEllipse(Pens.Red, txHomeRange, tyHomeRange, widthHome, widthHome); - graphics.DrawString(flock.Configuration.Name, new Font("Tahoma", 10), Brushes.Red, txSpawnMax, tySpawnMax); + var pen1 = new SKPaint { Color = SKColors.BlueViolet, Style = SKPaintStyle.Stroke }; + var rect1 = new SKRectI(txSpawnMax, tySpawnMax, widthSpawnMax, widthSpawnMax); + canvas.DrawOval(rect1, pen1); + var pen2 = new SKPaint { Color = SKColors.Red, Style = SKPaintStyle.Stroke }; + var rect2 = new SKRectI(txHomeRange, tyHomeRange, widthHome, widthHome); + canvas.DrawOval(rect2, pen2); + var textPaint = new SKPaint { Color = SKColors.Red }; + var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 10); + canvas.DrawText(flock.Configuration.Name, txSpawnMax, tySpawnMax, font, textPaint); } } - private Bitmap CreateMissionTargetsMap() + private SKBitmap CreateMissionTargetsMap() { - return CreateAltitudeBitmap().WithGraphics(DrawMissionTargetsOnGraphics); + return CreateAltitudeBitmap().WithCanvas(DrawMissionTargetsOnGraphics); } - private void DrawMissionTargetsOnGraphics(Graphics graphics) + private void DrawMissionTargetsOnGraphics(SKCanvas canvas) { List targets = _missionDataCache.GetAllMissionTargets.Where(t => t.ValidZoneSet && t.ZoneId == _zone.Id).ToList(); @@ -411,14 +426,18 @@ private void DrawMissionTargetsOnGraphics(Graphics graphics) int tx = missionTarget.targetPosition.intX - 2; int ty = missionTarget.targetPosition.intY - 2; const int width = 4; - graphics.DrawEllipse(Pens.BlueViolet, tx, ty, width, width); - graphics.DrawString(targetNames[missionTarget.id], new Font("Tahoma", 8), Brushes.White, tx, ty); + var pen = new SKPaint{ Color = SKColors.BlueViolet, Style = SKPaintStyle.Stroke }; + var rect = new SKRectI(tx, ty, width, width); + canvas.DrawOval(rect, pen); + var textPaint = new SKPaint { Color = SKColors.White }; + var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 8); + canvas.DrawText(targetNames[missionTarget.id], tx, ty, font, textPaint); } } - private Bitmap CreateDecorBlockingMap() + private SKBitmap CreateDecorBlockingMap() { return CreateAltitudeBitmap().ForEach((bmp, x, y) => { @@ -428,12 +447,12 @@ private Bitmap CreateDecorBlockingMap() return; } - Color color = blockingInfo.Height > 0 ? Color.Green : Color.Orange; + SKColor color = blockingInfo.Height > 0 ? SKColors.Green : SKColors.Orange; bmp.SetPixel(x, y, color); }); } - private Bitmap CreateElectroPlantMap() + private SKBitmap CreateElectroPlantMap() { return CreateAltitudeBitmap().ForEach((bmp, x, y) => { @@ -444,12 +463,12 @@ private Bitmap CreateElectroPlantMap() return; } - int r = (255 / 5 * plantInfo.state).Clamp(0, 255); - Color color = Color.FromArgb(255, r, 128, 0); + byte r = (byte)(255 / 5 * plantInfo.state).Clamp(0, 255); + SKColor color = new(r, 128, 0); if (plantInfo.state == 0) { - color = Color.FromArgb(255, 255, 0, 30); + color = new(255, 0, 30); } bmp.SetPixel(x, y, color); @@ -457,7 +476,7 @@ private Bitmap CreateElectroPlantMap() } - private Bitmap CreatePlantMap(PlantType plantType) + private SKBitmap CreatePlantMap(PlantType plantType) { return CreateAltitudeBitmap().ForEach((bmp, x, y) => { @@ -468,12 +487,12 @@ private Bitmap CreatePlantMap(PlantType plantType) return; } - int r = (255 / 5 * plantInfo.state).Clamp(0, 255); - Color color = Color.FromArgb(255, r, 128, 0); + byte r = (byte)(255 / 5 * plantInfo.state).Clamp(0, 255); + SKColor color = new(r, 128, 0); if (plantInfo.state == 0) { - color = Color.FromArgb(255, 255, 0, 30); + color = new(255, 0, 30); } bmp.SetPixel(x, y, color); @@ -481,7 +500,7 @@ private Bitmap CreatePlantMap(PlantType plantType) } - private Bitmap CreatePlantsMap() + private SKBitmap CreatePlantsMap() { return _zone.CreateBitmap().ForEach((bmp, x, y) => { @@ -497,44 +516,45 @@ private Bitmap CreatePlantsMap() return; } - bmp.SetPixel(x, y, Color.White); + bmp.SetPixel(x, y, SKColors.White); }); } - private Bitmap CreateStructuresMap() + private SKBitmap CreateStructuresMap() { - return _zone.CreateBitmap().WithGraphics(g => + return _zone.CreateBitmap().WithCanvas(c => { foreach (Unit unit in _zone.GetStaticUnits()) { int size = 3; - Pen pen = Pens.White; + var pen = new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Stroke }; if (unit.IsCategory(CategoryFlags.cf_outpost)) { - pen = Pens.LightSeaGreen; + pen.Color = SKColors.LightSeaGreen; size = 150; } else if (unit.IsCategory(CategoryFlags.cf_public_docking_base)) { - pen = Pens.Yellow; + pen.Color = SKColors.Yellow; size = 150; } else if (unit.IsCategory(CategoryFlags.cf_teleport_column)) { - pen = Pens.WhiteSmoke; + pen.Color = SKColors.WhiteSmoke; size = 100; } int x = unit.CurrentPosition.intX - (size / 2); int y = unit.CurrentPosition.intY - (size / 2); - g.DrawEllipse(pen, x, y, size, size); + var rect = new SKRectI(x, y, size, size); + c.DrawOval(rect, pen); } }); } - private Bitmap CreateWallMap() + private SKBitmap CreateWallMap() { return CreateAltitudeBitmap().ForEach((bmp, x, y) => { @@ -544,14 +564,14 @@ private Bitmap CreateWallMap() return; } - int r = (255 / 11 * pInfo.state).Clamp(0, 255); + byte r = (byte)(255 / 11 * pInfo.state).Clamp(0, 255); byte g = pInfo.health; - Color pColor = Color.FromArgb(255, r, g, 0); + SKColor pColor = new(r, g, 0); bmp.SetPixel(x, y, pColor); }); } - private Bitmap CreateWallPossibleMap() + private SKBitmap CreateWallPossibleMap() { Outpost[] outposts = _zone.Units.OfType().ToArray(); Teleport[] teleports = _zone.Units.OfType().ToArray(); @@ -587,11 +607,11 @@ private Bitmap CreateWallPossibleMap() return; } - Color pixel = bmp.GetPixel(x, y); + SKColor pixel = bmp.GetPixel(x, y); - byte r = pixel.R; - byte g = pixel.G; - byte b = pixel.B; + byte r = pixel.Red; + byte g = pixel.Green; + byte b = pixel.Blue; if (allowed) { @@ -605,12 +625,12 @@ private Bitmap CreateWallPossibleMap() b = 0; } - bmp.SetPixel(x, y, Color.FromArgb(255, r, g, b)); + bmp.SetPixel(x, y, new(r, g, b)); }); } - private Bitmap CreateWallPlaces() + private SKBitmap CreateWallPlaces() { Outpost[] outposts = _zone.Units.OfType().ToArray(); Teleport[] teleports = _zone.Units.OfType().ToArray(); @@ -640,12 +660,12 @@ private Bitmap CreateWallPlaces() if (allowed) { - bmp.SetPixel(x, y, Color.FromArgb(255, 255, 0, 0)); + bmp.SetPixel(x, y, new(255, 0, 0)); } }); } - private Bitmap CreateIslandMaskMap() + private SKBitmap CreateIslandMaskMap() { return _zone.CreateBitmap().ForEach((bmp, x, y) => { @@ -655,11 +675,11 @@ private Bitmap CreateIslandMaskMap() return; } - bmp.SetPixel(x, y, Color.White); + bmp.SetPixel(x, y, SKColors.White); }); } - private Func CreateControlFlagMap(TerrainControlFlags flag) + private Func CreateControlFlagMap(TerrainControlFlags flag) { return () => { @@ -671,35 +691,35 @@ private Func CreateControlFlagMap(TerrainControlFlags flag) return; } - bmp.SetPixel(x, y, Color.White); + bmp.SetPixel(x, y, SKColors.White); }); }; } - private Bitmap CreateControlMap() + private SKBitmap CreateControlMap() { return _zone.CreateBitmap().ForEach((bmp, x, y) => { TerrainControlInfo control = _zone.Terrain.Controls.GetValue(x, y); - int c = (int)control.Flags; - bmp.SetPixel(x, y, Color.FromArgb(c, c, c)); + byte c = (byte)control.Flags; + bmp.SetPixel(x, y, new(c, c, c)); }); } - private Bitmap CreateGroundTypeMap() + private SKBitmap CreateGroundTypeMap() { int numGroundTypes = Enum.GetNames(typeof(GroundType)).Length; - Color[] colors = new Color[numGroundTypes]; + SKColor[] colors = new SKColor[numGroundTypes]; Random random = new(numGroundTypes); for (int i = 0; i < colors.Length; i++) { - colors[i] = Color.FromArgb(random.Next(255), random.Next(255), random.Next(255)); + colors[i] = new((byte)random.Next(255), (byte)random.Next(255), (byte)random.Next(255)); } return _zone.CreateBitmap().ForEach((bmp, x, y) => { GroundType groundType = _zone.Terrain.Plants.GetValue(x, y).groundType; - Color c = colors[((int)groundType).Clamp(0, numGroundTypes - 1)]; + SKColor c = colors[((int)groundType).Clamp(0, numGroundTypes - 1)]; bmp.SetPixel(x, y, c); }); } @@ -714,9 +734,9 @@ private void CreateMineralBitmaps() } [CanBeNull] - private Bitmap CreateMineralsToNormalizedBitmap(MineralLayer layer) + private SKBitmap CreateMineralsToNormalizedBitmap(MineralLayer layer) { - Bitmap bitmap = _zone.CreatePassableBitmap(_passableColor); + SKBitmap bitmap = _zone.CreatePassableBitmap(_passableColor); foreach (MineralNode node in layer.Nodes) { @@ -729,24 +749,26 @@ private Bitmap CreateMineralsToNormalizedBitmap(MineralLayer layer) double n = (double)node.GetValue(x, y) / maxAmount; if (n > 0.0) { - int c = (int)(n * 255); - bitmap.SetPixel(x, y, Color.FromArgb(c, 0, 0)); + byte c = (byte)(n * 255); + bitmap.SetPixel(x, y, new(c, 0, 0)); } } } } - return bitmap.WithGraphics(g => + return bitmap.WithCanvas(c => { string infoString = $"{layer.Type}"; - g.DrawString(infoString, new Font("Tahoma", 10), Brushes.White, 10, 10); + var paint = new SKPaint { Color = SKColors.White }; + var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 10); + c.DrawText(infoString, 10, 10, font, paint); }); } private void CreateTeleportDecorMaps() { - CreateTeleportDecorMaps(out Bitmap bitmap, out Bitmap circlesBitmap); + CreateTeleportDecorMaps(out SKBitmap bitmap, out SKBitmap circlesBitmap); CreateAndSave("teleportdecor", () => bitmap); CreateAndSave("teleportdecor_circles", () => circlesBitmap); @@ -756,7 +778,7 @@ private void CreateTeleportDecorMaps() /// This function creates the blend map on gamma islands around the teleports /// Finds the farthest decor tile and draws a smooth circle gradient around the teleport /// - private void CreateTeleportDecorMaps(out Bitmap? bitmap, out Bitmap? circlesBitmap) + private void CreateTeleportDecorMaps(out SKBitmap? bitmap, out SKBitmap? circlesBitmap) { bitmap = null; circlesBitmap = null; @@ -768,12 +790,12 @@ private void CreateTeleportDecorMaps(out Bitmap? bitmap, out Bitmap? circlesBitm ITerrain terrain = _zone.Terrain; bitmap = _zone.CreateBitmap(); - Color color = Color.MediumVioletRed; - Font font = new("Tahoma", 8); - Graphics graphics = Graphics.FromImage(bitmap); + SKColor color = SKColors.MediumVioletRed; + SKFont font = new(SKTypeface.FromFamilyName("Tahoma"), 8); + SKCanvas canvas = new(bitmap); circlesBitmap = _zone.CreateBitmap(); - Graphics circlesGraphics = Graphics.FromImage(circlesBitmap); + SKCanvas circlesGraphics = new(circlesBitmap); ushort[] blendData = _zone.Size.CreateArray(); @@ -783,7 +805,7 @@ private void CreateTeleportDecorMaps(out Bitmap? bitmap, out Bitmap? circlesBitm double maximumDistance = 0.0; - Bitmap tmpBmp = bitmap; + SKBitmap tmpBmp = bitmap; area.ForEachXY((x, y) => { if (x < 0 || x >= _zone.Size.Width || y < 0 || y >= _zone.Size.Height) @@ -806,14 +828,17 @@ private void CreateTeleportDecorMaps(out Bitmap? bitmap, out Bitmap? circlesBitm tmpBmp.SetPixel(x, y, color); }); - graphics.DrawString(maximumDistance.ToString(CultureInfo.InvariantCulture), font, Brushes.White, (float)td.CurrentPosition.X, (float)td.CurrentPosition.Y); + var textPaint = new SKPaint { Color = SKColors.White }; + canvas.DrawText(maximumDistance.ToString(CultureInfo.InvariantCulture), (float)td.CurrentPosition.X, (float)td.CurrentPosition.Y, font, textPaint); if (maximumDistance <= 0) { continue; } - circlesGraphics.FillEllipse(Brushes.White, (float)(td.CurrentPosition.intX - maximumDistance), (float)(td.CurrentPosition.intY - maximumDistance), (float)(maximumDistance * 2), (float)(maximumDistance * 2)); + var paint = new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Fill }; + var rect = SKRect.Create((float)(td.CurrentPosition.intX - maximumDistance), (float)(td.CurrentPosition.intY - maximumDistance), (float)(maximumDistance * 2), (float)(maximumDistance * 2)); + circlesGraphics.DrawOval(rect, paint); Area tpArea = Area.FromRadius(td.CurrentPosition, (int)maximumDistance + 200); tpArea.ForEachXY((x, y) => @@ -850,39 +875,39 @@ private void CreateMissionMapByLevels() - private Bitmap DrawMissionByLevels() + private SKBitmap DrawMissionByLevels() { - Bitmap b = CreateAltitudeBitmap(); + SKBitmap b = CreateAltitudeBitmap(); DrawPixels(b); - b.WithGraphics(DrawLayers); + b.WithCanvas(DrawLayers); return b; } - private void DrawLayers(Graphics g) + private void DrawLayers(SKCanvas g) { DrawStringTopLeft(g, "valami cucc rajta"); //... tobbi graphics piszkalo } - private void DrawPixels(Bitmap bitmap) + private void DrawPixels(SKBitmap bitmap) { DrawPassableInGreen(bitmap); //... tobbi bitmap piszkalo } - private void DrawPassableInGreen(Bitmap bmp) + private void DrawPassableInGreen(SKBitmap bmp) { bmp.ForEach((b, x, y) => { - Color blockedColor = Color.FromArgb(255, 0, 0, 0); - Color passableColor = Color.FromArgb(255, 60, 60, 60); + SKColor blockedColor = new(0, 0, 0); + SKColor passableColor = new(60, 60, 60); if (_zone.Terrain.IsPassable(new Position(x, y))) { @@ -896,9 +921,11 @@ private void DrawPassableInGreen(Bitmap bmp) } - private void DrawStringTopLeft(Graphics graphics, string text) + private void DrawStringTopLeft(SKCanvas canvas, string text) { - graphics.DrawString(text, new Font("Tahoma", 20), Brushes.Chocolate, new PointF(50, 100)); + var paint = new SKPaint { Color = SKColors.Chocolate }; + var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 20); + canvas.DrawText(text, 50, 100, font, paint); } private void SendDrawFunctionFinished(IRequest request) diff --git a/src/Perpetuum.RequestHandlers/Zone/ZoneSetLayerWithBitMap.cs b/src/Perpetuum.RequestHandlers/Zone/ZoneSetLayerWithBitMap.cs index 6e5ddec8..430cd89e 100644 --- a/src/Perpetuum.RequestHandlers/Zone/ZoneSetLayerWithBitMap.cs +++ b/src/Perpetuum.RequestHandlers/Zone/ZoneSetLayerWithBitMap.cs @@ -1,8 +1,8 @@ -using System.Drawing; -using Perpetuum.Host.Requests; +using Perpetuum.Host.Requests; using Perpetuum.IO; using Perpetuum.Zones; using Perpetuum.Zones.Terrains; +using SkiaSharp; namespace Perpetuum.RequestHandlers.Zone { @@ -22,12 +22,12 @@ public void HandleRequest(IZoneRequest request) var flagValue = request.Data.GetOrDefault(k.flags); var controlFlag = EnumHelper.GetEnum(flagValue); var path = _fileSystem.CreatePath("bitmaps", zone.CreateTerrainDataFilename(fileName, "png")); - var img = Image.FromFile(path); - using (Bitmap bmp = new Bitmap(img)) + var img = SKImage.FromEncodedData(path); + using (SKBitmap bmp = SKBitmap.FromImage(img)) { zone.Terrain.Controls.UpdateAll((x, y, c) => { - if (bmp.GetPixel(x, y).A == 0) + if (bmp.GetPixel(x, y).GetLuminance() == 0) { return c; } diff --git a/src/Perpetuum.Server/Program.cs b/src/Perpetuum.Server/Program.cs index 84fb80a0..c24f4869 100644 --- a/src/Perpetuum.Server/Program.cs +++ b/src/Perpetuum.Server/Program.cs @@ -37,7 +37,8 @@ static int Main(string[] args) return 3; } - bootstrapper.Init(gameRoot.Value); + // TODO: fix a way to take optional argument to be able to configure the DistributedTransactions from here + bootstrapper.Init(gameRoot.Value, true); if (bootstrapper.TryInitUpnp(out bool upnpSuccess)) { diff --git a/src/Perpetuum/Area.cs b/src/Perpetuum/Area.cs index f1ebd63d..be939d44 100644 --- a/src/Perpetuum/Area.cs +++ b/src/Perpetuum/Area.cs @@ -1,7 +1,5 @@ using Perpetuum.Zones; -using System; -using System.Collections.Generic; -using System.Drawing; +using SkiaSharp; namespace Perpetuum { @@ -50,7 +48,7 @@ public static Area FromRadius(Position position, int radius) return FromRadius(position.intX, position.intY, radius); } - public static Area FromRadius(Point position, int radius) + public static Area FromRadius(SKPointI position, int radius) { return FromRadius(position.X, position.Y, radius); } @@ -95,7 +93,7 @@ public Position CenterPrecise get { return new Position((_x1 + _x2) / 2.0, (_y1 + _y2) / 2.0); } } - public Point Center + public SKPointI Center { get { return new Position((Width >> 1) + _x1, (Height >> 1) + _y1); } } @@ -116,7 +114,7 @@ public int GetOffset(int x, int y) return x + y * Width; } - public bool Contains(Point target) + public bool Contains(SKPointI target) { return Contains(target.X, target.Y); } @@ -152,7 +150,7 @@ public override string ToString() return $"X1 = {X1} Y1 = {Y1} X2 = {X2} Y2 = {Y2} Width = {Width} Height = {Height}"; } - public Area Clamp(Size size) + public Area Clamp(SKSizeI size) { return Clamp(size.Width, size.Height); } @@ -207,11 +205,11 @@ private IEnumerable Slice(int w,int h) } while (y1 < _y2); } - public Point GetRandomPosition() + public SKPointI GetRandomPosition() { var x = FastRandom.NextInt(_x1,_x2); var y = FastRandom.NextInt(_y1,_y2); - return new Point(x, y); + return new SKPointI(x, y); } public Area AddBorder(int border) @@ -230,7 +228,7 @@ public IEnumerable GetPositions() } } - public double Distance(Point p) + public double Distance(SKPointI p) { return Distance(p.X,p.Y); } @@ -240,7 +238,7 @@ public double Distance(int x, int y) return Math.Sqrt(SqrDistance(x, y)); } - public double SqrDistance(Point p) + public double SqrDistance(SKPointI p) { return SqrDistance(p.X,p.Y); } diff --git a/src/Perpetuum/BinaryStream.cs b/src/Perpetuum/BinaryStream.cs index e4dc7192..4c0b633e 100644 --- a/src/Perpetuum/BinaryStream.cs +++ b/src/Perpetuum/BinaryStream.cs @@ -1,7 +1,7 @@ using Perpetuum.Zones; using System.Diagnostics; -using System.Drawing; using System.Text; +using SkiaSharp; namespace Perpetuum { @@ -98,11 +98,11 @@ public void AppendObject(object o) return; } - if (o is Color color) + if (o is SKColor color) { - AppendByte(color.R); - AppendByte(color.G); - AppendByte(color.B); + AppendByte(color.Red); + AppendByte(color.Green); + AppendByte(color.Blue); return; } @@ -168,7 +168,7 @@ public void AppendStream(BinaryStream stream) AppendByteArray(stream.ToArray()); } - public void AppendPoint(Point p) + public void AppendPoint(SKPointI p) { AppendInt(p.X); AppendInt(p.Y); diff --git a/src/Perpetuum/BitmapExtensions.cs b/src/Perpetuum/BitmapExtensions.cs index 7a1ae46c..6b6392f8 100644 --- a/src/Perpetuum/BitmapExtensions.cs +++ b/src/Perpetuum/BitmapExtensions.cs @@ -1,20 +1,21 @@ -using System.Drawing; +using SkiaSharp; namespace Perpetuum { public static class BitmapExtensions { [CanBeNull] - public static Bitmap? WithGraphics(this Bitmap bitmap, Action action) + public static SKBitmap? WithCanvas(this SKBitmap bitmap, Action action) { if (bitmap == null) { return null; } - using (Graphics g = Graphics.FromImage(bitmap)) + using (var surface = SKSurface.Create(new SKImageInfo(bitmap.Width, bitmap.Height))) { - action(g); + var canvas = surface.Canvas; + action(canvas); } return bitmap; @@ -24,7 +25,7 @@ public static class BitmapExtensions /// Runs an action on every pixel of a bitmap /// [CanBeNull] - public static Bitmap? ForEach(this Bitmap bitmap, Action action) + public static SKBitmap? ForEach(this SKBitmap bitmap, Action action) { if (bitmap == null) { diff --git a/src/Perpetuum/Collections/Spatial/Grid.NonGeneric.cs b/src/Perpetuum/Collections/Spatial/Grid.NonGeneric.cs index 6d341261..62e49390 100644 --- a/src/Perpetuum/Collections/Spatial/Grid.NonGeneric.cs +++ b/src/Perpetuum/Collections/Spatial/Grid.NonGeneric.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Collections.Spatial { @@ -20,9 +19,9 @@ public class Grid {GridDistricts.RightLower,new CellCoord(1, 1)} }; - public static Size CalculateGridSize(Size size) + public static SKSizeI CalculateGridSize(SKSizeI size) { - return new Size(size.Width / TilesPerGrid, size.Height / TilesPerGrid); + return new SKSizeI(size.Width / TilesPerGrid, size.Height / TilesPerGrid); } } } \ No newline at end of file diff --git a/src/Perpetuum/Collections/Spatial/Grid.cs b/src/Perpetuum/Collections/Spatial/Grid.cs index 51838d9c..b2b01497 100644 --- a/src/Perpetuum/Collections/Spatial/Grid.cs +++ b/src/Perpetuum/Collections/Spatial/Grid.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Collections.Spatial { @@ -48,8 +46,8 @@ public Grid(int width, int height, int cellsX, int cellsY, Func cel neighbours[cell] = new List(); - Point p = new Point(x, y); - foreach (Point np in p.GetNeighbours()) + SKPointI p = new SKPointI(x, y); + foreach (SKPointI np in p.GetNeighbours()) { if (np.X < 0 || np.X >= numCellsX || np.Y < 0 || np.Y >= numCellsY) { @@ -68,7 +66,7 @@ private int GetCellCoordIndex(int x, int y) } [CanBeNull] - public TCell GetCell(Point p) + public TCell GetCell(SKPointI p) { return GetCell(p.X, p.Y); } diff --git a/src/Perpetuum/Collections/Spatial/QuadTree.cs b/src/Perpetuum/Collections/Spatial/QuadTree.cs index 5c4aa414..7db1eb21 100644 --- a/src/Perpetuum/Collections/Spatial/QuadTree.cs +++ b/src/Perpetuum/Collections/Spatial/QuadTree.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Collections.Spatial { @@ -12,7 +11,7 @@ public QuadTree(Area area) Root = new QuadTreeNode(area); } - public QuadTreeItem Add(Point position, T value) + public QuadTreeItem Add(SKPointI position, T value) { return Add(position.X, position.Y, value); } diff --git a/src/Perpetuum/ColorExtensions.cs b/src/Perpetuum/ColorExtensions.cs new file mode 100644 index 00000000..e5aeddfe --- /dev/null +++ b/src/Perpetuum/ColorExtensions.cs @@ -0,0 +1,17 @@ +using SkiaSharp; + +namespace Perpetuum +{ + public static class ColorExtensions + { + /// + /// Get the luminance/brightness of this SKColor. + /// + /// The color/pixel to evaluate + /// Luminance between 0.0 and 1.0 + public static float GetLuminance(this SKColor color) + { + return (0.299f * color.Red + 0.587f * color.Green + 0.114f * color.Blue) / 255f; + } + } +} \ No newline at end of file diff --git a/src/Perpetuum/Commands.cs b/src/Perpetuum/Commands.cs index f7e02dea..3a2229f6 100644 --- a/src/Perpetuum/Commands.cs +++ b/src/Perpetuum/Commands.cs @@ -1,5 +1,5 @@ -using System.Drawing; using System.Reflection; +using SkiaSharp; namespace Perpetuum { @@ -4825,7 +4825,7 @@ public static Command GetCommandByText(string commandText) Arguments = { new Argument(k.robotEID), - new Argument(k.tint), + new Argument(k.tint), } }; diff --git a/src/Perpetuum/EnumerableExtensions.cs b/src/Perpetuum/EnumerableExtensions.cs index f0d9f83c..a6271ed0 100644 --- a/src/Perpetuum/EnumerableExtensions.cs +++ b/src/Perpetuum/EnumerableExtensions.cs @@ -1,10 +1,7 @@ -using System; using System.Collections; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; -using System.Linq; namespace Perpetuum { diff --git a/src/Perpetuum/GenXY/GenxyConverter.cs b/src/Perpetuum/GenXY/GenxyConverter.cs index a34a570e..70084960 100644 --- a/src/Perpetuum/GenXY/GenxyConverter.cs +++ b/src/Perpetuum/GenXY/GenxyConverter.cs @@ -1,8 +1,8 @@ using Perpetuum.Zones; using System.Collections; using System.Diagnostics; -using System.Drawing; using System.Dynamic; +using SkiaSharp; namespace Perpetuum.GenXY { @@ -33,9 +33,9 @@ static GenxyConverter() RegisterConverter(ConvertDecimalArray); RegisterConverter(ConvertFloat); RegisterConverter(CreateDoubleConverter); - RegisterConverter(ConvertColor); + RegisterConverter(ConvertColor); RegisterConverter(ConvertDateTime); - RegisterConverter(ConvertPoint); + RegisterConverter(ConvertPoint); RegisterConverter(ConvertPosition); RegisterConverter(ConvertPositionArray); RegisterConverter(ConvertArea); @@ -127,16 +127,16 @@ private static void CreateDoubleConverter(GenxyWriter writer, double value) ConvertFloat(writer, (float)value); } - private static void ConvertColor(GenxyWriter writer, Color color) + private static void ConvertColor(GenxyWriter writer, SKColor color) { writer.WriteToken(GenxyToken.Color); - writer.WriteHexInteger(color.R); + writer.WriteHexInteger(color.Red); writer.WriteChar('.'); - writer.WriteHexInteger(color.G); + writer.WriteHexInteger(color.Green); writer.WriteChar('.'); - writer.WriteHexInteger(color.B); + writer.WriteHexInteger(color.Blue); writer.WriteChar('.'); - writer.WriteHexInteger(color.A); + writer.WriteHexInteger(color.Alpha); } private static void ConvertDateTime(GenxyWriter writer, DateTime date) @@ -155,7 +155,7 @@ private static void ConvertDateTime(GenxyWriter writer, DateTime date) writer.WriteInteger(date.Second); } - private static void ConvertPoint(GenxyWriter writer, Point point) + private static void ConvertPoint(GenxyWriter writer, SKPointI point) { writer.WriteToken(GenxyToken.Point); writer.WriteHexInteger(point.X); diff --git a/src/Perpetuum/GenXY/GenxyReader.cs b/src/Perpetuum/GenXY/GenxyReader.cs index f22f85d8..dcab0b96 100644 --- a/src/Perpetuum/GenXY/GenxyReader.cs +++ b/src/Perpetuum/GenXY/GenxyReader.cs @@ -1,8 +1,8 @@ using Perpetuum.Threading; using Perpetuum.Zones; -using System.Drawing; using System.Globalization; using System.Text; +using SkiaSharp; namespace Perpetuum.GenXY { @@ -245,10 +245,10 @@ private DateTime ReadDate() return new DateTime(n[0], n[1], n[2], n[3], n[4], n[5]); } - private Color ReadColor() + private SKColor ReadColor() { int[] n = ReadValueAsArray(ParseInt, '.'); - return Color.FromArgb(n[3], n[0], n[1], n[2]); + return new SKColor((byte)n[0], (byte)n[1], (byte)n[2], (byte)n[3]); } private Area ReadArea() @@ -262,10 +262,10 @@ private Area[] ReadAreaArray() return ReadValueAsArray(ParseArea); } - private Point ReadPoint() + private SKPointI ReadPoint() { int[] n = ReadValueAsArray(ParseInt, '.'); - return new Point(n[0], n[1]); + return new SKPointI(n[0], n[1]); } private Position ReadPosition() diff --git a/src/Perpetuum/Items/Paint.cs b/src/Perpetuum/Items/Paint.cs index 9bfa9dce..34e3a1fe 100644 --- a/src/Perpetuum/Items/Paint.cs +++ b/src/Perpetuum/Items/Paint.cs @@ -1,11 +1,4 @@ -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using Perpetuum.Accounting.Characters; -using Perpetuum.Common.Loggers.Transaction; -using Perpetuum.Containers; -using Perpetuum.Data; -using Perpetuum.EntityFramework; +using Perpetuum.Accounting.Characters; using Perpetuum.Robots; namespace Perpetuum.Items diff --git a/src/Perpetuum/Modules/DrillerModule.cs b/src/Perpetuum/Modules/DrillerModule.cs index c0216cb7..a34a8064 100644 --- a/src/Perpetuum/Modules/DrillerModule.cs +++ b/src/Perpetuum/Modules/DrillerModule.cs @@ -14,8 +14,8 @@ using Perpetuum.Zones.Terrains.Materials; using Perpetuum.Zones.Terrains.Materials.Minerals; using System.Diagnostics; -using System.Drawing; using System.Transactions; +using SkiaSharp; namespace Perpetuum.Modules { @@ -68,7 +68,7 @@ public override void UpdateProperty(AggregateField field) base.UpdateProperty(field); } - public List Extract(MineralLayer layer, Point location, uint amount) + public List Extract(MineralLayer layer, SKPointI location, uint amount) { if (!layer.HasMineral(location)) { diff --git a/src/Perpetuum/Modules/LargeHarvesterModule.cs b/src/Perpetuum/Modules/LargeHarvesterModule.cs index b2fa8fd1..b8617339 100644 --- a/src/Perpetuum/Modules/LargeHarvesterModule.cs +++ b/src/Perpetuum/Modules/LargeHarvesterModule.cs @@ -60,7 +60,7 @@ public override void DoHarvesting(IZone zone) Debug.Assert(ParentRobot != null, "ParentRobot != null"); - Robots.RobotInventory container = ParentRobot.GetContainer(); + Robots.RobotInventory container = ParentRobot.GetContainer(); Debug.Assert(container != null, "container != null"); container.EnlistTransaction(); Player player = ParentRobot is RemoteControlledCreature remoteControlledCreature && diff --git a/src/Perpetuum/PathFinders/AStarFinder.cs b/src/Perpetuum/PathFinders/AStarFinder.cs index 729e0786..a2516f13 100644 --- a/src/Perpetuum/PathFinders/AStarFinder.cs +++ b/src/Perpetuum/PathFinders/AStarFinder.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Threading; -using Perpetuum.Collections; -using Perpetuum.ExportedTypes; -using Perpetuum.Zones; +using Perpetuum.Collections; +using SkiaSharp; namespace Perpetuum.PathFinders { @@ -27,7 +22,7 @@ public AStarLimited(Heuristic heuristic, PathFinderNodePassableHandler passableH /// Start point /// End point /// True if path is found and shorter than MAX_DEPTH - public bool HasPath(Point start, Point end) + public bool HasPath(SKPointI start, SKPointI end) { if (!_passableHandler(end.X, end.Y)) return false; @@ -92,7 +87,7 @@ public AStarFinder(Heuristic heuristic,PathFinderNodePassableHandler passableHan public int Weight { get; set; } - public override Point[] FindPath(Point start, Point end,CancellationToken cancellationToken) + public override SKPointI[] FindPath(SKPointI start, SKPointI end, CancellationToken cancellationToken) { if (!_passableHandler(end.X, end.Y)) return null; @@ -138,9 +133,9 @@ public override Point[] FindPath(Point start, Point end,CancellationToken cancel return null; } - protected Point[] Backtrace(Node node) + protected SKPointI[] Backtrace(Node node) { - var stack = new Stack(); + var stack = new Stack(); while (node != null) { diff --git a/src/Perpetuum/PathFinders/PathFinder.cs b/src/Perpetuum/PathFinders/PathFinder.cs index 888c0009..34d16874 100644 --- a/src/Perpetuum/PathFinders/PathFinder.cs +++ b/src/Perpetuum/PathFinders/PathFinder.cs @@ -1,17 +1,15 @@ using System.Diagnostics; -using System.Drawing; -using System.Threading; -using System.Threading.Tasks; +using SkiaSharp; namespace Perpetuum.PathFinders { public class PathFinderNode { - public Point Location { get; private set; } + public SKPointI Location { get; private set; } public PathFinderNode(int x,int y) { - Location = new Point(x,y); + Location = new SKPointI(x,y); } public override string ToString() @@ -29,7 +27,7 @@ public abstract class PathFinder { public const float SQRT2 = 1.41f; - protected static readonly Point[] EmptyPath = new Point[0]; + protected static readonly SKPointI[] EmptyPath = []; public delegate bool PathFinderNodePassableHandler(int x, int y); @@ -40,18 +38,18 @@ public abstract class PathFinder #endif [CanBeNull] - public Point[] FindPath(Point start, Point end) + public SKPointI[] FindPath(SKPointI start, SKPointI end) { return FindPath(start, end, CancellationToken.None); } - public Task FindPathAsync(Point start, Point end) + public Task FindPathAsync(SKPointI start, SKPointI end) { return Task.Run(() => FindPath(start, end)); } [CanBeNull] - public abstract Point[] FindPath(Point start, Point end, CancellationToken cancellationToken); + public abstract SKPointI[] FindPath(SKPointI start, SKPointI end, CancellationToken cancellationToken); [Conditional("DEBUG")] public void RegisterDebugHandler(PathFinderDebugHandler handler) diff --git a/src/Perpetuum/Perpetuum.csproj b/src/Perpetuum/Perpetuum.csproj index 1f41f0fc..4c69af86 100644 --- a/src/Perpetuum/Perpetuum.csproj +++ b/src/Perpetuum/Perpetuum.csproj @@ -23,7 +23,8 @@ - + + diff --git a/src/Perpetuum/Players/PlayerMoveChecker.cs b/src/Perpetuum/Players/PlayerMoveChecker.cs index cbe8a64a..c6998dbe 100644 --- a/src/Perpetuum/Players/PlayerMoveChecker.cs +++ b/src/Perpetuum/Players/PlayerMoveChecker.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; +using System.Collections.Concurrent; using Perpetuum.Log; using Perpetuum.PathFinders; using Perpetuum.Threading; diff --git a/src/Perpetuum/PointExtensions.cs b/src/Perpetuum/PointExtensions.cs index 13e12bde..48889b05 100644 --- a/src/Perpetuum/PointExtensions.cs +++ b/src/Perpetuum/PointExtensions.cs @@ -1,6 +1,6 @@ using Perpetuum.Zones; -using System.Drawing; using System.Numerics; +using SkiaSharp; namespace Perpetuum { @@ -9,35 +9,35 @@ public static class PointExtensions private static readonly int[,] _neighbours = { { -1, -1 }, { 0, -1 }, { 1, -1 }, { -1, 0 }, { 1, 0 }, { -1, 1 }, { 0, 1 }, { 1, 1 } }; private static readonly int[,] _nonDiagonalNeighbours = { { 0, 1 }, { 1, 0 }, { 0, -1 }, { -1, 0 } }; - public static Position ToPosition(this Point p) + public static Position ToPosition(this SKPointI p) { return new Position(p.X + 0.5, p.Y + 0.5); } - public static Position ToPosition(this PointF p) + public static Position ToPosition(this SKPoint p) { return new Position(p.X, p.Y); } - public static IEnumerable GetNonDiagonalNeighbours(this Point point) + public static IEnumerable GetNonDiagonalNeighbours(this SKPointI point) { for (int i = 0; i < 4; i++) { int nx = point.X + _nonDiagonalNeighbours[i, 0]; int ny = point.Y + _nonDiagonalNeighbours[i, 1]; - yield return new Point(nx, ny); + yield return new SKPointI(nx, ny); } } - public static IEnumerable GetNeighbours(this Point point) + public static IEnumerable GetNeighbours(this SKPointI point) { for (int i = 0; i < 8; i++) { int nx = point.X + _neighbours[i, 0]; int ny = point.Y + _neighbours[i, 1]; - yield return new Point(nx, ny); + yield return new SKPointI(nx, ny); } } @@ -53,7 +53,7 @@ public static IEnumerable GetNeighbours(this Vector2 v) } - public static IEnumerable GetNeighbours(this Point point, int size) + public static IEnumerable GetNeighbours(this SKPointI point, int size) { for (int y = -size; y <= size; y++) { @@ -62,7 +62,7 @@ public static IEnumerable GetNeighbours(this Point point, int size) int nx = point.X + x; int ny = point.Y + y; - yield return new Point(nx, ny); + yield return new SKPointI(nx, ny); } } } @@ -81,12 +81,12 @@ public static IEnumerable GetNeighbours(this Vector2 v, int size) } } - public static Point GetNearestPoint(this Point point, IEnumerable points) + public static SKPointI GetNearestPoint(this SKPointI point, IEnumerable points) { - Point nearestPoint = Point.Empty; + SKPointI nearestPoint = SKPointI.Empty; int nearestDistSq = int.MaxValue; - foreach (Point p in points) + foreach (SKPointI p in points) { int distSqr = SqrDistance(point, p); if (distSqr >= nearestDistSq) @@ -101,22 +101,22 @@ public static Point GetNearestPoint(this Point point, IEnumerable points) return nearestPoint; } - public static bool IsInRange(this Point p1, Point p2, double range) + public static bool IsInRange(this SKPointI p1, SKPointI p2, double range) { return p1.SqrDistance(p2) <= range * range; } - public static double Distance(this Point p1, Point p2) + public static double Distance(this SKPointI p1, SKPointI p2) { return Math.Sqrt(SqrDistance(p1, p2)); } - public static int SqrDistance(this Point p1, Point p2) + public static int SqrDistance(this SKPointI p1, SKPointI p2) { return SqrDistance(p1, p2.X, p2.Y); } - public static int SqrDistance(this Point p1, int x, int y) + public static int SqrDistance(this SKPointI p1, int x, int y) { int dx = p1.X - x; int dy = p1.Y - y; @@ -124,7 +124,7 @@ public static int SqrDistance(this Point p1, int x, int y) } [UsedImplicitly] - public static double DirectionTo(this Point from, Point to) + public static double DirectionTo(this SKPointI from, SKPointI to) { int dx = to.X - from.X; int dy = to.Y - from.Y; @@ -157,29 +157,29 @@ public static double DirectionTo(this Point from, Point to) private const double PI2 = Math.PI * 2; - public static Point OffsetInDirection(this Point p, double direction, double distance) + public static SKPointI OffsetInDirection(this SKPointI p, double direction, double distance) { double angleRadians = direction * PI2; double deltaX = Math.Sin(angleRadians) * distance; double deltaY = Math.Cos(angleRadians) * distance; - return new Point((int)(p.X + deltaX), (int)(p.Y - deltaY)); + return new SKPointI((int)(p.X + deltaX), (int)(p.Y - deltaY)); } - public static IEnumerable FloodFill(this Point p, Func? validator = null) + public static IEnumerable FloodFill(this SKPointI p, Func? validator = null) { - Queue q = new(); + Queue q = new(); q.Enqueue(p); - HashSet closed = new() + HashSet closed = new() { p }; - while (q.TryDequeue(out Point current)) + while (q.TryDequeue(out SKPointI current)) { yield return current; - foreach (Point np in current.GetNeighbours()) + foreach (SKPointI np in current.GetNeighbours()) { if (closed.Contains(np)) { @@ -198,7 +198,7 @@ public static IEnumerable FloodFill(this Point p, Func? vali } } - public static Vector2 ToVector2(this Point p) + public static Vector2 ToVector2(this SKPointI p) { return new Vector2(p.X, p.Y); } diff --git a/src/Perpetuum/Robots/Robot.Properties.cs b/src/Perpetuum/Robots/Robot.Properties.cs index 8a2d491f..9410f934 100644 --- a/src/Perpetuum/Robots/Robot.Properties.cs +++ b/src/Perpetuum/Robots/Robot.Properties.cs @@ -2,14 +2,14 @@ using Perpetuum.Items; using Perpetuum.Modules; using Perpetuum.Units; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Robots { public partial class Robot { private UnitOptionalProperty decay; - private UnitOptionalProperty tint; + private UnitOptionalProperty tint; private ItemProperty powerGridMax; private ItemProperty powerGrid; @@ -30,7 +30,7 @@ private void InitProperties() }; OptionalProperties.Add(decay); - tint = new UnitOptionalProperty(this, UnitDataType.Tint, k.tint, () => ED.Config.Tint); + tint = new UnitOptionalProperty(this, UnitDataType.Tint, k.tint, () => ED.Config.Tint); OptionalProperties.Add(tint); powerGridMax = new UnitProperty(this, AggregateField.powergrid_max, AggregateField.powergrid_max_modifier); @@ -85,7 +85,7 @@ public int Decay set => decay.Value = value & 255; } - public Color Tint + public SKColor Tint { get => tint.Value; set => tint.Value = value; @@ -209,7 +209,7 @@ protected override double CalculateValue() protected virtual double CamouflageBonus() { // Average value of color components. - double average = (Tint.R + Tint.G + Tint.B) / 3.0; + double average = (Tint.Red + Tint.Green + Tint.Blue) / 3.0; if (Zone == null) { return 0; @@ -218,11 +218,11 @@ protected virtual double CamouflageBonus() var oneColor = Zone.Configuration.RaceId switch { // Pelistal - 1 => Tint.G, + 1 => Tint.Green, // Nuimqol - 2 => Tint.B, + 2 => Tint.Blue, // Thelodica - 3 => Tint.R, + 3 => Tint.Red, // Default for 0.00rF . _ => average, }; diff --git a/src/Perpetuum/Services/MissionEngine/MissionSpotObjects.cs b/src/Perpetuum/Services/MissionEngine/MissionSpotObjects.cs index 735dcc4e..2757edf2 100644 --- a/src/Perpetuum/Services/MissionEngine/MissionSpotObjects.cs +++ b/src/Perpetuum/Services/MissionEngine/MissionSpotObjects.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; -using System.Data; -using System.Drawing; -using System.Linq; +using System.Data; using Perpetuum.Data; using Perpetuum.ExportedTypes; using Perpetuum.Services.MissionEngine.MissionDataCacheObjects; @@ -121,7 +118,7 @@ public MissionSpotStat CountSelectableSpots(List allSpotsOnZone) private int CountSelectableByType(MissionSpotType missionSpotType, List spots) { return spots.Count(s => s.type == missionSpotType && - position.ToPoint().ToPosition().TotalDistance2D((Point) s.position.ToPoint()) > 0.5 && + position.ToPoint().ToPosition().TotalDistance2D(s.position.ToPoint()) > 0.5 && position.IsInRangeOf2D(s.position, s.findRadius) && _missionDataCache.IsTargetSelectionValid(Zone, position, s.position)); } diff --git a/src/Perpetuum/Services/MissionEngine/MissionTargets/ZoneMissionTargetObjects.cs b/src/Perpetuum/Services/MissionEngine/MissionTargets/ZoneMissionTargetObjects.cs index 87e49215..065c1338 100644 --- a/src/Perpetuum/Services/MissionEngine/MissionTargets/ZoneMissionTargetObjects.cs +++ b/src/Perpetuum/Services/MissionEngine/MissionTargets/ZoneMissionTargetObjects.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using System.Threading.Tasks; -using Perpetuum.EntityFramework; +using Perpetuum.EntityFramework; using Perpetuum.ExportedTypes; using Perpetuum.Items; using Perpetuum.Log; @@ -16,17 +11,18 @@ using Perpetuum.Zones.NpcSystem.Flocks; using Perpetuum.Zones.NpcSystem.Presences; using Perpetuum.Zones.Scanning; +using SkiaSharp; namespace Perpetuum.Services.MissionEngine.MissionTargets { public class LootMissionEventInfo : MissionEventInfo { public Item LootedItem { get; private set; } - public Point LootedPosition { get; private set; } + public SKPointI LootedPosition { get; private set; } public Guid MissionGuid { get; private set; } public int DisplayOrder { get; private set; } - public LootMissionEventInfo(Player player, Item lootedItem, Point lootedPosition, Guid missionGuid, int displayOrder) : base(player) + public LootMissionEventInfo(Player player, Item lootedItem, SKPointI lootedPosition, Guid missionGuid, int displayOrder) : base(player) { LootedItem = lootedItem; LootedPosition = lootedPosition; @@ -108,9 +104,9 @@ protected override Dictionary ToDictionary() public class ReachPositionEventInfo : MissionEventInfo { - public Point ReachedPoint { get; private set; } + public SKPointI ReachedPoint { get; private set; } - public ReachPositionEventInfo(Player player, Point reachedPoint) : base(player) + public ReachPositionEventInfo(Player player, SKPointI reachedPoint) : base(player) { ReachedPoint = reachedPoint; } @@ -154,9 +150,9 @@ protected override void OnHandleMissionEvent(ReachPositionEventInfo e) public class PopNpcEventInfo : MissionEventInfo { - public Point PoppedAtPoint { get; private set; } + public SKPointI PoppedAtPoint { get; private set; } - public PopNpcEventInfo(Player player, Point poppedAtpoint) : base(player) + public PopNpcEventInfo(Player player, SKPointI poppedAtpoint) : base(player) { PoppedAtPoint = poppedAtpoint; } @@ -248,9 +244,9 @@ protected override void OnTargetComplete() public class LockUnitEventInfo : MissionEventInfo { public Npc LockedNpc { get; private set; } - public Point LockedPosition { get; private set; } + public SKPointI LockedPosition { get; private set; } - public LockUnitEventInfo(Player player, Npc lockedUnit, Point lockedPosition) : base(player) + public LockUnitEventInfo(Player player, Npc lockedUnit, SKPointI lockedPosition) : base(player) { LockedNpc = lockedUnit; LockedPosition = lockedPosition; @@ -333,10 +329,10 @@ protected override Dictionary ToDictionary() public class KillEventInfo : MissionEventInfo { - public Point KillPoint { get; private set; } + public SKPointI KillPoint { get; private set; } public Npc KilledNpc { get; private set; } - public KillEventInfo(Player player, Npc killedNpc, Point killPoint) : base(player) + public KillEventInfo(Player player, Npc killedNpc, SKPointI killPoint) : base(player) { KillPoint = killPoint; KilledNpc = killedNpc; @@ -426,9 +422,9 @@ public class ScanMaterialEventInfo : MissionEventInfo { public int ScannedDefinition { get; private set; } public MaterialProbeType ScanProbeType { get; private set; } - public Point ScanPoint { get; private set; } + public SKPointI ScanPoint { get; private set; } - public ScanMaterialEventInfo(Player player, int scannedDefinition, MaterialProbeType probeType, Point scanPoint) : base(player) + public ScanMaterialEventInfo(Player player, int scannedDefinition, MaterialProbeType probeType, SKPointI scanPoint) : base(player) { ScannedDefinition = scannedDefinition; ScanProbeType = probeType; @@ -490,9 +486,9 @@ protected override void OnTargetComplete() public class ScanUnitEventInfo : MissionEventInfo { public Npc ScannedNpc { get; private set; } - public Point ScannedPoint { get; private set; } + public SKPointI ScannedPoint { get; private set; } - public ScanUnitEventInfo(Player player, Npc scannedNpc, Point scannedPoint) : base(player) + public ScanUnitEventInfo(Player player, Npc scannedNpc, SKPointI scannedPoint) : base(player) { ScannedNpc = scannedNpc; ScannedPoint = scannedPoint; @@ -566,9 +562,9 @@ protected override Dictionary ToDictionary() public class ScanContainerEventInfo : MissionEventInfo { public Npc ScannedNpc { get; private set; } - public Point ScanPoint { get; private set; } + public SKPointI ScanPoint { get; private set; } - public ScanContainerEventInfo(Player player, Npc scannedNpc, Point scanPoint) : base(player) + public ScanContainerEventInfo(Player player, Npc scannedNpc, SKPointI scanPoint) : base(player) { ScannedNpc = scannedNpc; ScanPoint = scanPoint; @@ -643,9 +639,9 @@ public class HarvestPlantEventInfo : MissionEventInfo { public int HarvestedDefinition { get;private set; } public int HarvestedQuantity { get; private set; } - public Point HarvestedPoint { get; private set; } + public SKPointI HarvestedPoint { get; private set; } - public HarvestPlantEventInfo(Player player, int harvestedDefinition, int harvestedQuantity, Point harvestedPoint):base(player) + public HarvestPlantEventInfo(Player player, int harvestedDefinition, int harvestedQuantity, SKPointI harvestedPoint):base(player) { HarvestedDefinition = harvestedDefinition; HarvestedQuantity = harvestedQuantity; @@ -727,9 +723,9 @@ public class DrillMineralEventInfo : MissionEventInfo { public int DrilledDefinition { get; private set; } public int DrilledQuantity { get; private set; } - public Point DrillPoint { get; private set; } + public SKPointI DrillPoint { get; private set; } - public DrillMineralEventInfo(Player player, int drilledDefinition, int drilledQuantity, Point drillPoint) : base(player) + public DrillMineralEventInfo(Player player, int drilledDefinition, int drilledQuantity, SKPointI drillPoint) : base(player) { DrilledDefinition = drilledDefinition; DrilledQuantity = drilledQuantity; @@ -814,9 +810,9 @@ public class SubmitItemEventInfo : MissionEventInfo { public Item SubmittedItem { get; private set; } public MissionStructure SubmitMissionStructure { get; private set; } - public Point SubmitPoint { get; private set; } + public SKPointI SubmitPoint { get; private set; } - public SubmitItemEventInfo(Player player, Item submittedItem, MissionStructure submitMissionStructure, Point submitPoint) : base(player) + public SubmitItemEventInfo(Player player, Item submittedItem, MissionStructure submitMissionStructure, SKPointI submitPoint) : base(player) { SubmittedItem = submittedItem; SubmitMissionStructure = submitMissionStructure; @@ -910,9 +906,9 @@ protected override Dictionary ToDictionary() public class SwitchEventInfo : MissionEventInfo { public MissionStructure SwitchMissionStructure { get; private set; } - public Point SwitchPosition { get; private set; } + public SKPointI SwitchPosition { get; private set; } - public SwitchEventInfo(Player player, MissionStructure switchMissionStructure, Point switchPosition) : base(player) + public SwitchEventInfo(Player player, MissionStructure switchMissionStructure, SKPointI switchPosition) : base(player) { SwitchMissionStructure = switchMissionStructure; SwitchPosition = switchPosition; @@ -955,9 +951,9 @@ public class ItemSupplyEventInfo : MissionEventInfo { public Item SuppliedItem { get; private set; } public MissionStructure ItemSupplyStructure { get; private set; } - public Point SupplyPoint { get; private set; } + public SKPointI SupplyPoint { get; private set; } - public ItemSupplyEventInfo(Player player, Item suppliedItem, MissionStructure itemSupplyStructure, Point supplyPoint) : base(player) + public ItemSupplyEventInfo(Player player, Item suppliedItem, MissionStructure itemSupplyStructure, SKPointI supplyPoint) : base(player) { SuppliedItem = suppliedItem; ItemSupplyStructure = itemSupplyStructure; @@ -1032,9 +1028,9 @@ public int GetCurrentProgress() public class FindArtifactEventInfo : MissionEventInfo { public ArtifactType FoundArtifactType { get; private set; } - public Point ArtifactPoint { get; private set; } + public SKPointI ArtifactPoint { get; private set; } - public FindArtifactEventInfo(Player player, ArtifactType foundArtifactType, Point artifactPoint) : base(player) + public FindArtifactEventInfo(Player player, ArtifactType foundArtifactType, SKPointI artifactPoint) : base(player) { FoundArtifactType = foundArtifactType; ArtifactPoint = artifactPoint; @@ -1124,9 +1120,9 @@ protected override void OnTargetComplete() public class SummonEggEventInfo : MissionEventInfo { public int SummonedEggDefinition { get; private set; } - public Point SummonedPoint { get; private set; } + public SKPointI SummonedPoint { get; private set; } - public SummonEggEventInfo(Player player, int summonedEggDefinition, Point summonedPoint) : base(player) + public SummonEggEventInfo(Player player, int summonedEggDefinition, SKPointI summonedPoint) : base(player) { SummonedEggDefinition = summonedEggDefinition; SummonedPoint = summonedPoint; diff --git a/src/Perpetuum/Services/Relics/RelicManagers/AbstractRelicManager.cs b/src/Perpetuum/Services/Relics/RelicManagers/AbstractRelicManager.cs index 922a83d0..3d268127 100644 --- a/src/Perpetuum/Services/Relics/RelicManagers/AbstractRelicManager.cs +++ b/src/Perpetuum/Services/Relics/RelicManagers/AbstractRelicManager.cs @@ -1,11 +1,7 @@ using Perpetuum.Zones; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Drawing; using Perpetuum.Log; -using System.Threading; using Perpetuum.Threading; +using SkiaSharp; namespace Perpetuum.Services.Relics { @@ -112,7 +108,7 @@ protected virtual List> DoGetRelicListDictionary() //Abstract methods for extension of behaviour' protected abstract void RefreshBeam(IRelic relic); - protected abstract Point FindRelicPosition(RelicInfo info); + protected abstract SKPointI FindRelicPosition(RelicInfo info); protected abstract RelicInfo GetNextRelicType(); @@ -152,7 +148,7 @@ private void SpawnRelic() } attempts = 0; - Point pt = FindRelicPosition(info); + SKPointI pt = FindRelicPosition(info); while (IsSpawnTooClose(pt) || !IsValidPos(pt)) { pt = FindRelicPosition(info); @@ -166,7 +162,7 @@ private void SpawnRelic() AddRelicToZone(info, pt.ToPosition()); } - private bool IsValidPos(Point pt) + private bool IsValidPos(SKPointI pt) { return Zone.IsWalkable(pt); } @@ -183,7 +179,7 @@ private void AddRelicToZone(RelicInfo info, Position position) } } - private bool IsSpawnTooClose(Point point) + private bool IsSpawnTooClose(SKPointI point) { using (Lock.Read(THREAD_TIMEOUT)) { diff --git a/src/Perpetuum/Services/Relics/RelicManagers/OutpostRelicManager.cs b/src/Perpetuum/Services/Relics/RelicManagers/OutpostRelicManager.cs index 2fd17e9f..89c8135b 100644 --- a/src/Perpetuum/Services/Relics/RelicManagers/OutpostRelicManager.cs +++ b/src/Perpetuum/Services/Relics/RelicManagers/OutpostRelicManager.cs @@ -1,12 +1,9 @@ using Perpetuum.Zones; -using System; -using System.Collections.Generic; -using System.Drawing; using Perpetuum.ExportedTypes; using Perpetuum.Zones.Beams; using Perpetuum.Zones.Intrusion; using Perpetuum.Zones.Finders.PositionFinders; -using System.Threading; +using SkiaSharp; namespace Perpetuum.Services.Relics { @@ -75,7 +72,7 @@ protected override RelicInfo GetNextRelicType() return _sapRelicInfo; } - protected override Point FindRelicPosition(RelicInfo info) + protected override SKPointI FindRelicPosition(RelicInfo info) { for(int i = 0; i < 10; i++) { @@ -84,13 +81,13 @@ protected override Point FindRelicPosition(RelicInfo info) var posFinder = new ClosestWalkablePositionFinder(_zone, randomPos); posFinder.Find(out Position p); - var result = _zone.FindWalkableArea(p, _zone.Size.ToArea(), SPAWN_AREA_REQUIRED_SIZE); + var result = _zone.FindWalkableArea(p, new Area(0, 0, _zone.Size.Width, _zone.Size.Height), SPAWN_AREA_REQUIRED_SIZE); if(result != null) { return p; } } - return Point.Empty; + return SKPointI.Empty; } protected override void RefreshBeam(IRelic relic) diff --git a/src/Perpetuum/Services/Relics/RelicManagers/ZoneRelicManager.cs b/src/Perpetuum/Services/Relics/RelicManagers/ZoneRelicManager.cs index 71112f20..dc7cbf4f 100644 --- a/src/Perpetuum/Services/Relics/RelicManagers/ZoneRelicManager.cs +++ b/src/Perpetuum/Services/Relics/RelicManagers/ZoneRelicManager.cs @@ -3,7 +3,7 @@ using Perpetuum.Zones; using Perpetuum.Zones.Beams; using Perpetuum.Zones.Intrusion; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Services.Relics.RelicManagers { @@ -127,7 +127,7 @@ protected override RelicInfo GetNextRelicType() return info; } - protected override Point FindRelicPosition(RelicInfo info) + protected override SKPointI FindRelicPosition(RelicInfo info) { if (info.HasStaticPosistion) //If the relic spawn info has a valid static position defined - use that { diff --git a/src/Perpetuum/Services/RiftSystem/RiftManager.cs b/src/Perpetuum/Services/RiftSystem/RiftManager.cs index fa2411e8..dc90a5a8 100644 --- a/src/Perpetuum/Services/RiftSystem/RiftManager.cs +++ b/src/Perpetuum/Services/RiftSystem/RiftManager.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Threading; using Perpetuum.EntityFramework; using Perpetuum.ExportedTypes; using Perpetuum.Log; @@ -9,6 +5,7 @@ using Perpetuum.Units; using Perpetuum.Zones; using Perpetuum.Zones.Terrains; +using SkiaSharp; namespace Perpetuum.Services.RiftSystem { @@ -22,12 +19,12 @@ protected RiftSpawnPositionFinder(IZone zone) _zone = zone; } - public Point FindSpawnPosition() + public SKPointI FindSpawnPosition() { return FindSpawnPosition(_zone); } - protected abstract Point FindSpawnPosition(IZone zone); + protected abstract SKPointI FindSpawnPosition(IZone zone); } public class PveRiftSpawnPositionFinder : RiftSpawnPositionFinder @@ -36,7 +33,7 @@ public PveRiftSpawnPositionFinder(IZone zone) : base(zone) { } - protected override Point FindSpawnPosition(IZone zone) + protected override SKPointI FindSpawnPosition(IZone zone) { return zone.GetRandomPassablePosition(); } @@ -48,9 +45,9 @@ public PvpRiftSpawnPositionFinder(IZone zone) : base(zone) { } - protected override Point FindSpawnPosition(IZone zone) + protected override SKPointI FindSpawnPosition(IZone zone) { - var p = zone.FindWalkableArea(zone.Size.ToArea(), 20); + var p = zone.FindWalkableArea(new Area(0, 0, zone.Size.Width, zone.Size.Height), 20); return p.RandomElement(); } } diff --git a/src/Perpetuum/SizeExtensions.cs b/src/Perpetuum/SizeExtensions.cs index e80160a1..73f2bfa7 100644 --- a/src/Perpetuum/SizeExtensions.cs +++ b/src/Perpetuum/SizeExtensions.cs @@ -1,31 +1,31 @@ using Perpetuum.Zones; -using System.Drawing; +using SkiaSharp; namespace Perpetuum { public static class SizeExtensions { - public static bool Contains(this Size size, Point p) + public static bool Contains(this SKSizeI size, SKPointI p) { return Contains(size, p.X, p.Y); } - public static bool Contains(this Size size, int x, int y) + public static bool Contains(this SKSizeI size, int x, int y) { return x >= 0 && x < size.Width && y >= 0 && y < size.Height; } - public static Point GetCenter(this Size size) + public static SKPointI GetCenter(this SKSizeI size) { - return new Point(size.Width / 2, size.Height / 2); + return new SKPointI(size.Width / 2, size.Height / 2); } - public static Area ToArea(this Size size) + public static Area ToArea(this SKSizeI size) { return Area.FromRectangle(0, 0, size.Width, size.Height); } - public static Position GetRandomPosition(this Size size, int margin) + public static Position GetRandomPosition(this SKSizeI size, int margin) { int minX = 0 + margin; int maxX = size.Width - margin; @@ -37,25 +37,25 @@ public static Position GetRandomPosition(this Size size, int margin) } [System.Diagnostics.Contracts.Pure] - public static int Ground(this Size size) + public static int Ground(this SKSizeI size) { return size.Width * size.Height; } [System.Diagnostics.Contracts.Pure] - public static T[] CreateArray(this Size size) + public static T[] CreateArray(this SKSizeI size) { return new T[size.Width * size.Height]; } [System.Diagnostics.Contracts.Pure] - public static T[,] Create2DArray(this Size size) + public static T[,] Create2DArray(this SKSizeI size) { return new T[size.Width, size.Height]; } [System.Diagnostics.Contracts.Pure] - public static double Diagonal(this Size size) + public static double Diagonal(this SKSizeI size) { return Math.Sqrt((size.Width * size.Width) + (size.Height * size.Height)); } diff --git a/src/Perpetuum/StateMachines/IState.cs b/src/Perpetuum/StateMachines/IState.cs index 0b3e9348..ac09170f 100644 --- a/src/Perpetuum/StateMachines/IState.cs +++ b/src/Perpetuum/StateMachines/IState.cs @@ -1,5 +1,3 @@ -using System; - namespace Perpetuum.StateMachines { public interface IState diff --git a/src/Perpetuum/Zones/Artifacts/Artifact.cs b/src/Perpetuum/Zones/Artifacts/Artifact.cs index 64aae74f..d6a8a875 100644 --- a/src/Perpetuum/Zones/Artifacts/Artifact.cs +++ b/src/Perpetuum/Zones/Artifacts/Artifact.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using Perpetuum.Accounting.Characters; +using Perpetuum.Accounting.Characters; namespace Perpetuum.Zones.Artifacts { diff --git a/src/Perpetuum/Zones/Artifacts/Generators/PersistentArtifactGenerator.cs b/src/Perpetuum/Zones/Artifacts/Generators/PersistentArtifactGenerator.cs index ad5f1ee0..82e8f10d 100644 --- a/src/Perpetuum/Zones/Artifacts/Generators/PersistentArtifactGenerator.cs +++ b/src/Perpetuum/Zones/Artifacts/Generators/PersistentArtifactGenerator.cs @@ -1,11 +1,10 @@ -using System.Drawing; -using System.Linq; using Perpetuum.Data; using Perpetuum.ExportedTypes; using Perpetuum.Log; using Perpetuum.Players; using Perpetuum.Zones.Artifacts.Repositories; using Perpetuum.Zones.Terrains; +using SkiaSharp; namespace Perpetuum.Zones.Artifacts.Generators { @@ -74,7 +73,7 @@ private ArtifactType GetNextArtifactType() return ArtifactType.undefined; } - private static Point FindArtifactPosition(IZone zone) + private static SKPointI FindArtifactPosition(IZone zone) { if (!zone.Configuration.Terraformable) { @@ -82,7 +81,7 @@ private static Point FindArtifactPosition(IZone zone) } // gamman keresunk teruletet - var p = zone.FindWalkableArea(zone.Size.ToArea(), 20); + var p = zone.FindWalkableArea(new Area(0, 0, zone.Size.Width, zone.Size.Height), 20); return p.RandomElement(); } diff --git a/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanResult.cs b/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanResult.cs index 08619385..63dcfcdf 100644 --- a/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanResult.cs +++ b/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanResult.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Zones.Artifacts.Scanners { @@ -8,7 +8,7 @@ namespace Perpetuum.Zones.Artifacts.Scanners public class ArtifactScanResult { public Artifact scannedArtifact; - public Point estimatedPosition; + public SKPointI estimatedPosition; public double radius; } } \ No newline at end of file diff --git a/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs b/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs index cef32bb2..f1560fbd 100644 --- a/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs +++ b/src/Perpetuum/Zones/Artifacts/Scanners/ArtifactScanner.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using Perpetuum.ExportedTypes; using Perpetuum.Players; using Perpetuum.Services.Looting; diff --git a/src/Perpetuum/Zones/Beams/BeamBuilder.cs b/src/Perpetuum/Zones/Beams/BeamBuilder.cs index c331b7b3..6e6df6b6 100644 --- a/src/Perpetuum/Zones/Beams/BeamBuilder.cs +++ b/src/Perpetuum/Zones/Beams/BeamBuilder.cs @@ -1,8 +1,7 @@ -using System; -using System.Drawing; using Perpetuum.Builders; using Perpetuum.ExportedTypes; using Perpetuum.Units; +using SkiaSharp; namespace Perpetuum.Zones.Beams { @@ -106,7 +105,7 @@ public BeamBuilder WithDuration(TimeSpan duration) return this; } - public BeamBuilder WithPosition(Point position) + public BeamBuilder WithPosition(SKPointI position) { return WithSourcePosition(position.ToPosition()).WithTargetPosition(position.ToPosition()); } diff --git a/src/Perpetuum/Zones/Finders/PositionFinders/RandomWalkableAroundPositionFinder.cs b/src/Perpetuum/Zones/Finders/PositionFinders/RandomWalkableAroundPositionFinder.cs index e7f8fe78..cb02c8ad 100644 --- a/src/Perpetuum/Zones/Finders/PositionFinders/RandomWalkableAroundPositionFinder.cs +++ b/src/Perpetuum/Zones/Finders/PositionFinders/RandomWalkableAroundPositionFinder.cs @@ -21,7 +21,7 @@ public RandomWalkableAroundPositionFinder(IZone zone, Position origin, int range _slope = slope; _origin = origin; _maxRange = range; - _zoneArea = zone.Size.ToArea(); + _zoneArea = new Area(0, 0, zone.Size.Width, zone.Size.Height); } protected Position FindClosestWalkable(IZone zone, Position pos) diff --git a/src/Perpetuum/Zones/IZone.cs b/src/Perpetuum/Zones/IZone.cs index 4a40afd9..9fd0e56d 100644 --- a/src/Perpetuum/Zones/IZone.cs +++ b/src/Perpetuum/Zones/IZone.cs @@ -1,12 +1,9 @@ -using System.Collections.Generic; -using System.Drawing; using Perpetuum.Accounting.Characters; using Perpetuum.Common.Loggers; using Perpetuum.Groups.Corporations; using Perpetuum.Log; using Perpetuum.Players; using Perpetuum.Services.Relics; -using Perpetuum.Services.Strongholds; using Perpetuum.Services.Weather; using Perpetuum.Units; using Perpetuum.Zones.Beams; @@ -20,6 +17,7 @@ using Perpetuum.Zones.Terrains.Materials.Plants; using Perpetuum.Zones.Terrains.Terraforming; using Perpetuum.Zones.ZoneEntityRepositories; +using SkiaSharp; namespace Perpetuum.Zones { @@ -29,7 +27,7 @@ public interface IZone bool IsLayerEditLocked { get; set; } int Id { get; } - Size Size { get; } + SKSizeI Size { get; } IEnumerable Units { get; } IEnumerable Players { get; } diff --git a/src/Perpetuum/Zones/Movements/PathMovement.cs b/src/Perpetuum/Zones/Movements/PathMovement.cs index 7af8ad08..e2d85175 100644 --- a/src/Perpetuum/Zones/Movements/PathMovement.cs +++ b/src/Perpetuum/Zones/Movements/PathMovement.cs @@ -1,9 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; using System.Numerics; using Perpetuum.Units; +using SkiaSharp; namespace Perpetuum.Zones.Movements { @@ -12,7 +9,7 @@ public class PathMovement : Movement private readonly Queue _path = new Queue(); private WaypointMovement _movement; - public PathMovement(IEnumerable path) + public PathMovement(IEnumerable path) { foreach (var point in path.Skip(1)) { diff --git a/src/Perpetuum/Zones/NpcSystem/AI/BaseAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/BaseAI.cs index ab747177..b9f6e2e1 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/BaseAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/BaseAI.cs @@ -4,10 +4,7 @@ using Perpetuum.Zones.NpcSystem.AI.Behaviors; using Perpetuum.Zones.NpcSystem.AI.IndustrialDrones; using Perpetuum.Zones.RemoteControl; -using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; namespace Perpetuum.Zones.NpcSystem.AI { diff --git a/src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs index ef65fa1c..860e9d74 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs @@ -10,7 +10,7 @@ using Perpetuum.Zones.NpcSystem.TargettingStrategies; using Perpetuum.Zones.NpcSystem.ThreatManaging; using Perpetuum.Zones.Terrains; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI { @@ -225,7 +225,7 @@ protected virtual void ReturnToHomePosition() WriteLog("Enter evade mode."); } - protected Task> FindNewAttackPositionAsync(Unit hostile) + protected Task> FindNewAttackPositionAsync(Unit hostile) { source?.Cancel(); source = new CancellationTokenSource(); @@ -286,7 +286,7 @@ protected void UpdateHostile(TimeSpan time, bool moveThreatToPseudoThreat = true return; } - List path = t.Result; + List path = t.Result; if (path == null) { @@ -370,9 +370,9 @@ private bool SelectPrimaryTarget() return validLocks.Length >= 1 && (stratSelector?.TryUseStrategy(smartCreature, validLocks) ?? false); } - private List FindNewAttackPosition(Unit hostile, CancellationToken cancellationToken) + private List FindNewAttackPosition(Unit hostile, CancellationToken cancellationToken) { - Point end = hostile.CurrentPosition.GetRandomPositionInRange2D(0, smartCreature.BestActionRange - 1).ToPoint(); + SKPointI end = hostile.CurrentPosition.GetRandomPositionInRange2D(0, smartCreature.BestActionRange - 1).ToPoint(); smartCreature.StopMoving(); // Nulling movement so that the unit does not resume it at zero speed if the path is not found. @@ -384,7 +384,7 @@ private List FindNewAttackPosition(Unit hostile, CancellationToken cancel priorityQueue.Enqueue(startNode); - HashSet closed = + HashSet closed = [ startNode.position ]; @@ -402,7 +402,7 @@ private List FindNewAttackPosition(Unit hostile, CancellationToken cancel return BuildPath(current); } - foreach (Point n in current.position.GetNeighbours()) + foreach (SKPointI n in current.position.GetNeighbours()) { if (closed.Contains(n)) { @@ -437,7 +437,7 @@ private List FindNewAttackPosition(Unit hostile, CancellationToken cancel return null; } - private bool IsValidAttackPosition(Unit hostile, Point position) + private bool IsValidAttackPosition(Unit hostile, SKPointI position) { Position position3 = smartCreature.Zone.FixZ(position.ToPosition()).AddToZ(smartCreature.Height); @@ -451,9 +451,9 @@ private bool IsValidAttackPosition(Unit hostile, Point position) return !r.hit; } - private static List BuildPath(Node current) + private static List BuildPath(Node current) { - Stack stack = new(); + Stack stack = new(); Node node = current; while (node != null) diff --git a/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/CombatDroneAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/CombatDroneAI.cs index e0f5315e..145aa146 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/CombatDroneAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/CombatDroneAI.cs @@ -6,7 +6,7 @@ using Perpetuum.Zones.Locking.Locks; using Perpetuum.Zones.Movements; using Perpetuum.Zones.RemoteControl; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI.CombatDrones { @@ -131,7 +131,7 @@ protected virtual void ReturnToHomePosition() WriteLog("Enter evade mode."); } - protected Task> FindNewAttackPositionAsync(Unit hostile) + protected Task> FindNewAttackPositionAsync(Unit hostile) { source?.Cancel(); source = new CancellationTokenSource(); @@ -192,7 +192,7 @@ protected void UpdateHostile(TimeSpan time) return; } - List path = t.Result; + List path = t.Result; if (path == null) { @@ -254,9 +254,9 @@ private bool SetLock(UnitLock unitLock) return isNewLock; } - private List FindNewAttackPosition(Unit hostile, CancellationToken cancellationToken) + private List FindNewAttackPosition(Unit hostile, CancellationToken cancellationToken) { - Point end = hostile.CurrentPosition.GetRandomPositionInRange2D(0, smartCreature.BestActionRange - 1).ToPoint(); + SKPointI end = hostile.CurrentPosition.GetRandomPositionInRange2D(0, smartCreature.BestActionRange - 1).ToPoint(); smartCreature.StopMoving(); // Nulling movement so that the unit does not resume it at zero speed if the path is not found. @@ -268,7 +268,7 @@ private List FindNewAttackPosition(Unit hostile, CancellationToken cancel priorityQueue.Enqueue(startNode); - HashSet closed = new HashSet + HashSet closed = new HashSet { startNode.position }; @@ -286,7 +286,7 @@ private List FindNewAttackPosition(Unit hostile, CancellationToken cancel return BuildPath(current); } - foreach (Point n in current.position.GetNeighbours()) + foreach (SKPointI n in current.position.GetNeighbours()) { if (closed.Contains(n)) { @@ -321,7 +321,7 @@ private List FindNewAttackPosition(Unit hostile, CancellationToken cancel return null; } - private bool IsValidAttackPosition(Unit hostile, Point position) + private bool IsValidAttackPosition(Unit hostile, SKPointI position) { Position position3 = smartCreature.Zone.FixZ(position.ToPosition()).AddToZ(smartCreature.Height); @@ -335,9 +335,9 @@ private bool IsValidAttackPosition(Unit hostile, Point position) return !r.hit; } - private static List BuildPath(Node current) + private static List BuildPath(Node current) { - Stack stack = new Stack(); + Stack stack = new Stack(); Node node = current; while (node != null) diff --git a/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/EscortCombatDroneAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/EscortCombatDroneAI.cs index a3f679b1..fe57b217 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/EscortCombatDroneAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/EscortCombatDroneAI.cs @@ -1,6 +1,7 @@ using Perpetuum.PathFinders; using Perpetuum.Zones.Movements; using Perpetuum.Zones.RemoteControl; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI.CombatDrones { @@ -29,7 +30,7 @@ public override void Enter() .FindPathAsync(smartCreature.CurrentPosition, randomHome) .ContinueWith(t => { - System.Drawing.Point[] path = t.Result; + SKPointI[] path = t.Result; if (path == null) { diff --git a/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/RetreatCombatDroneAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/RetreatCombatDroneAI.cs index 075049c7..5c10a30b 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/RetreatCombatDroneAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/CombatDrones/RetreatCombatDroneAI.cs @@ -1,7 +1,7 @@ using Perpetuum.PathFinders; using Perpetuum.Zones.Movements; using Perpetuum.Zones.RemoteControl; -using System; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI.CombatDrones { @@ -31,7 +31,7 @@ public override void Enter() .FindPathAsync(smartCreature.CurrentPosition, randomHome) .ContinueWith(t => { - System.Drawing.Point[] path = t.Result; + SKPointI[] path = t.Result; if (path == null) { diff --git a/src/Perpetuum/Zones/NpcSystem/AI/HomingAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/HomingAI.cs index 63a2dcd1..20f64862 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/HomingAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/HomingAI.cs @@ -1,9 +1,7 @@ using Perpetuum.Modules; using Perpetuum.PathFinders; using Perpetuum.Zones.Movements; -using System; -using System.Collections.Generic; -using System.Linq; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI { @@ -33,7 +31,7 @@ public override void Enter() .FindPathAsync(smartCreature.CurrentPosition, randomHome) .ContinueWith(t => { - System.Drawing.Point[] path = t.Result; + SKPointI[] path = t.Result; if (path == null) { diff --git a/src/Perpetuum/Zones/NpcSystem/AI/IndustrialAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/IndustrialAI.cs index 80a5e303..5fa8905b 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/IndustrialAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/IndustrialAI.cs @@ -6,12 +6,7 @@ using Perpetuum.Zones.Movements; using Perpetuum.Zones.NpcSystem.AI.IndustrialDrones; using Perpetuum.Zones.RemoteControl; -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI { @@ -137,7 +132,7 @@ protected void UpdateIndustrialTarget(TimeSpan time) return; } - List path = t.Result; + List path = t.Result; if (path == null) { @@ -177,7 +172,7 @@ protected void EjectCargo(TimeSpan time) } } - protected Task> FindNewAttackPositionAsync(Position position) + protected Task> FindNewAttackPositionAsync(Position position) { source?.Cancel(); source = new CancellationTokenSource(); @@ -226,9 +221,9 @@ private bool SetLock(TerrainLock terrainLock) return isNewLock; } - private List FindNewAttackPosition(Position position, CancellationToken cancellationToken) + private List FindNewAttackPosition(Position position, CancellationToken cancellationToken) { - Point end = position.GetRandomPositionInRange2D(0, smartCreature.BestActionRange - 1).ToPoint(); + SKPointI end = position.GetRandomPositionInRange2D(0, smartCreature.BestActionRange - 1).ToPoint(); smartCreature.StopMoving(); // Nulling movement so that the unit does not resume it at zero speed if the path is not found. @@ -240,7 +235,7 @@ private List FindNewAttackPosition(Position position, CancellationToken c priorityQueue.Enqueue(startNode); - HashSet closed = new HashSet + HashSet closed = new HashSet { startNode.position }; @@ -258,7 +253,7 @@ private List FindNewAttackPosition(Position position, CancellationToken c return BuildPath(current); } - foreach (Point n in current.position.GetNeighbours()) + foreach (SKPointI n in current.position.GetNeighbours()) { if (closed.Contains(n)) { @@ -293,7 +288,7 @@ private List FindNewAttackPosition(Position position, CancellationToken c return null; } - private bool IsValidAttackPosition(Position targetPosition, Point position) + private bool IsValidAttackPosition(Position targetPosition, SKPointI position) { Position position3 = smartCreature.Zone.FixZ(position.ToPosition()).AddToZ(smartCreature.Height); @@ -307,9 +302,9 @@ private bool IsValidAttackPosition(Position targetPosition, Point position) return !r.hit; } - private static List BuildPath(Node current) + private static List BuildPath(Node current) { - Stack stack = new Stack(); + Stack stack = new Stack(); Node node = current; while (node != null) diff --git a/src/Perpetuum/Zones/NpcSystem/AI/IndustrialDrones/EscortIndustrialDroneAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/IndustrialDrones/EscortIndustrialDroneAI.cs index 15161373..bcc2aef0 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/IndustrialDrones/EscortIndustrialDroneAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/IndustrialDrones/EscortIndustrialDroneAI.cs @@ -1,7 +1,7 @@ using Perpetuum.PathFinders; using Perpetuum.Zones.Movements; using Perpetuum.Zones.RemoteControl; -using System; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI.IndustrialDrones { @@ -30,7 +30,7 @@ public override void Enter() .FindPathAsync(smartCreature.CurrentPosition, randomHome) .ContinueWith(t => { - System.Drawing.Point[] path = t.Result; + SKPointI[] path = t.Result; if (path == null) { diff --git a/src/Perpetuum/Zones/NpcSystem/AI/IndustrialDrones/RetreatIndustrialDroneAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/IndustrialDrones/RetreatIndustrialDroneAI.cs index d5118144..a8a5b50d 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/IndustrialDrones/RetreatIndustrialDroneAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/IndustrialDrones/RetreatIndustrialDroneAI.cs @@ -1,7 +1,7 @@ using Perpetuum.PathFinders; using Perpetuum.Zones.Movements; using Perpetuum.Zones.RemoteControl; -using System; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI.IndustrialDrones { @@ -31,7 +31,7 @@ public override void Enter() .FindPathAsync(smartCreature.CurrentPosition, randomHome) .ContinueWith(t => { - System.Drawing.Point[] path = t.Result; + SKPointI[] path = t.Result; if (path == null) { diff --git a/src/Perpetuum/Zones/NpcSystem/AI/Node.cs b/src/Perpetuum/Zones/NpcSystem/AI/Node.cs index 36cb79c4..9a7362c2 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/Node.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/Node.cs @@ -1,16 +1,15 @@ -using System; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI { public class Node : IComparable { - public readonly Point position; + public readonly SKPointI position; public Node parent; public int g; public int f; - public Node(Point position) + public Node(SKPointI position) { this.position = position; } diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/DynamicPresence.cs b/src/Perpetuum/Zones/NpcSystem/Presences/DynamicPresence.cs index 0842cf6a..b57a0d1b 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/DynamicPresence.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/DynamicPresence.cs @@ -1,7 +1,6 @@ -using System; -using System.Linq; using Perpetuum.Timers; using Perpetuum.Zones.NpcSystem.Flocks; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.Presences { @@ -57,7 +56,7 @@ public DynamicPresence(IZone zone, IPresenceConfiguration configuration) : base( public override Area Area { - get { return Zone.Size.ToArea(); } + get { return new Area(0, 0, Zone.Size.Width, Zone.Size.Height); } } protected override void OnPresenceExpired() diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/ExpiringStaticPresence/RandomSpawningExpiringPresence.cs b/src/Perpetuum/Zones/NpcSystem/Presences/ExpiringStaticPresence/RandomSpawningExpiringPresence.cs index d8dbd10f..f08a7c80 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/ExpiringStaticPresence/RandomSpawningExpiringPresence.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/ExpiringStaticPresence/RandomSpawningExpiringPresence.cs @@ -1,8 +1,7 @@ using Perpetuum.StateMachines; using Perpetuum.Zones.NpcSystem.Presences.PathFinders; -using System; -using System.Drawing; using Perpetuum.Zones.NpcSystem.Presences.ExpiringStaticPresence; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.Presences.RandomExpiringPresence { @@ -15,7 +14,7 @@ public class RandomSpawningExpiringPresence : ExpiringPresence, IRandomStaticPre public Position SpawnOrigin { get; set; } public IRoamingPathFinder PathFinder { get; set; } public override Area Area => Configuration.Area; - public Point CurrentRoamingPosition { get; set; } + public SKPointI CurrentRoamingPosition { get; set; } public RandomSpawningExpiringPresence(IZone zone, IPresenceConfiguration configuration) : base(zone, configuration) { diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/InterzonePresences/InterzonePresence.cs b/src/Perpetuum/Zones/NpcSystem/Presences/InterzonePresences/InterzonePresence.cs index 76b13e6e..62829977 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/InterzonePresences/InterzonePresence.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/InterzonePresences/InterzonePresence.cs @@ -1,9 +1,8 @@ -using System; -using System.Text; -using System.Drawing; +using System.Text; using Perpetuum.StateMachines; using Perpetuum.Zones.NpcSystem.Flocks; using Perpetuum.Zones.NpcSystem.Presences.PathFinders; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.Presences.InterzonePresences { @@ -11,7 +10,7 @@ public class InterzoneRoamingPresence : InterzonePresence, IRoamingPresence { public StackFSM StackFSM { get; } public Position SpawnOrigin { get; set; } - public Point CurrentRoamingPosition { get; set; } + public SKPointI CurrentRoamingPosition { get; set; } public IRoamingPathFinder PathFinder { get; set; } public InterzoneRoamingPresence(IZone zone, IPresenceConfiguration configuration) : base(zone, configuration) { diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/FreeRoamingPathFinder.cs b/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/FreeRoamingPathFinder.cs index 1f8356b9..ef324e31 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/FreeRoamingPathFinder.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/FreeRoamingPathFinder.cs @@ -1,14 +1,11 @@ //#define VERBOSE -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; using Perpetuum.Collections; using Perpetuum.ExportedTypes; using Perpetuum.Log; using Perpetuum.PathFinders; using Perpetuum.Zones.NpcSystem.Flocks; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.Presences.PathFinders { @@ -49,7 +46,7 @@ private double TryGetMinSlope(IRoamingPresence presence) return ZoneExtensions.MIN_SLOPE; } - public Point FindSpawnPosition(IRoamingPresence presence) + public SKPointI FindSpawnPosition(IRoamingPresence presence) { var homeRange = TryGetMaxHomeRange(presence); var rangeMax = homeRange * 2; @@ -58,7 +55,7 @@ public Point FindSpawnPosition(IRoamingPresence presence) return walkableArea.RandomElement(); } - public Point FindNextRoamingPosition(IRoamingPresence presence) + public SKPointI FindNextRoamingPosition(IRoamingPresence presence) { var minSlope = TryGetMinSlope(presence); var maxHomeRange = TryGetMaxHomeRange(presence); @@ -68,7 +65,7 @@ public Point FindNextRoamingPosition(IRoamingPresence presence) var startNode = new Node(presence.CurrentRoamingPosition); queue.Enqueue(startNode); - var closed = new HashSet {presence.CurrentRoamingPosition}; + var closed = new HashSet {presence.CurrentRoamingPosition}; if (FastRandom.NextDouble() < 0.3) { @@ -116,10 +113,10 @@ public Point FindNextRoamingPosition(IRoamingPresence presence) private struct Node : IComparable { - public readonly Point location; + public readonly SKPointI location; private readonly int _cost; - public Node(Point location,int cost = 0) + public Node(SKPointI location,int cost = 0) { this.location = location; _cost = cost; diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/IRoamingPathFinder.cs b/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/IRoamingPathFinder.cs index 2c296c46..19d0185c 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/IRoamingPathFinder.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/IRoamingPathFinder.cs @@ -1,10 +1,10 @@ -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.Presences.PathFinders { public interface IRoamingPathFinder { - Point FindSpawnPosition(IRoamingPresence presence); - Point FindNextRoamingPosition(IRoamingPresence presence); + SKPointI FindSpawnPosition(IRoamingPresence presence); + SKPointI FindNextRoamingPosition(IRoamingPresence presence); } } \ No newline at end of file diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/NormalRoamingPathFinder.cs b/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/NormalRoamingPathFinder.cs index d0495b81..a6b35c91 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/NormalRoamingPathFinder.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/NormalRoamingPathFinder.cs @@ -1,8 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; using Perpetuum.Collections; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.Presences.PathFinders { @@ -17,13 +14,13 @@ public NormalRoamingPathFinder(IZone zone) _zone = zone; } - public Point FindSpawnPosition(IRoamingPresence presence) + public SKPointI FindSpawnPosition(IRoamingPresence presence) { var point = _zone.SafeSpawnPoints.GetAll().RandomElement(); return point.Location; } - public Point FindNextRoamingPosition(IRoamingPresence presence) + public SKPointI FindNextRoamingPosition(IRoamingPresence presence) { var homeRange = presence.Flocks.Max(f => f.HomeRange); var rangeMax = homeRange * 2; @@ -82,7 +79,7 @@ public Point FindNextRoamingPosition(IRoamingPresence presence) return startNode.position; } - private int CalculateCost(Point from, Point position) + private int CalculateCost(SKPointI from, SKPointI position) { var dx = Math.Abs(position.X - from.X); var dy = Math.Abs(position.Y - from.Y); @@ -97,7 +94,7 @@ private int CalculateCost(Point from, Point position) return cost; } - private bool IsRoamingPosition(Point roamingPosition) + private bool IsRoamingPosition(SKPointI roamingPosition) { var isRoaming = _zone.Terrain.Controls[roamingPosition.X,roamingPosition.Y].Roaming; var isWalkable = _zone.IsWalkable(roamingPosition.X, roamingPosition.Y); @@ -106,14 +103,14 @@ private bool IsRoamingPosition(Point roamingPosition) private struct Node : IComparable { - public readonly Point position; + public readonly SKPointI position; public int cost; - public Node(int x, int y) : this(new Point(x, y)) + public Node(int x, int y) : this(new SKPointI(x, y)) { } - public Node(Point position) + public Node(SKPointI position) { this.position = position; cost = 0; diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/RoamingState.cs b/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/RoamingState.cs index 9f053396..dcef1ad9 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/RoamingState.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/PathFinders/RoamingState.cs @@ -1,7 +1,5 @@ using Perpetuum.Zones.NpcSystem.AI; using Perpetuum.Zones.NpcSystem.Flocks; -using System; -using System.Linq; namespace Perpetuum.Zones.NpcSystem.Presences.PathFinders { diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs b/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs index b6810405..9c78869c 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs @@ -48,7 +48,7 @@ public class DirectPresenceConfiguration : PresenceConfiguration public DirectPresenceConfiguration(IZone zone) : base(_idGenerator.GetNextID(), PresenceType.Direct) { - Area = zone.Configuration.Size.ToArea(); + Area = new Area(0, 0, zone.Size.Width, zone.Size.Height); Name = "direct presence " + ID; SpawnId = 10; //dynamic kamubol, szerintem kicsit sem kell Note = "abs! rulez"; diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/RoamingPresence.cs b/src/Perpetuum/Zones/NpcSystem/Presences/RoamingPresence.cs index e3a8a580..961e6fba 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/RoamingPresence.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/RoamingPresence.cs @@ -1,9 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Drawing; using Perpetuum.StateMachines; using Perpetuum.Zones.NpcSystem.Flocks; using Perpetuum.Zones.NpcSystem.Presences.PathFinders; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.Presences { @@ -11,7 +9,7 @@ public interface IRoamingPresence { StackFSM StackFSM { get; } Position SpawnOrigin { get; set; } - Point CurrentRoamingPosition { get; set; } + SKPointI CurrentRoamingPosition { get; set; } IRoamingPathFinder PathFinder { get; set; } IPresenceConfiguration Configuration { get; } IZone Zone { get; } @@ -25,7 +23,7 @@ public class RoamingPresence : Presence, IRoamingPresence { public StackFSM StackFSM { get; } public Position SpawnOrigin { get; set; } - public Point CurrentRoamingPosition { get; set; } + public SKPointI CurrentRoamingPosition { get; set; } public IRoamingPathFinder PathFinder { get; set; } public override Area Area => Configuration.Area; diff --git a/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/ISafeSpawnPointsRepository.cs b/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/ISafeSpawnPointsRepository.cs index a3eb2142..2a233516 100644 --- a/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/ISafeSpawnPointsRepository.cs +++ b/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/ISafeSpawnPointsRepository.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace Perpetuum.Zones.NpcSystem.SafeSpawnPoints { public interface ISafeSpawnPointsRepository diff --git a/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/NpcSafeSpawnPointsRepository.cs b/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/NpcSafeSpawnPointsRepository.cs index d716af9d..3449cb1e 100644 --- a/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/NpcSafeSpawnPointsRepository.cs +++ b/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/NpcSafeSpawnPointsRepository.cs @@ -1,7 +1,5 @@ -using System.Collections.Generic; -using System.Drawing; -using System.Linq; using Perpetuum.Data; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.SafeSpawnPoints { @@ -45,7 +43,7 @@ public IEnumerable GetAll() { Id = r.GetValue("id"), ZoneId = r.GetValue("zoneId"), - Location = new Point(r.GetValue("x"), r.GetValue("y")) + Location = new SKPointI(r.GetValue("x"), r.GetValue("y")) }; return point; diff --git a/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/SafeSpawnPoint.cs b/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/SafeSpawnPoint.cs index 1873fdce..2a074824 100644 --- a/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/SafeSpawnPoint.cs +++ b/src/Perpetuum/Zones/NpcSystem/SafeSpawnPoints/SafeSpawnPoint.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.SafeSpawnPoints { @@ -7,7 +6,7 @@ public struct SafeSpawnPoint { public int Id { get; set; } public int ZoneId { private get; set; } - public Point Location { get; set; } + public SKPointI Location { get; set; } public IDictionary ToDictionary() { diff --git a/src/Perpetuum/Zones/PBS/EnergyWell/PBSEnergyWell.cs b/src/Perpetuum/Zones/PBS/EnergyWell/PBSEnergyWell.cs index 5bf33ccc..5f0483e5 100644 --- a/src/Perpetuum/Zones/PBS/EnergyWell/PBSEnergyWell.cs +++ b/src/Perpetuum/Zones/PBS/EnergyWell/PBSEnergyWell.cs @@ -1,11 +1,9 @@ -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using Perpetuum.Items; +using Perpetuum.Items; using Perpetuum.Log; using Perpetuum.Zones.PBS.Reactors; using Perpetuum.Zones.Terrains.Materials; using Perpetuum.Zones.Terrains.Materials.Minerals; +using SkiaSharp; namespace Perpetuum.Zones.PBS.EnergyWell { @@ -124,7 +122,7 @@ private void SaveToDb() DynamicProperties.Update(k.depleted, IsDepleted ? 1 : 0); } - public List ExtractWithinRange(MineralLayer layer, Point location, int range, uint amount) + public List ExtractWithinRange(MineralLayer layer, SKPointI location, int range, uint amount) { var nodes = layer.GetNodesWithinRange(location, range).OrderBy(n => n.Area.SqrDistance(location)); diff --git a/src/Perpetuum/Zones/Position.cs b/src/Perpetuum/Zones/Position.cs index a16d87e9..a57b0a34 100644 --- a/src/Perpetuum/Zones/Position.cs +++ b/src/Perpetuum/Zones/Position.cs @@ -1,6 +1,6 @@ using Perpetuum.Collections.Spatial; -using System.Drawing; using System.Numerics; +using SkiaSharp; namespace Perpetuum.Zones { @@ -32,7 +32,7 @@ public Position(double x, double y, double z = 0.0) public Position Center => new(intX + 0.5, intY + 0.5, _z); - public bool IsValid(Size size) + public bool IsValid(SKSizeI size) { return size.Contains(intX, intY) && intZ >= 0 && intZ < short.MaxValue; } @@ -87,7 +87,7 @@ public double TotalDistance2D(Position p) } [System.Diagnostics.Contracts.Pure] - public double TotalDistance2D(Point p) + public double TotalDistance2D(SKPointI p) { return TotalDistance2D(p.X, p.Y); } @@ -320,7 +320,7 @@ public Position Rotate90CCW() return new Position(_y, -1 * _x, _z); } - public Position Clamp(Size size) + public Position Clamp(SKSizeI size) { return new Position(_x.Clamp(0, size.Width - 1), _y.Clamp(0, size.Height - 1), _z.Clamp(0, short.MaxValue)); } @@ -372,7 +372,7 @@ public Position Clamp(Size size) return new Position(p._x * num, p._y * num, p._z * num); } - public static implicit operator Point(Position p) + public static implicit operator SKPointI(Position p) { return p.ToPoint(); } @@ -424,14 +424,14 @@ public Vector3 ToVector3() return new Vector3((float)_x, (float)_y, (float)_z); } - public Point ToPoint() + public SKPointI ToPoint() { - return new Point((int)_x, (int)_y); + return new SKPointI((int)_x, (int)_y); } - public PointF ToPointF() + public SKPoint ToPointF() { - return new PointF((float)_x, (float)_y); + return new SKPoint((float)_x, (float)_y); } public ulong GetUlongHashCode() @@ -471,7 +471,7 @@ public IEnumerable NonDiagonalNeighbours { -2, 2}, { -1, 2}, { 0, 2 }, { 1, 2 }, { 2, 2 }, }; - public IEnumerable GetEightNeighbours(Size size) + public IEnumerable GetEightNeighbours(SKSizeI size) { return EightNeighbours.Where(np => np.IsValid(size)); } @@ -490,7 +490,7 @@ public IEnumerable EightNeighbours } } - public IEnumerable GetTwentyFourNeighbours(Size size) + public IEnumerable GetTwentyFourNeighbours(SKSizeI size) { return TwentyFourNeighbours.Where(np => np.IsValid(size)); } @@ -536,14 +536,14 @@ public CellCoord ToCellCoord() return CellCoord.FromXY((int)X, (int)Y); } - public double DirectionTo(Point point) + public double DirectionTo(SKPointI point) { - return DirectionTo(point.ToPosition()); + return DirectionTo(new Position(point.X, point.Y)); } - public bool IsInRangeOf2D(Point point, double range) + public bool IsInRangeOf2D(SKPointI point, double range) { - return IsInRangeOf2D(point.ToPosition(), range); + return IsInRangeOf2D(new Position(point.X, point.Y), range); } diff --git a/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Artifact.cs b/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Artifact.cs index 55e7e4f7..876a19b1 100644 --- a/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Artifact.cs +++ b/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Artifact.cs @@ -1,6 +1,4 @@ -using System.Linq; -using System.Threading.Tasks; -using System.Transactions; +using System.Transactions; using Perpetuum.Data; using Perpetuum.EntityFramework; using Perpetuum.Zones.Artifacts.Scanners; diff --git a/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Directional.cs b/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Directional.cs index 558197a4..d09f3d0a 100644 --- a/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Directional.cs +++ b/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Directional.cs @@ -1,8 +1,8 @@ -using System.Drawing; -using Perpetuum.EntityFramework; +using Perpetuum.EntityFramework; using Perpetuum.Zones.Scanning.Ammos; using Perpetuum.Zones.Terrains; using Perpetuum.Zones.Terrains.Materials; +using SkiaSharp; namespace Perpetuum.Zones.Scanning.Scanners { @@ -17,7 +17,7 @@ public void Visit(DirectionalScannerAmmo ammo) var layer = _zone.Terrain.GetMineralLayerOrThrow(ammo.MaterialType); - var nearestMineralPosition = Point.Empty; + var nearestMineralPosition = SKPointI.Empty; var nearestDist = int.MaxValue; foreach (var node in layer.Nodes) @@ -52,12 +52,12 @@ private double RandomizeDirection(double direction) return direction; } - private static Packet BuildPacket(MaterialType materialType, Position fromPosition, Point nearestMineralPosition, double direction, bool isInRange) + private static Packet BuildPacket(MaterialType materialType, Position fromPosition, SKPointI nearestMineralPosition, double direction, bool isInRange) { var packet = new Packet(ZoneCommand.ScanMineralDirectionalResult); packet.AppendInt((int) materialType); packet.AppendPoint(fromPosition); - packet.AppendBool(nearestMineralPosition != Point.Empty); + packet.AppendBool(nearestMineralPosition != SKPointI.Empty); packet.AppendByte((byte) (direction*255)); packet.AppendBool(isInRange); return packet; diff --git a/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Intrusion.cs b/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Intrusion.cs index 572e6abb..adc5de30 100644 --- a/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Intrusion.cs +++ b/src/Perpetuum/Zones/Scanning/Scanners/Scanner.Intrusion.cs @@ -1,6 +1,4 @@ -using System; -using System.Linq; -using Perpetuum.EntityFramework; +using Perpetuum.EntityFramework; using Perpetuum.ExportedTypes; using Perpetuum.Units; using Perpetuum.Zones.Intrusion; diff --git a/src/Perpetuum/Zones/Scanning/Scanners/Scanner.OneTile.cs b/src/Perpetuum/Zones/Scanning/Scanners/Scanner.OneTile.cs index 2261f8c8..ca3cef14 100644 --- a/src/Perpetuum/Zones/Scanning/Scanners/Scanner.OneTile.cs +++ b/src/Perpetuum/Zones/Scanning/Scanners/Scanner.OneTile.cs @@ -1,8 +1,7 @@ -using System.Drawing; -using System.Linq; -using Perpetuum.EntityFramework; +using Perpetuum.EntityFramework; using Perpetuum.Zones.Scanning.Ammos; using Perpetuum.Zones.Terrains.Materials.Minerals; +using SkiaSharp; namespace Perpetuum.Zones.Scanning.Scanners { @@ -17,7 +16,7 @@ public void Visit(OneTileScannerAmmo ammo) OnMineralScanned(MaterialProbeType.OneTile); } - private Packet BuildScanOneTileResultPacket(Point location) + private Packet BuildScanOneTileResultPacket(SKPointI location) { var packet = new Packet(ZoneCommand.ScanOneTileResult); packet.AppendLong(_module.Eid); //module EID diff --git a/src/Perpetuum/Zones/Terrains/BlockingInfo.cs b/src/Perpetuum/Zones/Terrains/BlockingInfo.cs index 237f4d9a..563c9947 100644 --- a/src/Perpetuum/Zones/Terrains/BlockingInfo.cs +++ b/src/Perpetuum/Zones/Terrains/BlockingInfo.cs @@ -1,5 +1,3 @@ -using System; - namespace Perpetuum.Zones.Terrains { [Serializable] diff --git a/src/Perpetuum/Zones/Terrains/ITerrain.cs b/src/Perpetuum/Zones/Terrains/ITerrain.cs index 60aff867..dd1df3b3 100644 --- a/src/Perpetuum/Zones/Terrains/ITerrain.cs +++ b/src/Perpetuum/Zones/Terrains/ITerrain.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Perpetuum.Zones.Terrains.Materials; using Perpetuum.Zones.Terrains.Materials.Plants; diff --git a/src/Perpetuum/Zones/Terrains/IUpdateableLayer.cs b/src/Perpetuum/Zones/Terrains/IUpdateableLayer.cs index 6984bd79..3ffbb35f 100644 --- a/src/Perpetuum/Zones/Terrains/IUpdateableLayer.cs +++ b/src/Perpetuum/Zones/Terrains/IUpdateableLayer.cs @@ -1,5 +1,3 @@ -using System.IO; - namespace Perpetuum.Zones.Terrains { public interface IUpdateableLayer diff --git a/src/Perpetuum/Zones/Terrains/LayerExtensions.cs b/src/Perpetuum/Zones/Terrains/LayerExtensions.cs index f5b01f01..6f4dc74e 100644 --- a/src/Perpetuum/Zones/Terrains/LayerExtensions.cs +++ b/src/Perpetuum/Zones/Terrains/LayerExtensions.cs @@ -1,5 +1,4 @@ -using System; -using System.Drawing; +using SkiaSharp; using System.Numerics; namespace Perpetuum.Zones.Terrains @@ -11,7 +10,7 @@ public static bool IsValidPosition(this ILayer layer, int x, int y) return x >= 0 && x < layer.Width && y >= 0 && y < layer.Height; } - public static T GetValue(this ILayer layer, Point position) + public static T GetValue(this ILayer layer, SKPointI position) { return layer.GetValue(position.X, position.Y); } diff --git a/src/Perpetuum/Zones/Terrains/Materials/Minerals/Generators/MineralNodeGeneratorBase.cs b/src/Perpetuum/Zones/Terrains/Materials/Minerals/Generators/MineralNodeGeneratorBase.cs index a3d8805f..696ac5cd 100644 --- a/src/Perpetuum/Zones/Terrains/Materials/Minerals/Generators/MineralNodeGeneratorBase.cs +++ b/src/Perpetuum/Zones/Terrains/Materials/Minerals/Generators/MineralNodeGeneratorBase.cs @@ -1,11 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; using Perpetuum.Units; using Perpetuum.Units.DockingBases; using Perpetuum.Zones.Finders.PositionFinders; using Perpetuum.Zones.Teleporting; +using SkiaSharp; namespace Perpetuum.Zones.Terrains.Materials.Minerals.Generators { @@ -34,11 +31,11 @@ public MineralNode Generate(MineralLayer layer) return node; } - private Dictionary NormalizeNoise(Dictionary noise) + private Dictionary NormalizeNoise(Dictionary noise) { var max = noise.Values.Max(); - var result = new Dictionary(); + var result = new Dictionary(); foreach (var kvp in noise) { @@ -48,9 +45,9 @@ private Dictionary NormalizeNoise(Dictionary noise return result; } - protected abstract Dictionary GenerateNoise(Position startPosition); + protected abstract Dictionary GenerateNoise(Position startPosition); - protected bool IsValid(Point location) + protected bool IsValid(SKPointI location) { if (!_zone.Size.Contains(location.X, location.Y)) return false; @@ -77,7 +74,7 @@ protected bool IsValid(Point location) return true; } - private MineralNode CreateMineralNode(MineralLayer layer, Dictionary tiles) + private MineralNode CreateMineralNode(MineralLayer layer, Dictionary tiles) { int minx = int.MaxValue, miny = int.MaxValue, maxx = 0, maxy = 0; @@ -108,7 +105,7 @@ private MineralNode CreateMineralNode(MineralLayer layer, Dictionary().WithinRange2D(location.ToPosition(), dist).Any()) return true; diff --git a/src/Perpetuum/Zones/Terrains/Materials/Minerals/Generators/RandomWalkMineralNodeGenerator.cs b/src/Perpetuum/Zones/Terrains/Materials/Minerals/Generators/RandomWalkMineralNodeGenerator.cs index 6e51c149..24960f1f 100644 --- a/src/Perpetuum/Zones/Terrains/Materials/Minerals/Generators/RandomWalkMineralNodeGenerator.cs +++ b/src/Perpetuum/Zones/Terrains/Materials/Minerals/Generators/RandomWalkMineralNodeGenerator.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Drawing; -using System.Linq; +using SkiaSharp; namespace Perpetuum.Zones.Terrains.Materials.Minerals.Generators { @@ -13,29 +11,29 @@ public RandomWalkMineralNodeGenerator(IZone zone) : base(zone) private int BrushSize { get; set; } - protected override Dictionary GenerateNoise(Position startPosition) + protected override Dictionary GenerateNoise(Position startPosition) { - var closed = new HashSet(); - var tiles = new Dictionary(); + var closed = new HashSet(); + var tiles = new Dictionary(); - var q = new Queue(); + var q = new Queue(); q.Enqueue(startPosition); var i = 0; - while (q.TryDequeue(out Point current) && i < 50000) + while (q.TryDequeue(out SKPointI current) && i < 50000) { i++; foreach (var point in current.FloodFill(IsValid).Take(BrushSize * BrushSize)) { - tiles.AddOrUpdate(point,1,c => c + 1); + tiles.AddOrUpdate(point, 1, c => c + 1); if (tiles.Count >= MaxTiles) return tiles; } - var r = new List(); + var r = new List(); foreach (var np in current.GetNeighbours()) { diff --git a/src/Perpetuum/Zones/Terrains/Materials/Minerals/GravelLayer.cs b/src/Perpetuum/Zones/Terrains/Materials/Minerals/GravelLayer.cs index b8083c04..b4eb58ac 100644 --- a/src/Perpetuum/Zones/Terrains/Materials/Minerals/GravelLayer.cs +++ b/src/Perpetuum/Zones/Terrains/Materials/Minerals/GravelLayer.cs @@ -1,10 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.IO; using Perpetuum.IO; -using Perpetuum.Services.EventServices; using Perpetuum.Zones.Terrains.Materials.Minerals.Generators; +using SkiaSharp; namespace Perpetuum.Zones.Terrains.Materials.Minerals { @@ -51,9 +47,9 @@ public void Delete(MineralNode node) public List GetAll() { - var bmp = (Bitmap)Image.FromFile(_fileSystem.CreatePath( Path.Combine("layers", "mineral_gravel.0045.png"))); + var bmp = SKBitmap.Decode(_fileSystem.CreatePath(Path.Combine("layers", "mineral_gravel.0045.png"))); - var minerals = new Dictionary(); + var minerals = new Dictionary(); int minx = int.MaxValue, miny = int.MaxValue, maxx = 0, maxy = 0; @@ -63,7 +59,7 @@ public List GetAll() { var c = bmp.GetPixel(x, y); - var b = c.GetBrightness() * 350000; + var b = c.GetLuminance() * 350000; if (b > 0) { @@ -72,7 +68,7 @@ public List GetAll() maxx = Math.Max(maxx, x); maxy = Math.Max(maxy, y); - var point = new Point(x, y); + var point = new SKPointI(x, y); minerals.Add(point, (uint)b); } } diff --git a/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralExtractor.cs b/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralExtractor.cs index 5a09f42a..2a79d1a8 100644 --- a/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralExtractor.cs +++ b/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralExtractor.cs @@ -1,14 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Drawing; using Perpetuum.Collections; using Perpetuum.Items; +using SkiaSharp; namespace Perpetuum.Zones.Terrains.Materials.Minerals { public class MineralExtractor : MineralLayerVisitor { - private readonly Point _location; + private readonly SKPointI _location; private readonly uint _amount; private readonly MaterialHelper _materialHelper; @@ -16,7 +14,7 @@ public class MineralExtractor : MineralLayerVisitor public List Items => _items; - public MineralExtractor(Point location,uint amount,MaterialHelper materialHelper) + public MineralExtractor(SKPointI location,uint amount,MaterialHelper materialHelper) { _location = location; _amount = amount; @@ -48,10 +46,10 @@ public override void VisitOreLayer(OreLayer layer) private struct MineralDistance : IComparable { - public readonly Point location; + public readonly SKPointI location; private readonly int _sqrDistance; - public MineralDistance(Point location, int sqrDistance) + public MineralDistance(SKPointI location, int sqrDistance) { this.location = location; _sqrDistance = sqrDistance; @@ -79,7 +77,7 @@ public override void VisitLiquidLayer(LiquidLayer layer) continue; var d = _location.SqrDistance(x, y); - pq.Enqueue(new MineralDistance(new Point(x, y), d)); + pq.Enqueue(new MineralDistance(new SKPointI(x, y), d)); } } diff --git a/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralLayer.cs b/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralLayer.cs index 6418ce83..3c29993a 100644 --- a/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralLayer.cs +++ b/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralLayer.cs @@ -1,13 +1,11 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Drawing; using Perpetuum.Log; using Perpetuum.Services.EventServices; using Perpetuum.Services.EventServices.EventMessages; using Perpetuum.Timers; using Perpetuum.Zones.Terrains.Materials.Minerals.Actions; using Perpetuum.Zones.Terrains.Materials.Minerals.Generators; +using SkiaSharp; namespace Perpetuum.Zones.Terrains.Materials.Minerals { @@ -81,7 +79,7 @@ public void GenerateNewNode() RunAction(new GenerateMineralNode(generator)); } - public List GetNodesWithinRange(Point location, int range) + public List GetNodesWithinRange(SKPointI location, int range) { var area = Area.FromRadius(location.X, location.Y, range); return GetNodesByArea(area); @@ -100,7 +98,7 @@ public List GetNodesByArea(Area area) return nodes; } - public MineralNode GetNearestNode(Point p) + public MineralNode GetNearestNode(SKPointI p) { MineralNode nearestNode = null; var nearestDistSq = double.MaxValue; @@ -199,7 +197,7 @@ private void OnNodeExpired(MineralNode node) } [CanBeNull] - public MineralNode GetNode(Point p) + public MineralNode GetNode(SKPointI p) { MineralNode node; if (!TryGetNode(p, out node)) @@ -208,7 +206,7 @@ public MineralNode GetNode(Point p) return node; } - public bool TryGetNode(Point p, out MineralNode node) + public bool TryGetNode(SKPointI p, out MineralNode node) { return TryGetNode(p.X, p.Y, out node); } @@ -243,7 +241,7 @@ public void WriteLog(string message) Logger.Info($"Mineral ({_configuration.ZoneId}:{Type}) {message}"); } - public bool HasMineral(Point location) + public bool HasMineral(SKPointI location) { var node = GetNode(location); if (node == null) diff --git a/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralNode.cs b/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralNode.cs index 9180150d..2291279e 100644 --- a/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralNode.cs +++ b/src/Perpetuum/Zones/Terrains/Materials/Minerals/MineralNode.cs @@ -1,8 +1,7 @@ -using System; using System.Diagnostics; -using System.Drawing; using Perpetuum.Threading; using Perpetuum.Timers; +using SkiaSharp; namespace Perpetuum.Zones.Terrains.Materials.Minerals { @@ -86,7 +85,7 @@ private int GetOffset(int x, int y) return offset; } - public bool HasValue(Point p) + public bool HasValue(SKPointI p) { return HasValue(p.X, p.Y); } @@ -97,7 +96,7 @@ public bool HasValue(int x, int y) return v > 0; } - public uint DecreaseValue(Point p, uint value) + public uint DecreaseValue(SKPointI p, uint value) { OnDecrease(); return DecreaseValue(p.X, p.Y, value); @@ -150,7 +149,7 @@ public ulong GetTotalAmount() return sum; } - public uint GetValue(Point p) + public uint GetValue(SKPointI p) { return GetValue(p.X, p.Y); } @@ -166,7 +165,7 @@ public uint GetValue(int x, int y) return value; } - public void SetValue(Point p,uint value) + public void SetValue(SKPointI p,uint value) { SetValue(p.X,p.Y,value); } @@ -212,9 +211,9 @@ public void Update(TimeSpan time) OnUpdated(); } - public Point GetNearestMineralPosition(Point p) + public SKPointI GetNearestMineralPosition(SKPointI p) { - var nearest = Point.Empty; + var nearest = SKPointI.Empty; var nearestDist = int.MaxValue; var offset = 0; @@ -230,7 +229,7 @@ public Point GetNearestMineralPosition(Point p) if (d >= nearestDist) continue; - nearest = new Point(ax,ay); + nearest = new SKPointI(ax,ay); nearestDist = d; } } diff --git a/src/Perpetuum/Zones/Terrains/Materials/Plants/PlantHandler.cs b/src/Perpetuum/Zones/Terrains/Materials/Plants/PlantHandler.cs index dde5a74d..5b782836 100644 --- a/src/Perpetuum/Zones/Terrains/Materials/Plants/PlantHandler.cs +++ b/src/Perpetuum/Zones/Terrains/Materials/Plants/PlantHandler.cs @@ -1,10 +1,6 @@ using Perpetuum.Log; using Perpetuum.Threading.Process; using Perpetuum.Timers; -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; namespace Perpetuum.Zones.Terrains.Materials.Plants { @@ -51,7 +47,7 @@ public PlantHandler(IZone zone) : TimeSpan.FromHours(FULL_PLANT_REGEN_PASS / _total_area); _plantsTimer = new IntervalTimer(refreshRate); _natureSleepAmount = refreshRate; - WorkArea = zone.Size.ToArea(); + WorkArea = new Area(0, 0, zone.Size.Width, zone.Size.Height); ScannerMode = PlantScannerMode.Scanner; ResetState(); } diff --git a/src/Perpetuum/Zones/Terrains/PassableMapBuilder.cs b/src/Perpetuum/Zones/Terrains/PassableMapBuilder.cs index 726f5b31..bd4d5a52 100644 --- a/src/Perpetuum/Zones/Terrains/PassableMapBuilder.cs +++ b/src/Perpetuum/Zones/Terrains/PassableMapBuilder.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; -using System.Drawing; -using Perpetuum.Builders; +using Perpetuum.Builders; using Perpetuum.Log; +using SkiaSharp; namespace Perpetuum.Zones.Terrains { @@ -11,7 +10,7 @@ public class PassableMapBuilder : IBuilder> private readonly SlopeLayer _slopeLayer; private readonly IEnumerable _startPositions; - private Size _size; + private SKSizeI _size; public PassableMapBuilder(ILayer blocksLayer,SlopeLayer slopeLayer,IEnumerable startPositions) { @@ -19,7 +18,7 @@ public PassableMapBuilder(ILayer blocksLayer,SlopeLayer slopeLayer _slopeLayer = slopeLayer; _startPositions = startPositions; - _size = new Size(blocksLayer.Width, blocksLayer.Height); + _size = new SKSizeI(blocksLayer.Width, blocksLayer.Height); } public ILayer Build() diff --git a/src/Perpetuum/Zones/Terrains/Terraforming/Operations/BlurTerraformingOperation.cs b/src/Perpetuum/Zones/Terrains/Terraforming/Operations/BlurTerraformingOperation.cs index a271b2d2..93dc720b 100644 --- a/src/Perpetuum/Zones/Terrains/Terraforming/Operations/BlurTerraformingOperation.cs +++ b/src/Perpetuum/Zones/Terrains/Terraforming/Operations/BlurTerraformingOperation.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Zones.Terrains.Terraforming.Operations { @@ -17,7 +17,7 @@ public override void AcceptVisitor(TerraformingOperationVisitor visitor) protected override int ProduceDirection(IZone zone, int x, int y) { - var p = new Point(x, y); + var p = new SKPointI(x, y); var sum = 0.0; foreach (var n in p.GetNeighbours()) diff --git a/src/Perpetuum/Zones/Terrains/Terraforming/TerraformHandler.cs b/src/Perpetuum/Zones/Terrains/Terraforming/TerraformHandler.cs index 3fe7a0a3..17220abc 100644 --- a/src/Perpetuum/Zones/Terrains/Terraforming/TerraformHandler.cs +++ b/src/Perpetuum/Zones/Terrains/Terraforming/TerraformHandler.cs @@ -1,13 +1,10 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; using Perpetuum.ExportedTypes; using Perpetuum.Threading.Process; using Perpetuum.Timers; using Perpetuum.Zones.Beams; using Perpetuum.Zones.Terrains.Terraforming.Operations; +using SkiaSharp; namespace Perpetuum.Zones.Terrains.Terraforming { @@ -152,12 +149,12 @@ private void ProcessAffectedPositions() private void SendAffectedPositions() { - var affectedTiles = new Dictionary(); + var affectedTiles = new Dictionary(); AffectedTile tile; while (_affectedTiles.TryTake(out tile)) { - affectedTiles[new Point(tile.x,tile.y)] = tile.Type; + affectedTiles[new SKPointI(tile.x,tile.y)] = tile.Type; } foreach (var pair in affectedTiles) diff --git a/src/Perpetuum/Zones/Terrains/Terrain.cs b/src/Perpetuum/Zones/Terrains/Terrain.cs index 2debf118..8eb97c8a 100644 --- a/src/Perpetuum/Zones/Terrains/Terrain.cs +++ b/src/Perpetuum/Zones/Terrains/Terrain.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Perpetuum.Zones.Terrains.Materials; using Perpetuum.Zones.Terrains.Materials.Plants; diff --git a/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs b/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs index 2de367be..1d8ce202 100644 --- a/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs +++ b/src/Perpetuum/Zones/Terrains/TerrainUpdateMonitor.cs @@ -1,6 +1,5 @@ -using System; using System.Collections.Immutable; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Zones.Terrains { @@ -8,9 +7,9 @@ public abstract class TerrainUpdateInfo { public LayerType Type { get; } - public Point Position { get; } + public SKPointI Position { get; } - protected TerrainUpdateInfo(LayerType type, Point position) + protected TerrainUpdateInfo(LayerType type, SKPointI position) { Position = position; Type = type; @@ -68,7 +67,7 @@ public override int GetHashCode() public class TileUpdateInfo : TerrainUpdateInfo { - public TileUpdateInfo(LayerType type, Point position) + public TileUpdateInfo(LayerType type, SKPointI position) : base(type, position) { } @@ -178,7 +177,7 @@ private void OnAreaUpdated(LayerType layerType, Area area) private void OnTileUpdated(LayerType layerType, int x, int y) { - var info = new TileUpdateInfo(layerType,new Point(x,y)); + var info = new TileUpdateInfo(layerType, new SKPointI(x,y)); AddUpdateInfo(info); } diff --git a/src/Perpetuum/Zones/Terrains/TerrainUpdateNotifier.cs b/src/Perpetuum/Zones/Terrains/TerrainUpdateNotifier.cs index 55274c99..8001d039 100644 --- a/src/Perpetuum/Zones/Terrains/TerrainUpdateNotifier.cs +++ b/src/Perpetuum/Zones/Terrains/TerrainUpdateNotifier.cs @@ -1,9 +1,5 @@ -using System.Collections.Generic; -using System.Collections.Immutable; +using System.Collections.Immutable; using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Perpetuum.Collections.Spatial; using Perpetuum.Log; using Perpetuum.Players; diff --git a/src/Perpetuum/Zones/Zone.cs b/src/Perpetuum/Zones/Zone.cs index 099e66ab..cba26012 100644 --- a/src/Perpetuum/Zones/Zone.cs +++ b/src/Perpetuum/Zones/Zone.cs @@ -31,8 +31,8 @@ using Perpetuum.Zones.ZoneEntityRepositories; using System.Collections.Immutable; using System.Diagnostics; -using System.Drawing; using System.Net.Sockets; +using SkiaSharp; namespace Perpetuum.Zones { @@ -43,7 +43,7 @@ public abstract class Zone : Threading.Process.Process, IZone private ImmutableDictionary _players = ImmutableDictionary.Empty; public int Id => Configuration.Id; - public Size Size => Configuration.Size; + public SKSizeI Size => Configuration.Size; public IDecorHandler DecorHandler { get; set; } diff --git a/src/Perpetuum/Zones/ZoneConfiguration.cs b/src/Perpetuum/Zones/ZoneConfiguration.cs index 7c386dd2..7a61b95c 100644 --- a/src/Perpetuum/Zones/ZoneConfiguration.cs +++ b/src/Perpetuum/Zones/ZoneConfiguration.cs @@ -2,8 +2,7 @@ using Perpetuum.EntityFramework; using Perpetuum.ExportedTypes; using Perpetuum.Zones.Terrains.Materials.Plants; -using System.Collections.Generic; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Zones { @@ -44,8 +43,8 @@ public IEnumerable GetAll() ZoneConfiguration config = new ZoneConfiguration { Id = id, - WorldPosition = new Point(x, y), - Size = new Size(w, h), + WorldPosition = new SKPointI(x, y), + Size = new SKSizeI(w, h), Name = record.GetValue("name"), Fertility = record.GetValue("fertility"), PluginName = record.GetValue("zoneplugin"), @@ -91,8 +90,8 @@ public sealed class ZoneConfiguration public int plantRuleSetId; public int Id { get; set; } - public Size Size { get; set; } - public Point WorldPosition { get; set; } + public SKSizeI Size { get; set; } + public SKPointI WorldPosition { get; set; } public string PluginName { get; set; } public int Fertility { get; set; } public bool Terraformable { get; set; } diff --git a/src/Perpetuum/Zones/ZoneExtensions.Bitmap.cs b/src/Perpetuum/Zones/ZoneExtensions.Bitmap.cs index 665f3990..d93cabb6 100644 --- a/src/Perpetuum/Zones/ZoneExtensions.Bitmap.cs +++ b/src/Perpetuum/Zones/ZoneExtensions.Bitmap.cs @@ -1,7 +1,6 @@ -using System.Drawing; -using System.Drawing.Imaging; -using Perpetuum.IO; +using Perpetuum.IO; using Perpetuum.Zones.Terrains; +using SkiaSharp; namespace Perpetuum.Zones { @@ -15,10 +14,11 @@ public SaveBitmapHelper(IFileSystem fileSystem) } - public void SaveBitmap(IZone zone,Bitmap bitmap,string name) + public void SaveBitmap(IZone zone, SKBitmap bitmap, string name) { var fn = _fileSystem.CreatePath("bitmaps",zone.CreateTerrainDataFilename(name,"png")); - bitmap.Save(fn,ImageFormat.Png); + using var stream = File.Create(fn); + bitmap.Encode(stream, SKEncodedImageFormat.Png, 100); } } @@ -26,12 +26,17 @@ public void SaveBitmap(IZone zone,Bitmap bitmap,string name) public static partial class ZoneExtensions { - public static Bitmap CreatePassableBitmap(this IZone zone, Color passableTileColor, Color islandTileColor = default(Color)) + public static SKBitmap CreatePassableBitmap(this IZone zone, SKColor passableTileColor, SKColor islandTileColor = default) { - var skipIsland = islandTileColor.Equals(default(Color)); + var skipIsland = islandTileColor.Equals(default); var b = zone.CreateBitmap(); - b.WithGraphics(g => g.FillRectangle(new SolidBrush(Color.FromArgb(255, 0, 0, 0)), 0, 0, zone.Size.Width - 1, zone.Size.Height - 1)); + var canvas = new SKCanvas(b); + + SKPaint paint = new() { Color = SKColors.Black, Style = SKPaintStyle.Fill }; + b.WithCanvas(g => + g.DrawRect(0, 0, zone.Size.Width - 1, zone.Size.Height - 1, paint) + ); return b.ForEach((bmp, x, y) => { @@ -49,10 +54,10 @@ public static partial class ZoneExtensions }); } - public static Bitmap CreateBitmap(this IZone zone) + public static SKBitmap CreateBitmap(this IZone zone) { var size = zone.Size; - return new Bitmap(size.Width,size.Height,PixelFormat.Format32bppArgb); + return new SKBitmap(size.Width, size.Height, SKColorType.Rgba8888, SKAlphaType.Premul); } } diff --git a/src/Perpetuum/Zones/ZoneExtensions.Terrain.cs b/src/Perpetuum/Zones/ZoneExtensions.Terrain.cs index 62a04995..18bd8470 100644 --- a/src/Perpetuum/Zones/ZoneExtensions.Terrain.cs +++ b/src/Perpetuum/Zones/ZoneExtensions.Terrain.cs @@ -1,8 +1,6 @@ -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using Perpetuum.Data; +using Perpetuum.Data; using Perpetuum.Zones.Terrains; +using SkiaSharp; namespace Perpetuum.Zones { @@ -18,7 +16,7 @@ public static IEnumerable GetPassablePositionFromDb(this IZone zone) .Select(r => new Position(r.GetValue(0), r.GetValue(1))).ToList(); } - public static bool IsWalkableForNpc(this IZone zone,Point position, double slope = MIN_SLOPE) + public static bool IsWalkableForNpc(this IZone zone, SKPointI position, double slope = MIN_SLOPE) { return zone.IsWalkableForNpc(position.X, position.Y, slope); } @@ -36,7 +34,7 @@ public static bool IsWalkable(this IZone zone, int x, int y, double slope = MIN_ return zone.IsWalkable(new Position(x, y), slope); } - public static bool IsWalkable(this IZone zone, Point point, double slope = MIN_SLOPE) + public static bool IsWalkable(this IZone zone, SKPointI point, double slope = MIN_SLOPE) { return zone.IsWalkable(point.ToPosition(),slope); } diff --git a/src/Perpetuum/Zones/ZoneExtensions.cs b/src/Perpetuum/Zones/ZoneExtensions.cs index fe9e047b..3620c7cb 100644 --- a/src/Perpetuum/Zones/ZoneExtensions.cs +++ b/src/Perpetuum/Zones/ZoneExtensions.cs @@ -7,7 +7,7 @@ using Perpetuum.Units; using Perpetuum.Zones.RemoteControl; using Perpetuum.Zones.Terrains; -using System.Drawing; +using SkiaSharp; using System.Security.Cryptography; namespace Perpetuum.Zones @@ -64,23 +64,23 @@ public void SaveLayerToDisk(IZone zone, ILayer layer) where T : struct public static partial class ZoneExtensions { - public static List FindWalkableArea(this IZone zone, Area area, int size, double slope = 4.0) + public static List FindWalkableArea(this IZone zone, Area area, int size, double slope = 4.0) { area = area.Clamp(zone.Size); while (true) { - Point startPosition; + SKPointI startPosition; while (true) { startPosition = area.GetRandomPosition(); - if (!zone.Terrain.Blocks.GetValue(startPosition).Island && zone.IsWalkable(startPosition, slope)) + if (!zone.Terrain.Blocks.GetValue(startPosition.X, startPosition.Y).Island && zone.IsWalkable(startPosition, slope)) { break; } } - List p = FindWalkableArea(zone, startPosition, area, size, slope); + List p = FindWalkableArea(zone, startPosition, area, size, slope); if (p != null) { return p; @@ -91,14 +91,14 @@ public static List FindWalkableArea(this IZone zone, Area area, int size, } [CanBeNull] - public static List? FindWalkableArea(this IZone zone, Point startPosition, Area area, int size, double slope = 4.0) + public static List? FindWalkableArea(this IZone zone, SKPointI startPosition, Area area, int size, double slope = 4.0) { - Queue q = new(); + Queue q = new(); q.Enqueue(startPosition); - HashSet closed = [startPosition]; + HashSet closed = [startPosition]; - List result = []; - while (q.TryDequeue(out Point position)) + List result = []; + while (q.TryDequeue(out SKPointI position)) { result.Add(position); @@ -108,7 +108,7 @@ public static List FindWalkableArea(this IZone zone, Area area, int size, return result; } - foreach (Point np in position.GetNonDiagonalNeighbours()) + foreach (SKPointI np in position.GetNonDiagonalNeighbours()) { if (closed.Contains(np)) { @@ -138,7 +138,7 @@ public static List FindWalkableArea(this IZone zone, Area area, int size, /// End point of line segment /// Slope capability check for slope-based blocking /// True if tiles checked are walkable - public static bool CheckLinearPath(this IZone zone, Point start, Point end, double slope = 4.0) + public static bool CheckLinearPath(this IZone zone, SKPointI start, SKPointI end, double slope = 4.0) { int x = start.X; int y = start.Y; diff --git a/template/perpetuum.ini b/template/perpetuum.ini.template similarity index 100% rename from template/perpetuum.ini rename to template/perpetuum.ini.template From decdcfd0dec2487c4a663725ea6a4f9b3778bc95 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Fri, 20 Mar 2026 20:21:56 -0400 Subject: [PATCH 07/32] Fix migration order of copy to ensure the generated perpetuum.ini is kept --- script/migration.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/script/migration.sh b/script/migration.sh index d6da2880..e62c895c 100755 --- a/script/migration.sh +++ b/script/migration.sh @@ -19,6 +19,9 @@ set -eux # - Set initial state using backup # - Run migration for each patch +# Copy the Perpetuum.ServerService data directory +cp -rv /perpetuum-service-data/* /data/ + # Copy the generated perpetuum.ini cp -v /work/perpetuum.ini /data/ @@ -28,8 +31,6 @@ cp -rv /base-data/layers /data/ # Copy the custom-layers to the shared /data/layers cp -rv /custom-layers/* /data/layers -# Copy the Perpetuum.ServerService data directory -cp -rv /perpetuum-service-data/* /data/ runSqlCmd () { set +x From 67f8314330e2698cc1d86b1cc32d8f5a4601f646 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Fri, 20 Mar 2026 20:56:48 -0400 Subject: [PATCH 08/32] Add DefinitionConfig refactor --- .../EntityFramework/DefinitionConfig.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Perpetuum/EntityFramework/DefinitionConfig.cs b/src/Perpetuum/EntityFramework/DefinitionConfig.cs index bfce9500..c670491c 100644 --- a/src/Perpetuum/EntityFramework/DefinitionConfig.cs +++ b/src/Perpetuum/EntityFramework/DefinitionConfig.cs @@ -1,9 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Drawing; +using System.Data; using Perpetuum.Data; using Perpetuum.Log; +using SkiaSharp; namespace Perpetuum.EntityFramework { @@ -51,7 +49,7 @@ public class DefinitionConfig private readonly double _hitSize; - private readonly Color _tint = Color.White; + private readonly SKColor _tint = SKColors.White; private readonly double? _coreCalories; @@ -88,7 +86,7 @@ public int ConstructionRadius get { return (int) constructionRadius.ThrowIfNull(ErrorCodes.ServerError); } } - public Color Tint + public SKColor Tint { get { return _tint; } } @@ -146,7 +144,11 @@ public DefinitionConfig(IDataRecord record) if (!string.IsNullOrEmpty(tint)) { - _tint = ColorTranslator.FromHtml(tint); + bool success = SKColor.TryParse(tint, out _tint); + if (!success) + { + Logger.Info($"Counld not parse tint {_tint}"); + } } } From b31e8210b64d8f24a09c1b153590cd92ca6e8819 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Sat, 21 Mar 2026 16:40:13 -0400 Subject: [PATCH 09/32] Update db submodule + migration script to use P34 and P35 full sql files --- db | 2 +- script/migration.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/db b/db index 008632ea..049e4371 160000 --- a/db +++ b/db @@ -1 +1 @@ -Subproject commit 008632ea4b20af45068e77c469e2ad5a3a4c0253 +Subproject commit 049e4371f1d7279422d4c867ad0d4eee69d7dd3b diff --git a/script/migration.sh b/script/migration.sh index e62c895c..36ce383f 100755 --- a/script/migration.sh +++ b/script/migration.sh @@ -117,8 +117,8 @@ applyPatch Live_30 live_patch_30.sql Server applyPatch Live_31 live_patch_31.sql Server applyPatch Live_32 live_patch_32.sql Server applyPatch Live_33 live_patch_33.sql Server -applyPatch Live_34 Raw_SQL Server -applyPatch Live_35 Raw_SQL Server +applyPatch Live_34 live_patch_34.sql Server +applyPatch Live_35 live_patch_35.sql Server # Add test account (user: test, pass: test) From 1eba63b2325030202ced5e6995023390e973b9db Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Mon, 23 Mar 2026 21:50:39 -0400 Subject: [PATCH 10/32] Update port mapping to allow more ports for the server to fix black screen issue on client when trying to enter a zone. Now the client seems to work (feature validation is in progress). Disabled EnlistTransaction to ensure DistributedTransactions are disabled --- .env.local | 6 +++--- compose.yml | 2 +- src/Perpetuum/Data/DbQuery.cs | 3 ++- src/Perpetuum/Network/SocketExtensions.cs | 17 ++++++++++++++--- src/Perpetuum/Network/TcpConnection.cs | 4 +++- src/Perpetuum/Zones/ZoneExtensions.Bitmap.cs | 2 +- 6 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.env.local b/.env.local index 97d026ba..833ddaad 100644 --- a/.env.local +++ b/.env.local @@ -13,9 +13,9 @@ DB_PASSWORD="l3(Func execute) if (Transaction.Current != null && connection is DbConnection dbConnection) { - dbConnection.EnlistTransaction(Transaction.Current); + // TODO: Make this disabled only for non-Windows + // dbConnection.EnlistTransaction(Transaction.Current); } IDbCommand command = connection.CreateCommand(); diff --git a/src/Perpetuum/Network/SocketExtensions.cs b/src/Perpetuum/Network/SocketExtensions.cs index 5492835d..864ade74 100644 --- a/src/Perpetuum/Network/SocketExtensions.cs +++ b/src/Perpetuum/Network/SocketExtensions.cs @@ -1,5 +1,4 @@ -using System; -using System.Net.Sockets; +using System.Net.Sockets; using Perpetuum.Log; namespace Perpetuum.Network @@ -9,7 +8,19 @@ public static class SocketExtensions [UsedImplicitly] public static void SetKeepAlive(this Socket socket, bool state, TimeSpan time, TimeSpan interval) { - socket.SetKeepAlive(state, (uint)time.TotalMilliseconds, (uint)interval.TotalMilliseconds); + double keepAliveTime, keepAliveInterval; + if (OperatingSystem.IsWindows()) + { + keepAliveTime = time.TotalMilliseconds; + keepAliveInterval = interval.TotalMilliseconds; + socket.SetKeepAlive(state, (uint)keepAliveTime, (uint)keepAliveInterval); + } + // else + // { + // // Disabled: Not supported by linux + // keepAliveTime = time.Seconds; + // keepAliveInterval = interval.Seconds; + // } } public static void SetKeepAlive(this Socket socket, bool state, uint time, uint interval) diff --git a/src/Perpetuum/Network/TcpConnection.cs b/src/Perpetuum/Network/TcpConnection.cs index c1fcb2e8..dbd1be01 100644 --- a/src/Perpetuum/Network/TcpConnection.cs +++ b/src/Perpetuum/Network/TcpConnection.cs @@ -29,7 +29,9 @@ public TcpConnection(Socket socket) _socket.NoDelay = true; _socket.ReceiveBufferSize = RECEIVE_BUFFER_SIZE; _socket.SendBufferSize = SEND_BUFFER_SIZE; - _socket.SetKeepAlive(true, 1000 * 60 * 60 * 24, 5000); + var keepAliveTime = new TimeSpan(24, 0, 0); // 24 hours + var keepAliveInterval = new TimeSpan(0, 0, 5); // 5 seconds + _socket.SetKeepAlive(true, keepAliveTime, keepAliveInterval); RemoteEndPoint = (IPEndPoint)_socket.RemoteEndPoint; } diff --git a/src/Perpetuum/Zones/ZoneExtensions.Bitmap.cs b/src/Perpetuum/Zones/ZoneExtensions.Bitmap.cs index d93cabb6..a69fa76d 100644 --- a/src/Perpetuum/Zones/ZoneExtensions.Bitmap.cs +++ b/src/Perpetuum/Zones/ZoneExtensions.Bitmap.cs @@ -57,7 +57,7 @@ public static SKBitmap CreatePassableBitmap(this IZone zone, SKColor passableTil public static SKBitmap CreateBitmap(this IZone zone) { var size = zone.Size; - return new SKBitmap(size.Width, size.Height, SKColorType.Rgba8888, SKAlphaType.Premul); + return new SKBitmap(size.Width, size.Height, SKColorType.Rgba8888, SKAlphaType.Opaque); } } From 984ed0a9ac871085d6e7575a02e18e8e39ae68f7 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Mon, 23 Mar 2026 22:53:46 -0400 Subject: [PATCH 11/32] Fix port mapping, defined extra SERVER_PORTS for port range to foreward to the host. --- .env.local | 5 ++++- compose.yml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.env.local b/.env.local index 833ddaad..94b46351 100644 --- a/.env.local +++ b/.env.local @@ -11,9 +11,12 @@ DB_PASSWORD="l3 Date: Tue, 24 Mar 2026 21:26:58 -0400 Subject: [PATCH 12/32] Disable EnlistTransactaction --- src/Perpetuum/Data/DbQuery.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Perpetuum/Data/DbQuery.cs b/src/Perpetuum/Data/DbQuery.cs index 2e10aa3c..e5b4e6e1 100644 --- a/src/Perpetuum/Data/DbQuery.cs +++ b/src/Perpetuum/Data/DbQuery.cs @@ -49,7 +49,7 @@ private T ExecuteHelper(Func execute) if (Transaction.Current != null && connection is DbConnection dbConnection) { - // TODO: Make this disabled only for non-Windows + // TODO: Make this disabled only for non-Windows // dbConnection.EnlistTransaction(Transaction.Current); } From c19dedfd697789be7470c75baccec211859a15e8 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Fri, 27 Mar 2026 21:41:04 -0400 Subject: [PATCH 13/32] Add documentation in the readme for how to setup the containers, make the EnlistTransaction configurable with the DistributedTransactions state --- Makefile | 2 +- README.md | 110 ++++++++++++++++++ .../PerpetuumBootstrapper.cs | 1 + src/Perpetuum/Data/DbQuery.cs | 7 +- src/Perpetuum/GlobalConfiguration.cs | 2 + 5 files changed, 117 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 47229890..239e0639 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ stop: down: ./script/compose.sh down -# Stop and delete the containers, also delete the openperpetuum-data volume +# Stop and delete the containers, also delete the volumes (openperpetuum-data, openperpetuum-db) delete: ./script/compose.sh down -v diff --git a/README.md b/README.md index b74c0a2b..273c3a3e 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,113 @@ # The Open Perpetuum Server 2 +## Setup (container) + +This section explains how to setup for local development using docker/podman compose containers where it can be controlled using some make commands. + +Here is some context for how this works: + +The entry point is `compose.yml` which contains the containers, volumes and network definitions. + +There are 2 volumes: +- `openperpetuum-data`: contains the original `PerpetuumServer/Data` + custom layers + generated perpetuum.ini +- `openperpetuum-db`: contains the database files for persistence + +There is an environment file to control most of the configuration of this setup: +- `.env.local` + +### 0. Requirements +- (optional) make (to help with docker-compose commands) +- docker/podman +- Steam: Perpetuum Dedicated server installed +- Latest gamma islands layers: https://drive.google.com/file/d/1Xp0T1K57Pv-vjgmpXMG8Iea_ec0bWYR4/view?usp=drive_link +- Latest asset resource: https://drive.google.com/file/d/18fh8aRqMP1J7ycGBNGraFyQ31mMXZaq1/view?usp=drive_link + +### 1. Clone this repository and update submodules + +Start by cloning this repository +```sh +git clone https://github.com/OpenPerpetuum/PerpetuumServer2.git +``` +or +```sh +git clone git@github.com:OpenPerpetuum/PerpetuumServer2.git +``` + +Checkout on the develop branch +```sh +git checkout develop +``` + +This repository contains 2 submodules. +- db: (OPDB) database migration files for each game update +- asset: (OPResource) game client resources that are fetched once the client connect to the server. Contains definition files for all game entities, translations, gfx, map data (layers), audio files, custom bot models. + +You can initialize and update them with this command: +```sh +git submodule init && git submodule update +``` + +### 2. Update custom resources + +This section must be performed if there are updates on the gamma layers or asset resource. + +- Uncompress gamma layers into a temporary directory +- Copy gamma layers in `asset/lang0000/layers/GAMMA_LAYERS_NEW` (all .bin files) +- Create `custom-layers` directory +- Copy gamma layers in a new directory `custom-layers` (all .bin files) (the same one as above) +- Unarchive asset resource into a temporary directory +- Copy asset resource into `asset/lang0000` directory (gfx, sfx, textures) +- Create `perpetuum-data` directory +- Copy original PerpetuumServer/data folder into `perpetuum-data` (database, layers) + + +### 3. Run the server + +At this point, you are ready to run the server. + +Note: Take a look at the `.env.local` if you need to see what ports are used, what is the database password, or if you want to update various paths. + +#### 3.1. Start + +```sh +make up +``` + +This will build and start containers. The migration will run. Once the command exit, you might need to wait for a few minutes since the server is starting up. + +You can monitor the server status with this command: +```sh +make log-server +``` + +If you see logs like `Unit enter to zone` or `Planthandler STOP SIGNAL received`, then the server is ready for a client to connect. + +#### 3.2. Setup client with that local server +- Open the client +- Click on `Server list` +- Click on `ADD PRIVATE SERVER` +- Enter Name: `local`, Server address: `127.0.0.1:17700` (update the port to match your `SERVER_PORT` from the `.env.local` file) +- Click `OK` + +#### 3.3. Connect client to the local server +- Select `local` in the server list +- Click `Connect` (this might take a few minutes to load, at this point the asset server is transfering files to the client) +- Login with the test account user: `test`, password: `test` + +#### 3.4 Stop the server +```sh +make down +``` + +This will stop and delete the containers but keep the data and db volumes + +#### 3,5 Stop and delete server data +```sh +make delete +``` + +This will stop and delete all data such as containers and volumes (data, db) + + + diff --git a/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs b/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs index d3fa67d2..24c2bf60 100644 --- a/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs +++ b/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs @@ -136,6 +136,7 @@ public void Init(string gameRoot, bool distributedTransactions) Logger.Current = _container.Resolve>(); GlobalConfiguration config = _container.Resolve(); + config.DistributedTransactions = distributedTransactions; _container.Resolve().State = HostState.Init; diff --git a/src/Perpetuum/Data/DbQuery.cs b/src/Perpetuum/Data/DbQuery.cs index e5b4e6e1..f73b11e3 100644 --- a/src/Perpetuum/Data/DbQuery.cs +++ b/src/Perpetuum/Data/DbQuery.cs @@ -6,7 +6,7 @@ namespace Perpetuum.Data { public delegate IDbConnection DbConnectionFactory(); - public class DbQuery(DbConnectionFactory connectionFactory) + public class DbQuery(DbConnectionFactory connectionFactory, GlobalConfiguration configuration) { private readonly DbConnectionFactory _connectionFactory = connectionFactory; @@ -47,10 +47,9 @@ private T ExecuteHelper(Func execute) using IDbConnection connection = _connectionFactory(); connection.Open(); - if (Transaction.Current != null && connection is DbConnection dbConnection) + if (configuration.DistributedTransactions && Transaction.Current != null && connection is DbConnection dbConnection) { - // TODO: Make this disabled only for non-Windows - // dbConnection.EnlistTransaction(Transaction.Current); + dbConnection.EnlistTransaction(Transaction.Current); } IDbCommand command = connection.CreateCommand(); diff --git a/src/Perpetuum/GlobalConfiguration.cs b/src/Perpetuum/GlobalConfiguration.cs index 9fd5157a..ab41f0d1 100644 --- a/src/Perpetuum/GlobalConfiguration.cs +++ b/src/Perpetuum/GlobalConfiguration.cs @@ -23,6 +23,8 @@ public class GlobalConfiguration public bool EnableDev { get; set; } + public bool DistributedTransactions { get; set; } + public CorporationConfiguration Corporation { get; set; } public bool StartServerInAdminOnlyMode { get; set; } From 17c38fabbdce4d440cfe54976afe17a6e567cafa Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Fri, 27 Mar 2026 22:36:46 -0400 Subject: [PATCH 14/32] Update db image to use mssql-2025 --- compose.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/compose.yml b/compose.yml index d08d12a6..a11ae35c 100644 --- a/compose.yml +++ b/compose.yml @@ -12,8 +12,7 @@ services: - ${ASSET_PORT}:1337 db: - # TODO: update to 2022 - image: mcr.microsoft.com/mssql/server:2019-latest + image: mcr.microsoft.com/mssql/server:2025-latest ports: - ${DB_PORT}:1433 environment: From afe07ea53feea922f25cd07f099ccadc0157e5d5 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Sat, 28 Mar 2026 18:37:40 -0400 Subject: [PATCH 15/32] Fix WithCanvas extension to preserve the Bitmap using SKCanvas instead of SKSurface, update usage of canvas.DrawText to fix Y position by taking into account the font size since the origin is on top left instead of bottom left. --- .../Zone/StatsMapDrawing/DrawMissionTargetLog.cs | 4 ++-- .../Zone/StatsMapDrawing/GenerateMissionSpots.cs | 10 +++++----- .../Zone/StatsMapDrawing/WorstMissionSpots.cs | 8 ++++---- .../Zone/StatsMapDrawing/ZoneDrawStatMap.cs | 16 ++++++++-------- src/Perpetuum/BitmapExtensions.cs | 3 +-- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs index ed3fd365..9b803232 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs @@ -117,8 +117,8 @@ private void DrawOneCategory(IRequest request,MissionLocation missionLocation, M var category1 = category; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 15); var paint = new SKPaint { Color = SKColors.White }; - bitmap.WithCanvas(gx => gx.DrawText(category1.ToString(), 20, 40, font, paint)); - bitmap.WithCanvas(gx => gx.DrawText(littleText, 20, 60, font, paint)); + bitmap.WithCanvas(gx => gx.DrawText(category1.ToString(), 20, 40 + font.size, font, paint)); + bitmap.WithCanvas(gx => gx.DrawText(littleText, 20, 60 + font.size, font, paint)); var idString = $"{missionLocation.id:0000}"; diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs index 0ba7d622..c90dc60e 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs @@ -273,11 +273,11 @@ private SKBitmap DrawResultOnBitmap(List spotInfos, Dictionary c.DrawText(fttext, 20, 40, font, fieldTerminalColorPaint)); - b.WithCanvas(c => c.DrawText(swtext, 20, 60, font, switchPaint)); - b.WithCanvas(c => c.DrawText(kiotext, 20, 80, font, kioskPaint)); - b.WithCanvas(c => c.DrawText(istext, 20, 100, font, itemSupplyPaint)); - b.WithCanvas(c => c.DrawText(rptext, 20, 120, font, randomPointPaint)); + b.WithCanvas(c => c.DrawText(fttext, 20, 40 + font.size, font, fieldTerminalColorPaint)); + b.WithCanvas(c => c.DrawText(swtext, 20, 60 + font.size, font, switchPaint)); + b.WithCanvas(c => c.DrawText(kiotext, 20, 80 + font.size, font, kioskPaint)); + b.WithCanvas(c => c.DrawText(istext, 20, 100 + font.size, font, itemSupplyPaint)); + b.WithCanvas(c => c.DrawText(rptext, 20, 120 + font.size, font, randomPointPaint)); return b; } diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs index f8d563f0..8124cd03 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs @@ -37,10 +37,10 @@ private SKBitmap DrawWorstSpotsMap() var kioskPaint = new SKPaint { Color = _kioskColor }; var itemSupplyPaint = new SKPaint { Color = _itemSupplyColor }; var randomPointPaint = new SKPaint { Color = _randomPointColor }; - b.WithCanvas(c => c.DrawText("switch", 20, 60, font, switchPaint)); - b.WithCanvas(c => c.DrawText("item submit/kiosk", 20, 80, font, kioskPaint)); - b.WithCanvas(c => c.DrawText("item supply", 20, 100, font, itemSupplyPaint)); - b.WithCanvas(c => c.DrawText("random point", 20, 120, font, randomPointPaint)); + b.WithCanvas(c => c.DrawText("switch", 20, 60 + font.size, font, switchPaint)); + b.WithCanvas(c => c.DrawText("item submit/kiosk", 20, 80 + font.size, font, kioskPaint)); + b.WithCanvas(c => c.DrawText("item supply", 20, 100 + font.size, font, itemSupplyPaint)); + b.WithCanvas(c => c.DrawText("random point", 20, 120 + font.size, font, randomPointPaint)); return b; diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ZoneDrawStatMap.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ZoneDrawStatMap.cs index a3b2f386..cc500132 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ZoneDrawStatMap.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ZoneDrawStatMap.cs @@ -93,7 +93,7 @@ private void CreateAndSave(string postfix, Func bitmapFactory) var paint = new SKPaint { Color = SKColors.Red }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 20); - bmp.WithCanvas(c => c.DrawText(_zone.Configuration.Name, 10, 10, font, paint)); + bmp.WithCanvas(c => c.DrawText(_zone.Configuration.Name, 10, 10 + font.size, font, paint)); string fileName = "stat_" + postfix; if (_sendtoclient) // send to client. @@ -352,7 +352,7 @@ private SKBitmap CreatePlayersMap() c.DrawOval(rect2, pen2); var textPaint = new SKPaint { Color = SKColors.Red }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 12); - c.DrawText(unit.Nick, x + 10, y + 10, font, textPaint); + c.DrawText(unit.Nick, x + 10, y + 10 + font.size, font, textPaint); } }); } @@ -374,7 +374,7 @@ private void DrawNpcPresencesOnGraphic(SKCanvas canvas) canvas.DrawRect(presence.Area.X1, presence.Area.Y1, presence.Area.Width, presence.Area.Height, paint); var textPaint = new SKPaint { Color = SKColors.Red }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 8); - canvas.DrawText(presence.Configuration.Name, presence.Area.X1, presence.Area.Y1, font, textPaint); + canvas.DrawText(presence.Configuration.Name, presence.Area.X1, presence.Area.Y1 + font.size, font, textPaint); } } @@ -398,7 +398,7 @@ private void DrawNpcFlocksOnGraphic(SKCanvas canvas) canvas.DrawOval(rect2, pen2); var textPaint = new SKPaint { Color = SKColors.Red }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 10); - canvas.DrawText(flock.Configuration.Name, txSpawnMax, tySpawnMax, font, textPaint); + canvas.DrawText(flock.Configuration.Name, txSpawnMax, tySpawnMax + font.size, font, textPaint); } } @@ -431,7 +431,7 @@ private void DrawMissionTargetsOnGraphics(SKCanvas canvas) canvas.DrawOval(rect, pen); var textPaint = new SKPaint { Color = SKColors.White }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 8); - canvas.DrawText(targetNames[missionTarget.id], tx, ty, font, textPaint); + canvas.DrawText(targetNames[missionTarget.id], tx, ty + font.size, font, textPaint); } } @@ -762,7 +762,7 @@ private SKBitmap CreateMineralsToNormalizedBitmap(MineralLayer layer) string infoString = $"{layer.Type}"; var paint = new SKPaint { Color = SKColors.White }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 10); - c.DrawText(infoString, 10, 10, font, paint); + c.DrawText(infoString, 10, 10 + font.size, font, paint); }); } @@ -829,7 +829,7 @@ private void CreateTeleportDecorMaps(out SKBitmap? bitmap, out SKBitmap? circles }); var textPaint = new SKPaint { Color = SKColors.White }; - canvas.DrawText(maximumDistance.ToString(CultureInfo.InvariantCulture), (float)td.CurrentPosition.X, (float)td.CurrentPosition.Y, font, textPaint); + canvas.DrawText(maximumDistance.ToString(CultureInfo.InvariantCulture), (float)td.CurrentPosition.X, (float)td.CurrentPosition.Y + font.size, font, textPaint); if (maximumDistance <= 0) { @@ -925,7 +925,7 @@ private void DrawStringTopLeft(SKCanvas canvas, string text) { var paint = new SKPaint { Color = SKColors.Chocolate }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 20); - canvas.DrawText(text, 50, 100, font, paint); + canvas.DrawText(text, 50, 100 + font.size, font, paint); } private void SendDrawFunctionFinished(IRequest request) diff --git a/src/Perpetuum/BitmapExtensions.cs b/src/Perpetuum/BitmapExtensions.cs index 6b6392f8..baf5c6a6 100644 --- a/src/Perpetuum/BitmapExtensions.cs +++ b/src/Perpetuum/BitmapExtensions.cs @@ -12,9 +12,8 @@ public static class BitmapExtensions return null; } - using (var surface = SKSurface.Create(new SKImageInfo(bitmap.Width, bitmap.Height))) + using (var canvas = new SKCanvas(bitmap)) { - var canvas = surface.Canvas; action(canvas); } From cba0e766ddf65e5c754765de33521f0e1d783d63 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Sat, 28 Mar 2026 18:56:16 -0400 Subject: [PATCH 16/32] Fix font.size to use Size property --- .../Zone/StatsMapDrawing/DrawMissionTargetLog.cs | 4 ++-- .../Zone/StatsMapDrawing/GenerateMissionSpots.cs | 10 +++++----- .../Zone/StatsMapDrawing/WorstMissionSpots.cs | 8 ++++---- .../Zone/StatsMapDrawing/ZoneDrawStatMap.cs | 16 ++++++++-------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs index 9b803232..bcb7d857 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/DrawMissionTargetLog.cs @@ -117,8 +117,8 @@ private void DrawOneCategory(IRequest request,MissionLocation missionLocation, M var category1 = category; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 15); var paint = new SKPaint { Color = SKColors.White }; - bitmap.WithCanvas(gx => gx.DrawText(category1.ToString(), 20, 40 + font.size, font, paint)); - bitmap.WithCanvas(gx => gx.DrawText(littleText, 20, 60 + font.size, font, paint)); + bitmap.WithCanvas(gx => gx.DrawText(category1.ToString(), 20, 40 + font.Size, font, paint)); + bitmap.WithCanvas(gx => gx.DrawText(littleText, 20, 60 + font.Size, font, paint)); var idString = $"{missionLocation.id:0000}"; diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs index c90dc60e..bbde225f 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/GenerateMissionSpots.cs @@ -273,11 +273,11 @@ private SKBitmap DrawResultOnBitmap(List spotInfos, Dictionary c.DrawText(fttext, 20, 40 + font.size, font, fieldTerminalColorPaint)); - b.WithCanvas(c => c.DrawText(swtext, 20, 60 + font.size, font, switchPaint)); - b.WithCanvas(c => c.DrawText(kiotext, 20, 80 + font.size, font, kioskPaint)); - b.WithCanvas(c => c.DrawText(istext, 20, 100 + font.size, font, itemSupplyPaint)); - b.WithCanvas(c => c.DrawText(rptext, 20, 120 + font.size, font, randomPointPaint)); + b.WithCanvas(c => c.DrawText(fttext, 20, 40 + font.Size, font, fieldTerminalColorPaint)); + b.WithCanvas(c => c.DrawText(swtext, 20, 60 + font.Size, font, switchPaint)); + b.WithCanvas(c => c.DrawText(kiotext, 20, 80 + font.Size, font, kioskPaint)); + b.WithCanvas(c => c.DrawText(istext, 20, 100 + font.Size, font, itemSupplyPaint)); + b.WithCanvas(c => c.DrawText(rptext, 20, 120 + font.Size, font, randomPointPaint)); return b; } diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs index 8124cd03..9a86feea 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/WorstMissionSpots.cs @@ -37,10 +37,10 @@ private SKBitmap DrawWorstSpotsMap() var kioskPaint = new SKPaint { Color = _kioskColor }; var itemSupplyPaint = new SKPaint { Color = _itemSupplyColor }; var randomPointPaint = new SKPaint { Color = _randomPointColor }; - b.WithCanvas(c => c.DrawText("switch", 20, 60 + font.size, font, switchPaint)); - b.WithCanvas(c => c.DrawText("item submit/kiosk", 20, 80 + font.size, font, kioskPaint)); - b.WithCanvas(c => c.DrawText("item supply", 20, 100 + font.size, font, itemSupplyPaint)); - b.WithCanvas(c => c.DrawText("random point", 20, 120 + font.size, font, randomPointPaint)); + b.WithCanvas(c => c.DrawText("switch", 20, 60 + font.Size, font, switchPaint)); + b.WithCanvas(c => c.DrawText("item submit/kiosk", 20, 80 + font.Size, font, kioskPaint)); + b.WithCanvas(c => c.DrawText("item supply", 20, 100 + font.Size, font, itemSupplyPaint)); + b.WithCanvas(c => c.DrawText("random point", 20, 120 + font.Size, font, randomPointPaint)); return b; diff --git a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ZoneDrawStatMap.cs b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ZoneDrawStatMap.cs index cc500132..8929f7e2 100644 --- a/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ZoneDrawStatMap.cs +++ b/src/Perpetuum.RequestHandlers/Zone/StatsMapDrawing/ZoneDrawStatMap.cs @@ -93,7 +93,7 @@ private void CreateAndSave(string postfix, Func bitmapFactory) var paint = new SKPaint { Color = SKColors.Red }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 20); - bmp.WithCanvas(c => c.DrawText(_zone.Configuration.Name, 10, 10 + font.size, font, paint)); + bmp.WithCanvas(c => c.DrawText(_zone.Configuration.Name, 10, 10 + font.Size, font, paint)); string fileName = "stat_" + postfix; if (_sendtoclient) // send to client. @@ -352,7 +352,7 @@ private SKBitmap CreatePlayersMap() c.DrawOval(rect2, pen2); var textPaint = new SKPaint { Color = SKColors.Red }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 12); - c.DrawText(unit.Nick, x + 10, y + 10 + font.size, font, textPaint); + c.DrawText(unit.Nick, x + 10, y + 10 + font.Size, font, textPaint); } }); } @@ -374,7 +374,7 @@ private void DrawNpcPresencesOnGraphic(SKCanvas canvas) canvas.DrawRect(presence.Area.X1, presence.Area.Y1, presence.Area.Width, presence.Area.Height, paint); var textPaint = new SKPaint { Color = SKColors.Red }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 8); - canvas.DrawText(presence.Configuration.Name, presence.Area.X1, presence.Area.Y1 + font.size, font, textPaint); + canvas.DrawText(presence.Configuration.Name, presence.Area.X1, presence.Area.Y1 + font.Size, font, textPaint); } } @@ -398,7 +398,7 @@ private void DrawNpcFlocksOnGraphic(SKCanvas canvas) canvas.DrawOval(rect2, pen2); var textPaint = new SKPaint { Color = SKColors.Red }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 10); - canvas.DrawText(flock.Configuration.Name, txSpawnMax, tySpawnMax + font.size, font, textPaint); + canvas.DrawText(flock.Configuration.Name, txSpawnMax, tySpawnMax + font.Size, font, textPaint); } } @@ -431,7 +431,7 @@ private void DrawMissionTargetsOnGraphics(SKCanvas canvas) canvas.DrawOval(rect, pen); var textPaint = new SKPaint { Color = SKColors.White }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 8); - canvas.DrawText(targetNames[missionTarget.id], tx, ty + font.size, font, textPaint); + canvas.DrawText(targetNames[missionTarget.id], tx, ty + font.Size, font, textPaint); } } @@ -762,7 +762,7 @@ private SKBitmap CreateMineralsToNormalizedBitmap(MineralLayer layer) string infoString = $"{layer.Type}"; var paint = new SKPaint { Color = SKColors.White }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 10); - c.DrawText(infoString, 10, 10 + font.size, font, paint); + c.DrawText(infoString, 10, 10 + font.Size, font, paint); }); } @@ -829,7 +829,7 @@ private void CreateTeleportDecorMaps(out SKBitmap? bitmap, out SKBitmap? circles }); var textPaint = new SKPaint { Color = SKColors.White }; - canvas.DrawText(maximumDistance.ToString(CultureInfo.InvariantCulture), (float)td.CurrentPosition.X, (float)td.CurrentPosition.Y + font.size, font, textPaint); + canvas.DrawText(maximumDistance.ToString(CultureInfo.InvariantCulture), (float)td.CurrentPosition.X, (float)td.CurrentPosition.Y + font.Size, font, textPaint); if (maximumDistance <= 0) { @@ -925,7 +925,7 @@ private void DrawStringTopLeft(SKCanvas canvas, string text) { var paint = new SKPaint { Color = SKColors.Chocolate }; var font = new SKFont(SKTypeface.FromFamilyName("Tahoma"), 20); - canvas.DrawText(text, 50, 100 + font.size, font, paint); + canvas.DrawText(text, 50, 100 + font.Size, font, paint); } private void SendDrawFunctionFinished(IRequest request) From 8a4061094e55743e3465f640215bac031c516ae9 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Sun, 29 Mar 2026 11:49:59 -0400 Subject: [PATCH 17/32] Fix pixel check to use Alpha instead of the Luminance --- src/Perpetuum.RequestHandlers/Zone/ZoneSetLayerWithBitMap.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Perpetuum.RequestHandlers/Zone/ZoneSetLayerWithBitMap.cs b/src/Perpetuum.RequestHandlers/Zone/ZoneSetLayerWithBitMap.cs index 430cd89e..4f8d6071 100644 --- a/src/Perpetuum.RequestHandlers/Zone/ZoneSetLayerWithBitMap.cs +++ b/src/Perpetuum.RequestHandlers/Zone/ZoneSetLayerWithBitMap.cs @@ -27,7 +27,7 @@ public void HandleRequest(IZoneRequest request) { zone.Terrain.Controls.UpdateAll((x, y, c) => { - if (bmp.GetPixel(x, y).GetLuminance() == 0) + if (bmp.GetPixel(x, y).Alpha == 0) { return c; } From 005b8ce8b5c91ec88b9d461836723b0a027080cc Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Sun, 29 Mar 2026 17:21:54 -0400 Subject: [PATCH 18/32] Fix typo, replace usage of manual creation of Area with size.ToArea(), update comment --- src/Perpetuum.Server/Program.cs | 2 +- src/Perpetuum/EntityFramework/DefinitionConfig.cs | 2 +- .../Services/Relics/RelicManagers/OutpostRelicManager.cs | 2 +- src/Perpetuum/Services/RiftSystem/RiftManager.cs | 2 +- .../Zones/Artifacts/Generators/PersistentArtifactGenerator.cs | 2 +- .../PositionFinders/RandomWalkableAroundPositionFinder.cs | 2 +- src/Perpetuum/Zones/NpcSystem/Presences/DynamicPresence.cs | 2 +- .../Zones/NpcSystem/Presences/PresenceConfiguration.cs | 2 +- src/Perpetuum/Zones/Terrains/Materials/Plants/PlantHandler.cs | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Perpetuum.Server/Program.cs b/src/Perpetuum.Server/Program.cs index c24f4869..d828f3d2 100644 --- a/src/Perpetuum.Server/Program.cs +++ b/src/Perpetuum.Server/Program.cs @@ -37,7 +37,7 @@ static int Main(string[] args) return 3; } - // TODO: fix a way to take optional argument to be able to configure the DistributedTransactions from here + // When using PerpetuumServer, the DistributedTransactions is enabled bootstrapper.Init(gameRoot.Value, true); if (bootstrapper.TryInitUpnp(out bool upnpSuccess)) diff --git a/src/Perpetuum/EntityFramework/DefinitionConfig.cs b/src/Perpetuum/EntityFramework/DefinitionConfig.cs index c670491c..b28b5ba6 100644 --- a/src/Perpetuum/EntityFramework/DefinitionConfig.cs +++ b/src/Perpetuum/EntityFramework/DefinitionConfig.cs @@ -147,7 +147,7 @@ public DefinitionConfig(IDataRecord record) bool success = SKColor.TryParse(tint, out _tint); if (!success) { - Logger.Info($"Counld not parse tint {_tint}"); + Logger.Info($"Could not parse tint {_tint}"); } } } diff --git a/src/Perpetuum/Services/Relics/RelicManagers/OutpostRelicManager.cs b/src/Perpetuum/Services/Relics/RelicManagers/OutpostRelicManager.cs index 89c8135b..46b6ccca 100644 --- a/src/Perpetuum/Services/Relics/RelicManagers/OutpostRelicManager.cs +++ b/src/Perpetuum/Services/Relics/RelicManagers/OutpostRelicManager.cs @@ -81,7 +81,7 @@ protected override SKPointI FindRelicPosition(RelicInfo info) var posFinder = new ClosestWalkablePositionFinder(_zone, randomPos); posFinder.Find(out Position p); - var result = _zone.FindWalkableArea(p, new Area(0, 0, _zone.Size.Width, _zone.Size.Height), SPAWN_AREA_REQUIRED_SIZE); + var result = _zone.FindWalkableArea(p, _zone.Size.ToArea(), SPAWN_AREA_REQUIRED_SIZE); if(result != null) { return p; diff --git a/src/Perpetuum/Services/RiftSystem/RiftManager.cs b/src/Perpetuum/Services/RiftSystem/RiftManager.cs index dc90a5a8..e717796b 100644 --- a/src/Perpetuum/Services/RiftSystem/RiftManager.cs +++ b/src/Perpetuum/Services/RiftSystem/RiftManager.cs @@ -47,7 +47,7 @@ public PvpRiftSpawnPositionFinder(IZone zone) : base(zone) protected override SKPointI FindSpawnPosition(IZone zone) { - var p = zone.FindWalkableArea(new Area(0, 0, zone.Size.Width, zone.Size.Height), 20); + var p = zone.FindWalkableArea(zone.Size.ToArea(), 20); return p.RandomElement(); } } diff --git a/src/Perpetuum/Zones/Artifacts/Generators/PersistentArtifactGenerator.cs b/src/Perpetuum/Zones/Artifacts/Generators/PersistentArtifactGenerator.cs index 82e8f10d..7e970151 100644 --- a/src/Perpetuum/Zones/Artifacts/Generators/PersistentArtifactGenerator.cs +++ b/src/Perpetuum/Zones/Artifacts/Generators/PersistentArtifactGenerator.cs @@ -81,7 +81,7 @@ private static SKPointI FindArtifactPosition(IZone zone) } // gamman keresunk teruletet - var p = zone.FindWalkableArea(new Area(0, 0, zone.Size.Width, zone.Size.Height), 20); + var p = zone.FindWalkableArea(zone.Size.ToArea(), 20); return p.RandomElement(); } diff --git a/src/Perpetuum/Zones/Finders/PositionFinders/RandomWalkableAroundPositionFinder.cs b/src/Perpetuum/Zones/Finders/PositionFinders/RandomWalkableAroundPositionFinder.cs index cb02c8ad..e7f8fe78 100644 --- a/src/Perpetuum/Zones/Finders/PositionFinders/RandomWalkableAroundPositionFinder.cs +++ b/src/Perpetuum/Zones/Finders/PositionFinders/RandomWalkableAroundPositionFinder.cs @@ -21,7 +21,7 @@ public RandomWalkableAroundPositionFinder(IZone zone, Position origin, int range _slope = slope; _origin = origin; _maxRange = range; - _zoneArea = new Area(0, 0, zone.Size.Width, zone.Size.Height); + _zoneArea = zone.Size.ToArea(); } protected Position FindClosestWalkable(IZone zone, Position pos) diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/DynamicPresence.cs b/src/Perpetuum/Zones/NpcSystem/Presences/DynamicPresence.cs index b57a0d1b..1b9809f0 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/DynamicPresence.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/DynamicPresence.cs @@ -56,7 +56,7 @@ public DynamicPresence(IZone zone, IPresenceConfiguration configuration) : base( public override Area Area { - get { return new Area(0, 0, Zone.Size.Width, Zone.Size.Height); } + get { return Zone.Size.ToArea(); } } protected override void OnPresenceExpired() diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs b/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs index 9c78869c..3536dda4 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs @@ -48,7 +48,7 @@ public class DirectPresenceConfiguration : PresenceConfiguration public DirectPresenceConfiguration(IZone zone) : base(_idGenerator.GetNextID(), PresenceType.Direct) { - Area = new Area(0, 0, zone.Size.Width, zone.Size.Height); + Area = zone.Size.ToArea(); Name = "direct presence " + ID; SpawnId = 10; //dynamic kamubol, szerintem kicsit sem kell Note = "abs! rulez"; diff --git a/src/Perpetuum/Zones/Terrains/Materials/Plants/PlantHandler.cs b/src/Perpetuum/Zones/Terrains/Materials/Plants/PlantHandler.cs index 5b782836..77f9554b 100644 --- a/src/Perpetuum/Zones/Terrains/Materials/Plants/PlantHandler.cs +++ b/src/Perpetuum/Zones/Terrains/Materials/Plants/PlantHandler.cs @@ -47,7 +47,7 @@ public PlantHandler(IZone zone) : TimeSpan.FromHours(FULL_PLANT_REGEN_PASS / _total_area); _plantsTimer = new IntervalTimer(refreshRate); _natureSleepAmount = refreshRate; - WorkArea = new Area(0, 0, zone.Size.Width, zone.Size.Height); + WorkArea = zone.Size.ToArea(); ScannerMode = PlantScannerMode.Scanner; ResetState(); } From 14a1e651d58ead01dec7d5b714b18ecf09109a2c Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Sun, 29 Mar 2026 17:24:31 -0400 Subject: [PATCH 19/32] Fix PresenceConfiguration to use the Configuration size instead of the zone size. --- .../Zones/NpcSystem/Presences/PresenceConfiguration.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs b/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs index 3536dda4..b6810405 100644 --- a/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs +++ b/src/Perpetuum/Zones/NpcSystem/Presences/PresenceConfiguration.cs @@ -48,7 +48,7 @@ public class DirectPresenceConfiguration : PresenceConfiguration public DirectPresenceConfiguration(IZone zone) : base(_idGenerator.GetNextID(), PresenceType.Direct) { - Area = zone.Size.ToArea(); + Area = zone.Configuration.Size.ToArea(); Name = "direct presence " + ID; SpawnId = 10; //dynamic kamubol, szerintem kicsit sem kell Note = "abs! rulez"; From c1a425d7eb1ab49cb041ac2d7d482448881e2494 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Sat, 29 Aug 2026 23:22:14 -0400 Subject: [PATCH 20/32] Merge develop branch into linux+containerize --- .github/workflows/dotnet.yml | 84 +- .gitignore | 13 + PerpetuumServer2.sln | 28 + README.md | 161 +- src/Perpetuum.AdminTool/App.xaml | 7 + src/Perpetuum.AdminTool/App.xaml.cs | 37 + src/Perpetuum.AdminTool/AppSession.cs | 21 + .../AutoMarket/AddAutoMarketItemPickItem.cs | 9 + .../AutoMarket/AutoMarketConfigRow.cs | 18 + .../AutoMarketCoveredMaterialRow.cs | 25 + .../AutoMarket/AutoMarketGatherRow.cs | 12 + .../AutoMarket/AutoMarketLabels.cs | 22 + .../AutoMarket/AutoMarketNicFlowRow.cs | 12 + .../AutoMarket/AutoMarketOrderData.cs | 10 + .../AutoMarket/AutoMarketOrderRow.cs | 12 + .../AutoMarket/AutoMarketPricingTraceRow.cs | 15 + .../AutoMarket/AutoMarketRawMaterialRow.cs | 8 + .../AutoMarket/AutoMarketRepository.cs | 428 ++++ .../AutoMarket/AutoMarketTradeListRow.cs | 26 + .../Common/BindingProxy.cs | 21 + .../Common/EntityPickItem.cs | 15 + .../Common/EnumIntConverter.cs | 23 + .../Common/FlagDescriptionConverters.cs | 35 + .../Common/InverseBoolConverter.cs | 17 + .../Common/LongToForegroundConverter.cs | 18 + src/Perpetuum.AdminTool/Common/LookupCache.cs | 99 + .../Common/PresencePickItem.cs | 9 + .../Common/TemplatePickItem.cs | 9 + .../Common/ZoneSpawnPickItem.cs | 9 + src/Perpetuum.AdminTool/Data/Authenticator.cs | 103 + src/Perpetuum.AdminTool/Data/DbProbe.cs | 24 + .../Economy/EconomyCorporationWealthRow.cs | 13 + .../Economy/EconomyInsuranceRepository.cs | 80 + .../Economy/EconomyListingAgeBuckets.cs | 10 + .../Economy/EconomyMarketData.cs | 13 + .../Economy/EconomyMarketHealthRepository.cs | 135 ++ .../Economy/EconomyMoneySupplyData.cs | 15 + .../Economy/EconomyMoneySupplyRepository.cs | 161 ++ .../Economy/EconomyNicFlowRow.cs | 12 + .../Economy/EconomyPriceIndexBasketItem.cs | 12 + .../Economy/EconomyPriceIndexRow.cs | 10 + .../Economy/EconomyRepository.cs | 231 +++ .../Economy/EconomySinkData.cs | 11 + .../Economy/EconomySinkRepository.cs | 147 ++ .../Economy/EconomySinkRow.cs | 10 + .../Economy/EconomySnapshotRow.cs | 10 + .../Economy/EconomyVelocityRow.cs | 10 + .../Economy/EconomyWealthRow.cs | 10 + .../Economy/InsuranceConfigRow.cs | 19 + .../Economy/InsuranceLabels.cs | 16 + .../Economy/InsurancePriceRow.cs | 10 + .../Editing/ChangeApplier.cs | 29 + .../Editing/ChangeQueue.cs | 26 + .../Editing/EntityChanges.cs | 175 ++ .../Editing/FlockChanges.cs | 98 + .../Editing/IPendingChange.cs | 9 + .../Editing/NpcLootChanges.cs | 79 + .../Editing/PresenceChanges.cs | 119 ++ .../Editing/RawSqlChange.cs | 18 + src/Perpetuum.AdminTool/Editing/SqlLiteral.cs | 31 + .../Editing/SqlScriptBuilder.cs | 53 + .../Editing/TemplateChanges.cs | 70 + .../Editing/TemplateRelationChanges.cs | 93 + .../Entities/AggregateFieldInfo.cs | 23 + .../Entities/AttributeFlagsCatalog.cs | 50 + .../Entities/CategoryFlagsCatalog.cs | 41 + .../Entities/CategoryFlagsHierarchy.cs | 58 + .../Entities/CategoryFlagsNode.cs | 49 + .../Entities/EntityDefaultRow.cs | 151 ++ .../Entities/EntityRepository.cs | 119 ++ .../Entities/EntitySnapshot.cs | 11 + src/Perpetuum.AdminTool/Entities/StatRow.cs | 31 + .../EquipmentSets/AggregateFieldPickItem.cs | 11 + .../EquipmentSets/EquipmentSetChanges.cs | 66 + .../EquipmentSets/EquipmentSetMemberRow.cs | 10 + .../EquipmentSets/EquipmentSetRepository.cs | 108 + .../EquipmentSets/EquipmentSetRow.cs | 12 + .../EquipmentSets/EquipmentSetThresholdRow.cs | 16 + .../EquipmentSets/SetMemberPickItem.cs | 13 + .../Export/ExportScriptViewModel.cs | 39 + .../Export/ItemExporter.cs | 341 ++++ .../Export/RobotExporter.cs | 144 ++ .../Export/SeasonExporter.cs | 426 ++++ .../Export/SqlExportBuilder.cs | 19 + .../Loot/NpcLootRepository.cs | 45 + src/Perpetuum.AdminTool/Loot/NpcLootRow.cs | 88 + .../NewItem/BasicPanelMode.cs | 9 + .../NewItem/BasicPanelViewModel.cs | 120 ++ .../NewItem/CloneExtendedData.cs | 13 + .../NewItem/DefinitionConfigColumnInfo.cs | 10 + .../NewItem/DefinitionConfigRow.cs | 11 + .../NewItem/EnablerExtensionRow.cs | 11 + .../NewItem/ExtensionPickItem.cs | 6 + .../NewItem/ItemSqlBuilder.cs | 120 ++ .../NewItem/NewComponentRow.cs | 10 + .../NewItem/NewItemLookups.cs | 18 + .../NewItem/NewItemRepository.cs | 201 ++ src/Perpetuum.AdminTool/NewItem/NewStatRow.cs | 10 + .../NewItem/OptionsVisualPanelViewModel.cs | 63 + .../NewItem/PointTypePickItem.cs | 6 + .../NewItem/ProductionPanelViewModel.cs | 57 + .../NewItem/PropertyModifierRow.cs | 11 + .../PropertyModifiersPanelViewModel.cs | 40 + .../NewItem/ResearchCostRow.cs | 10 + .../NewItem/ResearchPanelViewModel.cs | 79 + .../NewItem/StatsPanelViewModel.cs | 39 + .../NewItem/TechTreeGroupPickItem.cs | 6 + .../NewItem/TechTreePlacementRow.cs | 15 + .../NewRobot/BonusesPanelViewModel.cs | 57 + .../NewRobot/NewBonusRow.cs | 13 + .../NewRobot/NewRobotRepository.cs | 65 + .../NewRobot/RobotSqlBuilder.cs | 188 ++ .../NewRobot/RobotTemplatePanelViewModel.cs | 13 + .../RobotTemplateRelationPanelViewModel.cs | 31 + .../Npc/FlockRepository.cs | 127 ++ src/Perpetuum.AdminTool/Npc/FlockRow.cs | 117 ++ .../Npc/PresenceRepository.cs | 87 + src/Perpetuum.AdminTool/Npc/PresenceRow.cs | 132 ++ .../Packages/PackageChanges.cs | 60 + .../Packages/PackageItemPickItem.cs | 92 + .../Packages/PackageItemRow.cs | 23 + .../Packages/PackageRepository.cs | 111 + .../Packages/PackageRow.cs | 30 + .../Perpetuum.AdminTool.csproj | 31 + .../Seasons/MaterialPickItem.cs | 7 + .../Seasons/SeasonActivityRateRow.cs | 97 + .../Seasons/SeasonChanges.cs | 136 ++ .../Seasons/SeasonLeaderboardRewardRow.cs | 42 + .../Seasons/SeasonObjectiveRow.cs | 99 + .../Seasons/SeasonRepository.cs | 406 ++++ src/Perpetuum.AdminTool/Seasons/SeasonRow.cs | 96 + .../Seasons/SeasonTierRow.cs | 43 + .../Seasons/TodaysDailyObjectiveRow.cs | 10 + .../Settings/AppSettings.cs | 11 + .../Settings/AppSettingsStore.cs | 48 + src/Perpetuum.AdminTool/Settings/ApplyMode.cs | 8 + .../Settings/ConnectionSettings.cs | 39 + .../Templates/RobotTemplateEditorEntity.cs | 46 + .../RobotTemplateEditorRepository.cs | 96 + .../RobotTemplateRelationRepository.cs | 46 + .../Templates/RobotTemplateRelationRow.cs | 89 + .../Templates/RobotTemplateRepository.cs | 39 + .../Templates/RobotTemplateRow.cs | 79 + .../Translations/LanguageCatalog.cs | 36 + .../Translations/TranslationRow.cs | 44 + .../Translations/TranslationStore.cs | 160 ++ .../ViewModels/AddAutoMarketItemViewModel.cs | 65 + .../ViewModels/AddFlockRowViewModel.cs | 36 + .../ViewModels/AddKeyViewModel.cs | 10 + .../ViewModels/AddLanguageViewModel.cs | 27 + .../ViewModels/AddNpcLootRowViewModel.cs | 24 + .../ViewModels/AddPresenceRowViewModel.cs | 34 + .../ViewModels/AddSetMemberViewModel.cs | 72 + .../ViewModels/AddSetThresholdViewModel.cs | 41 + .../AddTemplateRelationRowViewModel.cs | 25 + .../AttributeFlagsPickerViewModel.cs | 44 + .../ViewModels/AutoMarketConfigViewModel.cs | 62 + .../ViewModels/AutoMarketOrdersViewModel.cs | 112 ++ .../AutoMarketRawMaterialsViewModel.cs | 118 ++ .../AutoMarketStatisticsViewModel.cs | 84 + .../AutoMarketTradeListViewModel.cs | 152 ++ .../ViewModels/AutoMarketViewModel.cs | 67 + .../CategoryFlagsPickerViewModel.cs | 35 + .../ViewModels/ConfirmSqlViewModel.cs | 16 + .../ViewModels/ConnectionSettingsViewModel.cs | 72 + .../ViewModels/EconomyInsuranceViewModel.cs | 114 ++ .../EconomyMarketHealthViewModel.cs | 142 ++ .../ViewModels/EconomyMoneySupplyViewModel.cs | 65 + .../ViewModels/EconomyNicFlowViewModel.cs | 69 + .../EconomySinkEffectivenessViewModel.cs | 53 + .../ViewModels/EconomyViewModel.cs | 32 + .../ViewModels/EntitiesViewModel.cs | 336 ++++ .../ViewModels/EntityDetailViewModel.cs | 163 ++ .../ViewModels/EquipmentSetsViewModel.cs | 341 ++++ .../ViewModels/FlocksViewModel.cs | 197 ++ .../ViewModels/LoginViewModel.cs | 95 + .../ViewModels/MainViewModel.cs | 296 +++ .../ViewModels/NewEntityViewModel.cs | 10 + .../ViewModels/NewItemDialogViewModel.cs | 274 +++ .../ViewModels/NewRobotDialogViewModel.cs | 457 +++++ .../ViewModels/NpcLootViewModel.cs | 186 ++ .../ViewModels/PackagesViewModel.cs | 317 +++ .../ViewModels/PresenceFlocksViewModel.cs | 54 + .../ViewModels/PresencesViewModel.cs | 178 ++ .../RobotTemplateEditorViewModel.cs | 272 +++ .../RobotTemplateRelationsViewModel.cs | 224 +++ .../ViewModels/RobotTemplateSlotViewModel.cs | 132 ++ .../ViewModels/RobotTemplatesViewModel.cs | 222 ++ .../ViewModels/SeasonDetailViewModel.cs | 588 ++++++ .../ViewModels/SeasonStatisticsViewModel.cs | 63 + .../ViewModels/SeasonWizardViewModel.cs | 392 ++++ .../ViewModels/SeasonsViewModel.cs | 146 ++ .../ViewModels/TranslationsViewModel.cs | 119 ++ .../Views/AddAutoMarketItemWindow.xaml | 33 + .../Views/AddAutoMarketItemWindow.xaml.cs | 23 + .../Views/AddFlockRowWindow.xaml | 126 ++ .../Views/AddFlockRowWindow.xaml.cs | 25 + .../Views/AddKeyWindow.xaml | 20 + .../Views/AddKeyWindow.xaml.cs | 28 + .../Views/AddLanguageWindow.xaml | 31 + .../Views/AddLanguageWindow.xaml.cs | 33 + .../Views/AddNpcLootRowWindow.xaml | 76 + .../Views/AddNpcLootRowWindow.xaml.cs | 21 + .../Views/AddPresenceRowWindow.xaml | 134 ++ .../Views/AddPresenceRowWindow.xaml.cs | 23 + .../Views/AddSetMemberWindow.xaml | 38 + .../Views/AddSetMemberWindow.xaml.cs | 27 + .../Views/AddSetThresholdWindow.xaml | 58 + .../Views/AddSetThresholdWindow.xaml.cs | 41 + .../Views/AddTemplateRelationRowWindow.xaml | 84 + .../AddTemplateRelationRowWindow.xaml.cs | 21 + .../Views/AttributeFlagsPickerWindow.xaml | 26 + .../Views/AttributeFlagsPickerWindow.xaml.cs | 27 + .../Views/AutoMarketConfigView.xaml | 56 + .../Views/AutoMarketConfigView.xaml.cs | 9 + .../Views/AutoMarketOrdersView.xaml | 57 + .../Views/AutoMarketOrdersView.xaml.cs | 9 + .../Views/AutoMarketRawMaterialsView.xaml | 60 + .../Views/AutoMarketRawMaterialsView.xaml.cs | 12 + .../Views/AutoMarketStatisticsView.xaml | 89 + .../Views/AutoMarketStatisticsView.xaml.cs | 9 + .../Views/AutoMarketTradeListView.xaml | 114 ++ .../Views/AutoMarketTradeListView.xaml.cs | 18 + .../Views/AutoMarketView.xaml | 62 + .../Views/AutoMarketView.xaml.cs | 24 + .../Views/CategoryFlagsPickerWindow.xaml | 31 + .../Views/CategoryFlagsPickerWindow.xaml.cs | 35 + .../Views/ConfirmSqlWindow.xaml | 28 + .../Views/ConfirmSqlWindow.xaml.cs | 24 + .../Views/ConnectionSettingsWindow.xaml | 80 + .../Views/ConnectionSettingsWindow.xaml.cs | 60 + .../Views/EconomyInsuranceView.xaml | 99 + .../Views/EconomyInsuranceView.xaml.cs | 22 + .../Views/EconomyMarketHealthView.xaml | 138 ++ .../Views/EconomyMarketHealthView.xaml.cs | 9 + .../Views/EconomyMoneySupplyView.xaml | 110 + .../Views/EconomyMoneySupplyView.xaml.cs | 9 + .../Views/EconomyNicFlowView.xaml | 109 + .../Views/EconomyNicFlowView.xaml.cs | 21 + .../Views/EconomySinkEffectivenessView.xaml | 75 + .../EconomySinkEffectivenessView.xaml.cs | 9 + .../Views/EconomyView.xaml | 27 + .../Views/EconomyView.xaml.cs | 9 + .../Views/EntitiesView.xaml | 102 + .../Views/EntitiesView.xaml.cs | 80 + .../Views/EntityDetailView.xaml | 170 ++ .../Views/EntityDetailView.xaml.cs | 112 ++ .../Views/EquipmentSetsView.xaml | 177 ++ .../Views/EquipmentSetsView.xaml.cs | 39 + .../Views/ExportScriptWindow.xaml | 29 + .../Views/ExportScriptWindow.xaml.cs | 15 + src/Perpetuum.AdminTool/Views/FlocksView.xaml | 129 ++ .../Views/FlocksView.xaml.cs | 95 + .../Views/LoginWindow.xaml | 66 + .../Views/LoginWindow.xaml.cs | 26 + src/Perpetuum.AdminTool/Views/MainWindow.xaml | 100 + .../Views/MainWindow.xaml.cs | 42 + .../Views/NewEntityWindow.xaml | 38 + .../Views/NewEntityWindow.xaml.cs | 28 + .../Views/NewItemDialog.xaml | 925 +++++++++ .../Views/NewItemDialog.xaml.cs | 80 + .../Views/NewRobotDialog.xaml | 1784 +++++++++++++++++ .../Views/NewRobotDialog.xaml.cs | 140 ++ .../Views/NewTemplateWindow.xaml | 18 + .../Views/NewTemplateWindow.xaml.cs | 25 + .../Views/NpcLootView.xaml | 109 + .../Views/NpcLootView.xaml.cs | 76 + .../Views/PackagesView.xaml | 156 ++ .../Views/PackagesView.xaml.cs | 22 + .../Views/PresenceFlocksWindow.xaml | 61 + .../Views/PresenceFlocksWindow.xaml.cs | 25 + .../Views/PresencesView.xaml | 119 ++ .../Views/PresencesView.xaml.cs | 117 ++ .../Views/RobotTemplateEditorWindow.xaml | 155 ++ .../Views/RobotTemplateEditorWindow.xaml.cs | 29 + .../Views/RobotTemplateRelationsView.xaml | 107 + .../Views/RobotTemplateRelationsView.xaml.cs | 79 + .../Views/RobotTemplatesView.xaml | 134 ++ .../Views/RobotTemplatesView.xaml.cs | 93 + .../Views/SeasonDetailView.xaml | 537 +++++ .../Views/SeasonDetailView.xaml.cs | 28 + .../Views/SeasonWizardWindow.xaml | 446 +++++ .../Views/SeasonWizardWindow.xaml.cs | 31 + .../Views/SeasonsView.xaml | 163 ++ .../Views/SeasonsView.xaml.cs | 34 + .../Views/TranslationsView.xaml | 45 + .../Views/TranslationsView.xaml.cs | 156 ++ src/Perpetuum.AdminTool/app.manifest | 26 + src/Perpetuum.AdminTool/docs/DECISIONS.md | 54 + src/Perpetuum.AdminTool/docs/PROJECT_PLAN.md | 66 + src/Perpetuum.AdminTool/docs/STATE_SUMMARY.md | 122 ++ src/Perpetuum.AdminTool/docs/TASKS.md | 89 + .../prompts/continue_phase.txt | 25 + .../prompts/implement_task.txt | 16 + .../prompts/summarize_state.txt | 14 + .../AdminToolComponents.wxs | 28 + src/Perpetuum.AdminToolInstaller/Folders.wxs | 7 + .../Package.en-us.wxl | 3 + src/Perpetuum.AdminToolInstaller/Package.wxs | 12 + .../Perpetuum.AdminToolInstaller.wixproj | 11 + .../Modules/EffectsModule.cs | 4 + .../Modules/EntitiesModule.cs | 17 + .../Modules/SeasonModule.cs | 26 + .../PerpetuumBootstrapper.cs | 37 + src/Perpetuum.ExportedTypes/AggregateField.cs | 5 + src/Perpetuum.ExportedTypes/CategoryFlags.cs | 4 + src/Perpetuum.ExportedTypes/EffectType.cs | 2 + .../Markets/MarketBuyItem.cs | 11 + .../Markets/MarketCreateBuyOrder.cs | 3 +- .../Markets/MarketCreateSellOrder.cs | 3 +- .../SignInRequestHandler.cs | 18 +- .../AssemblyInfo.cs | 3 + .../Content/ContentInvariantTests.cs | 165 ++ .../Data/InsuranceQueryTests.cs | 89 + .../Infrastructure/DatabaseCollection.cs | 16 + .../Infrastructure/DatabaseFixture.cs | 35 + .../EnvironmentDiscoveryTests.cs | 50 + .../Infrastructure/GameRootEnvironment.cs | 72 + .../RequiresGameRootFactAttribute.cs | 20 + .../Perpetuum.Tests.Integration.csproj | 21 + .../Schema/StoredProcedureConformanceTests.cs | 131 ++ src/Perpetuum.Tests/AssemblyInfo.cs | 3 + .../Fakes/Data/FakeDataReader.cs | 81 + src/Perpetuum.Tests/Fakes/Data/FakeDb.cs | 102 + .../Fakes/Data/FakeDbCommand.cs | 75 + .../Fakes/Data/FakeDbConnection.cs | 37 + .../Fakes/Data/FakeResultSet.cs | 28 + src/Perpetuum.Tests/Fakes/RecordingLogger.cs | 43 + .../Fakes/Sessions/FakeAccountRepository.cs | 31 + .../Fakes/Sessions/FakeRelayServices.cs | 41 + .../Fakes/Sessions/FakeSession.cs | 59 + .../Fakes/Sessions/FakeSessionManager.cs | 35 + .../AssemblyLoggerInitializer.cs | 27 + .../PerpetuumStaticsCollection.cs | 16 + .../Infrastructure/PerpetuumStaticsFixture.cs | 36 + src/Perpetuum.Tests/Perpetuum.Tests.csproj | 23 + .../Regression/Issue033EmptyFlockTests.cs | 69 + .../Issue039InsuranceTransactionTests.cs | 92 + .../Unit/ConnectionActivityTests.cs | 123 ++ .../Unit/ConnectionStringSupportTests.cs | 138 ++ src/Perpetuum.Tests/Unit/DbQueryTests.cs | 166 ++ src/Perpetuum.Tests/Unit/GuardTests.cs | 242 +++ .../Unit/RecordingLoggerTests.cs | 34 + .../Unit/SessionDiagnosticsTests.cs | 80 + .../Unit/StaleLoginReportingTests.cs | 83 + .../Unit/StaleOnlineFlagCensusTests.cs | 83 + .../Unit/StaleOnlineFlagsTests.cs | 60 + .../Unit/ValueTypeExtensionsTests.cs | 42 + src/Perpetuum/Accounting/AccountManager.cs | 4 + .../Accounting/Characters/CharacterWallet.cs | 39 +- src/Perpetuum/Data/ConnectionStringSupport.cs | 73 + src/Perpetuum/Data/DbQuery.cs | 8 + src/Perpetuum/Modules/ArmorRepairModule.cs | 6 + src/Perpetuum/Modules/DrillerModule.cs | 5 +- .../Modules/EnergyNeutralizerModule.cs | 13 +- .../Modules/EnergyTransfererModule.cs | 7 + src/Perpetuum/Modules/EnergyVampireModule.cs | 11 + src/Perpetuum/Modules/HarvesterModule.cs | 5 +- src/Perpetuum/Modules/LargeDrillerModule.cs | 5 +- src/Perpetuum/Modules/LargeHarvesterModule.cs | 5 +- .../HunterRemoteControllerModule.cs | 52 + src/Perpetuum/Modules/ScorcherModule.cs | 11 + src/Perpetuum/Modules/SelfDestructModule.cs | 68 + src/Perpetuum/Network/ConnectionActivity.cs | 74 + src/Perpetuum/Network/ITcpConnection.cs | 5 + src/Perpetuum/Network/TcpConnection.cs | 24 +- src/Perpetuum/Players/Player.cs | 24 + .../EquipmentSetBonusCalculator.cs | 60 + .../EquipmentSets/EquipmentSetBonusResult.cs | 25 + .../EquipmentSets/EquipmentSetRepository.cs | 43 + .../IEquipmentSetBonusCalculator.cs | 9 + .../EquipmentSets/IEquipmentSetRepository.cs | 15 + .../EquipmentSets/SetBonusEffectApplicator.cs | 56 + .../Robots/EquipmentSets/SetBonusThreshold.cs | 18 + src/Perpetuum/Robots/Robot.Properties.cs | 6 + src/Perpetuum/Robots/Robot.cs | 15 + .../Services/Channels/ChannelManager.cs | 30 + .../ChatCommands/GameAdminCommands.cs | 4 +- .../SeasonAdminCommandHandlers.cs | 171 ++ .../Services/Channels/IChannelManager.cs | 2 + .../Economy/EconomySnapshotService.cs | 50 + .../DiscordPinStateRepository.cs | 48 + .../EventServices/EventListenerService.cs | 65 +- .../EventMessages/DiscordPinnableMessage.cs | 19 + .../EventServices/EventMessages/PinSlot.cs | 8 + .../IDiscordPinStateRepository.cs | 10 + .../GiveExtensionPointsService.cs | 5 + .../Services/Insurance/InsuranceHelper.cs | 2 - .../Insurance/InsurancePriceRefreshService.cs | 59 + .../Services/Looting/LootContainer.cs | 3 +- src/Perpetuum/Services/MarketEngine/Market.cs | 121 ++ .../MarketEngine/MarketAutoOrdersManager.cs | 37 +- .../MarketEngine/MarketOrderRepository.cs | 6 +- .../MissionProcessorAdvanceTarget.cs | 7 + .../TransportAssignment.cs | 64 +- .../Facilities/InsuraceFacility.cs | 6 + .../ProductionEngine/ProductionProcessor.cs | 15 + .../Services/Relics/Relics/AbstractRelic.cs | 2 + .../Services/Seasons/ActivityEvent.cs | 4 + .../Services/Seasons/ISeasonService.cs | 10 + .../Services/Seasons/SeasonActivityType.cs | 31 + .../Services/Seasons/SeasonModels.cs | 79 + .../Services/Seasons/SeasonRepository.cs | 629 ++++++ .../Services/Seasons/SeasonScoringMode.cs | 8 + .../Services/Seasons/SeasonService.cs | 751 +++++++ .../Services/Seasons/SeasonServiceLocator.cs | 7 + src/Perpetuum/Services/Sessions/Session.cs | 49 +- .../Services/Sessions/SessionDiagnostics.cs | 65 + .../Services/Sessions/SessionManager.cs | 19 +- .../Sessions/StaleOnlineFlagCensus.cs | 64 + .../Services/Sessions/StaleOnlineFlags.cs | 38 + src/Perpetuum/Units/Unit.cs | 19 +- src/Perpetuum/Units/WreckBeamBuilder.cs | 2 +- .../Artifacts/Scanners/ArtifactScanner.cs | 2 + .../Effects/SelfDestructCountdownEffect.cs | 19 + .../Zones/Effects/SelfDestructDetonation.cs | 63 + src/Perpetuum/Zones/Intrusion/Outpost.cs | 2 + .../Zones/NpcSystem/AI/AggressorAI.cs | 14 + src/Perpetuum/Zones/NpcSystem/AI/BaseAI.cs | 10 + src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs | 16 +- .../Zones/NpcSystem/AI/CoveringAI.cs | 537 +++++ src/Perpetuum/Zones/NpcSystem/AI/FleeAI.cs | 208 ++ .../AI/HunterDrones/HunterApproachAI.cs | 78 + .../AI/HunterDrones/HunterDroneAI.cs | 34 + .../AI/HunterDrones/HunterPatrolAI.cs | 52 + .../AI/HunterDrones/HunterRetreatAI.cs | 73 + .../AI/HunterDrones/HunterSelfDestructAI.cs | 104 + src/Perpetuum/Zones/NpcSystem/AI/IdleAI.cs | 7 + .../Zones/NpcSystem/AI/ModuleActivator.cs | 1 - src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs | 576 ++++++ .../NpcSystem/AI/SupportModuleActivator.cs | 62 + src/Perpetuum/Zones/NpcSystem/Flocks/Flock.cs | 9 +- src/Perpetuum/Zones/NpcSystem/Npc.cs | 14 + .../PathFinders/FreeRoamingPathFinder.cs | 11 +- .../Zones/NpcSystem/SmartCreature.cs | 153 ++ .../Zones/RemoteControl/HunterDrone.cs | 81 + .../Zones/RemoteControl/TurretType.cs | 2 + src/Perpetuum/Zones/ZoneSession.cs | 38 +- 438 files changed, 32790 insertions(+), 164 deletions(-) create mode 100644 src/Perpetuum.AdminTool/App.xaml create mode 100644 src/Perpetuum.AdminTool/App.xaml.cs create mode 100644 src/Perpetuum.AdminTool/AppSession.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AddAutoMarketItemPickItem.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AutoMarketConfigRow.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AutoMarketCoveredMaterialRow.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AutoMarketGatherRow.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AutoMarketLabels.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AutoMarketNicFlowRow.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AutoMarketOrderData.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AutoMarketOrderRow.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AutoMarketPricingTraceRow.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AutoMarketRawMaterialRow.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs create mode 100644 src/Perpetuum.AdminTool/AutoMarket/AutoMarketTradeListRow.cs create mode 100644 src/Perpetuum.AdminTool/Common/BindingProxy.cs create mode 100644 src/Perpetuum.AdminTool/Common/EntityPickItem.cs create mode 100644 src/Perpetuum.AdminTool/Common/EnumIntConverter.cs create mode 100644 src/Perpetuum.AdminTool/Common/FlagDescriptionConverters.cs create mode 100644 src/Perpetuum.AdminTool/Common/InverseBoolConverter.cs create mode 100644 src/Perpetuum.AdminTool/Common/LongToForegroundConverter.cs create mode 100644 src/Perpetuum.AdminTool/Common/LookupCache.cs create mode 100644 src/Perpetuum.AdminTool/Common/PresencePickItem.cs create mode 100644 src/Perpetuum.AdminTool/Common/TemplatePickItem.cs create mode 100644 src/Perpetuum.AdminTool/Common/ZoneSpawnPickItem.cs create mode 100644 src/Perpetuum.AdminTool/Data/Authenticator.cs create mode 100644 src/Perpetuum.AdminTool/Data/DbProbe.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyCorporationWealthRow.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyInsuranceRepository.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyListingAgeBuckets.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyMarketData.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyMarketHealthRepository.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyMoneySupplyData.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyMoneySupplyRepository.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyNicFlowRow.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyPriceIndexBasketItem.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyPriceIndexRow.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyRepository.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomySinkData.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomySinkRepository.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomySinkRow.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomySnapshotRow.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyVelocityRow.cs create mode 100644 src/Perpetuum.AdminTool/Economy/EconomyWealthRow.cs create mode 100644 src/Perpetuum.AdminTool/Economy/InsuranceConfigRow.cs create mode 100644 src/Perpetuum.AdminTool/Economy/InsuranceLabels.cs create mode 100644 src/Perpetuum.AdminTool/Economy/InsurancePriceRow.cs create mode 100644 src/Perpetuum.AdminTool/Editing/ChangeApplier.cs create mode 100644 src/Perpetuum.AdminTool/Editing/ChangeQueue.cs create mode 100644 src/Perpetuum.AdminTool/Editing/EntityChanges.cs create mode 100644 src/Perpetuum.AdminTool/Editing/FlockChanges.cs create mode 100644 src/Perpetuum.AdminTool/Editing/IPendingChange.cs create mode 100644 src/Perpetuum.AdminTool/Editing/NpcLootChanges.cs create mode 100644 src/Perpetuum.AdminTool/Editing/PresenceChanges.cs create mode 100644 src/Perpetuum.AdminTool/Editing/RawSqlChange.cs create mode 100644 src/Perpetuum.AdminTool/Editing/SqlLiteral.cs create mode 100644 src/Perpetuum.AdminTool/Editing/SqlScriptBuilder.cs create mode 100644 src/Perpetuum.AdminTool/Editing/TemplateChanges.cs create mode 100644 src/Perpetuum.AdminTool/Editing/TemplateRelationChanges.cs create mode 100644 src/Perpetuum.AdminTool/Entities/AggregateFieldInfo.cs create mode 100644 src/Perpetuum.AdminTool/Entities/AttributeFlagsCatalog.cs create mode 100644 src/Perpetuum.AdminTool/Entities/CategoryFlagsCatalog.cs create mode 100644 src/Perpetuum.AdminTool/Entities/CategoryFlagsHierarchy.cs create mode 100644 src/Perpetuum.AdminTool/Entities/CategoryFlagsNode.cs create mode 100644 src/Perpetuum.AdminTool/Entities/EntityDefaultRow.cs create mode 100644 src/Perpetuum.AdminTool/Entities/EntityRepository.cs create mode 100644 src/Perpetuum.AdminTool/Entities/EntitySnapshot.cs create mode 100644 src/Perpetuum.AdminTool/Entities/StatRow.cs create mode 100644 src/Perpetuum.AdminTool/EquipmentSets/AggregateFieldPickItem.cs create mode 100644 src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetChanges.cs create mode 100644 src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetMemberRow.cs create mode 100644 src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetRepository.cs create mode 100644 src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetRow.cs create mode 100644 src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetThresholdRow.cs create mode 100644 src/Perpetuum.AdminTool/EquipmentSets/SetMemberPickItem.cs create mode 100644 src/Perpetuum.AdminTool/Export/ExportScriptViewModel.cs create mode 100644 src/Perpetuum.AdminTool/Export/ItemExporter.cs create mode 100644 src/Perpetuum.AdminTool/Export/RobotExporter.cs create mode 100644 src/Perpetuum.AdminTool/Export/SeasonExporter.cs create mode 100644 src/Perpetuum.AdminTool/Export/SqlExportBuilder.cs create mode 100644 src/Perpetuum.AdminTool/Loot/NpcLootRepository.cs create mode 100644 src/Perpetuum.AdminTool/Loot/NpcLootRow.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/BasicPanelMode.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/BasicPanelViewModel.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/CloneExtendedData.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/DefinitionConfigColumnInfo.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/DefinitionConfigRow.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/EnablerExtensionRow.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/ExtensionPickItem.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/ItemSqlBuilder.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/NewComponentRow.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/NewItemLookups.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/NewItemRepository.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/NewStatRow.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/OptionsVisualPanelViewModel.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/PointTypePickItem.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/ProductionPanelViewModel.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/PropertyModifierRow.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/PropertyModifiersPanelViewModel.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/ResearchCostRow.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/ResearchPanelViewModel.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/StatsPanelViewModel.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/TechTreeGroupPickItem.cs create mode 100644 src/Perpetuum.AdminTool/NewItem/TechTreePlacementRow.cs create mode 100644 src/Perpetuum.AdminTool/NewRobot/BonusesPanelViewModel.cs create mode 100644 src/Perpetuum.AdminTool/NewRobot/NewBonusRow.cs create mode 100644 src/Perpetuum.AdminTool/NewRobot/NewRobotRepository.cs create mode 100644 src/Perpetuum.AdminTool/NewRobot/RobotSqlBuilder.cs create mode 100644 src/Perpetuum.AdminTool/NewRobot/RobotTemplatePanelViewModel.cs create mode 100644 src/Perpetuum.AdminTool/NewRobot/RobotTemplateRelationPanelViewModel.cs create mode 100644 src/Perpetuum.AdminTool/Npc/FlockRepository.cs create mode 100644 src/Perpetuum.AdminTool/Npc/FlockRow.cs create mode 100644 src/Perpetuum.AdminTool/Npc/PresenceRepository.cs create mode 100644 src/Perpetuum.AdminTool/Npc/PresenceRow.cs create mode 100644 src/Perpetuum.AdminTool/Packages/PackageChanges.cs create mode 100644 src/Perpetuum.AdminTool/Packages/PackageItemPickItem.cs create mode 100644 src/Perpetuum.AdminTool/Packages/PackageItemRow.cs create mode 100644 src/Perpetuum.AdminTool/Packages/PackageRepository.cs create mode 100644 src/Perpetuum.AdminTool/Packages/PackageRow.cs create mode 100644 src/Perpetuum.AdminTool/Perpetuum.AdminTool.csproj create mode 100644 src/Perpetuum.AdminTool/Seasons/MaterialPickItem.cs create mode 100644 src/Perpetuum.AdminTool/Seasons/SeasonActivityRateRow.cs create mode 100644 src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs create mode 100644 src/Perpetuum.AdminTool/Seasons/SeasonLeaderboardRewardRow.cs create mode 100644 src/Perpetuum.AdminTool/Seasons/SeasonObjectiveRow.cs create mode 100644 src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs create mode 100644 src/Perpetuum.AdminTool/Seasons/SeasonRow.cs create mode 100644 src/Perpetuum.AdminTool/Seasons/SeasonTierRow.cs create mode 100644 src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs create mode 100644 src/Perpetuum.AdminTool/Settings/AppSettings.cs create mode 100644 src/Perpetuum.AdminTool/Settings/AppSettingsStore.cs create mode 100644 src/Perpetuum.AdminTool/Settings/ApplyMode.cs create mode 100644 src/Perpetuum.AdminTool/Settings/ConnectionSettings.cs create mode 100644 src/Perpetuum.AdminTool/Templates/RobotTemplateEditorEntity.cs create mode 100644 src/Perpetuum.AdminTool/Templates/RobotTemplateEditorRepository.cs create mode 100644 src/Perpetuum.AdminTool/Templates/RobotTemplateRelationRepository.cs create mode 100644 src/Perpetuum.AdminTool/Templates/RobotTemplateRelationRow.cs create mode 100644 src/Perpetuum.AdminTool/Templates/RobotTemplateRepository.cs create mode 100644 src/Perpetuum.AdminTool/Templates/RobotTemplateRow.cs create mode 100644 src/Perpetuum.AdminTool/Translations/LanguageCatalog.cs create mode 100644 src/Perpetuum.AdminTool/Translations/TranslationRow.cs create mode 100644 src/Perpetuum.AdminTool/Translations/TranslationStore.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AddAutoMarketItemViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AddFlockRowViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AddKeyViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AddLanguageViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AddNpcLootRowViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AddPresenceRowViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AddSetMemberViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AddSetThresholdViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AddTemplateRelationRowViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AttributeFlagsPickerViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AutoMarketConfigViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AutoMarketOrdersViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AutoMarketRawMaterialsViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AutoMarketStatisticsViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AutoMarketTradeListViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/AutoMarketViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/CategoryFlagsPickerViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/ConfirmSqlViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/ConnectionSettingsViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/EconomyInsuranceViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/EconomyMarketHealthViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/EconomyMoneySupplyViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/EconomyNicFlowViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/EconomySinkEffectivenessViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/EconomyViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/EntitiesViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/EntityDetailViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/EquipmentSetsViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/FlocksViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/LoginViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/MainViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/NewEntityViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/NewItemDialogViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/NewRobotDialogViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/NpcLootViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/PackagesViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/PresenceFlocksViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/PresencesViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/RobotTemplateEditorViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/RobotTemplateRelationsViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/RobotTemplateSlotViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/RobotTemplatesViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/SeasonDetailViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/SeasonsViewModel.cs create mode 100644 src/Perpetuum.AdminTool/ViewModels/TranslationsViewModel.cs create mode 100644 src/Perpetuum.AdminTool/Views/AddAutoMarketItemWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AddAutoMarketItemWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AddFlockRowWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AddFlockRowWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AddKeyWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AddKeyWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AddLanguageWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AddLanguageWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AddNpcLootRowWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AddNpcLootRowWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AddPresenceRowWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AddPresenceRowWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AddSetMemberWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AddSetMemberWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AddSetThresholdWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AddSetThresholdWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AddTemplateRelationRowWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AddTemplateRelationRowWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AttributeFlagsPickerWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AttributeFlagsPickerWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketConfigView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketConfigView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketOrdersView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketOrdersView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketRawMaterialsView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketRawMaterialsView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketStatisticsView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketStatisticsView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketTradeListView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketTradeListView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/AutoMarketView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/CategoryFlagsPickerWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/CategoryFlagsPickerWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/ConfirmSqlWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/ConfirmSqlWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/ConnectionSettingsWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/ConnectionSettingsWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/EconomyInsuranceView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/EconomyInsuranceView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/EconomyMarketHealthView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/EconomyMarketHealthView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/EconomyMoneySupplyView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/EconomyMoneySupplyView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/EconomyNicFlowView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/EconomyNicFlowView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/EconomySinkEffectivenessView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/EconomySinkEffectivenessView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/EconomyView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/EconomyView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/EntitiesView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/EntitiesView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/EntityDetailView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/EntityDetailView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/EquipmentSetsView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/EquipmentSetsView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/ExportScriptWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/ExportScriptWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/FlocksView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/FlocksView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/LoginWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/LoginWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/MainWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/MainWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/NewEntityWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/NewEntityWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/NewItemDialog.xaml create mode 100644 src/Perpetuum.AdminTool/Views/NewItemDialog.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/NewRobotDialog.xaml create mode 100644 src/Perpetuum.AdminTool/Views/NewRobotDialog.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/NewTemplateWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/NewTemplateWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/NpcLootView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/NpcLootView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/PackagesView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/PackagesView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/PresenceFlocksWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/PresenceFlocksWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/PresencesView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/PresencesView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/RobotTemplateEditorWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/RobotTemplateEditorWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/RobotTemplateRelationsView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/RobotTemplateRelationsView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/RobotTemplatesView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/RobotTemplatesView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/SeasonDetailView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/SeasonWizardWindow.xaml create mode 100644 src/Perpetuum.AdminTool/Views/SeasonWizardWindow.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/SeasonsView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/SeasonsView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/Views/TranslationsView.xaml create mode 100644 src/Perpetuum.AdminTool/Views/TranslationsView.xaml.cs create mode 100644 src/Perpetuum.AdminTool/app.manifest create mode 100644 src/Perpetuum.AdminTool/docs/DECISIONS.md create mode 100644 src/Perpetuum.AdminTool/docs/PROJECT_PLAN.md create mode 100644 src/Perpetuum.AdminTool/docs/STATE_SUMMARY.md create mode 100644 src/Perpetuum.AdminTool/docs/TASKS.md create mode 100644 src/Perpetuum.AdminTool/prompts/continue_phase.txt create mode 100644 src/Perpetuum.AdminTool/prompts/implement_task.txt create mode 100644 src/Perpetuum.AdminTool/prompts/summarize_state.txt create mode 100644 src/Perpetuum.AdminToolInstaller/AdminToolComponents.wxs create mode 100644 src/Perpetuum.AdminToolInstaller/Folders.wxs create mode 100644 src/Perpetuum.AdminToolInstaller/Package.en-us.wxl create mode 100644 src/Perpetuum.AdminToolInstaller/Package.wxs create mode 100644 src/Perpetuum.AdminToolInstaller/Perpetuum.AdminToolInstaller.wixproj create mode 100644 src/Perpetuum.Bootstrapper/Modules/SeasonModule.cs create mode 100644 src/Perpetuum.Tests.Integration/AssemblyInfo.cs create mode 100644 src/Perpetuum.Tests.Integration/Content/ContentInvariantTests.cs create mode 100644 src/Perpetuum.Tests.Integration/Data/InsuranceQueryTests.cs create mode 100644 src/Perpetuum.Tests.Integration/Infrastructure/DatabaseCollection.cs create mode 100644 src/Perpetuum.Tests.Integration/Infrastructure/DatabaseFixture.cs create mode 100644 src/Perpetuum.Tests.Integration/Infrastructure/EnvironmentDiscoveryTests.cs create mode 100644 src/Perpetuum.Tests.Integration/Infrastructure/GameRootEnvironment.cs create mode 100644 src/Perpetuum.Tests.Integration/Infrastructure/RequiresGameRootFactAttribute.cs create mode 100644 src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj create mode 100644 src/Perpetuum.Tests.Integration/Schema/StoredProcedureConformanceTests.cs create mode 100644 src/Perpetuum.Tests/AssemblyInfo.cs create mode 100644 src/Perpetuum.Tests/Fakes/Data/FakeDataReader.cs create mode 100644 src/Perpetuum.Tests/Fakes/Data/FakeDb.cs create mode 100644 src/Perpetuum.Tests/Fakes/Data/FakeDbCommand.cs create mode 100644 src/Perpetuum.Tests/Fakes/Data/FakeDbConnection.cs create mode 100644 src/Perpetuum.Tests/Fakes/Data/FakeResultSet.cs create mode 100644 src/Perpetuum.Tests/Fakes/RecordingLogger.cs create mode 100644 src/Perpetuum.Tests/Fakes/Sessions/FakeAccountRepository.cs create mode 100644 src/Perpetuum.Tests/Fakes/Sessions/FakeRelayServices.cs create mode 100644 src/Perpetuum.Tests/Fakes/Sessions/FakeSession.cs create mode 100644 src/Perpetuum.Tests/Fakes/Sessions/FakeSessionManager.cs create mode 100644 src/Perpetuum.Tests/Infrastructure/AssemblyLoggerInitializer.cs create mode 100644 src/Perpetuum.Tests/Infrastructure/PerpetuumStaticsCollection.cs create mode 100644 src/Perpetuum.Tests/Infrastructure/PerpetuumStaticsFixture.cs create mode 100644 src/Perpetuum.Tests/Perpetuum.Tests.csproj create mode 100644 src/Perpetuum.Tests/Regression/Issue033EmptyFlockTests.cs create mode 100644 src/Perpetuum.Tests/Regression/Issue039InsuranceTransactionTests.cs create mode 100644 src/Perpetuum.Tests/Unit/ConnectionActivityTests.cs create mode 100644 src/Perpetuum.Tests/Unit/ConnectionStringSupportTests.cs create mode 100644 src/Perpetuum.Tests/Unit/DbQueryTests.cs create mode 100644 src/Perpetuum.Tests/Unit/GuardTests.cs create mode 100644 src/Perpetuum.Tests/Unit/RecordingLoggerTests.cs create mode 100644 src/Perpetuum.Tests/Unit/SessionDiagnosticsTests.cs create mode 100644 src/Perpetuum.Tests/Unit/StaleLoginReportingTests.cs create mode 100644 src/Perpetuum.Tests/Unit/StaleOnlineFlagCensusTests.cs create mode 100644 src/Perpetuum.Tests/Unit/StaleOnlineFlagsTests.cs create mode 100644 src/Perpetuum.Tests/Unit/ValueTypeExtensionsTests.cs create mode 100644 src/Perpetuum/Data/ConnectionStringSupport.cs create mode 100644 src/Perpetuum/Modules/RemoteControl/HunterRemoteControllerModule.cs create mode 100644 src/Perpetuum/Modules/SelfDestructModule.cs create mode 100644 src/Perpetuum/Network/ConnectionActivity.cs create mode 100644 src/Perpetuum/Robots/EquipmentSets/EquipmentSetBonusCalculator.cs create mode 100644 src/Perpetuum/Robots/EquipmentSets/EquipmentSetBonusResult.cs create mode 100644 src/Perpetuum/Robots/EquipmentSets/EquipmentSetRepository.cs create mode 100644 src/Perpetuum/Robots/EquipmentSets/IEquipmentSetBonusCalculator.cs create mode 100644 src/Perpetuum/Robots/EquipmentSets/IEquipmentSetRepository.cs create mode 100644 src/Perpetuum/Robots/EquipmentSets/SetBonusEffectApplicator.cs create mode 100644 src/Perpetuum/Robots/EquipmentSets/SetBonusThreshold.cs create mode 100644 src/Perpetuum/Services/Channels/ChatCommands/SeasonAdminCommandHandlers.cs create mode 100644 src/Perpetuum/Services/Economy/EconomySnapshotService.cs create mode 100644 src/Perpetuum/Services/EventServices/DiscordPinStateRepository.cs create mode 100644 src/Perpetuum/Services/EventServices/EventMessages/DiscordPinnableMessage.cs create mode 100644 src/Perpetuum/Services/EventServices/EventMessages/PinSlot.cs create mode 100644 src/Perpetuum/Services/EventServices/IDiscordPinStateRepository.cs create mode 100644 src/Perpetuum/Services/Insurance/InsurancePriceRefreshService.cs create mode 100644 src/Perpetuum/Services/Seasons/ActivityEvent.cs create mode 100644 src/Perpetuum/Services/Seasons/ISeasonService.cs create mode 100644 src/Perpetuum/Services/Seasons/SeasonActivityType.cs create mode 100644 src/Perpetuum/Services/Seasons/SeasonModels.cs create mode 100644 src/Perpetuum/Services/Seasons/SeasonRepository.cs create mode 100644 src/Perpetuum/Services/Seasons/SeasonScoringMode.cs create mode 100644 src/Perpetuum/Services/Seasons/SeasonService.cs create mode 100644 src/Perpetuum/Services/Seasons/SeasonServiceLocator.cs create mode 100644 src/Perpetuum/Services/Sessions/SessionDiagnostics.cs create mode 100644 src/Perpetuum/Services/Sessions/StaleOnlineFlagCensus.cs create mode 100644 src/Perpetuum/Services/Sessions/StaleOnlineFlags.cs create mode 100644 src/Perpetuum/Zones/Effects/SelfDestructCountdownEffect.cs create mode 100644 src/Perpetuum/Zones/Effects/SelfDestructDetonation.cs create mode 100644 src/Perpetuum/Zones/NpcSystem/AI/CoveringAI.cs create mode 100644 src/Perpetuum/Zones/NpcSystem/AI/FleeAI.cs create mode 100644 src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterApproachAI.cs create mode 100644 src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterDroneAI.cs create mode 100644 src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterPatrolAI.cs create mode 100644 src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterRetreatAI.cs create mode 100644 src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterSelfDestructAI.cs create mode 100644 src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs create mode 100644 src/Perpetuum/Zones/NpcSystem/AI/SupportModuleActivator.cs create mode 100644 src/Perpetuum/Zones/RemoteControl/HunterDrone.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b10103a9..06328b33 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,7 +16,7 @@ jobs: env: Workspace: ${{ github.workspace }} - + steps: - uses: actions/checkout@v4 - name: Setup .NET @@ -33,3 +33,85 @@ jobs: name: Perpetuum-Server-v2-${{ github.sha }} path: ${{ env.Workspace }}/bin/x64/Release/net8.0 if: ${{ github.event_name == 'push'}} + + test: + + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Restore dependencies + run: dotnet restore + - name: Run unit tests + run: dotnet test src/Perpetuum.Tests/Perpetuum.Tests.csproj --no-restore --configuration Release -p:Platform=x64 + + build-admintool-installer: + + runs-on: windows-latest + + env: + Workspace: ${{ github.workspace }} + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Restore dependencies + run: dotnet restore -p:Platform=x64 + - name: Build AdminTool installer + run: dotnet build src/Perpetuum.AdminToolInstaller/Perpetuum.AdminToolInstaller.wixproj --no-restore --configuration Release --verbosity quiet -p:Platform=x64 + - name: Upload AdminTool installer + uses: actions/upload-artifact@v4 + with: + name: Perpetuum-AdminTool-Installer-${{ github.sha }} + path: ${{ env.Workspace }}/src/Perpetuum.AdminToolInstaller/bin/x64/Release/en-US/*.msi + if: ${{ github.event_name == 'push' }} + + publish-wiki: + + runs-on: windows-latest + needs: build + if: github.event_name == 'push' + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10 (graphify tool requirement) + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore dotnet tools + run: dotnet tool restore + + - name: Generate codebase report + working-directory: ${{ github.workspace }} + run: dotnet tool run graphify run src -o graphify-out -f report + + - name: Publish report to GitHub Wiki + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT_SHA: ${{ github.sha }} + run: | + git clone "https://x-access-token:$env:GH_TOKEN@github.com/OpenPerpetuum/PerpetuumServer2.wiki.git" wiki-repo + Copy-Item "graphify-out/GRAPH_REPORT.md" -Destination "wiki-repo/Codebase-Graph.md" -Force + Set-Location wiki-repo + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add "Codebase-Graph.md" + $changes = git status --porcelain + if ($changes) { + git commit -m "Update codebase graph report [$env:COMMIT_SHA]" + git push + } else { + Write-Host "No wiki changes to publish" + } diff --git a/.gitignore b/.gitignore index 8aca2a82..d576e6ff 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ src/Perpetuum/bin/ src/Perpetuum.ServerService2/Properties/PublishProfiles/ *.user packages/ +!src/Perpetuum.AdminTool/Packages/ src/Perpetuum.ServerService2Installer/bin/ src/Perpetuum.ServerService2/data/bitmaps/ src/Perpetuum.ServerService2/data/chatlogs/ @@ -26,3 +27,15 @@ custom-layers/ # Original directory folder from Stream: Perpetuum Dedicated Server installer perpetuum-data/ + +.claude/settings.local.json +.planning/ +*.log +*_wpftmp.csproj + +# graphify-dotnet generated output (regenerates on build) +docs/graph/ +graphify-out/ + +# Claude Code worktrees +.claude/worktrees/ diff --git a/PerpetuumServer2.sln b/PerpetuumServer2.sln index 7611cedd..9a70b043 100644 --- a/PerpetuumServer2.sln +++ b/PerpetuumServer2.sln @@ -17,8 +17,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.RequestHandlers", EndProject Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "Perpetuum.ServerService2Installer", "src\Perpetuum.ServerService2Installer\Perpetuum.ServerService2Installer.wixproj", "{ABBCAAB8-AA31-4705-BDA8-B4EDDFFA7F4E}" EndProject +Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "Perpetuum.AdminToolInstaller", "src\Perpetuum.AdminToolInstaller\Perpetuum.AdminToolInstaller.wixproj", "{D3F8A2B1-6C9E-4D7F-A5B8-2E0C4F9A3D6B}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Perpetuum.ServerService2", "src\Perpetuum.ServerService2\Perpetuum.ServerService2.csproj", "{A2FCE930-E013-4731-B354-F7DA00322D38}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.AdminTool", "src\Perpetuum.AdminTool\Perpetuum.AdminTool.csproj", "{A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.Tests", "src\Perpetuum.Tests\Perpetuum.Tests.csproj", "{C8C45427-6E5C-496C-9446-0817EADC05DD}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.Tests.Integration", "src\Perpetuum.Tests.Integration\Perpetuum.Tests.Integration.csproj", "{ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -49,10 +57,26 @@ Global {ABBCAAB8-AA31-4705-BDA8-B4EDDFFA7F4E}.Debug|x64.Build.0 = Debug|x64 {ABBCAAB8-AA31-4705-BDA8-B4EDDFFA7F4E}.Release|x64.ActiveCfg = Release|x64 {ABBCAAB8-AA31-4705-BDA8-B4EDDFFA7F4E}.Release|x64.Build.0 = Release|x64 + {D3F8A2B1-6C9E-4D7F-A5B8-2E0C4F9A3D6B}.Debug|x64.ActiveCfg = Debug|x64 + {D3F8A2B1-6C9E-4D7F-A5B8-2E0C4F9A3D6B}.Debug|x64.Build.0 = Debug|x64 + {D3F8A2B1-6C9E-4D7F-A5B8-2E0C4F9A3D6B}.Release|x64.ActiveCfg = Release|x64 + {D3F8A2B1-6C9E-4D7F-A5B8-2E0C4F9A3D6B}.Release|x64.Build.0 = Release|x64 {A2FCE930-E013-4731-B354-F7DA00322D38}.Debug|x64.ActiveCfg = Debug|x64 {A2FCE930-E013-4731-B354-F7DA00322D38}.Debug|x64.Build.0 = Debug|x64 {A2FCE930-E013-4731-B354-F7DA00322D38}.Release|x64.ActiveCfg = Release|x64 {A2FCE930-E013-4731-B354-F7DA00322D38}.Release|x64.Build.0 = Release|x64 + {A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}.Debug|x64.ActiveCfg = Debug|x64 + {A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}.Debug|x64.Build.0 = Debug|x64 + {A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}.Release|x64.ActiveCfg = Release|x64 + {A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}.Release|x64.Build.0 = Release|x64 + {C8C45427-6E5C-496C-9446-0817EADC05DD}.Debug|x64.ActiveCfg = Debug|x64 + {C8C45427-6E5C-496C-9446-0817EADC05DD}.Debug|x64.Build.0 = Debug|x64 + {C8C45427-6E5C-496C-9446-0817EADC05DD}.Release|x64.ActiveCfg = Release|x64 + {C8C45427-6E5C-496C-9446-0817EADC05DD}.Release|x64.Build.0 = Release|x64 + {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}.Debug|x64.ActiveCfg = Debug|x64 + {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}.Debug|x64.Build.0 = Debug|x64 + {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}.Release|x64.ActiveCfg = Release|x64 + {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -64,7 +88,11 @@ Global {5992F619-7655-43F1-8A33-21B5877714A5} = {0BC991A1-133C-49ED-A141-80E2A906898B} {C1F1B228-8286-4207-9C51-09AB3FDFE935} = {0BC991A1-133C-49ED-A141-80E2A906898B} {ABBCAAB8-AA31-4705-BDA8-B4EDDFFA7F4E} = {0BC991A1-133C-49ED-A141-80E2A906898B} + {D3F8A2B1-6C9E-4D7F-A5B8-2E0C4F9A3D6B} = {0BC991A1-133C-49ED-A141-80E2A906898B} {A2FCE930-E013-4731-B354-F7DA00322D38} = {0BC991A1-133C-49ED-A141-80E2A906898B} + {A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0} = {0BC991A1-133C-49ED-A141-80E2A906898B} + {C8C45427-6E5C-496C-9446-0817EADC05DD} = {0BC991A1-133C-49ED-A141-80E2A906898B} + {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B} = {0BC991A1-133C-49ED-A141-80E2A906898B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {91874B60-30B0-4B48-9155-370E40BC70E6} diff --git a/README.md b/README.md index 273c3a3e..1493d777 100644 --- a/README.md +++ b/README.md @@ -4,113 +4,144 @@ # The Open Perpetuum Server 2 -## Setup (container) +Local development runs in **Linux containers** (Docker or Podman). That is the setup documented below. -This section explains how to setup for local development using docker/podman compose containers where it can be controlled using some make commands. +The console host `Perpetuum.Server` and the WPF Admin Tool remain Windows-only. Use them only if you are running the server on a Windows machine without containers — see [Native Windows host](#native-windows-host). -Here is some context for how this works: +## Local development (containers) -The entry point is `compose.yml` which contains the containers, volumes and network definitions. +`compose.yml` defines the asset server, SQL Server, migration job, and game server. Configuration lives in `.env.local`. -There are 2 volumes: -- `openperpetuum-data`: contains the original `PerpetuumServer/Data` + custom layers + generated perpetuum.ini -- `openperpetuum-db`: contains the database files for persistence +Two named volumes persist between restarts: -There is an environment file to control most of the configuration of this setup: -- `.env.local` +- `openperpetuum-data` — original `PerpetuumServer/data`, custom layers, and a generated `perpetuum.ini` +- `openperpetuum-db` — SQL Server files -### 0. Requirements -- (optional) make (to help with docker-compose commands) -- docker/podman -- Steam: Perpetuum Dedicated server installed -- Latest gamma islands layers: https://drive.google.com/file/d/1Xp0T1K57Pv-vjgmpXMG8Iea_ec0bWYR4/view?usp=drive_link +`make` wraps the compose commands; you can call `docker compose` / `podman compose` yourself if you prefer. + +### Requirements + +- Docker or Podman (Linux containers) +- (optional) `make` +- Steam: Perpetuum Dedicated Server installed +- Latest gamma island layers: https://drive.google.com/file/d/1Xp0T1K57Pv-vjgmpXMG8Iea_ec0bWYR4/view?usp=drive_link - Latest asset resource: https://drive.google.com/file/d/18fh8aRqMP1J7ycGBNGraFyQ31mMXZaq1/view?usp=drive_link -### 1. Clone this repository and update submodules +### 1. Clone and submodules -Start by cloning this repository ```sh git clone https://github.com/OpenPerpetuum/PerpetuumServer2.git -``` -or -```sh -git clone git@github.com:OpenPerpetuum/PerpetuumServer2.git +# or: git clone git@github.com:OpenPerpetuum/PerpetuumServer2.git +cd PerpetuumServer2 +git submodule init && git submodule update ``` -Checkout on the develop branch -```sh -git checkout develop -``` +Submodules: -This repository contains 2 submodules. -- db: (OPDB) database migration files for each game update -- asset: (OPResource) game client resources that are fetched once the client connect to the server. Contains definition files for all game entities, translations, gfx, map data (layers), audio files, custom bot models. +- `db` (OPDB) — database migration files per game update +- `asset` (OPResource) — client resources served when a client connects (definitions, translations, gfx, layers, audio, custom bot models) -You can initialize and update them with this command: -```sh -git submodule init && git submodule update -``` +### 2. Custom resources + +Do this whenever the gamma layers or asset pack are updated. -### 2. Update custom resources +- Uncompress the gamma layers and copy every `.bin` into both: + - `asset/lang0000/layers/GAMMA_LAYERS_NEW` + - a new `custom-layers` directory (same files) +- Unarchive the asset resource and copy `gfx`, `sfx`, and `textures` into `asset/lang0000` +- Create `perpetuum-data` and copy the Dedicated Server installer `data` folder into it (`database`, `layers`) -This section must be performed if there are updates on the gamma layers or asset resource. +Paths for `perpetuum-data` and `custom-layers` can be changed in `.env.local`. -- Uncompress gamma layers into a temporary directory -- Copy gamma layers in `asset/lang0000/layers/GAMMA_LAYERS_NEW` (all .bin files) -- Create `custom-layers` directory -- Copy gamma layers in a new directory `custom-layers` (all .bin files) (the same one as above) -- Unarchive asset resource into a temporary directory -- Copy asset resource into `asset/lang0000` directory (gfx, sfx, textures) -- Create `perpetuum-data` directory -- Copy original PerpetuumServer/data folder into `perpetuum-data` (database, layers) +### 3. Configuration +Edit `.env.local` for ports, the database password, paths, and the SQL connection string. -### 3. Run the server +The migration job writes `perpetuum.ini` from `template/perpetuum.ini.template`. Do not copy the installer `perpetuum.ini` into the data volume — that file was written for `System.Data.SqlClient` and this server uses `Microsoft.Data.SqlClient`. The template already uses a Linux-compatible string: SQL authentication (`sa`), `TrustServerCertificate=True`, no `Trusted_Connection`, and no keywords the driver refuses (`Connection Reset`, `Network Library`, `Context Connection`). -At this point, you are ready to run the server. +Linux does not support distributed transactions. `.env.local` sets `DISTRIBUTED_TRANSACTIONS=false` for that reason. -Note: Take a look at the `.env.local` if you need to see what ports are used, what is the database password, or if you want to update various paths. +`SERVER_PORTS` must be a range of about 300 ports starting at `SERVER_PORT` (default `17700-17900`). A single mapped port is enough to log in; entering a zone then shows a black screen. -#### 3.1. Start +### 4. Run the server ```sh make up ``` -This will build and start containers. The migration will run. Once the command exit, you might need to wait for a few minutes since the server is starting up. +This builds and starts the containers and runs migrations. The command returns before the game host is fully up; wait a few minutes. -You can monitor the server status with this command: ```sh make log-server ``` -If you see logs like `Unit enter to zone` or `Planthandler STOP SIGNAL received`, then the server is ready for a client to connect. +The server is ready for a client when you see lines such as `Unit enter to zone` or `Planthandler STOP SIGNAL received`. + +### 5. Point the client at this host + +- Open the client → **Server list** → **ADD PRIVATE SERVER** +- Name: `local` +- Address: `127.0.0.1:17700` (use `SERVER_PORT` from `.env.local` if you changed it) +- Connect, then log in with user `test` / password `test` -#### 3.2. Setup client with that local server -- Open the client -- Click on `Server list` -- Click on `ADD PRIVATE SERVER` -- Enter Name: `local`, Server address: `127.0.0.1:17700` (update the port to match your `SERVER_PORT` from the `.env.local` file) -- Click `OK` +The first connect can take several minutes while the asset server transfers files. -#### 3.3. Connect client to the local server -- Select `local` in the server list -- Click `Connect` (this might take a few minutes to load, at this point the asset server is transfering files to the client) -- Login with the test account user: `test`, password: `test` +### 6. Stop -#### 3.4 Stop the server ```sh -make down +make down # stop and remove containers; keep data and db volumes +make delete # also delete the volumes ``` -This will stop and delete the containers but keep the data and db volumes +## Native Windows host + +Skip this if you are using the containers above. + +`Perpetuum.Server` is annotated `[SupportedOSPlatform("windows")]` and the Admin Tool is WPF. You need the .NET 8 SDK, a SQL Server instance, and the `perpetuumsa` database — see [OPDB](https://github.com/OpenPerpetuum/OPDB) for restore and patches. + +### Build -#### 3,5 Stop and delete server data -```sh -make delete ``` +dotnet build PerpetuumServer2.sln -c Release -p:Platform=x64 +``` + +### Configure + +The server reads `perpetuum.ini` from the game root. `ConnectionString` must match your machine. + +**The `perpetuum.ini` written by the Perpetuum Dedicated Server installer will not start this server.** `Microsoft.Data.SqlClient` differs from `System.Data.SqlClient` in two ways that abort startup before any zone loads: + +1. **`Connection Reset` was removed.** The driver refuses the keyword while the connection is being constructed: + + ``` + The keyword 'Connection Reset' is not supported on this platform. + ``` + + The server checks the string before it connects. If anything is refused, it logs `perpetuum.ini`, the directory it is in, and **every** rejected setting, then stops — one restart is enough to clear them all. Delete those keys from the file. `Connection Reset` is safe to drop: a pooled connection is always reset. `Network Library` and `Context Connection` are refused the same way. The check asks the driver; it does not keep its own list of keywords. + +2. **`Encrypt` now defaults to `true`.** Since version 4.0 the driver encrypts and validates the server certificate. A local SQL Server with a self-signed certificate fails logon in the SSL provider: -This will stop and delete all data such as containers and volumes (data, db) + ``` + A connection was successfully established with the server, but then an error occurred during + the login process. (provider: SSL Provider, error: 0 - The certificate chain was issued by an + authority that is not trusted.) + ``` + The trailing part of that message comes from Windows and appears in the system language, so match on the condition rather than the exact text. The server will not decide for you whether skipping certificate validation is acceptable. +A connection string that works against a local named instance with Windows authentication: + +``` +Server=localhost\PERPSQL;Database=perpetuumsa;Trusted_Connection=True;TrustServerCertificate=True;Pooling=True;Connection Timeout=30;Connection Lifetime=260;Min Pool Size=20;Max Pool Size=60; +``` + +`TrustServerCertificate=True` keeps the connection encrypted but skips validating the certificate. **Use that only on a local development instance.** On a deployment that leaves the machine, install a trusted certificate instead. `Encrypt=False` also works locally and is strictly worse: it drops encryption as well. + +### Run + +``` +cd src/Perpetuum.Server +dotnet run -- "C:\PerpetuumServer\data" +``` +The server is up when the log reads `>>>> Perpetuum Server State : [Online]`. Ctrl+C shuts it down; a clean shutdown ends at `State : [Off]`. diff --git a/src/Perpetuum.AdminTool/App.xaml b/src/Perpetuum.AdminTool/App.xaml new file mode 100644 index 00000000..464bee8d --- /dev/null +++ b/src/Perpetuum.AdminTool/App.xaml @@ -0,0 +1,7 @@ + + + + + diff --git a/src/Perpetuum.AdminTool/App.xaml.cs b/src/Perpetuum.AdminTool/App.xaml.cs new file mode 100644 index 00000000..07c14715 --- /dev/null +++ b/src/Perpetuum.AdminTool/App.xaml.cs @@ -0,0 +1,37 @@ +using System.Windows; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.Views; + +namespace Perpetuum.AdminTool +{ + public partial class App : Application + { + public static AppSettingsStore SettingsStore { get; private set; } = null!; + public static AppSession Session { get; } = new AppSession(); + + protected override void OnStartup(StartupEventArgs e) + { + // Keep app alive across the login -> main-window transition. + // We restore close-on-main behavior after MainWindow is shown. + ShutdownMode = ShutdownMode.OnExplicitShutdown; + base.OnStartup(e); + + SettingsStore = new AppSettingsStore(); + SettingsStore.Load(); + + var login = new LoginWindow(); + var ok = login.ShowDialog(); + if (ok == true) + { + var main = new MainWindow(); + MainWindow = main; + main.Closed += (_, _) => Shutdown(); + main.Show(); + } + else + { + Shutdown(); + } + } + } +} diff --git a/src/Perpetuum.AdminTool/AppSession.cs b/src/Perpetuum.AdminTool/AppSession.cs new file mode 100644 index 00000000..abc98552 --- /dev/null +++ b/src/Perpetuum.AdminTool/AppSession.cs @@ -0,0 +1,21 @@ +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool +{ + public class AppSession + { + public int? AccountId { get; set; } + public string? Email { get; set; } + public AccessLevel AccessLevel { get; set; } = AccessLevel.notDefined; + public ApplyMode CurrentMode { get; set; } = ApplyMode.SqlScript; + public ChangeQueue Changes { get; } = new ChangeQueue(); + public LookupCache Lookups { get; } = new LookupCache(); + + public bool IsAuthenticated => AccountId.HasValue; + + public string DisplayName => + IsAuthenticated ? $"{Email} ({AccessLevel})" : "(not signed in)"; + } +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AddAutoMarketItemPickItem.cs b/src/Perpetuum.AdminTool/AutoMarket/AddAutoMarketItemPickItem.cs new file mode 100644 index 00000000..fdfa631c --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AddAutoMarketItemPickItem.cs @@ -0,0 +1,9 @@ +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AddAutoMarketItemPickItem + { + public int Definition { get; init; } + public string DefinitionName { get; init; } = ""; + public string DisplayName { get; init; } = ""; + } +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AutoMarketConfigRow.cs b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketConfigRow.cs new file mode 100644 index 00000000..7dffc389 --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketConfigRow.cs @@ -0,0 +1,18 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.AutoMarket +{ + public partial class AutoMarketConfigRow : ObservableObject + { + public string ParamName { get; init; } = ""; + public string Label { get; init; } = ""; + public string Description { get; init; } = ""; + public double OriginalValue { get; set; } + + [ObservableProperty] private double _paramValue; + + public bool IsDirty => Math.Abs(ParamValue - OriginalValue) > 1e-9; + + partial void OnParamValueChanged(double value) => OnPropertyChanged(nameof(IsDirty)); + } +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AutoMarketCoveredMaterialRow.cs b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketCoveredMaterialRow.cs new file mode 100644 index 00000000..cc0e2c34 --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketCoveredMaterialRow.cs @@ -0,0 +1,25 @@ +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketCoveredMaterialRow + { + public string DefinitionName { get; init; } = ""; + public string DisplayName { get; set; } = ""; + public double CurrentPrice { get; init; } + public long EffectiveCap { get; init; } // BIGINT: COALESCE(override, global default) + public int? WeeklyCapOverride { get; set; } // INT NULL: matches DB column type + public long BoughtThisWeek { get; init; } + public bool CreateBuyOrders { get; set; } + public bool CreateSellOrders { get; set; } + + // Originals for change detection — need set because QueueSave updates them after dispatch + public int? OriginalCapOverride { get; set; } + public bool OriginalBuyOrders { get; set; } + public bool OriginalSellOrders { get; set; } + + public bool HasOverride => + WeeklyCapOverride.HasValue || !CreateBuyOrders || !CreateSellOrders; + + public bool IsAtDefaults => + !WeeklyCapOverride.HasValue && CreateBuyOrders && CreateSellOrders; + } +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AutoMarketGatherRow.cs b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketGatherRow.cs new file mode 100644 index 00000000..be621dd8 --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketGatherRow.cs @@ -0,0 +1,12 @@ +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketGatherRow + { + public string ResourceName { get; init; } = ""; + public string DisplayName { get; set; } = ""; + public long PveQty { get; init; } + public long PvpQty { get; init; } + public long TotalQty => PveQty + PvpQty; + public double PvpPct => TotalQty > 0 ? PvpQty * 100.0 / TotalQty : 0.0; + } +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AutoMarketLabels.cs b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketLabels.cs new file mode 100644 index 00000000..7618c377 --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketLabels.cs @@ -0,0 +1,22 @@ +namespace Perpetuum.AdminTool.AutoMarket +{ + internal static class AutoMarketLabels + { + internal record LabelMeta(string Label, string Description); + + internal static readonly IReadOnlyDictionary Map = + new Dictionary + { + ["plasma_anchor_fraction"] = new("Plasma Anchor Fraction", "Fraction of alpha plasma price used as raw material pricing anchor"), + ["plasma_buy_qty_fraction"] = new("Plasma Buy Quantity", "Fraction of gathered plasma placed as buy orders"), + ["daily_plasma_budget_nic"] = new("Daily Plasma Budget (NIC)", "Max NIC spent on plasma buy orders per calendar day"), + ["daily_rawmat_budget_nic"] = new("Daily Rawmat Budget (NIC, 0=∞)", "Max NIC spent on raw material buy orders per calendar day. 0 = unlimited."), + ["weekly_rawmat_cap_default"] = new("Weekly Rawmat Cap (default, 0=∞)","Default max units AutoMarket buys per raw material per week. 0 = unlimited."), + ["resource_ds_ratio_min"] = new("S/D Ratio Min", "Lower clamp for supply/demand ratio in pricing formula"), + ["resource_ds_ratio_max"] = new("S/D Ratio Max", "Upper clamp for supply/demand ratio in pricing formula"), + ["product_sell_margin"] = new("Product Sell Margin", "Production item sell orders priced at production_cost × this value"), + ["raw_mat_sell_multiplier"] = new("Rawmat Sell Multiplier", "Raw material sell orders priced at production_cost × this value"), + ["product_buyback_margin"] = new("Product Buyback Margin", "Buyback buy orders priced at production_cost × this value"), + }; + } +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AutoMarketNicFlowRow.cs b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketNicFlowRow.cs new file mode 100644 index 00000000..8d7e9d15 --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketNicFlowRow.cs @@ -0,0 +1,12 @@ +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketNicFlowRow + { + public string Period { get; init; } = ""; + public long PlasmaIn { get; init; } + public long RawmatOut { get; init; } + public long NetDelta => PlasmaIn - RawmatOut; + public double? PlasmaBudgetPct { get; init; } + public double? RawmatBudgetPct { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AutoMarketOrderData.cs b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketOrderData.cs new file mode 100644 index 00000000..b0edaec8 --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketOrderData.cs @@ -0,0 +1,10 @@ +namespace Perpetuum.AdminTool.AutoMarket +{ + public record AutoMarketOrderData( + int ItemDefinition, + string DefinitionName, + bool IsSell, + double Price, + int Quantity, + string MarketDefinitionName); +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AutoMarketOrderRow.cs b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketOrderRow.cs new file mode 100644 index 00000000..ab12983b --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketOrderRow.cs @@ -0,0 +1,12 @@ +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketOrderRow + { + public string DisplayName { get; init; } = ""; + public string OrderType { get; init; } = ""; + public double Price { get; init; } + public int Amount { get; init; } + public string MarketName { get; init; } = ""; + public string Category { get; init; } = ""; + } +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AutoMarketPricingTraceRow.cs b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketPricingTraceRow.cs new file mode 100644 index 00000000..fcb884c0 --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketPricingTraceRow.cs @@ -0,0 +1,15 @@ +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketPricingTraceRow + { + public string ResourceName { get; init; } = ""; + public string DisplayName { get; set; } = ""; + public double PlasmaAnchor { get; init; } + public double SdRatio { get; init; } + public double RiskMultiplier { get; init; } + public double ComputedPrice { get; init; } + public double? StoredPrice { get; init; } + public long BoughtThisWeek { get; init; } + public long EffectiveCap { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AutoMarketRawMaterialRow.cs b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketRawMaterialRow.cs new file mode 100644 index 00000000..82bc993f --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketRawMaterialRow.cs @@ -0,0 +1,8 @@ +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketRawMaterialRow + { + public string RawMaterialName { get; init; } = ""; + public long TotalQuantity { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs new file mode 100644 index 00000000..b3594e17 --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketRepository.cs @@ -0,0 +1,428 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.AutoMarket +{ + public class AutoMarketRepository + { + private readonly ConnectionSettings _connection; + + public AutoMarketRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task> LoadConfigAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT param_name, param_value FROM automarket_config ORDER BY param_name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var name = reader.GetString(0); + var value = reader.GetDouble(1); + AutoMarketLabels.Map.TryGetValue(name, out var meta); + result.Add(new AutoMarketConfigRow + { + ParamName = name, + ParamValue = value, + OriginalValue = value, + Label = meta?.Label ?? name, + Description = meta?.Description ?? "", + }); + } + return result; + } + + public async Task> LoadTradeListAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT definitionname, amount, create_sell_orders, create_buyback_orders " + + "FROM market_orders_configuration ORDER BY definitionname"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var name = reader.GetString(0); + var amount = reader.GetInt32(1); + var createSell = reader.GetBoolean(2); + var createBuyback = reader.GetBoolean(3); + result.Add(new AutoMarketTradeListRow + { + DefinitionName = name, + DisplayName = name, + Amount = amount, + OriginalAmount = amount, + CreateSellOrders = createSell, + OriginalCreateSellOrders = createSell, + CreateBuybackOrders = createBuyback, + OriginalCreateBuybackOrders = createBuyback, + }); + } + return result; + } + + public async Task> LoadDerivedMaterialsAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT raw_material, SUM(total_quantity) " + + "FROM v_trade_list_raw_material_demand " + + "GROUP BY raw_material " + + "ORDER BY raw_material"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + result.Add(new AutoMarketRawMaterialRow + { + RawMaterialName = reader.GetString(0), + TotalQuantity = reader.GetInt64(1), + }); + return result; + } + + public async Task> LoadNicFlowAsync() + { + long todayPlasma, weekPlasma, allPlasma; + long todayRawmat, weekRawmat, allRawmat; + double plasmaBudget = 0, rawmatBudget = 0; + + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT " + + " ISNULL(SUM(CASE WHEN sold_on = CAST(GETUTCDATE() AS DATE) THEN income ELSE 0 END), 0), " + + " ISNULL(SUM(CASE WHEN sold_on >= DATEADD(DAY,-7,CAST(GETUTCDATE() AS DATE)) THEN income ELSE 0 END), 0), " + + " ISNULL(SUM(income), 0) " + + "FROM plasma_sold"; + await using var r = await cmd.ExecuteReaderAsync(); + await r.ReadAsync(); + todayPlasma = (long)Math.Round(r.GetDouble(0)); + weekPlasma = (long)Math.Round(r.GetDouble(1)); + allPlasma = (long)Math.Round(r.GetDouble(2)); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT " + + " ISNULL(SUM(CASE WHEN purchased_on = CAST(GETUTCDATE() AS DATE) THEN income ELSE 0 END), 0), " + + " ISNULL(SUM(CASE WHEN purchased_on >= DATEADD(DAY,-7,CAST(GETUTCDATE() AS DATE)) THEN income ELSE 0 END), 0), " + + " ISNULL(SUM(income), 0) " + + "FROM rawmat_purchased"; + await using var r = await cmd.ExecuteReaderAsync(); + await r.ReadAsync(); + todayRawmat = (long)Math.Round(r.GetDouble(0)); + weekRawmat = (long)Math.Round(r.GetDouble(1)); + allRawmat = (long)Math.Round(r.GetDouble(2)); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT param_name, param_value FROM automarket_config " + + "WHERE param_name IN ('daily_plasma_budget_nic', 'daily_rawmat_budget_nic')"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + if (r.GetString(0) == "daily_plasma_budget_nic") plasmaBudget = r.GetDouble(1); + if (r.GetString(0) == "daily_rawmat_budget_nic") rawmatBudget = r.GetDouble(1); + } + } + + return new List + { + new() { + Period = "Today", + PlasmaIn = todayPlasma, + RawmatOut = todayRawmat, + PlasmaBudgetPct = plasmaBudget > 0 ? todayPlasma * 100.0 / plasmaBudget : null, + RawmatBudgetPct = rawmatBudget > 0 ? todayRawmat * 100.0 / rawmatBudget : null, + }, + new() { Period = "Last 7 Days", PlasmaIn = weekPlasma, RawmatOut = weekRawmat }, + new() { Period = "All Time", PlasmaIn = allPlasma, RawmatOut = allRawmat }, + }; + } + + public async Task> LoadPricingTraceAsync() + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + // 1. Alpha plasma anchor price + double alphaPlasmaPrice = 0; + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT TOP 1 dynamic_price FROM fn_CalculateDynamicPlasmaPrices(1) " + + "WHERE plasma_type = 'def_common_reactor_plasma'"; + await using var r = await cmd.ExecuteReaderAsync(); + if (await r.ReadAsync()) alphaPlasmaPrice = r.IsDBNull(0) ? 0 : (double)r.GetDecimal(0); + } + + // 2. Config params + double anchorFraction = 0.15, dsMin = 0.25, dsMax = 4.0; + long weeklyCapDefault = 500_000_000; + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT param_name, param_value FROM automarket_config " + + "WHERE param_name IN ('plasma_anchor_fraction','resource_ds_ratio_min','resource_ds_ratio_max','weekly_rawmat_cap_default')"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + switch (r.GetString(0)) + { + case "plasma_anchor_fraction": anchorFraction = r.GetDouble(1); break; + case "resource_ds_ratio_min": dsMin = r.GetDouble(1); break; + case "resource_ds_ratio_max": dsMax = r.GetDouble(1); break; + case "weekly_rawmat_cap_default": weeklyCapDefault = (long)r.GetDouble(1); break; + } + } + } + + var plasmaAnchor = alphaPlasmaPrice * anchorFraction; + + // 3. Supply data (last 7 days) + var supply = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT resource_name, " + + " SUM(CASE WHEN is_pvp = 1 THEN quantity ELSE 0 END), " + + " SUM(quantity), " + + " SUM(quantity) / 7.0 " + + "FROM resources_gathered " + + "WHERE gathered_on >= DATEADD(DAY,-7,CAST(GETUTCDATE() AS DATE)) " + + "GROUP BY resource_name"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + supply[r.GetString(0)] = ((double)r.GetDecimal(3), r.GetInt64(1), r.GetInt64(2)); + } + + // 4. Demand data (from trade-list BOM — demand signal only) + var demand = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT raw_material, SUM(total_quantity) / 7.0 " + + "FROM v_trade_list_raw_material_demand GROUP BY raw_material"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) demand[r.GetString(0)] = (double)r.GetDecimal(1); + } + + // 5. Materials list — cf_organic (0x10114), cf_ore (0x20114), cf_liquid (0x40114) + var materials = new List(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT definitionname FROM entitydefaults " + + "WHERE categoryflags IN (0x10114, 0x20114, 0x40114) AND enabled = 1 AND hidden = 0 " + + "ORDER BY definitionname"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) materials.Add(r.GetString(0)); + } + + // 6. Stored prices (latest week) + var storedPrices = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT resource_name, unit_price FROM resource_market_prices " + + "WHERE calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices)"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) storedPrices[r.GetString(0)] = (double)r.GetDecimal(1); + } + + // 7. Weekly purchases this week per material + var weeklyPurchased = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "DECLARE @ws DATE = DATEADD(DAY, -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, CAST(GETUTCDATE() AS DATE)); " + + "SELECT definitionname, ISNULL(SUM(qty_purchased),0) " + + "FROM automarket_rawmat_weekly_tracking WHERE week_start >= @ws " + + "GROUP BY definitionname"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) weeklyPurchased[r.GetString(0)] = r.GetInt64(1); + } + + // 8. Per-material cap overrides + var capOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT definitionname, weekly_cap_override FROM automarket_rawmat_overrides WHERE weekly_cap_override IS NOT NULL"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) capOverrides[r.GetString(0)] = r.GetInt32(1); + } + + // Compute rows + var result = new List(); + foreach (var name in materials) + { + var hasSupply = supply.TryGetValue(name, out var sup); + double supplyDailyAvg = hasSupply ? sup.DailyAvg : 0; + demand.TryGetValue(name, out var dailyDemand); + + double sdRatio = supplyDailyAvg <= 0 + ? dsMax + : Math.Clamp(dailyDemand / supplyDailyAvg, dsMin, dsMax); + + double pvpFraction = (hasSupply && sup.TotalQty > 0) + ? (double)sup.PvpQty / sup.TotalQty + : 1.0; + + var riskMultiplier = 1.0 + pvpFraction; + var computedPrice = Math.Round(plasmaAnchor * sdRatio * riskMultiplier, 2); + var effectiveCap = capOverrides.TryGetValue(name, out var ov) ? ov : weeklyCapDefault; + + result.Add(new AutoMarketPricingTraceRow + { + ResourceName = name, + PlasmaAnchor = Math.Round(plasmaAnchor, 4), + SdRatio = Math.Round(sdRatio, 4), + RiskMultiplier = Math.Round(riskMultiplier, 4), + ComputedPrice = computedPrice, + StoredPrice = storedPrices.TryGetValue(name, out var sp) ? sp : null, + BoughtThisWeek = weeklyPurchased.TryGetValue(name, out var bw) ? bw : 0, + EffectiveCap = effectiveCap, + }); + } + return result; + } + + public async Task> LoadCoveredMaterialsAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandTimeout = 30; + cmd.CommandText = + "DECLARE @ws DATE = DATEADD(DAY, -DATEPART(WEEKDAY, CAST(GETUTCDATE() AS DATE)) + 2, CAST(GETUTCDATE() AS DATE)); " + + "SELECT " + + " ed.definitionname, " + + " ISNULL(rmp.unit_price, 0) AS current_price, " + + " COALESCE(o.weekly_cap_override, CAST(cfg.param_value AS BIGINT)) AS effective_cap, " + + " o.weekly_cap_override, " + + " ISNULL(o.create_buy_orders, 1) AS create_buy_orders, " + + " ISNULL(o.create_sell_orders, 1) AS create_sell_orders, " + + " ISNULL(wt.qty_purchased, 0) AS bought_this_week " + + "FROM entitydefaults ed " + + "LEFT JOIN automarket_rawmat_overrides o ON o.definitionname = ed.definitionname " + + "LEFT JOIN ( " + + " SELECT resource_name, unit_price FROM resource_market_prices " + + " WHERE calculated_on = (SELECT MAX(calculated_on) FROM resource_market_prices) " + + ") rmp ON rmp.resource_name = ed.definitionname " + + "LEFT JOIN ( " + + " SELECT definitionname, SUM(qty_purchased) AS qty_purchased " + + " FROM automarket_rawmat_weekly_tracking WHERE week_start >= @ws " + + " GROUP BY definitionname " + + ") wt ON wt.definitionname = ed.definitionname " + + "CROSS JOIN (SELECT param_value FROM automarket_config WHERE param_name = 'weekly_rawmat_cap_default') cfg " + + "WHERE ed.categoryflags IN (0x10114, 0x20114, 0x40114) AND ed.enabled = 1 AND ed.hidden = 0 " + + "ORDER BY ed.definitionname"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var capOverride = reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3); + var buy = reader.GetBoolean(4); + var sell = reader.GetBoolean(5); + result.Add(new AutoMarketCoveredMaterialRow + { + DefinitionName = reader.GetString(0), + CurrentPrice = reader.IsDBNull(1) ? 0.0 : (double)reader.GetDecimal(1), + EffectiveCap = reader.GetInt64(2), + WeeklyCapOverride = capOverride, + CreateBuyOrders = buy, + CreateSellOrders = sell, + BoughtThisWeek = reader.GetInt64(6), + OriginalCapOverride = capOverride, + OriginalBuyOrders = buy, + OriginalSellOrders = sell, + }); + } + return result; + } + + public async Task> LoadGatherBreakdownAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT resource_name, " + + " SUM(CASE WHEN is_pvp = 0 THEN quantity ELSE 0 END), " + + " SUM(CASE WHEN is_pvp = 1 THEN quantity ELSE 0 END) " + + "FROM resources_gathered_daily " + + "WHERE gathered_on >= DATEADD(DAY,-7,CAST(GETUTCDATE() AS DATE)) " + + "GROUP BY resource_name " + + "ORDER BY resource_name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + result.Add(new AutoMarketGatherRow + { + ResourceName = reader.GetString(0), + PveQty = reader.GetInt64(1), + PvpQty = reader.GetInt64(2), + }); + return result; + } + + public async Task> LoadOrdersAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT mi.itemdefinition, ISNULL(ed.definitionname,''), mi.isSell, " + + " mi.price, mi.quantity, ISNULL(ed2.definitionname,'') " + + "FROM marketitems mi " + + "LEFT JOIN entitydefaults ed ON ed.definition = mi.itemdefinition " + + "LEFT JOIN entities ent ON ent.eid = mi.marketeid " + + "LEFT JOIN entitydefaults ed2 ON ed2.definition = ent.definition " + + "WHERE mi.isAutoOrder = 1 " + + "ORDER BY ed.definitionname"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + result.Add(new AutoMarketOrderData( + r.GetInt32(0), r.GetString(1), r.GetBoolean(2), + r.GetDouble(3), r.GetInt32(4), r.GetString(5))); + } + return result; + } + + public async Task RefreshNowAsync() + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandTimeout = 120; + cmd.CommandText = "EXEC recalculate_raw_material_prices"; + await cmd.ExecuteNonQueryAsync(); + } + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandTimeout = 120; + cmd.CommandText = "EXEC usp_RefreshAutoMarketOrders"; + await cmd.ExecuteNonQueryAsync(); + } + } + } +} diff --git a/src/Perpetuum.AdminTool/AutoMarket/AutoMarketTradeListRow.cs b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketTradeListRow.cs new file mode 100644 index 00000000..1e48f67d --- /dev/null +++ b/src/Perpetuum.AdminTool/AutoMarket/AutoMarketTradeListRow.cs @@ -0,0 +1,26 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.AutoMarket +{ + public partial class AutoMarketTradeListRow : ObservableObject + { + public string DefinitionName { get; init; } = ""; + public string DisplayName { get; set; } = ""; + public int OriginalAmount { get; set; } + public bool OriginalCreateSellOrders { get; set; } + public bool OriginalCreateBuybackOrders { get; set; } + + [ObservableProperty] private int _amount; + [ObservableProperty] private bool _createSellOrders; + [ObservableProperty] private bool _createBuybackOrders; + + public bool IsDirty => + Amount != OriginalAmount + || CreateSellOrders != OriginalCreateSellOrders + || CreateBuybackOrders != OriginalCreateBuybackOrders; + + partial void OnAmountChanged(int value) => OnPropertyChanged(nameof(IsDirty)); + partial void OnCreateSellOrdersChanged(bool value) => OnPropertyChanged(nameof(IsDirty)); + partial void OnCreateBuybackOrdersChanged(bool value) => OnPropertyChanged(nameof(IsDirty)); + } +} diff --git a/src/Perpetuum.AdminTool/Common/BindingProxy.cs b/src/Perpetuum.AdminTool/Common/BindingProxy.cs new file mode 100644 index 00000000..0b45475c --- /dev/null +++ b/src/Perpetuum.AdminTool/Common/BindingProxy.cs @@ -0,0 +1,21 @@ +using System.Windows; + +namespace Perpetuum.AdminTool.Common +{ + // Forwards the parent's DataContext into places where the visual tree breaks the binding chain + // — most commonly DataGridColumn cell templates, which live outside the grid's visual tree and + // therefore cannot reach the grid's DataContext via RelativeSource. + public class BindingProxy : Freezable + { + public static readonly DependencyProperty DataProperty = + DependencyProperty.Register(nameof(Data), typeof(object), typeof(BindingProxy)); + + public object? Data + { + get => GetValue(DataProperty); + set => SetValue(DataProperty, value); + } + + protected override Freezable CreateInstanceCore() => new BindingProxy(); + } +} diff --git a/src/Perpetuum.AdminTool/Common/EntityPickItem.cs b/src/Perpetuum.AdminTool/Common/EntityPickItem.cs new file mode 100644 index 00000000..bc48e77c --- /dev/null +++ b/src/Perpetuum.AdminTool/Common/EntityPickItem.cs @@ -0,0 +1,15 @@ +namespace Perpetuum.AdminTool.Common +{ + public class EntityPickItem + { + public int Definition { get; init; } + public string Name { get; init; } = ""; + public long CategoryFlags { get; init; } + public bool Enabled { get; init; } + public bool Hidden { get; init; } + public int TierType { get; init; } + public int TierLevel { get; init; } + + public string Display => $"{Definition} — {Name}"; + } +} diff --git a/src/Perpetuum.AdminTool/Common/EnumIntConverter.cs b/src/Perpetuum.AdminTool/Common/EnumIntConverter.cs new file mode 100644 index 00000000..1a8d87af --- /dev/null +++ b/src/Perpetuum.AdminTool/Common/EnumIntConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace Perpetuum.AdminTool.Common +{ + public class EnumIntConverter : IValueConverter + { + public static readonly EnumIntConverter Instance = new EnumIntConverter(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value == null) return ""; + if (value is Enum) return System.Convert.ToInt32(value).ToString(CultureInfo.InvariantCulture); + return value.ToString() ?? ""; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return Binding.DoNothing; + } + } +} diff --git a/src/Perpetuum.AdminTool/Common/FlagDescriptionConverters.cs b/src/Perpetuum.AdminTool/Common/FlagDescriptionConverters.cs new file mode 100644 index 00000000..9ca7ccca --- /dev/null +++ b/src/Perpetuum.AdminTool/Common/FlagDescriptionConverters.cs @@ -0,0 +1,35 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using Perpetuum.AdminTool.Entities; + +namespace Perpetuum.AdminTool.Common +{ + public class CategoryFlagsDescriptionConverter : IValueConverter + { + public static readonly CategoryFlagsDescriptionConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is long l) return CategoryFlagsCatalog.Describe(l); + return ""; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; + } + + public class AttributeFlagsDescriptionConverter : IValueConverter + { + public static readonly AttributeFlagsDescriptionConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is long l) return AttributeFlagsCatalog.Describe(unchecked((ulong)l)); + return ""; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; + } +} diff --git a/src/Perpetuum.AdminTool/Common/InverseBoolConverter.cs b/src/Perpetuum.AdminTool/Common/InverseBoolConverter.cs new file mode 100644 index 00000000..c730eff9 --- /dev/null +++ b/src/Perpetuum.AdminTool/Common/InverseBoolConverter.cs @@ -0,0 +1,17 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace Perpetuum.AdminTool.Common +{ + public class InverseBoolConverter : IValueConverter + { + public static readonly InverseBoolConverter Instance = new InverseBoolConverter(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : true; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : false; + } +} diff --git a/src/Perpetuum.AdminTool/Common/LongToForegroundConverter.cs b/src/Perpetuum.AdminTool/Common/LongToForegroundConverter.cs new file mode 100644 index 00000000..c97de85f --- /dev/null +++ b/src/Perpetuum.AdminTool/Common/LongToForegroundConverter.cs @@ -0,0 +1,18 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace Perpetuum.AdminTool.Common +{ + public class LongToForegroundConverter : IValueConverter + { + public static readonly LongToForegroundConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is long v && v < 0 ? Brushes.DarkRed : Brushes.DarkGreen; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } +} diff --git a/src/Perpetuum.AdminTool/Common/LookupCache.cs b/src/Perpetuum.AdminTool/Common/LookupCache.cs new file mode 100644 index 00000000..75a2bd10 --- /dev/null +++ b/src/Perpetuum.AdminTool/Common/LookupCache.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Common +{ + /// + /// Process-wide cache of small lookup tables that drive dropdowns in multiple tabs: + /// entitydefaults (definition, definitionname) and robottemplates (id, name). + /// Refresh on app start, after every successful Direct-DB commit, and from the + /// per-tab Reload buttons. + /// + public class LookupCache + { + public ObservableCollection Entities { get; } = new(); + public ObservableCollection Templates { get; } = new(); + + public Dictionary EntityNamesByDefinition { get; private set; } = new(); + public Dictionary TemplateNamesById { get; private set; } = new(); + + public async Task RefreshEntitiesAsync(ConnectionSettings connection) + { + await using var cn = new SqlConnection(connection.BuildConnectionString()); + await cn.OpenAsync(); + await RefreshEntitiesAsync(cn); + } + + public async Task RefreshTemplatesAsync(ConnectionSettings connection) + { + await using var cn = new SqlConnection(connection.BuildConnectionString()); + await cn.OpenAsync(); + await RefreshTemplatesAsync(cn); + } + + public async Task RefreshAllAsync(ConnectionSettings connection) + { + await using var cn = new SqlConnection(connection.BuildConnectionString()); + await cn.OpenAsync(); + await RefreshEntitiesAsync(cn); + await RefreshTemplatesAsync(cn); + } + + private async Task RefreshEntitiesAsync(SqlConnection cn) + { + var fresh = new List(); + var names = new Dictionary(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "select definition, definitionname, categoryflags, enabled, hidden, " + + "ISNULL(tiertype,0), ISNULL(tierlevel,0) " + + "from entitydefaults order by definitionname"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var def = reader.GetInt32(0); + var name = reader.IsDBNull(1) ? "" : reader.GetString(1); + var categoryFlags = reader.IsDBNull(2) ? 0L : reader.GetInt64(2); + var enabled = !reader.IsDBNull(3) && reader.GetBoolean(3); + var hidden = !reader.IsDBNull(4) && reader.GetBoolean(4); + var tierType = reader.GetInt32(5); + var tierLevel = reader.GetInt32(6); + fresh.Add(new EntityPickItem + { + Definition = def, + Name = name, + CategoryFlags = categoryFlags, + Enabled = enabled, + Hidden = hidden, + TierType = tierType, + TierLevel = tierLevel, + }); + names[def] = name; + } + Entities.Clear(); + foreach (var p in fresh) Entities.Add(p); + EntityNamesByDefinition = names; + } + + private async Task RefreshTemplatesAsync(SqlConnection cn) + { + var fresh = new List(); + var names = new Dictionary(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "select id, name from robottemplates order by name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var id = reader.GetInt32(0); + var name = reader.IsDBNull(1) ? "" : reader.GetString(1); + fresh.Add(new TemplatePickItem { Id = id, Name = name }); + names[id] = name; + } + Templates.Clear(); + foreach (var p in fresh) Templates.Add(p); + TemplateNamesById = names; + } + } +} diff --git a/src/Perpetuum.AdminTool/Common/PresencePickItem.cs b/src/Perpetuum.AdminTool/Common/PresencePickItem.cs new file mode 100644 index 00000000..7b69ef28 --- /dev/null +++ b/src/Perpetuum.AdminTool/Common/PresencePickItem.cs @@ -0,0 +1,9 @@ +namespace Perpetuum.AdminTool.Common +{ + public class PresencePickItem + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public string Display => $"{Id} — {Name}"; + } +} diff --git a/src/Perpetuum.AdminTool/Common/TemplatePickItem.cs b/src/Perpetuum.AdminTool/Common/TemplatePickItem.cs new file mode 100644 index 00000000..1918a25d --- /dev/null +++ b/src/Perpetuum.AdminTool/Common/TemplatePickItem.cs @@ -0,0 +1,9 @@ +namespace Perpetuum.AdminTool.Common +{ + public class TemplatePickItem + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public string Display => $"{Id} — {Name}"; + } +} diff --git a/src/Perpetuum.AdminTool/Common/ZoneSpawnPickItem.cs b/src/Perpetuum.AdminTool/Common/ZoneSpawnPickItem.cs new file mode 100644 index 00000000..079b5876 --- /dev/null +++ b/src/Perpetuum.AdminTool/Common/ZoneSpawnPickItem.cs @@ -0,0 +1,9 @@ +namespace Perpetuum.AdminTool.Common +{ + public class ZoneSpawnPickItem + { + public int SpawnId { get; init; } + public string Name { get; init; } = ""; + public string Display => $"{SpawnId} — {Name}"; + } +} diff --git a/src/Perpetuum.AdminTool/Data/Authenticator.cs b/src/Perpetuum.AdminTool/Data/Authenticator.cs new file mode 100644 index 00000000..498aa5aa --- /dev/null +++ b/src/Perpetuum.AdminTool/Data/Authenticator.cs @@ -0,0 +1,103 @@ +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Data +{ + public enum AuthResult + { + Success, + InvalidCredentials, + InsufficientAccess, + ConnectionFailed + } + + public class AuthOutcome + { + public AuthResult Result { get; init; } + public int? AccountId { get; init; } + public AccessLevel AccessLevel { get; init; } = AccessLevel.notDefined; + public string? Email { get; init; } + public string? ErrorMessage { get; init; } + } + + public class Authenticator + { + private readonly ConnectionSettings _connection; + private readonly AccessLevel _minimumAccess; + + public Authenticator(ConnectionSettings connection, AccessLevel minimumAccess = AccessLevel.gameAdmin) + { + _connection = connection; + _minimumAccess = minimumAccess; + } + + public async Task AuthenticateAsync(string email, string password) + { + try + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "select accountid, accLevel from accounts " + + "where email = @email and password = @password"; + cmd.Parameters.Add(new SqlParameter("@email", email)); + cmd.Parameters.Add(new SqlParameter("@password", ToSha1(password))); + + await using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + { + return new AuthOutcome { Result = AuthResult.InvalidCredentials }; + } + + var accountId = reader.GetInt32(0); + var accLevel = (AccessLevel)reader.GetInt32(1); + + if ((accLevel & _minimumAccess) != _minimumAccess) + { + return new AuthOutcome + { + Result = AuthResult.InsufficientAccess, + AccountId = accountId, + AccessLevel = accLevel, + Email = email + }; + } + + return new AuthOutcome + { + Result = AuthResult.Success, + AccountId = accountId, + AccessLevel = accLevel, + Email = email + }; + } + catch (Exception ex) + { + return new AuthOutcome + { + Result = AuthResult.ConnectionFailed, + ErrorMessage = ex.Message + }; + } + } + + private string ToSha1(string input) + { + if (input.IsNullOrEmpty()) + { + return ""; + } + + System.Text.ASCIIEncoding encoding = new(); + byte[] bytes = encoding.GetBytes(input); + + System.Security.Cryptography.SHA1CryptoServiceProvider sha = new(); + + string result = System.BitConverter.ToString(sha.ComputeHash(bytes)).Replace("-", string.Empty); + + return result; + } + } +} diff --git a/src/Perpetuum.AdminTool/Data/DbProbe.cs b/src/Perpetuum.AdminTool/Data/DbProbe.cs new file mode 100644 index 00000000..535bb171 --- /dev/null +++ b/src/Perpetuum.AdminTool/Data/DbProbe.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Data +{ + public static class DbProbe + { + public static async Task<(bool Ok, string Message)> TestConnectionAsync(ConnectionSettings cs) + { + try + { + await using var cn = new SqlConnection(cs.BuildConnectionString()); + await cn.OpenAsync(); + return (true, $"Connected to {cn.DataSource} / {cn.Database} (server v{cn.ServerVersion})."); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyCorporationWealthRow.cs b/src/Perpetuum.AdminTool/Economy/EconomyCorporationWealthRow.cs new file mode 100644 index 00000000..531a4c12 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyCorporationWealthRow.cs @@ -0,0 +1,13 @@ +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyCorporationWealthRow + { + public int Rank { get; init; } + public string Name { get; init; } = ""; + public string Tag { get; init; } = ""; + public int MemberCount { get; init; } + public long CorpWallet { get; init; } + public long MemberAggregate { get; init; } + public long Combined => CorpWallet + MemberAggregate; + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyInsuranceRepository.cs b/src/Perpetuum.AdminTool/Economy/EconomyInsuranceRepository.cs new file mode 100644 index 00000000..1dfb7008 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyInsuranceRepository.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyInsuranceRepository + { + private readonly ConnectionSettings _connection; + + public EconomyInsuranceRepository(ConnectionSettings connection) => _connection = connection; + + public async Task> LoadConfigAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT param_name, param_value FROM insurance_config ORDER BY param_name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var name = reader.GetString(0); + var value = reader.GetDouble(1); + InsuranceLabels.Map.TryGetValue(name, out var meta); + result.Add(new InsuranceConfigRow + { + ParamName = name, + ParamValue = value, + OriginalValue = value, + Label = meta?.Label ?? name, + Description = meta?.Description ?? "", + }); + } + return result; + } + + public async Task> LoadPricesAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT ed.definitionname, " + + " ISNULL(vpc.production_cost_nic, 0), " + + " ip.fee, " + + " ip.payout " + + "FROM insuranceprices ip " + + "JOIN entitydefaults ed ON ip.definition = ed.definition " + + "LEFT JOIN v_all_production_costs vpc " + + " ON vpc.product = ed.definitionname COLLATE DATABASE_DEFAULT " + + "ORDER BY ed.definitionname"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new InsurancePriceRow + { + ItemName = reader.GetString(0), + ProductionCostNic = reader.IsDBNull(1) ? 0.0 : reader.GetDouble(1), + Fee = reader.GetDouble(2), + Payout = reader.GetDouble(3), + }); + } + return result; + } + + public async Task RecalculateAsync() + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "exec usp_RecalculateInsurancePrices"; + cmd.CommandTimeout = 60; + await cmd.ExecuteNonQueryAsync(); + } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyListingAgeBuckets.cs b/src/Perpetuum.AdminTool/Economy/EconomyListingAgeBuckets.cs new file mode 100644 index 00000000..a19a8b98 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyListingAgeBuckets.cs @@ -0,0 +1,10 @@ +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyListingAgeBuckets + { + public int Today { get; init; } + public int D1To7 { get; init; } + public int D7To30 { get; init; } + public int D30Plus { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyMarketData.cs b/src/Perpetuum.AdminTool/Economy/EconomyMarketData.cs new file mode 100644 index 00000000..d7e26c41 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyMarketData.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyMarketData + { + public IReadOnlyList VelocityRows { get; init; } = System.Array.Empty(); + public IReadOnlyList PriceIndexRows { get; init; } = System.Array.Empty(); + public EconomyListingAgeBuckets AgeBuckets { get; init; } = new(); + public int AutoMarketOrderCount { get; init; } + public int PlayerOrderCount { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyMarketHealthRepository.cs b/src/Perpetuum.AdminTool/Economy/EconomyMarketHealthRepository.cs new file mode 100644 index 00000000..3a2b97b5 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyMarketHealthRepository.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyMarketHealthRepository + { + private readonly ConnectionSettings _connection; + + public EconomyMarketHealthRepository(ConnectionSettings connection) + => _connection = connection; + + public async Task LoadMarketDataAsync() + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + var velocity = await LoadVelocityAsync(cn); + var priceIndex = await LoadPriceIndexAsync(cn); + var ageBuckets = await LoadAgeBucketsAsync(cn); + var (amCount, playerCount) = await LoadOrderCountsAsync(cn); + + return new EconomyMarketData + { + VelocityRows = velocity, + PriceIndexRows = priceIndex, + AgeBuckets = ageBuckets, + AutoMarketOrderCount = amCount, + PlayerOrderCount = playerCount, + }; + } + + public async Task> LoadBasketAsync() + { + var items = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT b.id, b.definition, e.definitionname, CAST(b.weight AS FLOAT) " + + "FROM economy_price_index_basket b " + + "JOIN entitydefaults e ON e.definition = b.definition " + + "ORDER BY e.definitionname"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + var item = new EconomyPriceIndexBasketItem + { + Id = r.GetInt32(0), + Definition = r.GetInt32(1), + DefinitionName = r.IsDBNull(2) ? "" : r.GetString(2), + }; + item.Weight = r.GetDouble(3); + items.Add(item); + } + return items; + } + + private static async Task> LoadVelocityAsync(SqlConnection cn) + { + var rows = new List(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT date, ISNULL(SUM(totalprice),0) AS nic_traded " + + "FROM marketaverageprices " + + "WHERE date >= DATEADD(DAY,-30,CAST(GETUTCDATE() AS DATE)) " + + "GROUP BY date ORDER BY date DESC"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + rows.Add(new EconomyVelocityRow { Date = r.GetDateTime(0), NicTraded = (long)Math.Round(r.GetDouble(1)) }); + return rows; + } + + private static async Task> LoadPriceIndexAsync(SqlConnection cn) + { + var rows = new List(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT m.date, " + + " SUM((m.totalprice / NULLIF(m.quantity,0)) * CAST(b.weight AS FLOAT)) " + + " / NULLIF(SUM(CAST(b.weight AS FLOAT)),0) AS index_value " + + "FROM marketaverageprices m " + + "JOIN economy_price_index_basket b ON b.definition = m.itemdefinition " + + "WHERE m.date >= DATEADD(DAY,-30,CAST(GETUTCDATE() AS DATE)) " + + " AND m.quantity > 0 " + + "GROUP BY m.date " + + "ORDER BY m.date DESC"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + if (r.IsDBNull(1)) continue; + rows.Add(new EconomyPriceIndexRow { Date = r.GetDateTime(0), IndexValue = r.GetDouble(1) }); + } + return rows; + } + + private static async Task LoadAgeBucketsAsync(SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT " + + " SUM(CASE WHEN DATEDIFF(DAY,submitted,GETUTCDATE()) < 1 THEN 1 ELSE 0 END)," + + " SUM(CASE WHEN DATEDIFF(DAY,submitted,GETUTCDATE()) BETWEEN 1 AND 6 THEN 1 ELSE 0 END)," + + " SUM(CASE WHEN DATEDIFF(DAY,submitted,GETUTCDATE()) BETWEEN 7 AND 29 THEN 1 ELSE 0 END)," + + " SUM(CASE WHEN DATEDIFF(DAY,submitted,GETUTCDATE()) >= 30 THEN 1 ELSE 0 END) " + + "FROM marketitems " + + "WHERE isSell=1 AND (isAutoOrder=0 OR isAutoOrder IS NULL)"; + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return new EconomyListingAgeBuckets(); + return new EconomyListingAgeBuckets + { + Today = r.IsDBNull(0) ? 0 : r.GetInt32(0), + D1To7 = r.IsDBNull(1) ? 0 : r.GetInt32(1), + D7To30 = r.IsDBNull(2) ? 0 : r.GetInt32(2), + D30Plus = r.IsDBNull(3) ? 0 : r.GetInt32(3), + }; + } + + private static async Task<(int AmCount, int PlayerCount)> LoadOrderCountsAsync(SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT " + + " SUM(CASE WHEN isAutoOrder=1 THEN 1 ELSE 0 END)," + + " SUM(CASE WHEN isAutoOrder=0 OR isAutoOrder IS NULL THEN 1 ELSE 0 END) " + + "FROM marketitems WHERE isSell=1"; + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return (0, 0); + return (r.IsDBNull(0) ? 0 : r.GetInt32(0), r.IsDBNull(1) ? 0 : r.GetInt32(1)); + } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyMoneySupplyData.cs b/src/Perpetuum.AdminTool/Economy/EconomyMoneySupplyData.cs new file mode 100644 index 00000000..284fd39a --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyMoneySupplyData.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyMoneySupplyData + { + public long TotalNic { get; init; } + public long MedianNic { get; init; } + public double Top1PctShare { get; init; } + public long IdleNic { get; init; } + public IReadOnlyList SnapshotRows { get; init; } = System.Array.Empty(); + public IReadOnlyList Top10Rows { get; init; } = System.Array.Empty(); + public IReadOnlyList Top10CorpRows { get; init; } = System.Array.Empty(); + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyMoneySupplyRepository.cs b/src/Perpetuum.AdminTool/Economy/EconomyMoneySupplyRepository.cs new file mode 100644 index 00000000..305511f2 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyMoneySupplyRepository.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyMoneySupplyRepository + { + private readonly ConnectionSettings _connection; + + public EconomyMoneySupplyRepository(ConnectionSettings connection) + => _connection = connection; + + public async Task LoadAsync() + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + long totalNic = await LoadTotalNicAsync(cn); + var snapshots = await LoadSnapshotsAsync(cn); + var top10 = await LoadTop10Async(cn); + var top10Corps = await LoadTop10CorpAsync(cn); + var balances = await LoadAllBalancesAsync(cn); + long idleNic = await LoadIdleNicAsync(cn); + + long medianNic = balances.Count == 0 ? 0L + : balances.Count % 2 == 1 + ? balances[balances.Count / 2] + : (balances[balances.Count / 2 - 1] + balances[balances.Count / 2]) / 2; + int top1Count = (int)Math.Ceiling(balances.Count * 0.01); + long top1Nic = top1Count > 0 ? balances.Take(top1Count).Sum() : 0L; + long charTotal = balances.Count > 0 ? balances.Sum() : 0L; + double top1Share = charTotal > 0 ? (double)top1Nic / charTotal * 100.0 : 0.0; + + return new EconomyMoneySupplyData + { + TotalNic = totalNic, + MedianNic = medianNic, + Top1PctShare = top1Share, + IdleNic = idleNic, + SnapshotRows = snapshots, + Top10Rows = top10, + Top10CorpRows = top10Corps, + }; + } + + private static async Task LoadTotalNicAsync(SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT ISNULL((SELECT SUM(CAST(credit AS BIGINT)) FROM characters WHERE active=1 AND deletedAt IS NULL),0)" + + " + ISNULL((SELECT SUM(CAST(wallet AS BIGINT)) FROM corporations WHERE active=1 AND defaultcorp=0),0)"; + var result = await cmd.ExecuteScalarAsync(); + return result == null || result == DBNull.Value ? 0L : Convert.ToInt64(result); + } + + private static async Task> LoadSnapshotsAsync(SqlConnection cn) + { + var rows = new List(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT TOP 90 snapshot_date, total_nic " + + "FROM economy_daily_snapshot ORDER BY snapshot_date DESC"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + rows.Add(new EconomySnapshotRow { Date = r.GetDateTime(0), TotalNic = r.GetInt64(1) }); + return rows; + } + + private static async Task> LoadTop10Async(SqlConnection cn) + { + var rows = new List(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT TOP 10 " + + " ISNULL(ch.nick, N'(no nick)') AS nick, " + + " CAST(ch.credit AS BIGINT) AS credit, " + + " ISNULL((" + + " SELECT TOP 1 co.nick " + + " FROM corporationmembers cm " + + " JOIN corporations co ON co.eid = cm.corporationEID " + + " AND co.defaultcorp = 0 " + + " AND co.active = 1 " + + " WHERE cm.memberid = ch.characterID" + + " ), N'') AS corp_tag " + + "FROM characters ch " + + "WHERE ch.active = 1 AND ch.deletedAt IS NULL " + + "ORDER BY ch.credit DESC"; + await using var r = await cmd.ExecuteReaderAsync(); + int rank = 1; + while (await r.ReadAsync()) + rows.Add(new EconomyWealthRow + { + Rank = rank++, + Nick = r.GetString(0), + Credit = r.GetInt64(1), + CorpTag = r.GetString(2), + }); + return rows; + } + + private static async Task> LoadTop10CorpAsync(SqlConnection cn) + { + var rows = new List(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT TOP 10 " + + " co.name, " + + " ISNULL(co.nick, N'') AS tag, " + + " COUNT(cm.memberid) AS member_count, " + + " CAST(co.wallet AS BIGINT) AS corp_wallet, " + + " ISNULL(SUM(CAST(ch.credit AS BIGINT)), 0) AS member_aggregate " + + "FROM corporations co " + + "LEFT JOIN corporationmembers cm ON cm.corporationEID = co.eid " + + "LEFT JOIN characters ch ON ch.characterID = cm.memberid " + + "WHERE co.active = 1 AND co.defaultcorp = 0 " + + "GROUP BY co.eid, co.name, co.nick, co.wallet " + + "ORDER BY (CAST(co.wallet AS BIGINT) + ISNULL(SUM(CAST(ch.credit AS BIGINT)), 0)) DESC"; + await using var r = await cmd.ExecuteReaderAsync(); + int rank = 1; + while (await r.ReadAsync()) + rows.Add(new EconomyCorporationWealthRow + { + Rank = rank++, + Name = r.GetString(0), + Tag = r.GetString(1), + MemberCount = r.GetInt32(2), + CorpWallet = r.GetInt64(3), + MemberAggregate = r.GetInt64(4), + }); + return rows; + } + + private static async Task> LoadAllBalancesAsync(SqlConnection cn) + { + var balances = new List(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT CAST(credit AS BIGINT) FROM characters " + + "WHERE active=1 AND deletedAt IS NULL ORDER BY credit DESC"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + balances.Add(r.GetInt64(0)); + return balances; + } + + private static async Task LoadIdleNicAsync(SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT ISNULL(SUM(CAST(credit AS BIGINT)),0) FROM characters " + + "WHERE active=1 AND deletedAt IS NULL " + + " AND (lastUsed IS NULL OR lastUsed < DATEADD(DAY,-30,GETUTCDATE()))"; + var result = await cmd.ExecuteScalarAsync(); + return result == null || result == DBNull.Value ? 0L : Convert.ToInt64(result); + } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyNicFlowRow.cs b/src/Perpetuum.AdminTool/Economy/EconomyNicFlowRow.cs new file mode 100644 index 00000000..4595d7dd --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyNicFlowRow.cs @@ -0,0 +1,12 @@ +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyNicFlowRow + { + public string Category { get; init; } = ""; + public long Today { get; init; } + public long Last7Days { get; init; } + public long Last30Days { get; init; } + public long AllTime { get; init; } + public bool IsTotal { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyPriceIndexBasketItem.cs b/src/Perpetuum.AdminTool/Economy/EconomyPriceIndexBasketItem.cs new file mode 100644 index 00000000..de5a08f5 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyPriceIndexBasketItem.cs @@ -0,0 +1,12 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.Economy +{ + public partial class EconomyPriceIndexBasketItem : ObservableObject + { + public int Id { get; init; } + public int Definition { get; init; } + public string DefinitionName { get; init; } = ""; + [ObservableProperty] private double _weight; + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyPriceIndexRow.cs b/src/Perpetuum.AdminTool/Economy/EconomyPriceIndexRow.cs new file mode 100644 index 00000000..af671be7 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyPriceIndexRow.cs @@ -0,0 +1,10 @@ +using System; + +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyPriceIndexRow + { + public DateTime Date { get; init; } + public double IndexValue { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyRepository.cs b/src/Perpetuum.AdminTool/Economy/EconomyRepository.cs new file mode 100644 index 00000000..87309b8c --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyRepository.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyRepository + { + private readonly ConnectionSettings _connection; + + public EconomyRepository(ConnectionSettings connection) + { + _connection = connection; + } + + private static readonly string[] NicInOrder = + { + "Mission Rewards", + "Insurance Payouts", + "Intrusion Income", + "AutoMarket Plasma", + "System Credits & Refunds", + }; + + private static readonly string[] NicOutOrder = + { + "Market Fees & Taxes", + "Production Costs", + "Repair Costs", + "Insurance Fees", + "Infrastructure Costs", + "Extension Learning", + "Spark Costs", + "Corporate & Alliance Fees", + "Other Fees", + "AutoMarket Raw Materials", + }; + + // NIC In: types that represent server-side NIC creation into character/corp wallets. + // Excludes escrow returns (buyOrderPayBack, siege collateral) and player-to-player transfers. + private const string NicInSql = + "SELECT category," + + " SUM(CASE WHEN transactiondate >= CAST(GETUTCDATE() AS DATE) THEN ABS(amount) ELSE 0 END)," + + " SUM(CASE WHEN transactiondate >= DATEADD(DAY,-7, CAST(GETUTCDATE() AS DATE)) THEN ABS(amount) ELSE 0 END)," + + " SUM(CASE WHEN transactiondate >= DATEADD(DAY,-30, CAST(GETUTCDATE() AS DATE)) THEN ABS(amount) ELSE 0 END)," + + " SUM(ABS(amount)) " + + "FROM (" + + " SELECT amount, transactiondate," + + " CASE" + + " WHEN transactiontype IN (10,86,78,79,29) THEN 'Mission Rewards'" + + " WHEN transactiontype IN (33) THEN 'Insurance Payouts'" + + " WHEN transactiontype IN (40,39) THEN 'Intrusion Income'" + + " WHEN transactiontype IN (75,13,91,87,63,36) THEN 'System Credits & Refunds'" + + " END AS category" + + " FROM charactertransactions" + + " WHERE transactiontype IN (10,86,78,79,33,40,39,75,13,91,87,63,29,36)" + + " UNION ALL" + + " SELECT amount, transactiondate," + + " CASE" + + " WHEN transactiontype IN (10,86,78,79,29) THEN 'Mission Rewards'" + + " WHEN transactiontype IN (33) THEN 'Insurance Payouts'" + + " WHEN transactiontype IN (40,39) THEN 'Intrusion Income'" + + " WHEN transactiontype IN (75,13,91,87,63,36) THEN 'System Credits & Refunds'" + + " END AS category" + + " FROM corporationtransactions" + + " WHERE transactiontype IN (10,86,78,79,33,40,39,75,13,91,87,63,29,36)" + + ") t WHERE category IS NOT NULL" + + " GROUP BY category"; + + // NIC Out: types that represent server-side NIC destruction from character/corp wallets. + private const string NicOutSql = + "SELECT category," + + " SUM(CASE WHEN transactiondate >= CAST(GETUTCDATE() AS DATE) THEN ABS(amount) ELSE 0 END)," + + " SUM(CASE WHEN transactiondate >= DATEADD(DAY,-7, CAST(GETUTCDATE() AS DATE)) THEN ABS(amount) ELSE 0 END)," + + " SUM(CASE WHEN transactiondate >= DATEADD(DAY,-30, CAST(GETUTCDATE() AS DATE)) THEN ABS(amount) ELSE 0 END)," + + " SUM(ABS(amount)) " + + "FROM (" + + " SELECT amount, transactiondate," + + " CASE" + + " WHEN transactiontype IN (6,35,43) THEN 'Market Fees & Taxes'" + + " WHEN transactiontype IN (18,25,27,28,71,19,20,21,22) THEN 'Production Costs'" + + " WHEN transactiontype IN (15,26) THEN 'Repair Costs'" + + " WHEN transactiontype IN (32) THEN 'Insurance Fees'" + + " WHEN transactiontype IN (0,4,68,69) THEN 'Infrastructure Costs'" + + " WHEN transactiontype IN (14) THEN 'Extension Learning'" + + " WHEN transactiontype IN (64,65,83,84) THEN 'Spark Costs'" + + " WHEN transactiontype IN (12,11,2) THEN 'Corporate & Alliance Fees'" + + " WHEN transactiontype IN (34,70,88,73) THEN 'Other Fees'" + + " END AS category" + + " FROM charactertransactions" + + " WHERE transactiontype IN (6,35,43,18,25,27,28,71,19,20,21,22,15,26,32,0,4,68,69,14,64,65,83,84,12,11,2,34,70,88,73)" + + " UNION ALL" + + " SELECT amount, transactiondate," + + " CASE" + + " WHEN transactiontype IN (6,35,43) THEN 'Market Fees & Taxes'" + + " WHEN transactiontype IN (18,25,27,28,71,19,20,21,22) THEN 'Production Costs'" + + " WHEN transactiontype IN (15,26) THEN 'Repair Costs'" + + " WHEN transactiontype IN (32) THEN 'Insurance Fees'" + + " WHEN transactiontype IN (0,4,68,69) THEN 'Infrastructure Costs'" + + " WHEN transactiontype IN (14) THEN 'Extension Learning'" + + " WHEN transactiontype IN (64,65,83,84) THEN 'Spark Costs'" + + " WHEN transactiontype IN (12,11,2) THEN 'Corporate & Alliance Fees'" + + " WHEN transactiontype IN (34,70,88,73) THEN 'Other Fees'" + + " END AS category" + + " FROM corporationtransactions" + + " WHERE transactiontype IN (6,35,43,18,25,27,28,71,19,20,21,22,15,26,32,0,4,68,69,14,64,65,83,84,12,11,2,34,70,88,73)" + + ") t WHERE category IS NOT NULL" + + " GROUP BY category"; + + public async Task<(List In, List Out)> LoadNicFlowAsync() + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + // Run sequentially on a single connection — ADO.NET does not support + // concurrent commands on the same SqlConnection. + var nicIn = await LoadCategoryRowsAsync(cn, NicInSql, NicInOrder); + var nicOut = await LoadCategoryRowsAsync(cn, NicOutSql, NicOutOrder); + var (plasmaRow, rawmatRow) = await LoadAutoMarketRowsAsync(cn); + + // Splice AutoMarket rows into their fixed positions (they are not in the UNION query) + var plasmaIdx = Array.IndexOf(NicInOrder, "AutoMarket Plasma"); + if (plasmaIdx >= 0) nicIn[plasmaIdx] = plasmaRow; + + var rawmatIdx = Array.IndexOf(NicOutOrder, "AutoMarket Raw Materials"); + if (rawmatIdx >= 0) nicOut[rawmatIdx] = rawmatRow; + + // Append bold Total rows + nicIn.Add(new EconomyNicFlowRow + { + Category = "Total NIC In", + Today = nicIn.Sum(r => r.Today), + Last7Days = nicIn.Sum(r => r.Last7Days), + Last30Days = nicIn.Sum(r => r.Last30Days), + AllTime = nicIn.Sum(r => r.AllTime), + IsTotal = true, + }); + nicOut.Add(new EconomyNicFlowRow + { + Category = "Total NIC Out", + Today = nicOut.Sum(r => r.Today), + Last7Days = nicOut.Sum(r => r.Last7Days), + Last30Days = nicOut.Sum(r => r.Last30Days), + AllTime = nicOut.Sum(r => r.AllTime), + IsTotal = true, + }); + + return (nicIn, nicOut); + } + + private static async Task> LoadCategoryRowsAsync( + SqlConnection cn, string sql, string[] order) + { + var raw = new Dictionary(StringComparer.Ordinal); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = sql; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + var cat = r.GetString(0); + raw[cat] = new EconomyNicFlowRow + { + Category = cat, + Today = (long)Math.Round(r.GetDouble(1)), + Last7Days = (long)Math.Round(r.GetDouble(2)), + Last30Days = (long)Math.Round(r.GetDouble(3)), + AllTime = (long)Math.Round(r.GetDouble(4)), + }; + } + // Enforce display order; categories absent from DB results appear as zero rows + return order + .Select(name => raw.TryGetValue(name, out var row) + ? row + : new EconomyNicFlowRow { Category = name }) + .ToList(); + } + + private static async Task<(EconomyNicFlowRow Plasma, EconomyNicFlowRow Rawmat)> + LoadAutoMarketRowsAsync(SqlConnection cn) + { + long todayP = 0, last7P = 0, last30P = 0, allP = 0; + long todayR = 0, last7R = 0, last30R = 0, allR = 0; + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT" + + " ISNULL(SUM(CASE WHEN sold_on = CAST(GETUTCDATE() AS DATE) THEN income ELSE 0 END), 0)," + + " ISNULL(SUM(CASE WHEN sold_on >= DATEADD(DAY,-7, CAST(GETUTCDATE() AS DATE)) THEN income ELSE 0 END), 0)," + + " ISNULL(SUM(CASE WHEN sold_on >= DATEADD(DAY,-30, CAST(GETUTCDATE() AS DATE)) THEN income ELSE 0 END), 0)," + + " ISNULL(SUM(income), 0)" + + " FROM plasma_sold"; + await using var r = await cmd.ExecuteReaderAsync(); + if (await r.ReadAsync()) + { + todayP = (long)Math.Round(r.GetDouble(0)); + last7P = (long)Math.Round(r.GetDouble(1)); + last30P = (long)Math.Round(r.GetDouble(2)); + allP = (long)Math.Round(r.GetDouble(3)); + } + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT" + + " ISNULL(SUM(CASE WHEN purchased_on = CAST(GETUTCDATE() AS DATE) THEN income ELSE 0 END), 0)," + + " ISNULL(SUM(CASE WHEN purchased_on >= DATEADD(DAY,-7, CAST(GETUTCDATE() AS DATE)) THEN income ELSE 0 END), 0)," + + " ISNULL(SUM(CASE WHEN purchased_on >= DATEADD(DAY,-30, CAST(GETUTCDATE() AS DATE)) THEN income ELSE 0 END), 0)," + + " ISNULL(SUM(income), 0)" + + " FROM rawmat_purchased"; + await using var r = await cmd.ExecuteReaderAsync(); + if (await r.ReadAsync()) + { + todayR = (long)Math.Round(r.GetDouble(0)); + last7R = (long)Math.Round(r.GetDouble(1)); + last30R = (long)Math.Round(r.GetDouble(2)); + allR = (long)Math.Round(r.GetDouble(3)); + } + } + + return ( + new EconomyNicFlowRow { Category = "AutoMarket Plasma", Today = todayP, Last7Days = last7P, Last30Days = last30P, AllTime = allP }, + new EconomyNicFlowRow { Category = "AutoMarket Raw Materials", Today = todayR, Last7Days = last7R, Last30Days = last30R, AllTime = allR } + ); + } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomySinkData.cs b/src/Perpetuum.AdminTool/Economy/EconomySinkData.cs new file mode 100644 index 00000000..9475a328 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomySinkData.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Perpetuum.AdminTool.Economy +{ + public class EconomySinkData + { + public int ActivePlayerCount { get; init; } + public double InsuranceCoveragePct { get; init; } + public IReadOnlyList SinkRows { get; init; } = System.Array.Empty(); + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomySinkRepository.cs b/src/Perpetuum.AdminTool/Economy/EconomySinkRepository.cs new file mode 100644 index 00000000..e35e4745 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomySinkRepository.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Economy +{ + public class EconomySinkRepository + { + private readonly ConnectionSettings _connection; + + private static readonly string[] NicOutOrder = + { + "Market Fees & Taxes", "Production Costs", "Repair Costs", + "Insurance Fees", "Infrastructure Costs", "Extension Learning", + "Spark Costs", "Corporate & Alliance Fees", "Other Fees", "AutoMarket Raw Materials", + }; + + private const string NicOutLast30Sql = + "SELECT category, SUM(CASE WHEN transactiondate >= DATEADD(DAY,-30,CAST(GETUTCDATE() AS DATE)) THEN ABS(amount) ELSE 0 END) " + + "FROM (" + + " SELECT amount, transactiondate," + + " CASE" + + " WHEN transactiontype IN (6,35,43) THEN N'Market Fees & Taxes'" + + " WHEN transactiontype IN (18,25,27,28,71,19,20,21,22) THEN N'Production Costs'" + + " WHEN transactiontype IN (15,26) THEN N'Repair Costs'" + + " WHEN transactiontype IN (32) THEN N'Insurance Fees'" + + " WHEN transactiontype IN (0,4,68,69) THEN N'Infrastructure Costs'" + + " WHEN transactiontype IN (14) THEN N'Extension Learning'" + + " WHEN transactiontype IN (64,65,83,84) THEN N'Spark Costs'" + + " WHEN transactiontype IN (12,11,2) THEN N'Corporate & Alliance Fees'" + + " WHEN transactiontype IN (34,70,88,73) THEN N'Other Fees'" + + " END AS category" + + " FROM charactertransactions" + + " WHERE transactiontype IN (6,35,43,18,25,27,28,71,19,20,21,22,15,26,32,0,4,68,69,14,64,65,83,84,12,11,2,34,70,88,73)" + + " UNION ALL" + + " SELECT amount, transactiondate," + + " CASE" + + " WHEN transactiontype IN (6,35,43) THEN N'Market Fees & Taxes'" + + " WHEN transactiontype IN (18,25,27,28,71,19,20,21,22) THEN N'Production Costs'" + + " WHEN transactiontype IN (15,26) THEN N'Repair Costs'" + + " WHEN transactiontype IN (32) THEN N'Insurance Fees'" + + " WHEN transactiontype IN (0,4,68,69) THEN N'Infrastructure Costs'" + + " WHEN transactiontype IN (14) THEN N'Extension Learning'" + + " WHEN transactiontype IN (64,65,83,84) THEN N'Spark Costs'" + + " WHEN transactiontype IN (12,11,2) THEN N'Corporate & Alliance Fees'" + + " WHEN transactiontype IN (34,70,88,73) THEN N'Other Fees'" + + " END AS category" + + " FROM corporationtransactions" + + " WHERE transactiontype IN (6,35,43,18,25,27,28,71,19,20,21,22,15,26,32,0,4,68,69,14,64,65,83,84,12,11,2,34,70,88,73)" + + ") t WHERE category IS NOT NULL" + + " GROUP BY category"; + + public EconomySinkRepository(ConnectionSettings connection) => _connection = connection; + + public async Task LoadAsync() + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + int activePlayerCount = await LoadActivePlayerCountAsync(cn); + long rawmatLast30 = await LoadRawmatLast30Async(cn); + double insurancePct = await LoadInsuranceCoverageAsync(cn); + var nicOutRaw = await LoadNicOutLast30Async(cn); + + var rows = NicOutOrder + .Select(name => + { + nicOutRaw.TryGetValue(name, out var nic); + long nicValue = name == "AutoMarket Raw Materials" ? rawmatLast30 : nic; + return new EconomySinkRow + { + Category = name, + NicLast30Days = nicValue, + NicPerPlayer = activePlayerCount > 0 ? (double)nicValue / activePlayerCount : 0.0, + }; + }) + .ToList(); + + long totalNic = rows.Sum(r => r.NicLast30Days); + rows.Add(new EconomySinkRow + { + Category = "Total NIC Out", + NicLast30Days = totalNic, + NicPerPlayer = activePlayerCount > 0 ? (double)totalNic / activePlayerCount : 0.0, + IsTotal = true, + }); + + return new EconomySinkData + { + ActivePlayerCount = activePlayerCount, + InsuranceCoveragePct = insurancePct, + SinkRows = rows, + }; + } + + private static async Task LoadActivePlayerCountAsync(SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT COUNT(*) FROM characters " + + "WHERE active=1 AND deletedAt IS NULL " + + " AND lastUsed >= DATEADD(DAY,-30,GETUTCDATE())"; + var result = await cmd.ExecuteScalarAsync(); + return result == null || result == DBNull.Value ? 0 : Convert.ToInt32(result); + } + + private static async Task LoadRawmatLast30Async(SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT ISNULL(SUM(income),0) FROM rawmat_purchased " + + "WHERE purchased_on >= DATEADD(DAY,-30,CAST(GETUTCDATE() AS DATE))"; + var result = await cmd.ExecuteScalarAsync(); + return result == null || result == DBNull.Value ? 0L : (long)Math.Round(Convert.ToDouble(result)); + } + + private static async Task LoadInsuranceCoverageAsync(SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT " + + " CAST(COUNT(DISTINCT i.characterid) AS FLOAT) / NULLIF(COUNT(DISTINCT c.characterID),0) * 100.0 " + + "FROM characters c " + + "LEFT JOIN insurance i ON i.characterid = c.characterID AND i.enddate > GETUTCDATE() " + + "WHERE c.active=1 AND c.deletedAt IS NULL"; + var result = await cmd.ExecuteScalarAsync(); + return result == null || result == DBNull.Value ? 0.0 : Convert.ToDouble(result); + } + + private static async Task> LoadNicOutLast30Async(SqlConnection cn) + { + var raw = new Dictionary(StringComparer.Ordinal); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = NicOutLast30Sql; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + if (r.IsDBNull(0)) continue; + raw[r.GetString(0)] = (long)Math.Round(r.GetDouble(1)); + } + return raw; + } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomySinkRow.cs b/src/Perpetuum.AdminTool/Economy/EconomySinkRow.cs new file mode 100644 index 00000000..6329493b --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomySinkRow.cs @@ -0,0 +1,10 @@ +namespace Perpetuum.AdminTool.Economy +{ + public class EconomySinkRow + { + public string Category { get; init; } = ""; + public long NicLast30Days { get; init; } + public double NicPerPlayer { get; init; } + public bool IsTotal { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomySnapshotRow.cs b/src/Perpetuum.AdminTool/Economy/EconomySnapshotRow.cs new file mode 100644 index 00000000..acc2cd3d --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomySnapshotRow.cs @@ -0,0 +1,10 @@ +using System; + +namespace Perpetuum.AdminTool.Economy +{ + public class EconomySnapshotRow + { + public DateTime Date { get; init; } + public long TotalNic { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyVelocityRow.cs b/src/Perpetuum.AdminTool/Economy/EconomyVelocityRow.cs new file mode 100644 index 00000000..3f2468da --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyVelocityRow.cs @@ -0,0 +1,10 @@ +using System; + +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyVelocityRow + { + public DateTime Date { get; init; } + public long NicTraded { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Economy/EconomyWealthRow.cs b/src/Perpetuum.AdminTool/Economy/EconomyWealthRow.cs new file mode 100644 index 00000000..40bfffcd --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/EconomyWealthRow.cs @@ -0,0 +1,10 @@ +namespace Perpetuum.AdminTool.Economy +{ + public class EconomyWealthRow + { + public int Rank { get; init; } + public string Nick { get; init; } = ""; + public long Credit { get; init; } + public string CorpTag { get; init; } = ""; + } +} diff --git a/src/Perpetuum.AdminTool/Economy/InsuranceConfigRow.cs b/src/Perpetuum.AdminTool/Economy/InsuranceConfigRow.cs new file mode 100644 index 00000000..891c5362 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/InsuranceConfigRow.cs @@ -0,0 +1,19 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.Economy +{ + public partial class InsuranceConfigRow : ObservableObject + { + public string ParamName { get; init; } = ""; + public string Label { get; init; } = ""; + public string Description { get; init; } = ""; + public double OriginalValue { get; set; } + + [ObservableProperty] private double _paramValue; + + public bool IsDirty => Math.Abs(ParamValue - OriginalValue) > 1e-9; + + partial void OnParamValueChanged(double value) => OnPropertyChanged(nameof(IsDirty)); + } +} diff --git a/src/Perpetuum.AdminTool/Economy/InsuranceLabels.cs b/src/Perpetuum.AdminTool/Economy/InsuranceLabels.cs new file mode 100644 index 00000000..503edb0c --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/InsuranceLabels.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Perpetuum.AdminTool.Economy +{ + internal static class InsuranceLabels + { + internal record LabelMeta(string Label, string Description); + + internal static readonly IReadOnlyDictionary Map = + new Dictionary + { + ["fee_pct"] = new("Fee %", "Insurance fee charged at purchase, as a fraction of production cost (e.g. 0.10 = 10%)"), + ["payout_pct"] = new("Payout %", "Insurance payout on robot death, as a fraction of production cost (must be less than Fee %)"), + }; + } +} diff --git a/src/Perpetuum.AdminTool/Economy/InsurancePriceRow.cs b/src/Perpetuum.AdminTool/Economy/InsurancePriceRow.cs new file mode 100644 index 00000000..8ff64ae6 --- /dev/null +++ b/src/Perpetuum.AdminTool/Economy/InsurancePriceRow.cs @@ -0,0 +1,10 @@ +namespace Perpetuum.AdminTool.Economy +{ + public class InsurancePriceRow + { + public string ItemName { get; init; } = ""; + public double ProductionCostNic { get; init; } + public double Fee { get; init; } + public double Payout { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Editing/ChangeApplier.cs b/src/Perpetuum.AdminTool/Editing/ChangeApplier.cs new file mode 100644 index 00000000..0c2b9b2b --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/ChangeApplier.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Editing +{ + public class ChangeApplier + { + private readonly ConnectionSettings _connection; + + public ChangeApplier(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task ExecuteAsync(IReadOnlyList changes) + { + var script = SqlScriptBuilder.Build(changes); + + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = script; + cmd.CommandType = System.Data.CommandType.Text; + await cmd.ExecuteNonQueryAsync(); + } + } +} diff --git a/src/Perpetuum.AdminTool/Editing/ChangeQueue.cs b/src/Perpetuum.AdminTool/Editing/ChangeQueue.cs new file mode 100644 index 00000000..69e04a0a --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/ChangeQueue.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Perpetuum.AdminTool.Editing +{ + public class ChangeQueue + { + public ObservableCollection Items { get; } = new(); + + // Definition names of brand-new entitydefaults rows that were queued for INSERT. + // Drained at commit time to auto-create the matching translation keys. + // Cleared by Clear() so discarding the queue also discards the pending keys. + public List PendingNewEntityNames { get; } = new(); + + public void Add(IPendingChange change) => Items.Add(change); + public void AddNewEntityName(string definitionName) => PendingNewEntityNames.Add(definitionName); + + public void Clear() + { + Items.Clear(); + PendingNewEntityNames.Clear(); + } + + public bool HasPending => Items.Count > 0; + } +} diff --git a/src/Perpetuum.AdminTool/Editing/EntityChanges.cs b/src/Perpetuum.AdminTool/Editing/EntityChanges.cs new file mode 100644 index 00000000..c92a8fd4 --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/EntityChanges.cs @@ -0,0 +1,175 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Perpetuum.AdminTool.Entities; + +namespace Perpetuum.AdminTool.Editing +{ + public static class EntityChanges + { + public static IEnumerable ComputeChanges(EntityDefaultRow row) + { + if (row.IsNew) + { + // For new rows, definition is autoincrement IDENTITY: emit a single combined + // change that does the INSERT, captures SCOPE_IDENTITY into a per-row variable, + // and inserts every stat referencing that variable. All in one batch so the + // T-SQL variable stays in scope across statements. + yield return BuildInsertEntityWithStats(row); + yield break; + } + + var entityUpdate = BuildEntityUpdate(row); + if (entityUpdate != null) yield return entityUpdate; + + foreach (var change in BuildStatChanges(row)) + { + yield return change; + } + } + + public static IEnumerable ComputeDeleteChanges(EntityDefaultRow row) + { + // Delete dependent stats first, then the entity definition itself. + yield return new RawSqlChange( + $"aggregatevalues: delete all stats for definition {row.Definition} ({row.Original.DefinitionName})", + $"DELETE FROM aggregatevalues WHERE definition = {row.Definition}", + isDestructive: true); + + yield return new RawSqlChange( + $"entitydefaults: delete definition {row.Definition} ({row.Original.DefinitionName})", + $"DELETE FROM entitydefaults WHERE definition = {row.Definition}", + isDestructive: true); + } + + private static IPendingChange BuildInsertEntityWithStats(EntityDefaultRow row) + { + var varName = $"@new_def_{row.NewIdToken}"; + + var sql = new StringBuilder(); + sql.Append("INSERT INTO entitydefaults "); + // Note: 'definition' is an IDENTITY column; SQL Server assigns it. + sql.Append("(definitionName, descriptionToken, categoryflags, attributeflags, "); + sql.Append("mass, volume, health, quantity, hidden, purchasable, tiertype, tierlevel, options, enabled) "); + sql.Append("VALUES ("); + sql.Append(SqlLiteral.Of(row.DefinitionName)).Append(", "); + sql.Append(SqlLiteral.Of(row.DescriptionToken)).Append(", "); + sql.Append(SqlLiteral.Of(row.CategoryFlags)).Append(", "); + sql.Append(SqlLiteral.Of(row.AttributeFlags)).Append(", "); + sql.Append(SqlLiteral.Of(row.Mass)).Append(", "); + sql.Append(SqlLiteral.Of(row.Volume)).Append(", "); + sql.Append(SqlLiteral.Of(row.Health)).Append(", "); + sql.Append(SqlLiteral.Of(row.Quantity)).Append(", "); + sql.Append(SqlLiteral.Of(row.Hidden)).Append(", "); + sql.Append(SqlLiteral.Of(row.Purchasable)).Append(", "); + sql.Append(SqlLiteral.OfNullableInt(row.TierType)).Append(", "); + sql.Append(SqlLiteral.OfNullableInt(row.TierLevel)).Append(", "); + sql.Append(SqlLiteral.Of(row.Options)).Append(", "); + sql.Append(SqlLiteral.Of(row.Enabled)).Append(");"); + sql.AppendLine(); + sql.Append("DECLARE ").Append(varName).Append(" INT = SCOPE_IDENTITY();"); + + foreach (var stat in row.Stats) + { + var fieldId = (int)stat.Field; + sql.AppendLine(); + sql.Append("INSERT INTO aggregatevalues (definition, field, value) VALUES ("); + sql.Append(varName).Append(", "); + sql.Append(fieldId).Append(", "); + sql.Append(SqlLiteral.Of(stat.Value)); + sql.Append(");"); + } + + return new RawSqlChange( + $"entitydefaults: insert ({row.DefinitionName}) + {row.Stats.Count} stat(s) — id auto-assigned via {varName}", + sql.ToString()); + } + + private static IPendingChange? BuildEntityUpdate(EntityDefaultRow row) + { + var o = row.Original; + var sets = new List(); + + void AddIfChanged(string column, object? originalValue, object? currentValue) + { + if (Equals(originalValue, currentValue)) return; + sets.Add($"{SqlLiteral.Identifier(column)} = {SqlLiteral.Of(currentValue)}"); + } + + AddIfChanged("definitionName", o.DefinitionName, row.DefinitionName); + AddIfChanged("descriptionToken", o.DescriptionToken, row.DescriptionToken); + AddIfChanged("categoryflags", o.CategoryFlags, row.CategoryFlags); + AddIfChanged("attributeflags", o.AttributeFlags, row.AttributeFlags); + AddIfChanged("mass", o.Mass, row.Mass); + AddIfChanged("volume", o.Volume, row.Volume); + AddIfChanged("health", o.Health, row.Health); + AddIfChanged("quantity", o.Quantity, row.Quantity); + AddIfChanged("hidden", o.Hidden, row.Hidden); + AddIfChanged("purchasable", o.Purchasable, row.Purchasable); + AddIfChanged("enabled", o.Enabled, row.Enabled); + AddIfChanged("options", o.Options, row.Options); + + if (o.TierType != row.TierType) + { + sets.Add($"{SqlLiteral.Identifier("tiertype")} = {SqlLiteral.OfNullableInt(row.TierType)}"); + } + if (o.TierLevel != row.TierLevel) + { + sets.Add($"{SqlLiteral.Identifier("tierlevel")} = {SqlLiteral.OfNullableInt(row.TierLevel)}"); + } + + if (sets.Count == 0) return null; + + var sql = new StringBuilder(); + sql.Append("UPDATE entitydefaults SET "); + sql.Append(string.Join(", ", sets)); + sql.Append(" WHERE definition = ").Append(row.Definition); + + return new RawSqlChange( + $"entitydefaults: update definition {row.Definition} ({row.DefinitionName}) — {sets.Count} column(s)", + sql.ToString()); + } + + private static IEnumerable BuildStatChanges(EntityDefaultRow row) + { + var def = row.Definition; + var currentByField = row.Stats.ToDictionary(s => (int)s.Field); + + // Inserts and updates + foreach (var stat in row.Stats) + { + var fieldId = (int)stat.Field; + if (row.OriginalStats.TryGetValue(fieldId, out var originalValue)) + { + if (originalValue != stat.Value) + { + yield return new RawSqlChange( + $"aggregatevalues: update def {def} field {fieldId} → {stat.Value}", + $"UPDATE aggregatevalues SET value = {SqlLiteral.Of(stat.Value)} " + + $"WHERE definition = {def} AND field = {fieldId}"); + } + } + else + { + yield return new RawSqlChange( + $"aggregatevalues: insert def {def} field {fieldId} = {stat.Value}", + $"INSERT INTO aggregatevalues (definition, field, value) " + + $"VALUES ({def}, {fieldId}, {SqlLiteral.Of(stat.Value)})"); + } + } + + // Deletions: present originally, not present now + foreach (var (fieldId, _) in row.OriginalStats) + { + if (!currentByField.ContainsKey(fieldId)) + { + yield return new RawSqlChange( + $"aggregatevalues: delete def {def} field {fieldId}", + $"DELETE FROM aggregatevalues " + + $"WHERE definition = {def} AND field = {fieldId}", + isDestructive: true); + } + } + } + } +} diff --git a/src/Perpetuum.AdminTool/Editing/FlockChanges.cs b/src/Perpetuum.AdminTool/Editing/FlockChanges.cs new file mode 100644 index 00000000..7b39ae4c --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/FlockChanges.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using Perpetuum.AdminTool.Npc; + +namespace Perpetuum.AdminTool.Editing +{ + public static class FlockChanges + { + public static IEnumerable ComputeBulkChanges( + IEnumerable currentRows, + IReadOnlyDictionary originalById) + { + var seenIds = new HashSet(); + + foreach (var row in currentRows) + { + if (row.IsNew || row.Id <= 0) + { + yield return BuildInsert(row); + continue; + } + + seenIds.Add(row.Id); + + if (originalById.TryGetValue(row.Id, out var original)) + { + var update = BuildUpdate(row, original); + if (update != null) yield return update; + } + else + { + yield return BuildInsert(row); + } + } + + foreach (var kv in originalById) + { + if (seenIds.Contains(kv.Key)) continue; + yield return BuildDelete(kv.Value); + } + } + + private static IPendingChange BuildInsert(FlockRow row) + { + return new RawSqlChange( + $"npcflock: insert '{row.Name}' (presence {row.PresenceId}, def {row.Definition})", + "INSERT INTO npcflock " + + "(name, presenceid, flockmembercount, definition, spawnoriginX, spawnoriginY, " + + "spawnrangeMin, spawnrangeMax, respawnseconds, totalspawncount, homerange, note, " + + "respawnmultiplierlow, enabled, iscallforhelp, behaviorType, npcSpecialType) " + + "VALUES (" + + $"{SqlLiteral.Of(row.Name)}, {row.PresenceId}, {row.FlockMemberCount}, {row.Definition}, " + + $"{row.SpawnOriginX}, {row.SpawnOriginY}, {row.SpawnRangeMin}, {row.SpawnRangeMax}, " + + $"{row.RespawnSeconds}, {row.TotalSpawnCount}, {row.HomeRange}, " + + $"{(string.IsNullOrEmpty(row.Note) ? "NULL" : SqlLiteral.Of(row.Note))}, " + + $"{SqlLiteral.Of(row.RespawnMultiplierLow)}, " + + $"{SqlLiteral.Of(row.Enabled)}, {SqlLiteral.Of(row.IsCallForHelp)}, " + + $"{row.BehaviorType}, {row.NpcSpecialType})"); + } + + private static IPendingChange? BuildUpdate(FlockRow row, FlockSnapshot original) + { + var sets = new List(); + if (row.Name != original.Name) sets.Add($"name = {SqlLiteral.Of(row.Name)}"); + if (row.PresenceId != original.PresenceId) sets.Add($"presenceid = {row.PresenceId}"); + if (row.FlockMemberCount != original.FlockMemberCount) sets.Add($"flockmembercount = {row.FlockMemberCount}"); + if (row.Definition != original.Definition) sets.Add($"definition = {row.Definition}"); + if (row.SpawnOriginX != original.SpawnOriginX) sets.Add($"spawnoriginX = {row.SpawnOriginX}"); + if (row.SpawnOriginY != original.SpawnOriginY) sets.Add($"spawnoriginY = {row.SpawnOriginY}"); + if (row.SpawnRangeMin != original.SpawnRangeMin) sets.Add($"spawnrangeMin = {row.SpawnRangeMin}"); + if (row.SpawnRangeMax != original.SpawnRangeMax) sets.Add($"spawnrangeMax = {row.SpawnRangeMax}"); + if (row.RespawnSeconds != original.RespawnSeconds) sets.Add($"respawnseconds = {row.RespawnSeconds}"); + if (row.TotalSpawnCount != original.TotalSpawnCount) sets.Add($"totalspawncount = {row.TotalSpawnCount}"); + if (row.HomeRange != original.HomeRange) sets.Add($"homerange = {row.HomeRange}"); + if ((row.Note ?? "") != (original.Note ?? "")) + sets.Add($"note = {(string.IsNullOrEmpty(row.Note) ? "NULL" : SqlLiteral.Of(row.Note))}"); + if (row.RespawnMultiplierLow != original.RespawnMultiplierLow) + sets.Add($"respawnmultiplierlow = {SqlLiteral.Of(row.RespawnMultiplierLow)}"); + if (row.Enabled != original.Enabled) sets.Add($"enabled = {SqlLiteral.Of(row.Enabled)}"); + if (row.IsCallForHelp != original.IsCallForHelp) sets.Add($"iscallforhelp = {SqlLiteral.Of(row.IsCallForHelp)}"); + if (row.BehaviorType != original.BehaviorType) sets.Add($"behaviorType = {row.BehaviorType}"); + if (row.NpcSpecialType != original.NpcSpecialType) sets.Add($"npcSpecialType = {row.NpcSpecialType}"); + + if (sets.Count == 0) return null; + + return new RawSqlChange( + $"npcflock: update id {row.Id} ('{row.Name}', {sets.Count} column(s))", + $"UPDATE npcflock SET {string.Join(", ", sets)} WHERE id = {row.Id}"); + } + + private static IPendingChange BuildDelete(FlockSnapshot snap) + { + return new RawSqlChange( + $"npcflock: delete id {snap.Id} (was '{snap.Name}', presence {snap.PresenceId})", + $"DELETE FROM npcflock WHERE id = {snap.Id}", + isDestructive: true); + } + } +} diff --git a/src/Perpetuum.AdminTool/Editing/IPendingChange.cs b/src/Perpetuum.AdminTool/Editing/IPendingChange.cs new file mode 100644 index 00000000..04637af6 --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/IPendingChange.cs @@ -0,0 +1,9 @@ +namespace Perpetuum.AdminTool.Editing +{ + public interface IPendingChange + { + string Description { get; } + string ToSql(); + bool IsDestructive => false; + } +} diff --git a/src/Perpetuum.AdminTool/Editing/NpcLootChanges.cs b/src/Perpetuum.AdminTool/Editing/NpcLootChanges.cs new file mode 100644 index 00000000..3b370cde --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/NpcLootChanges.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using Perpetuum.AdminTool.Loot; + +namespace Perpetuum.AdminTool.Editing +{ + public static class NpcLootChanges + { + public static IEnumerable ComputeBulkChanges( + IEnumerable currentRows, + IReadOnlyDictionary originalById) + { + var seenIds = new HashSet(); + + foreach (var row in currentRows) + { + if (row.IsNew || row.Id <= 0) + { + yield return BuildInsert(row); + continue; + } + + seenIds.Add(row.Id); + + if (originalById.TryGetValue(row.Id, out var original)) + { + var update = BuildUpdate(row, original); + if (update != null) yield return update; + } + else + { + // Row claims an id but has no matching baseline — load was inconsistent. + yield return BuildInsert(row); + } + } + + foreach (var kv in originalById) + { + if (seenIds.Contains(kv.Key)) continue; + yield return BuildDelete(kv.Value); + } + } + + private static IPendingChange BuildInsert(NpcLootRow row) + { + return new RawSqlChange( + $"npcloot: insert NPC {row.Definition} → item {row.LootDefinition} (prob {row.Probability})", + "INSERT INTO npcloot (definition, lootdefinition, minquantity, quantity, probability, dontdamage, repackaged) " + + $"VALUES ({row.Definition}, {row.LootDefinition}, {row.MinQuantity}, {row.Quantity}, " + + $"{SqlLiteral.Of(row.Probability)}, {SqlLiteral.Of(row.DontDamage)}, {SqlLiteral.Of(row.Repackaged)})"); + } + + private static IPendingChange? BuildUpdate(NpcLootRow row, NpcLootSnapshot original) + { + var sets = new List(); + if (row.Definition != original.Definition) sets.Add($"definition = {row.Definition}"); + if (row.LootDefinition != original.LootDefinition) sets.Add($"lootdefinition = {row.LootDefinition}"); + if (row.MinQuantity != original.MinQuantity) sets.Add($"minquantity = {row.MinQuantity}"); + if (row.Quantity != original.Quantity) sets.Add($"quantity = {row.Quantity}"); + if (row.Probability != original.Probability) sets.Add($"probability = {SqlLiteral.Of(row.Probability)}"); + if (row.DontDamage != original.DontDamage) sets.Add($"dontdamage = {SqlLiteral.Of(row.DontDamage)}"); + if (row.Repackaged != original.Repackaged) sets.Add($"repackaged = {SqlLiteral.Of(row.Repackaged)}"); + + if (sets.Count == 0) return null; + + return new RawSqlChange( + $"npcloot: update id {row.Id} (NPC {row.Definition} → item {row.LootDefinition}, {sets.Count} column(s))", + $"UPDATE npcloot SET {string.Join(", ", sets)} WHERE id = {row.Id}"); + } + + private static IPendingChange BuildDelete(NpcLootSnapshot snap) + { + return new RawSqlChange( + $"npcloot: delete id {snap.Id} (was NPC {snap.Definition} → item {snap.LootDefinition})", + $"DELETE FROM npcloot WHERE id = {snap.Id}", + isDestructive: true); + } + } +} diff --git a/src/Perpetuum.AdminTool/Editing/PresenceChanges.cs b/src/Perpetuum.AdminTool/Editing/PresenceChanges.cs new file mode 100644 index 00000000..d9934d41 --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/PresenceChanges.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using Perpetuum.AdminTool.Npc; + +namespace Perpetuum.AdminTool.Editing +{ + public static class PresenceChanges + { + public static IEnumerable ComputeBulkChanges( + IEnumerable currentRows, + IReadOnlyDictionary originalById) + { + var seenIds = new HashSet(); + + foreach (var row in currentRows) + { + if (row.IsNew || row.Id <= 0) + { + yield return BuildInsert(row); + continue; + } + + seenIds.Add(row.Id); + + if (originalById.TryGetValue(row.Id, out var original)) + { + var update = BuildUpdate(row, original); + if (update != null) yield return update; + } + else + { + yield return BuildInsert(row); + } + } + + foreach (var kv in originalById) + { + if (seenIds.Contains(kv.Key)) continue; + yield return BuildDelete(kv.Value); + } + } + + private static IPendingChange BuildInsert(PresenceRow row) + { + return new RawSqlChange( + $"npcpresence: insert '{row.Name}' (spawn {SqlLiteral.OfNullableInt(row.SpawnId)})", + "INSERT INTO npcpresence " + + "(name, topx, topy, bottomx, bottomy, note, spawnid, enabled, " + + "roaming, roamingrespawnseconds, presencetype, maxrandomflock, " + + "randomcenterx, randomcentery, randomradius, dynamiclifetime, " + + "isbodypull, isrespawnallowed, safebodypull, izgroupid, growthseconds) " + + "VALUES (" + + $"{SqlLiteral.Of(row.Name)}, {row.TopX}, {row.TopY}, {row.BottomX}, {row.BottomY}, " + + $"{(string.IsNullOrEmpty(row.Note) ? "NULL" : SqlLiteral.Of(row.Note))}, " + + $"{SqlLiteral.OfNullableInt(row.SpawnId)}, " + + $"{SqlLiteral.Of(row.Enabled)}, {SqlLiteral.Of(row.Roaming)}, " + + $"{row.RoamingRespawnSeconds}, {row.PresenceType}, " + + $"{SqlLiteral.OfNullableInt(row.MaxRandomFlock)}, " + + $"{SqlLiteral.OfNullableInt(row.RandomCenterX)}, " + + $"{SqlLiteral.OfNullableInt(row.RandomCenterY)}, " + + $"{SqlLiteral.OfNullableInt(row.RandomRadius)}, " + + $"{SqlLiteral.OfNullableInt(row.DynamicLifetime)}, " + + $"{SqlLiteral.Of(row.IsBodyPull)}, {SqlLiteral.Of(row.IsRespawnAllowed)}, {SqlLiteral.Of(row.SafeBodyPull)}, " + + $"{SqlLiteral.OfNullableInt(row.IzGroupId)}, " + + $"{SqlLiteral.OfNullableInt(row.GrowthSeconds)})"); + } + + private static IPendingChange? BuildUpdate(PresenceRow row, PresenceSnapshot original) + { + var sets = new List(); + if (row.Name != original.Name) sets.Add($"name = {SqlLiteral.Of(row.Name)}"); + if (row.TopX != original.TopX) sets.Add($"topx = {row.TopX}"); + if (row.TopY != original.TopY) sets.Add($"topy = {row.TopY}"); + if (row.BottomX != original.BottomX) sets.Add($"bottomx = {row.BottomX}"); + if (row.BottomY != original.BottomY) sets.Add($"bottomy = {row.BottomY}"); + if ((row.Note ?? "") != (original.Note ?? "")) + sets.Add($"note = {(string.IsNullOrEmpty(row.Note) ? "NULL" : SqlLiteral.Of(row.Note))}"); + if (row.SpawnId != original.SpawnId) + sets.Add($"spawnid = {SqlLiteral.OfNullableInt(row.SpawnId)}"); + if (row.Enabled != original.Enabled) sets.Add($"enabled = {SqlLiteral.Of(row.Enabled)}"); + if (row.Roaming != original.Roaming) sets.Add($"roaming = {SqlLiteral.Of(row.Roaming)}"); + if (row.RoamingRespawnSeconds != original.RoamingRespawnSeconds) + sets.Add($"roamingrespawnseconds = {row.RoamingRespawnSeconds}"); + if (row.PresenceType != original.PresenceType) sets.Add($"presencetype = {row.PresenceType}"); + if (row.MaxRandomFlock != original.MaxRandomFlock) + sets.Add($"maxrandomflock = {SqlLiteral.OfNullableInt(row.MaxRandomFlock)}"); + if (row.RandomCenterX != original.RandomCenterX) + sets.Add($"randomcenterx = {SqlLiteral.OfNullableInt(row.RandomCenterX)}"); + if (row.RandomCenterY != original.RandomCenterY) + sets.Add($"randomcentery = {SqlLiteral.OfNullableInt(row.RandomCenterY)}"); + if (row.RandomRadius != original.RandomRadius) + sets.Add($"randomradius = {SqlLiteral.OfNullableInt(row.RandomRadius)}"); + if (row.DynamicLifetime != original.DynamicLifetime) + sets.Add($"dynamiclifetime = {SqlLiteral.OfNullableInt(row.DynamicLifetime)}"); + if (row.IsBodyPull != original.IsBodyPull) sets.Add($"isbodypull = {SqlLiteral.Of(row.IsBodyPull)}"); + if (row.IsRespawnAllowed != original.IsRespawnAllowed) + sets.Add($"isrespawnallowed = {SqlLiteral.Of(row.IsRespawnAllowed)}"); + if (row.SafeBodyPull != original.SafeBodyPull) + sets.Add($"safebodypull = {SqlLiteral.Of(row.SafeBodyPull)}"); + if (row.IzGroupId != original.IzGroupId) + sets.Add($"izgroupid = {SqlLiteral.OfNullableInt(row.IzGroupId)}"); + if (row.GrowthSeconds != original.GrowthSeconds) + sets.Add($"growthseconds = {SqlLiteral.OfNullableInt(row.GrowthSeconds)}"); + + if (sets.Count == 0) return null; + + return new RawSqlChange( + $"npcpresence: update id {row.Id} ('{row.Name}', {sets.Count} column(s))", + $"UPDATE npcpresence SET {string.Join(", ", sets)} WHERE id = {row.Id}"); + } + + private static IPendingChange BuildDelete(PresenceSnapshot snap) + { + return new RawSqlChange( + $"npcpresence: delete id {snap.Id} (was '{snap.Name}')", + $"DELETE FROM npcpresence WHERE id = {snap.Id}", + isDestructive: true); + } + } +} diff --git a/src/Perpetuum.AdminTool/Editing/RawSqlChange.cs b/src/Perpetuum.AdminTool/Editing/RawSqlChange.cs new file mode 100644 index 00000000..c20fcbf0 --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/RawSqlChange.cs @@ -0,0 +1,18 @@ +namespace Perpetuum.AdminTool.Editing +{ + public class RawSqlChange : IPendingChange + { + public string Description { get; } + public bool IsDestructive { get; } + private readonly string _sql; + + public RawSqlChange(string description, string sql, bool isDestructive = false) + { + Description = description; + _sql = sql; + IsDestructive = isDestructive; + } + + public string ToSql() => _sql; + } +} diff --git a/src/Perpetuum.AdminTool/Editing/SqlLiteral.cs b/src/Perpetuum.AdminTool/Editing/SqlLiteral.cs new file mode 100644 index 00000000..c1e9fdcd --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/SqlLiteral.cs @@ -0,0 +1,31 @@ +using System.Globalization; + +namespace Perpetuum.AdminTool.Editing +{ + public static class SqlLiteral + { + public static string Of(object? value) + { + return value switch + { + null => "NULL", + string s => "N'" + s.Replace("'", "''") + "'", + bool b => b ? "1" : "0", + int i => i.ToString(CultureInfo.InvariantCulture), + long l => l.ToString(CultureInfo.InvariantCulture), + double d => d.ToString("R", CultureInfo.InvariantCulture), + float f => f.ToString("R", CultureInfo.InvariantCulture), + decimal m => m.ToString(CultureInfo.InvariantCulture), + _ => "N'" + value.ToString()!.Replace("'", "''") + "'" + }; + } + + public static string OfNullableLong(long? value) => + value.HasValue ? value.Value.ToString(CultureInfo.InvariantCulture) : "NULL"; + + public static string OfNullableInt(int? value) => + value.HasValue ? value.Value.ToString(CultureInfo.InvariantCulture) : "NULL"; + + public static string Identifier(string name) => "[" + name.Replace("]", "]]") + "]"; + } +} diff --git a/src/Perpetuum.AdminTool/Editing/SqlScriptBuilder.cs b/src/Perpetuum.AdminTool/Editing/SqlScriptBuilder.cs new file mode 100644 index 00000000..150daeda --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/SqlScriptBuilder.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace Perpetuum.AdminTool.Editing +{ + public static class SqlScriptBuilder + { + public static string Build(IEnumerable changes, string? authorEmail = null) + { + var sb = new StringBuilder(); + sb.AppendLine("-- Perpetuum.AdminTool generated script"); + sb.AppendLine($"-- Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); + if (!string.IsNullOrWhiteSpace(authorEmail)) + { + sb.AppendLine($"-- Author: {authorEmail}"); + } + sb.AppendLine(); + sb.AppendLine("SET XACT_ABORT ON;"); + sb.AppendLine("BEGIN TRANSACTION;"); + sb.AppendLine(); + + int i = 1; + foreach (var change in changes) + { + sb.AppendLine($"-- [{i}] {change.Description}"); + var body = change.ToSql().TrimEnd(); + sb.AppendLine(body); + if (!body.EndsWith(";", StringComparison.Ordinal)) + { + sb.AppendLine(";"); + } + sb.AppendLine(); + i++; + } + + sb.AppendLine("COMMIT TRANSACTION;"); + return sb.ToString(); + } + + public static string BuildFileName(string prefix, string? name = null) + { + var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + if (string.IsNullOrWhiteSpace(name)) + return $"{prefix}_{ts}.sql"; + var safe = Regex.Replace( + Regex.Replace(name.ToLowerInvariant(), @"[^a-z0-9_]", "_"), + @"_+", "_").Trim('_'); + return $"{prefix}_{safe}_{ts}.sql"; + } + } +} diff --git a/src/Perpetuum.AdminTool/Editing/TemplateChanges.cs b/src/Perpetuum.AdminTool/Editing/TemplateChanges.cs new file mode 100644 index 00000000..3ecf1e08 --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/TemplateChanges.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Text; +using Perpetuum.AdminTool.Templates; + +namespace Perpetuum.AdminTool.Editing +{ + public static class TemplateChanges + { + public static IEnumerable ComputeChanges(RobotTemplateRow row) + { + if (row.IsNew) + { + yield return BuildInsert(row); + yield break; + } + + var update = BuildUpdate(row); + if (update != null) yield return update; + } + + public static IEnumerable ComputeDeleteChanges(RobotTemplateRow row) + { + yield return new RawSqlChange( + $"robottemplates: delete id {row.Id} ({row.Original.Name})", + $"DELETE FROM robottemplates WHERE id = {row.Id}", + isDestructive: true); + } + + private static IPendingChange BuildInsert(RobotTemplateRow row) + { + var varName = $"@new_tmpl_{row.NewIdToken}"; + var noteLiteral = string.IsNullOrEmpty(row.Note) ? "NULL" : SqlLiteral.Of(row.Note); + var sql = new StringBuilder(); + sql.Append("INSERT INTO robottemplates (name, description, note) VALUES ("); + sql.Append(SqlLiteral.Of(row.Name)).Append(", "); + sql.Append(SqlLiteral.Of(row.Description)).Append(", "); + sql.Append(noteLiteral).Append(");"); + sql.AppendLine(); + sql.Append("DECLARE ").Append(varName).Append(" INT = SCOPE_IDENTITY();"); + + return new RawSqlChange( + $"robottemplates: insert ({row.Name}) — id auto-assigned via {varName}", + sql.ToString()); + } + + private static IPendingChange? BuildUpdate(RobotTemplateRow row) + { + var sets = new List(); + if (row.Name != row.Original.Name) + sets.Add($"name = {SqlLiteral.Of(row.Name)}"); + if (row.Description != row.Original.Description) + sets.Add($"description = {SqlLiteral.Of(row.Description)}"); + if (!StringEqualsNullSafe(row.Note, row.Original.Note)) + sets.Add($"note = {(string.IsNullOrEmpty(row.Note) ? "NULL" : SqlLiteral.Of(row.Note))}"); + + if (sets.Count == 0) return null; + + return new RawSqlChange( + $"robottemplates: update id {row.Id} ({row.Name}) — {sets.Count} column(s)", + $"UPDATE robottemplates SET {string.Join(", ", sets)} WHERE id = {row.Id}"); + } + + private static bool StringEqualsNullSafe(string? a, string? b) + { + var aa = a ?? ""; + var bb = b ?? ""; + return aa == bb; + } + } +} diff --git a/src/Perpetuum.AdminTool/Editing/TemplateRelationChanges.cs b/src/Perpetuum.AdminTool/Editing/TemplateRelationChanges.cs new file mode 100644 index 00000000..6b5a215d --- /dev/null +++ b/src/Perpetuum.AdminTool/Editing/TemplateRelationChanges.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Linq; +using Perpetuum.AdminTool.Templates; + +namespace Perpetuum.AdminTool.Editing +{ + public static class TemplateRelationChanges + { + public static IEnumerable ComputeBulkChanges( + IEnumerable currentRows, + IReadOnlyDictionary originalByDefinition) + { + var seen = new HashSet(); + + foreach (var row in currentRows) + { + if (row.IsNew) + { + yield return BuildInsert(row); + continue; + } + + seen.Add(row.Original.Definition); + + if (originalByDefinition.TryGetValue(row.Original.Definition, out var original)) + { + var update = BuildUpdate(row, original); + if (update != null) yield return update; + } + else + { + yield return BuildInsert(row); + } + } + + foreach (var kv in originalByDefinition) + { + if (seen.Contains(kv.Key)) continue; + yield return BuildDelete(kv.Value); + } + } + + private static IPendingChange BuildInsert(RobotTemplateRelationRow row) + { + return new RawSqlChange( + $"robottemplaterelation: insert def {row.Definition} → template {row.TemplateId}", + "INSERT INTO robottemplaterelation " + + "(definition, templateid, itemscoresum, raceid, missionlevel, missionleveloverride, killep, note) " + + "VALUES (" + + $"{row.Definition}, {row.TemplateId}, {row.ItemScoreSum}, {row.RaceId}, " + + $"{SqlLiteral.OfNullableInt(row.MissionLevel)}, " + + $"{SqlLiteral.OfNullableInt(row.MissionLevelOverride)}, " + + $"{SqlLiteral.OfNullableInt(row.KillEp)}, " + + $"{(string.IsNullOrEmpty(row.Note) ? "NULL" : SqlLiteral.Of(row.Note))})"); + } + + private static IPendingChange? BuildUpdate(RobotTemplateRelationRow row, RobotTemplateRelationSnapshot original) + { + var sets = new List(); + if (row.Definition != original.Definition) + sets.Add($"definition = {row.Definition}"); + if (row.TemplateId != original.TemplateId) + sets.Add($"templateid = {row.TemplateId}"); + if (row.ItemScoreSum != original.ItemScoreSum) + sets.Add($"itemscoresum = {row.ItemScoreSum}"); + if (row.RaceId != original.RaceId) + sets.Add($"raceid = {row.RaceId}"); + if (row.MissionLevel != original.MissionLevel) + sets.Add($"missionlevel = {SqlLiteral.OfNullableInt(row.MissionLevel)}"); + if (row.MissionLevelOverride != original.MissionLevelOverride) + sets.Add($"missionleveloverride = {SqlLiteral.OfNullableInt(row.MissionLevelOverride)}"); + if (row.KillEp != original.KillEp) + sets.Add($"killep = {SqlLiteral.OfNullableInt(row.KillEp)}"); + if ((row.Note ?? "") != (original.Note ?? "")) + sets.Add($"note = {(string.IsNullOrEmpty(row.Note) ? "NULL" : SqlLiteral.Of(row.Note))}"); + + if (sets.Count == 0) return null; + + return new RawSqlChange( + $"robottemplaterelation: update def {original.Definition} ({sets.Count} column(s))", + $"UPDATE robottemplaterelation SET {string.Join(", ", sets)} " + + $"WHERE definition = {original.Definition}"); + } + + private static IPendingChange BuildDelete(RobotTemplateRelationSnapshot snap) + { + return new RawSqlChange( + $"robottemplaterelation: delete def {snap.Definition} (was → template {snap.TemplateId})", + $"DELETE FROM robottemplaterelation WHERE definition = {snap.Definition}", + isDestructive: true); + } + } +} diff --git a/src/Perpetuum.AdminTool/Entities/AggregateFieldInfo.cs b/src/Perpetuum.AdminTool/Entities/AggregateFieldInfo.cs new file mode 100644 index 00000000..9ddeee74 --- /dev/null +++ b/src/Perpetuum.AdminTool/Entities/AggregateFieldInfo.cs @@ -0,0 +1,23 @@ +namespace Perpetuum.AdminTool.Entities +{ + public class AggregateFieldInfo + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public int Formula { get; init; } + public string MeasurementUnit { get; init; } = ""; + public double MeasurementMultiplier { get; init; } + public double MeasurementOffset { get; init; } + public int Category { get; init; } + public int Digits { get; init; } + + public string DisplayLabel => + string.IsNullOrEmpty(Name) + ? $"#{Id}" + : $"{Name} (#{Id})"; + + public bool IsMissingFromEnum => + !System.Enum.IsDefined(typeof(Perpetuum.ExportedTypes.AggregateField), + (Perpetuum.ExportedTypes.AggregateField)Id); + } +} diff --git a/src/Perpetuum.AdminTool/Entities/AttributeFlagsCatalog.cs b/src/Perpetuum.AdminTool/Entities/AttributeFlagsCatalog.cs new file mode 100644 index 00000000..ebaa5463 --- /dev/null +++ b/src/Perpetuum.AdminTool/Entities/AttributeFlagsCatalog.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Perpetuum.ExportedTypes; + +namespace Perpetuum.AdminTool.Entities +{ + public static class AttributeFlagsCatalog + { + public record Bit(string Name, int Position) + { + public ulong Mask => 1UL << Position; + public string Display => $"[{Position,2}] {Name}"; + } + + public static IReadOnlyList Bits { get; } + + static AttributeFlagsCatalog() + { + var list = new List(); + foreach (var name in Enum.GetNames(typeof(AttributeFlags))) + { + if (name.StartsWith("undefined_", StringComparison.Ordinal)) continue; + if (name == "NOT_USED") continue; + if (name == "player") continue; // bit 0 — meaningful only for player entities, rarely set as a flag + var pos = (int)Enum.Parse(name); + list.Add(new Bit(name, pos)); + } + Bits = list.OrderBy(b => b.Position).ToList(); + } + + public static bool IsSet(ulong value, int position) => + (value & (1UL << position)) != 0; + + public static ulong Set(ulong value, int position, bool on) + { + var mask = 1UL << position; + return on ? (value | mask) : (value & ~mask); + } + + public static string Describe(ulong value) + { + if (value == 0) return "(none)"; + var names = Bits.Where(b => (value & b.Mask) != 0).Select(b => b.Name).ToList(); + return names.Count == 0 + ? $"(unknown: 0x{value:X})" + : string.Join(", ", names); + } + } +} diff --git a/src/Perpetuum.AdminTool/Entities/CategoryFlagsCatalog.cs b/src/Perpetuum.AdminTool/Entities/CategoryFlagsCatalog.cs new file mode 100644 index 00000000..9b65ccd0 --- /dev/null +++ b/src/Perpetuum.AdminTool/Entities/CategoryFlagsCatalog.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Perpetuum.ExportedTypes; + +namespace Perpetuum.AdminTool.Entities +{ + public static class CategoryFlagsCatalog + { + public record Entry(string Name, long Value) + { + public string Hex => "0x" + Value.ToString("X"); + public string Display => $"{Name} ({Hex})"; + } + + public static IReadOnlyList Entries { get; } + + static CategoryFlagsCatalog() + { + // Enum.GetNames returns declaration order; first occurrence per value is canonical. + var seen = new HashSet(); + var list = new List(); + foreach (var name in Enum.GetNames(typeof(CategoryFlags))) + { + var value = (long)Enum.Parse(name); + if (!seen.Add(value)) continue; + list.Add(new Entry(name, value)); + } + Entries = list + .OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public static string Describe(long value) + { + var name = Enum.GetName(typeof(CategoryFlags), (CategoryFlags)value); + return name ?? "(custom: 0x" + value.ToString("X") + ")"; + } + } +} diff --git a/src/Perpetuum.AdminTool/Entities/CategoryFlagsHierarchy.cs b/src/Perpetuum.AdminTool/Entities/CategoryFlagsHierarchy.cs new file mode 100644 index 00000000..1d112c9e --- /dev/null +++ b/src/Perpetuum.AdminTool/Entities/CategoryFlagsHierarchy.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Perpetuum.AdminTool.Entities +{ + public static class CategoryFlagsHierarchy + { + public static IReadOnlyList BuildRoots() + { + var byValue = CategoryFlagsCatalog.Entries + .Where(e => e.Value != 0) // skip `undefined` + .ToDictionary( + e => e.Value, + e => new CategoryFlagsNode { Value = e.Value, Name = e.Name }); + + var roots = new List(); + + foreach (var node in byValue.Values) + { + // Walk up until we find a parent that is in the dict, or hit zero. + // (Defends against gaps where an intermediate byte-level isn't in the enum.) + var pv = CategoryFlagsNode.ParentOf(node.Value); + while (pv != 0 && !byValue.ContainsKey(pv)) + { + pv = CategoryFlagsNode.ParentOf(pv); + } + + if (pv == 0) + { + roots.Add(node); + } + else + { + var parent = byValue[pv]; + parent.Children.Add(node); + node.Parent = parent; + } + } + + void SortChildren(CategoryFlagsNode n) + { + var sorted = n.Children + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + n.Children.Clear(); + foreach (var c in sorted) n.Children.Add(c); + foreach (var c in n.Children) SortChildren(c); + } + + foreach (var r in roots) SortChildren(r); + + return roots + .OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/src/Perpetuum.AdminTool/Entities/CategoryFlagsNode.cs b/src/Perpetuum.AdminTool/Entities/CategoryFlagsNode.cs new file mode 100644 index 00000000..b021000f --- /dev/null +++ b/src/Perpetuum.AdminTool/Entities/CategoryFlagsNode.cs @@ -0,0 +1,49 @@ +using System.Collections.ObjectModel; + +namespace Perpetuum.AdminTool.Entities +{ + /// + /// One node in the categoryflags hierarchy. The hierarchy is encoded by the bit pattern: + /// each value is a path of bytes from the lowest byte upward. The parent of a value is + /// the value with its highest non-zero byte cleared. + /// + public class CategoryFlagsNode + { + public long Value { get; init; } + public string Name { get; init; } = ""; + public string Hex => "0x" + Value.ToString("X"); + public string Display => $"{Name} ({Hex})"; + + public ObservableCollection Children { get; } = new(); + public CategoryFlagsNode? Parent { get; set; } + + /// + /// Returns true if `target` is this node's value or any descendant — i.e. walking + /// `target` up its parent chain eventually yields `Value`. + /// + public bool ContainsOrEquals(long target) + { + var v = target; + while (true) + { + if (v == Value) return true; + if (v == 0) return false; + v = ParentOf(v); + } + } + + /// + /// Bit-math parent: clear the highest non-zero byte. Returns 0 if the value is a + /// root (only the lowest byte set) or already 0. + /// + public static long ParentOf(long v) + { + for (var b = 7; b >= 1; b--) + { + var mask = 0xFFL << (b * 8); + if ((v & mask) != 0) return v & ~mask; + } + return 0; + } + } +} diff --git a/src/Perpetuum.AdminTool/Entities/EntityDefaultRow.cs b/src/Perpetuum.AdminTool/Entities/EntityDefaultRow.cs new file mode 100644 index 00000000..8afe7906 --- /dev/null +++ b/src/Perpetuum.AdminTool/Entities/EntityDefaultRow.cs @@ -0,0 +1,151 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.Entities +{ + public partial class EntityDefaultRow : ObservableObject + { + // Negative placeholder ids for in-memory new rows (real ids come from SQL identity). + private static int _placeholderCounter = 0; + + // Definition is the primary key; not editable. + // For new rows it is a negative placeholder until SQL Server assigns the real id. + public int Definition { get; } + + // True for rows created in-memory. The autoincrement id is unknown until insert applies, + // so this stays true even after Save until the user Reloads from DB. + public bool IsNew { get; set; } + + // Suffix for SCOPE_IDENTITY-capturing T-SQL variable names. Stable per new row. + public string NewIdToken { get; init; } + + // Set to true after Save queues this new row; locks the row from further edits. + [ObservableProperty] private bool _isQueued; + + partial void OnIsQueuedChanged(bool value) + { + OnPropertyChanged(nameof(DefinitionDisplay)); + } + + public string DefinitionDisplay => + IsNew + ? (IsQueued + ? "(pending insert — reload after commit)" + : "(autoincrement on save)") + : Definition.ToString(); + + // Snapshot of the row as loaded from DB; used for diffing. + public EntityDefaultSnapshot Original { get; private set; } + + [ObservableProperty] private string _definitionName = ""; + [ObservableProperty] private string? _descriptionToken; + [ObservableProperty] private long _categoryFlags; + [ObservableProperty] private long _attributeFlags; + [ObservableProperty] private double _mass; + [ObservableProperty] private double _volume; + [ObservableProperty] private double _health; + [ObservableProperty] private int _quantity; + [ObservableProperty] private bool _hidden; + [ObservableProperty] private bool _purchasable; + [ObservableProperty] private bool _enabled = true; + [ObservableProperty] private int? _tierType; + [ObservableProperty] private int? _tierLevel; + [ObservableProperty] private string? _options; // raw Genxy string; read-only in 3a + + // Stats live alongside the row; loaded from aggregatevalues. + public ObservableCollection Stats { get; } = new(); + + // Stats that existed in DB at load (definition, field) → original value. + // Used to determine UPDATE vs INSERT and to detect deletions. + public System.Collections.Generic.Dictionary OriginalStats { get; } = new(); + + public EntityDefaultRow(EntityDefaultSnapshot snapshot) + { + Definition = snapshot.Definition; + NewIdToken = ""; + Original = snapshot; + ApplySnapshot(snapshot); + } + + public static EntityDefaultRow CreateNew(string definitionName) + { + var placeholderDef = System.Threading.Interlocked.Decrement(ref _placeholderCounter); + var snapshot = new EntityDefaultSnapshot + { + Definition = placeholderDef, + DefinitionName = definitionName, + Mass = 1.0, + Volume = 1.0, + Health = 100.0, + Quantity = 1, + Enabled = true + }; + var row = new EntityDefaultRow(snapshot) + { + IsNew = true, + NewIdToken = System.Guid.NewGuid().ToString("N").Substring(0, 8) + }; + return row; + } + + public void ApplySnapshot(EntityDefaultSnapshot snapshot) + { + Original = snapshot; + DefinitionName = snapshot.DefinitionName; + DescriptionToken = snapshot.DescriptionToken; + CategoryFlags = snapshot.CategoryFlags; + AttributeFlags = snapshot.AttributeFlags; + Mass = snapshot.Mass; + Volume = snapshot.Volume; + Health = snapshot.Health; + Quantity = snapshot.Quantity; + Hidden = snapshot.Hidden; + Purchasable = snapshot.Purchasable; + Enabled = snapshot.Enabled; + TierType = snapshot.TierType; + TierLevel = snapshot.TierLevel; + Options = snapshot.Options; + } + + public void RefreshOriginalFromCurrent() + { + Original = new EntityDefaultSnapshot + { + Definition = Definition, + DefinitionName = DefinitionName, + DescriptionToken = DescriptionToken, + CategoryFlags = CategoryFlags, + AttributeFlags = AttributeFlags, + Mass = Mass, + Volume = Volume, + Health = Health, + Quantity = Quantity, + Hidden = Hidden, + Purchasable = Purchasable, + Enabled = Enabled, + TierType = TierType, + TierLevel = TierLevel, + Options = Options + }; + } + } + + public class EntityDefaultSnapshot + { + public int Definition { get; init; } + public string DefinitionName { get; init; } = ""; + public string? DescriptionToken { get; init; } + public long CategoryFlags { get; init; } + public long AttributeFlags { get; init; } + public double Mass { get; init; } + public double Volume { get; init; } + public double Health { get; init; } + public int Quantity { get; init; } + public bool Hidden { get; init; } + public bool Purchasable { get; init; } + public bool Enabled { get; init; } = true; + public int? TierType { get; init; } + public int? TierLevel { get; init; } + public string? Options { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Entities/EntityRepository.cs b/src/Perpetuum.AdminTool/Entities/EntityRepository.cs new file mode 100644 index 00000000..69b87533 --- /dev/null +++ b/src/Perpetuum.AdminTool/Entities/EntityRepository.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.ExportedTypes; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Entities +{ + public class EntityRepository + { + private readonly ConnectionSettings _connection; + + public EntityRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task LoadAsync() + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + var fields = await LoadFieldsAsync(cn); + var rows = await LoadDefaultsAsync(cn); + var byDef = new Dictionary(rows.Count); + foreach (var r in rows) byDef[r.Definition] = r; + + await LoadStatsAsync(cn, byDef); + + return new EntitiesSnapshot + { + Rows = rows, + Fields = fields + }; + } + + private async Task> LoadFieldsAsync(SqlConnection cn) + { + var dict = new Dictionary(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "select id, name, formula, measurementunit, measurementmultiplier, measurementoffset, category, digits from aggregatefields"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var info = new AggregateFieldInfo + { + Id = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + Formula = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + MeasurementUnit = reader.IsDBNull(3) ? "" : reader.GetString(3), + MeasurementMultiplier = reader.IsDBNull(4) ? 0d : reader.GetDouble(4), + MeasurementOffset = reader.IsDBNull(5) ? 0d : reader.GetDouble(5), + Category = reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + Digits = reader.IsDBNull(7) ? 0 : reader.GetInt32(7) + }; + dict[info.Id] = info; + } + return dict; + } + + private async Task> LoadDefaultsAsync(SqlConnection cn) + { + var rows = new List(2048); + await using var cmd = cn.CreateCommand(); + // Load every row regardless of `enabled`. Disabled entities still need to be + // visible so the user can flip them back on; downstream selectors apply their + // own enabled filter. + cmd.CommandText = + "select definition, definitionName, descriptionToken, " + + "categoryflags, attributeflags, mass, volume, health, quantity, " + + "hidden, purchasable, enabled, tiertype, tierlevel, options " + + "from entitydefaults order by definition"; + + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var snapshot = new EntityDefaultSnapshot + { + Definition = reader.GetInt32(0), + DefinitionName = reader.IsDBNull(1) ? "" : reader.GetString(1), + DescriptionToken = reader.IsDBNull(2) ? null : reader.GetString(2), + CategoryFlags = reader.IsDBNull(3) ? 0L : reader.GetInt64(3), + AttributeFlags = reader.IsDBNull(4) ? 0L : reader.GetInt64(4), + Mass = reader.IsDBNull(5) ? 0d : reader.GetDouble(5), + Volume = reader.IsDBNull(6) ? 0d : reader.GetDouble(6), + Health = reader.IsDBNull(7) ? 0d : reader.GetDouble(7), + Quantity = reader.IsDBNull(8) ? 0 : reader.GetInt32(8), + Hidden = !reader.IsDBNull(9) && reader.GetBoolean(9), + Purchasable = !reader.IsDBNull(10) && reader.GetBoolean(10), + Enabled = reader.IsDBNull(11) || reader.GetBoolean(11), + TierType = reader.IsDBNull(12) ? null : (int?)reader.GetInt32(12), + TierLevel = reader.IsDBNull(13) ? null : (int?)reader.GetInt32(13), + Options = reader.IsDBNull(14) ? null : reader.GetString(14), + }; + rows.Add(new EntityDefaultRow(snapshot)); + } + return rows; + } + + private async Task LoadStatsAsync(SqlConnection cn, Dictionary byDef) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "select definition, field, value from aggregatevalues"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var def = reader.GetInt32(0); + var fieldId = reader.GetInt32(1); + var value = reader.IsDBNull(2) ? 0d : reader.GetDouble(2); + + if (!byDef.TryGetValue(def, out var row)) continue; + + row.Stats.Add(new StatRow(def, (AggregateField)fieldId, value, wasInDb: true)); + row.OriginalStats[fieldId] = value; + } + } + } +} diff --git a/src/Perpetuum.AdminTool/Entities/EntitySnapshot.cs b/src/Perpetuum.AdminTool/Entities/EntitySnapshot.cs new file mode 100644 index 00000000..5cd8cd25 --- /dev/null +++ b/src/Perpetuum.AdminTool/Entities/EntitySnapshot.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Perpetuum.AdminTool.Entities +{ + public class EntitiesSnapshot + { + public IReadOnlyList Rows { get; init; } = new List(); + public IReadOnlyDictionary Fields { get; init; } + = new Dictionary(); + } +} diff --git a/src/Perpetuum.AdminTool/Entities/StatRow.cs b/src/Perpetuum.AdminTool/Entities/StatRow.cs new file mode 100644 index 00000000..ad18caa5 --- /dev/null +++ b/src/Perpetuum.AdminTool/Entities/StatRow.cs @@ -0,0 +1,31 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.ExportedTypes; + +namespace Perpetuum.AdminTool.Entities +{ + public partial class StatRow : ObservableObject + { + public int Definition { get; } + + // Was this row present in the DB at load time? + // If false, save will INSERT; if true, save will UPDATE on value change. + public bool WasInDb { get; } + + // Original value at load time (only meaningful when WasInDb is true). + public double OriginalValue { get; } + + [ObservableProperty] private AggregateField _field; + [ObservableProperty] private double _value; + + public StatRow(int definition, AggregateField field, double value, bool wasInDb) + { + Definition = definition; + _field = field; + _value = value; + WasInDb = wasInDb; + OriginalValue = wasInDb ? value : 0d; + } + + public bool IsModified => WasInDb && Value != OriginalValue; + } +} diff --git a/src/Perpetuum.AdminTool/EquipmentSets/AggregateFieldPickItem.cs b/src/Perpetuum.AdminTool/EquipmentSets/AggregateFieldPickItem.cs new file mode 100644 index 00000000..a3559969 --- /dev/null +++ b/src/Perpetuum.AdminTool/EquipmentSets/AggregateFieldPickItem.cs @@ -0,0 +1,11 @@ +namespace Perpetuum.AdminTool.EquipmentSets +{ + public class AggregateFieldPickItem + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public string TranslatedName { get; init; } = ""; + + public string Display => string.IsNullOrEmpty(TranslatedName) ? Name : TranslatedName; + } +} diff --git a/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetChanges.cs b/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetChanges.cs new file mode 100644 index 00000000..469097d9 --- /dev/null +++ b/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetChanges.cs @@ -0,0 +1,66 @@ +using Perpetuum.AdminTool.Editing; + +namespace Perpetuum.AdminTool.EquipmentSets +{ + public static class EquipmentSetChanges + { + // Returns either the literal integer (existing sets) or a subquery (new sets). + private static string SetIdExpr(int setId, string setName) => + setId > 0 + ? setId.ToString() + : $"(SELECT set_id FROM equipment_sets WHERE name = {SqlLiteral.Of(setName)})"; + + public static IPendingChange BuildInsertSet(string name) => + new RawSqlChange( + $"equipment_sets: insert '{name}'", + $"INSERT INTO equipment_sets (name) VALUES ({SqlLiteral.Of(name)})"); + + public static IPendingChange BuildRenameSet(int setId, string newName) => + new RawSqlChange( + $"equipment_sets: rename id {setId} to '{newName}'", + $"UPDATE equipment_sets SET name = {SqlLiteral.Of(newName)} WHERE set_id = {setId}"); + + public static IPendingChange BuildDeleteSet(int setId, string name) => + new RawSqlChange( + $"equipment_sets: cascade delete '{name}' (id {setId})", + $"DELETE FROM equipment_set_bonus_thresholds WHERE set_id = {setId};\n" + + $"DELETE FROM equipment_set_members WHERE set_id = {setId};\n" + + $"DELETE FROM equipment_sets WHERE set_id = {setId}", + isDestructive: true); + + public static IPendingChange BuildInsertMember(int setId, string setName, int definition) => + new RawSqlChange( + $"equipment_set_members: add definition {definition} to set '{setName}'", + $"INSERT INTO equipment_set_members (set_id, definition) " + + $"VALUES ({SetIdExpr(setId, setName)}, {definition})"); + + public static IPendingChange BuildDeleteMember(int setId, string setName, int definition) => + new RawSqlChange( + $"equipment_set_members: remove definition {definition} from set '{setName}'", + $"DELETE FROM equipment_set_members " + + $"WHERE set_id = {SetIdExpr(setId, setName)} AND definition = {definition}", + isDestructive: true); + + public static IPendingChange BuildUpsertThreshold( + int setId, string setName, int requiredPieces, int aggregateFieldId, double bonusValue) + { + var sid = SetIdExpr(setId, setName); + var val = SqlLiteral.Of(bonusValue); + return new RawSqlChange( + $"equipment_set_bonus_thresholds: upsert set '{setName}' pieces {requiredPieces}", + $"MERGE INTO equipment_set_bonus_thresholds AS target " + + $"USING (SELECT {sid} AS set_id, {requiredPieces} AS required_pieces) AS src " + + $"ON target.set_id = src.set_id AND target.required_pieces = src.required_pieces " + + $"WHEN MATCHED THEN UPDATE SET aggregate_field = {aggregateFieldId}, bonus_value = {val} " + + $"WHEN NOT MATCHED THEN INSERT (set_id, required_pieces, aggregate_field, bonus_value) " + + $"VALUES (src.set_id, {requiredPieces}, {aggregateFieldId}, {val})"); + } + + public static IPendingChange BuildDeleteThreshold(int setId, string setName, int requiredPieces) => + new RawSqlChange( + $"equipment_set_bonus_thresholds: delete set '{setName}' pieces {requiredPieces}", + $"DELETE FROM equipment_set_bonus_thresholds " + + $"WHERE set_id = {SetIdExpr(setId, setName)} AND required_pieces = {requiredPieces}", + isDestructive: true); + } +} diff --git a/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetMemberRow.cs b/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetMemberRow.cs new file mode 100644 index 00000000..154e0ecb --- /dev/null +++ b/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetMemberRow.cs @@ -0,0 +1,10 @@ +namespace Perpetuum.AdminTool.EquipmentSets +{ + public class EquipmentSetMemberRow + { + public int SetId { get; init; } + public int Definition { get; init; } + public string DefinitionName { get; init; } = ""; + public string TranslatedName { get; init; } = ""; + } +} diff --git a/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetRepository.cs b/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetRepository.cs new file mode 100644 index 00000000..a7a1818e --- /dev/null +++ b/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetRepository.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Entities; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.EquipmentSets +{ + public class EquipmentSetRepository + { + private readonly ConnectionSettings _connection; + + public EquipmentSetRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task> LoadAllSetsAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT set_id, name FROM equipment_sets ORDER BY name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + result.Add(new EquipmentSetRow + { + SetId = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + }); + return result; + } + + public async Task> LoadMembersAsync(int setId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT m.definition, ISNULL(e.definitionname, '') " + + "FROM equipment_set_members m " + + "LEFT JOIN entitydefaults e ON e.definition = m.definition " + + "WHERE m.set_id = @setId " + + "ORDER BY e.definitionname"; + cmd.Parameters.AddWithValue("@setId", setId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + result.Add(new EquipmentSetMemberRow + { + SetId = setId, + Definition = reader.GetInt32(0), + DefinitionName = reader.GetString(1), + }); + return result; + } + + public async Task> LoadThresholdsAsync(int setId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT required_pieces, aggregate_field, bonus_value " + + "FROM equipment_set_bonus_thresholds " + + "WHERE set_id = @setId " + + "ORDER BY required_pieces"; + cmd.Parameters.AddWithValue("@setId", setId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + result.Add(new EquipmentSetThresholdRow + { + SetId = setId, + RequiredPieces = reader.GetInt32(0), + AggregateFieldId = reader.GetInt32(1), + BonusValue = reader.GetDouble(2), + }); + return result; + } + + public async Task> LoadAggregateFieldsAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT id, name, formula, measurementunit, measurementmultiplier, " + + "measurementoffset, category, digits FROM aggregatefields"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + result.Add(new AggregateFieldInfo + { + Id = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + Formula = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + MeasurementUnit = reader.IsDBNull(3) ? "" : reader.GetString(3), + MeasurementMultiplier = reader.IsDBNull(4) ? 0d : reader.GetDouble(4), + MeasurementOffset = reader.IsDBNull(5) ? 0d : reader.GetDouble(5), + Category = reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + Digits = reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + }); + return result; + } + } +} diff --git a/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetRow.cs b/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetRow.cs new file mode 100644 index 00000000..278367ea --- /dev/null +++ b/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetRow.cs @@ -0,0 +1,12 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.EquipmentSets +{ + public partial class EquipmentSetRow : ObservableObject + { + public int SetId { get; set; } + [ObservableProperty] private string _name = ""; + + public bool IsNew => SetId == 0; + } +} diff --git a/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetThresholdRow.cs b/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetThresholdRow.cs new file mode 100644 index 00000000..385e4a48 --- /dev/null +++ b/src/Perpetuum.AdminTool/EquipmentSets/EquipmentSetThresholdRow.cs @@ -0,0 +1,16 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.EquipmentSets +{ + public partial class EquipmentSetThresholdRow : ObservableObject + { + public int SetId { get; init; } + public int OriginalRequiredPieces { get; set; } = -1; + + [ObservableProperty] private int _requiredPieces; + [ObservableProperty] private int _aggregateFieldId; + [ObservableProperty] private string _fieldSystemName = ""; + [ObservableProperty] private string _fieldDisplay = ""; + [ObservableProperty] private double _bonusValue; + } +} diff --git a/src/Perpetuum.AdminTool/EquipmentSets/SetMemberPickItem.cs b/src/Perpetuum.AdminTool/EquipmentSets/SetMemberPickItem.cs new file mode 100644 index 00000000..c2b043f3 --- /dev/null +++ b/src/Perpetuum.AdminTool/EquipmentSets/SetMemberPickItem.cs @@ -0,0 +1,13 @@ +namespace Perpetuum.AdminTool.EquipmentSets +{ + public class SetMemberPickItem + { + public int Definition { get; init; } + public string DefinitionName { get; init; } = ""; + public string TranslatedName { get; init; } = ""; + + public string Display => string.IsNullOrEmpty(TranslatedName) + ? $"{Definition} — {DefinitionName}" + : $"{Definition} — {DefinitionName} ({TranslatedName})"; + } +} diff --git a/src/Perpetuum.AdminTool/Export/ExportScriptViewModel.cs b/src/Perpetuum.AdminTool/Export/ExportScriptViewModel.cs new file mode 100644 index 00000000..c076bc2d --- /dev/null +++ b/src/Perpetuum.AdminTool/Export/ExportScriptViewModel.cs @@ -0,0 +1,39 @@ +using System.IO; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Win32; +using Perpetuum.AdminTool.Editing; + +namespace Perpetuum.AdminTool.Export +{ + public partial class ExportScriptViewModel : ObservableObject + { + public string Title { get; } + public string Script { get; } + + public ExportScriptViewModel(string title, string script) + { + Title = title; + Script = script; + } + + [RelayCommand] + private void CopyToClipboard() => + Clipboard.SetText(Script); + + [RelayCommand] + private void SaveAs() + { + var dlg = new SaveFileDialog + { + Filter = "SQL scripts (*.sql)|*.sql|All files (*.*)|*.*", + DefaultExt = ".sql", + FileName = SqlScriptBuilder.BuildFileName("export", Title), + InitialDirectory = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop) + }; + if (dlg.ShowDialog() != true) return; + File.WriteAllText(dlg.FileName, Script, System.Text.Encoding.UTF8); + } + } +} diff --git a/src/Perpetuum.AdminTool/Export/ItemExporter.cs b/src/Perpetuum.AdminTool/Export/ItemExporter.cs new file mode 100644 index 00000000..27665185 --- /dev/null +++ b/src/Perpetuum.AdminTool/Export/ItemExporter.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Editing; + +namespace Perpetuum.AdminTool.Export +{ + internal static class ItemExporter + { + internal static async Task> ExportAsync(int definitionId, SqlConnection cn) + { + var changes = new List(); + string? defName = await GetDefinitionNameAsync(definitionId, cn); + if (defName == null) return changes; + + string v = $"@def_{definitionId}"; + + changes.Add(new RawSqlChange( + $"declare id variable for '{defName}'", + SqlExportBuilder.DeclareIdVar(v, defName))); + + await AddEntityDefaultsMergeAsync(changes, definitionId, defName, v, cn); + await AddAggregateValuesAsync(changes, definitionId, defName, v, cn); + await AddComponentsAsync(changes, definitionId, defName, v, cn); + await AddItemResearchLevelsAsync(changes, definitionId, defName, v, cn); + await AddTechTreeAsync(changes, definitionId, defName, v, cn); + await AddTechTreeNodePricesAsync(changes, definitionId, defName, v, cn); + await AddPrototypesAsync(changes, definitionId, defName, v, cn); + await AddEnablerExtensionsAsync(changes, definitionId, defName, v, cn); + await AddBeamAssignmentAsync(changes, definitionId, defName, v, cn); + await AddDefinitionConfigAsync(changes, definitionId, defName, v, cn); + + return changes; + } + + internal static string VarName(int definitionId) => $"@def_{definitionId}"; + + private static async Task GetDefinitionNameAsync(int definitionId, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT definitionname FROM entitydefaults WHERE definition = @id"; + cmd.Parameters.AddWithValue("@id", definitionId); + var r = await cmd.ExecuteScalarAsync(); + return r == null || r == DBNull.Value ? null : (string)r; + } + + private static async Task AddEntityDefaultsMergeAsync( + List changes, int definitionId, string defName, string v, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT descriptiontoken, categoryflags, attributeflags, mass, volume, health, quantity, " + + "hidden, purchasable, enabled, tiertype, tierlevel, options " + + "FROM entitydefaults WHERE definition = @id"; + cmd.Parameters.AddWithValue("@id", definitionId); + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return; + + var desc = r.IsDBNull(0) ? "NULL" : SqlLiteral.Of(r.GetString(0)); + var catF = r.IsDBNull(1) ? "0" : r.GetInt64(1).ToString(); + var attF = r.IsDBNull(2) ? "0" : r.GetInt64(2).ToString(); + var mass = r.IsDBNull(3) ? "NULL" : r.GetDouble(3).ToString("R", CultureInfo.InvariantCulture); + var vol = r.IsDBNull(4) ? "NULL" : r.GetDouble(4).ToString("R", CultureInfo.InvariantCulture); + var health = r.IsDBNull(5) ? "NULL" : r.GetDouble(5).ToString("R", CultureInfo.InvariantCulture); + var qty = r.IsDBNull(6) ? "1" : r.GetInt32(6).ToString(); + var hidden = r.IsDBNull(7) ? "0" : (r.GetBoolean(7) ? "1" : "0"); + var purch = r.IsDBNull(8) ? "0" : (r.GetBoolean(8) ? "1" : "0"); + var enabled = r.IsDBNull(9) || r.GetBoolean(9) ? "1" : "0"; + var ttType = r.IsDBNull(10) ? "NULL" : r.GetInt32(10).ToString(); + var ttLvl = r.IsDBNull(11) ? "NULL" : r.GetInt32(11).ToString(); + var opts = r.IsDBNull(12) ? "NULL" : SqlLiteral.Of(r.GetString(12)); + + var cols = "definitionname, descriptiontoken, categoryflags, attributeflags, mass, volume, health, quantity, hidden, purchasable, enabled, tiertype, tierlevel, options"; + var vals = $"{SqlLiteral.Of(defName)}, {desc}, {catF}, {attF}, {mass}, {vol}, {health}, {qty}, {hidden}, {purch}, {enabled}, {ttType}, {ttLvl}, {opts}"; + var update = $"descriptiontoken={desc}, categoryflags={catF}, attributeflags={attF}, mass={mass}, volume={vol}, health={health}, quantity={qty}, hidden={hidden}, purchasable={purch}, enabled={enabled}, tiertype={ttType}, tierlevel={ttLvl}, options={opts}"; + + var sql = + $"MERGE entitydefaults AS target\n" + + $"USING (VALUES ({vals})) AS src({cols})\n" + + $"ON target.definitionname = src.definitionname\n" + + $"WHEN MATCHED THEN UPDATE SET {update}\n" + + $"WHEN NOT MATCHED THEN INSERT ({cols}) VALUES ({vals})"; + changes.Add(new RawSqlChange($"entitydefaults: merge '{defName}'", sql)); + } + + private static async Task AddAggregateValuesAsync( + List changes, int definitionId, string defName, string v, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT af.name, av.value FROM aggregatevalues av " + + "JOIN aggregatefields af ON af.id = av.field " + + "WHERE av.definition = @id ORDER BY af.name"; + cmd.Parameters.AddWithValue("@id", definitionId); + + var rows = new List<(string Field, double Value)>(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + rows.Add((r.GetString(0), r.IsDBNull(1) ? 0d : r.GetDouble(1))); + if (rows.Count == 0) return; + + var sb = new StringBuilder(); + sb.AppendLine($"DELETE FROM aggregatevalues WHERE definition = {v};"); + foreach (var (field, val) in rows) + sb.AppendLine( + $"INSERT INTO aggregatevalues (definition, field, value) VALUES ({v}, " + + $"(SELECT id FROM aggregatefields WHERE name = {SqlLiteral.Of(field)}), " + + $"{val.ToString("R", CultureInfo.InvariantCulture)});"); + changes.Add(new RawSqlChange($"aggregatevalues: {rows.Count} stat(s) for '{defName}'", sb.ToString().TrimEnd())); + } + + private static async Task AddComponentsAsync( + List changes, int definitionId, string defName, string v, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT e.definitionname, c.componentamount FROM components c " + + "JOIN entitydefaults e ON e.definition = c.componentdefinition " + + "WHERE c.definition = @id ORDER BY e.definitionname"; + cmd.Parameters.AddWithValue("@id", definitionId); + + var rows = new List<(string CompName, int Amount)>(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) rows.Add((r.GetString(0), r.GetInt32(1))); + if (rows.Count == 0) return; + + var sb = new StringBuilder(); + foreach (var (comp, amount) in rows) + { + var compVar = $"(SELECT definition FROM entitydefaults WHERE definitionname = {SqlLiteral.Of(comp)})"; + sb.AppendLine( + $"MERGE components AS target " + + $"USING (SELECT {v} AS definition, {compVar} AS componentdefinition) AS src " + + $"ON target.definition = src.definition AND target.componentdefinition = src.componentdefinition " + + $"WHEN MATCHED THEN UPDATE SET componentamount = {amount} " + + $"WHEN NOT MATCHED THEN INSERT (definition, componentdefinition, componentamount) " + + $"VALUES (src.definition, src.componentdefinition, {amount});"); + } + changes.Add(new RawSqlChange($"components: {rows.Count} recipe component(s) for '{defName}'", sb.ToString().TrimEnd())); + } + + private static async Task AddItemResearchLevelsAsync( + List changes, int definitionId, string defName, string v, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT irl.researchlevel, e.definitionname, irl.enabled " + + "FROM itemresearchlevels irl " + + "LEFT JOIN entitydefaults e ON e.definition = irl.calibrationprogram " + + "WHERE irl.definition = @id"; + cmd.Parameters.AddWithValue("@id", definitionId); + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return; // definition has a unique index — at most one row + + var level = r.GetInt32(0).ToString(); + var calProg = r.IsDBNull(1) ? "NULL" : $"(SELECT definition FROM entitydefaults WHERE definitionname = {SqlLiteral.Of(r.GetString(1))})"; + var enabled = r.IsDBNull(2) || r.GetBoolean(2) ? "1" : "0"; + + var sql = + $"DELETE FROM itemresearchlevels WHERE definition = {v};\n" + + $"INSERT INTO itemresearchlevels (definition, researchlevel, calibrationprogram, enabled) " + + $"VALUES ({v}, {level}, {calProg}, {enabled})"; + changes.Add(new RawSqlChange($"itemresearchlevels: '{defName}'", sql)); + } + + private static async Task AddTechTreeAsync( + List changes, int definitionId, string defName, string v, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT ep.definitionname, t.groupID, t.x, t.y, ex.extensionname " + + "FROM techtree t " + + "LEFT JOIN entitydefaults ep ON ep.definition = t.parentdefinition " + + "LEFT JOIN extensions ex ON ex.extensionid = t.enablerextensionid " + + "WHERE t.childdefinition = @id"; + cmd.Parameters.AddWithValue("@id", definitionId); + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return; + + var parentVar = r.IsDBNull(0) ? "NULL" : $"(SELECT definition FROM entitydefaults WHERE definitionname = {SqlLiteral.Of(r.GetString(0))})"; + var groupId = r.GetInt32(1).ToString(); + var x = r.GetInt32(2).ToString(); + var y = r.GetInt32(3).ToString(); + var enablerExt = r.IsDBNull(4) ? "NULL" : $"(SELECT extensionid FROM extensions WHERE extensionname = {SqlLiteral.Of(r.GetString(4))})"; + + var sql = + $"MERGE techtree AS target " + + $"USING (SELECT {parentVar} AS parentdefinition, {v} AS childdefinition) AS src " + + $"ON target.parentdefinition = src.parentdefinition AND target.childdefinition = src.childdefinition " + + $"WHEN MATCHED THEN UPDATE SET groupID = {groupId}, x = {x}, y = {y}, enablerextensionid = {enablerExt} " + + $"WHEN NOT MATCHED THEN INSERT (parentdefinition, childdefinition, groupID, x, y, enablerextensionid) " + + $"VALUES (src.parentdefinition, src.childdefinition, {groupId}, {x}, {y}, {enablerExt})"; + changes.Add(new RawSqlChange($"techtree: node for '{defName}'", sql)); + } + + private static async Task AddTechTreeNodePricesAsync( + List changes, int definitionId, string defName, string v, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT pointtype, amount FROM techtreenodeprices WHERE definition = @id"; + cmd.Parameters.AddWithValue("@id", definitionId); + + var rows = new List<(int PointType, int Amount)>(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) rows.Add((r.GetInt32(0), r.GetInt32(1))); + if (rows.Count == 0) return; + + var sb = new StringBuilder(); + foreach (var (pt, amount) in rows) + sb.AppendLine(SqlExportBuilder.IfNotExistsInsert( + "techtreenodeprices", + $"definition = {v} AND pointtype = {pt}", + "definition, pointtype, amount", + $"{v}, {pt}, {amount}")); + changes.Add(new RawSqlChange($"techtreenodeprices: {rows.Count} price(s) for '{defName}'", sb.ToString().TrimEnd())); + } + + private static async Task AddPrototypesAsync( + List changes, int definitionId, string defName, string v, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT e.definitionname FROM prototypes p " + + "JOIN entitydefaults e ON e.definition = p.prototype " + + "WHERE p.definition = @id"; + cmd.Parameters.AddWithValue("@id", definitionId); + + var rows = new List(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) rows.Add(r.GetString(0)); + if (rows.Count == 0) return; + + var sb = new StringBuilder(); + foreach (var prototypeName in rows) + { + var protoVar = $"(SELECT definition FROM entitydefaults WHERE definitionname = {SqlLiteral.Of(prototypeName)})"; + sb.AppendLine( + $"MERGE prototypes AS target " + + $"USING (SELECT {v} AS definition, {protoVar} AS prototype) AS src " + + $"ON target.definition = src.definition AND target.prototype = src.prototype " + + $"WHEN NOT MATCHED THEN INSERT (definition, prototype) VALUES (src.definition, src.prototype);"); + } + changes.Add(new RawSqlChange($"prototypes: {rows.Count} link(s) for '{defName}'", sb.ToString().TrimEnd())); + } + + private static async Task AddEnablerExtensionsAsync( + List changes, int definitionId, string defName, string v, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT ex.extensionname, ee.extensionlevel FROM enablerextensions ee " + + "JOIN extensions ex ON ex.extensionid = ee.extensionid " + + "WHERE ee.definition = @id ORDER BY ex.extensionname"; + cmd.Parameters.AddWithValue("@id", definitionId); + + var rows = new List<(string ExtName, int Level)>(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) rows.Add((r.GetString(0), r.GetInt32(1))); + if (rows.Count == 0) return; + + var sb = new StringBuilder(); + sb.AppendLine($"DELETE FROM enablerextensions WHERE definition = {v};"); + foreach (var (extName, level) in rows) + sb.AppendLine( + $"INSERT INTO enablerextensions (definition, extensionid, extensionlevel) " + + $"VALUES ({v}, (SELECT extensionid FROM extensions WHERE extensionname = {SqlLiteral.Of(extName)}), {level});"); + changes.Add(new RawSqlChange($"enablerextensions: {rows.Count} skill req(s) for '{defName}'", sb.ToString().TrimEnd())); + } + + private static async Task AddBeamAssignmentAsync( + List changes, int definitionId, string defName, string v, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT beam FROM beamassignment WHERE definition = @id"; + cmd.Parameters.AddWithValue("@id", definitionId); + var result = await cmd.ExecuteScalarAsync(); + if (result == null || result == DBNull.Value) return; + + int beamId = (int)result; + var sql = + $"DELETE FROM beamassignment WHERE definition = {v};\n" + + $"INSERT INTO beamassignment (definition, beam) VALUES ({v}, {beamId})"; + changes.Add(new RawSqlChange($"beamassignment: beam {beamId} for '{defName}'", sql)); + } + + private static async Task AddDefinitionConfigAsync( + List changes, int definitionId, string defName, string v, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + // Read all non-id columns. targetdefinition is resolved to definitionname via join. + cmd.CommandText = + "SELECT e.definitionname, dc.summonerscount, dc.npcpresenceid, dc.item_work_range, dc.explosion_radius, " + + "dc.cycle_time, dc.damage_chemical, dc.damage_explosive, dc.damage_kinetic, dc.damage_thermal, dc.lifetime, " + + "dc.activationtime, dc.waves, dc.missionrelated, dc.constructionradius, dc.action_delay, dc.deploy_radius, " + + "dc.transmitradius, dc.constructionlevelmax, dc.blockingradius, dc.chargeamount, dc.inconnections, dc.outconnections, " + + "dc.coretransferred, dc.transferefficiency, dc.productionupgradeamount, dc.productionlevel, dc.coreconsumption, " + + "dc.effectid, dc.corecalories, dc.corekickstartthreshold, dc.reinforcecountermax, dc.bandwidthusage, dc.bandwidthcapacity, " + + "dc.emitradius, dc.tint, dc.typeexclusiverange, dc.network_node_range, dc.hitsize, dc.note, dc.damage_toxic " + + "FROM definitionconfig dc " + + "LEFT JOIN entitydefaults e ON e.definition = dc.targetdefinition " + + "WHERE dc.definition = @id"; + cmd.Parameters.AddWithValue("@id", definitionId); + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return; + + string N(int i) => r.IsDBNull(i) ? "NULL" : r.GetInt32(i).ToString(); + string F(int i) => r.IsDBNull(i) ? "NULL" : r.GetDouble(i).ToString("R", CultureInfo.InvariantCulture); + string B(int i) => r.IsDBNull(i) ? "NULL" : (r.GetBoolean(i) ? "1" : "0"); + string S(int i) => r.IsDBNull(i) ? "NULL" : SqlLiteral.Of(r.GetString(i)); + + // Column 0 is the resolved targetdefinition name + var targetDef = r.IsDBNull(0) ? "NULL" : $"(SELECT definition FROM entitydefaults WHERE definitionname = {SqlLiteral.Of(r.GetString(0))})"; + + var cols = + "definition, targetdefinition, summonerscount, npcpresenceid, item_work_range, explosion_radius, " + + "cycle_time, damage_chemical, damage_explosive, damage_kinetic, damage_thermal, lifetime, " + + "activationtime, waves, missionrelated, constructionradius, action_delay, deploy_radius, " + + "transmitradius, constructionlevelmax, blockingradius, chargeamount, inconnections, outconnections, " + + "coretransferred, transferefficiency, productionupgradeamount, productionlevel, coreconsumption, " + + "effectid, corecalories, corekickstartthreshold, reinforcecountermax, bandwidthusage, bandwidthcapacity, " + + "emitradius, tint, typeexclusiverange, network_node_range, hitsize, note, damage_toxic"; + + var vals = + $"{v}, {targetDef}, {N(1)}, {N(2)}, {F(3)}, {F(4)}, " + + $"{N(5)}, {F(6)}, {F(7)}, {F(8)}, {F(9)}, {N(10)}, " + + $"{N(11)}, {N(12)}, {B(13)}, {N(14)}, {N(15)}, {N(16)}, " + + $"{N(17)}, {N(18)}, {N(19)}, {N(20)}, {N(21)}, {N(22)}, " + + $"{F(23)}, {F(24)}, {N(25)}, {N(26)}, {F(27)}, " + + $"{N(28)}, {F(29)}, {F(30)}, {N(31)}, {N(32)}, {N(33)}, " + + $"{N(34)}, {S(35)}, {N(36)}, {N(37)}, {F(38)}, {S(39)}, {F(40)}"; + + var sql = + $"DELETE FROM definitionconfig WHERE definition = {v};\n" + + $"INSERT INTO definitionconfig ({cols}) VALUES ({vals})"; + changes.Add(new RawSqlChange($"definitionconfig: '{defName}'", sql)); + } + } +} diff --git a/src/Perpetuum.AdminTool/Export/RobotExporter.cs b/src/Perpetuum.AdminTool/Export/RobotExporter.cs new file mode 100644 index 00000000..9023e489 --- /dev/null +++ b/src/Perpetuum.AdminTool/Export/RobotExporter.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Settings; +using Perpetuum.GenXY; + +namespace Perpetuum.AdminTool.Export +{ + internal static class RobotExporter + { + internal static async Task ExportAsync(int templateId, ConnectionSettings conn) + { + var changes = new List(); + await using var cn = new SqlConnection(conn.BuildConnectionString()); + await cn.OpenAsync(); + + var (name, description, note) = await LoadTemplateAsync(templateId, cn); + if (name == null) return string.Empty; + + var partIds = ParsePartIds(description); + + // Export each unique part definition (parts before template — FK order) + var exported = new HashSet(); + var failedExports = new HashSet(); + foreach (var defId in partIds.Values) + { + if (defId <= 0 || !exported.Add(defId)) continue; + var itemChanges = await ItemExporter.ExportAsync(defId, cn); + if (itemChanges.Count == 0) { failedExports.Add(defId); exported.Remove(defId); continue; } + changes.AddRange(itemChanges); + } + + // chassisbonus (DELETE+INSERT per exported part) + foreach (var defId in exported) + await AddChassisBonusAsync(changes, defId, cn); + + // robottemplates MERGE with dynamically-built description + await AddRobotTemplateMergeAsync(changes, name, note, partIds, failedExports); + + return SqlScriptBuilder.Build(changes); + } + + private static async Task<(string? Name, string Description, string? Note)> LoadTemplateAsync( + int templateId, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT name, description, note FROM robottemplates WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", templateId); + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return (null, "", null); + return ( + r.IsDBNull(0) ? null : r.GetString(0), + r.IsDBNull(1) ? "" : r.GetString(1), + r.IsDBNull(2) ? null : r.GetString(2)); + } + + private static Dictionary ParsePartIds(string genxy) + { + var result = new Dictionary(StringComparer.Ordinal); + if (string.IsNullOrEmpty(genxy)) return result; + try + { + var dict = GenxyConverter.Deserialize(genxy); + foreach (var key in new[] { "robot", "head", "chassis", "leg", "container" }) + if (dict.TryGetValue(key, out var val)) + result[key] = val is int i ? i : val is long l ? (int)l : 0; + } + catch { /* malformed description — skip */ } + return result; + } + + private static async Task AddChassisBonusAsync( + List changes, int definitionId, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT ex.extensionname, cb.bonus, cb.note, cb.targetpropertyID, cb.effectenhancer " + + "FROM chassisbonus cb " + + "JOIN extensions ex ON ex.extensionid = cb.extension " + + "WHERE cb.definition = @id ORDER BY ex.extensionname, cb.targetpropertyID"; + cmd.Parameters.AddWithValue("@id", definitionId); + + var rows = new List<(string ExtName, double Bonus, string? Note, int TargetPropId, bool Enhancer)>(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + rows.Add(( + r.GetString(0), + r.GetDouble(1), + r.IsDBNull(2) ? null : r.GetString(2), + r.GetInt32(3), + !r.IsDBNull(4) && r.GetBoolean(4))); + if (rows.Count == 0) return; + + var v = ItemExporter.VarName(definitionId); + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"DELETE FROM chassisbonus WHERE definition = {v};"); + foreach (var (extName, bonus, rowNote, tpId, enhancer) in rows) + sb.AppendLine( + $"INSERT INTO chassisbonus (definition, extension, bonus, note, targetpropertyID, effectenhancer) " + + $"VALUES ({v}, (SELECT extensionid FROM extensions WHERE extensionname = {SqlLiteral.Of(extName)}), " + + $"{bonus.ToString("R", System.Globalization.CultureInfo.InvariantCulture)}, " + + $"{(rowNote == null ? "NULL" : SqlLiteral.Of(rowNote))}, {tpId}, {(enhancer ? 1 : 0)});"); + changes.Add(new RawSqlChange($"chassisbonus: {rows.Count} bonus(es) for definition {definitionId}", sb.ToString().TrimEnd())); + } + + private static Task AddRobotTemplateMergeAsync( + List changes, string name, string? note, Dictionary partIds, + HashSet failedExports) + { + // Build the description as a T-SQL NVARCHAR expression using the @def_* variables + // already declared by ItemExporter above. The result is correct even when target-DB + // IDENTITY IDs differ from the source. + string PartRef(string key) + { + if (!partIds.TryGetValue(key, out var defId) || defId <= 0 || failedExports.Contains(defId)) + return "0"; + return $"CAST({ItemExporter.VarName(defId)} AS NVARCHAR(20))"; + } + + var descExpr = + $"N'#robot=' + {PartRef("robot")} + " + + $"N'#head=' + {PartRef("head")} + " + + $"N'#chassis=' + {PartRef("chassis")} + " + + $"N'#leg=' + {PartRef("leg")} + " + + $"N'#container=' + {PartRef("container")}"; + + var noteLiteral = note == null ? "NULL" : SqlLiteral.Of(note); + var nameLiteral = SqlLiteral.Of(name); + + // One export per script — @tmpl_desc is unique within the batch. + var sql = + $"DECLARE @tmpl_desc NVARCHAR(MAX) = {descExpr};\n" + + $"MERGE robottemplates AS target\n" + + $"USING (SELECT {nameLiteral} AS name) AS src\n" + + $"ON target.name = src.name\n" + + $"WHEN MATCHED THEN UPDATE SET description = @tmpl_desc, note = {noteLiteral}\n" + + $"WHEN NOT MATCHED THEN INSERT (name, description, note) " + + $"VALUES (src.name, @tmpl_desc, {noteLiteral})"; + changes.Add(new RawSqlChange($"robottemplates: merge '{name}'", sql)); + return Task.CompletedTask; + } + } +} diff --git a/src/Perpetuum.AdminTool/Export/SeasonExporter.cs b/src/Perpetuum.AdminTool/Export/SeasonExporter.cs new file mode 100644 index 00000000..05630894 --- /dev/null +++ b/src/Perpetuum.AdminTool/Export/SeasonExporter.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Export +{ + internal static class SeasonExporter + { + internal static async Task ExportAsync(int seasonId, ConnectionSettings conn) + { + var changes = new List(); + await using var cn = new SqlConnection(conn.BuildConnectionString()); + await cn.OpenAsync(); + + var (seasonName, seasonDesc) = await LoadSeasonHeaderAsync(seasonId, cn); + if (string.IsNullOrEmpty(seasonName)) return string.Empty; + var seasonVar = $"(SELECT id FROM seasons WHERE name = {SqlLiteral.Of(seasonName)})"; + + var (packageIds, setIds) = await CollectRewardRefsAsync(seasonId, cn); + var defIds = new HashSet(); + await CollectPackageItemDefsAsync(packageIds, defIds, cn); + await CollectSetMemberDefsAsync(setIds, defIds, cn); + + // Reward item definitions first (packageitems and set_members FK to entitydefaults) + foreach (var defId in defIds) + { + var itemChanges = await ItemExporter.ExportAsync(defId, cn); + changes.AddRange(itemChanges); + } + + // Prerequisite data after item definitions exist + await AddPackagesMergeAsync(changes, packageIds, cn); + await AddPackageItemsMergeAsync(changes, packageIds, cn); + await AddEquipmentSetsMergeAsync(changes, setIds, cn); + await AddEquipmentSetMembersAsync(changes, setIds, cn); + await AddEquipmentSetThresholdsAsync(changes, setIds, cn); + + // Season and its child tables + await AddSeasonMergeAsync(changes, seasonId, seasonName, seasonDesc, cn); + await AddActivityRatesAsync(changes, seasonId, seasonVar, cn); + await AddObjectivesAsync(changes, seasonId, seasonVar, cn); + await AddTiersAsync(changes, seasonId, seasonVar, cn); + await AddLeaderboardRewardsAsync(changes, seasonId, seasonVar, cn); + + return SqlScriptBuilder.Build(changes); + } + + private static async Task<(string Name, string Description)> LoadSeasonHeaderAsync(int seasonId, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT name, description FROM seasons WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", seasonId); + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return ("", ""); + return (r.IsDBNull(0) ? "" : r.GetString(0), r.IsDBNull(1) ? "" : r.GetString(1)); + } + + private static async Task<(HashSet PackageIds, HashSet SetIds)> CollectRewardRefsAsync( + int seasonId, SqlConnection cn) + { + var packageIds = new HashSet(); + var setIds = new HashSet(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT package_id, equipment_set_id FROM season_objectives WHERE season_id = @id " + + " AND (package_id IS NOT NULL OR equipment_set_id IS NOT NULL) " + + "UNION ALL " + + "SELECT package_id, equipment_set_id FROM season_tiers WHERE season_id = @id " + + " AND (package_id IS NOT NULL OR equipment_set_id IS NOT NULL) " + + "UNION ALL " + + "SELECT package_id, equipment_set_id FROM season_leaderboard_rewards WHERE season_id = @id " + + " AND (package_id IS NOT NULL OR equipment_set_id IS NOT NULL)"; + cmd.Parameters.AddWithValue("@id", seasonId); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + if (!r.IsDBNull(0)) packageIds.Add(r.GetInt32(0)); + if (!r.IsDBNull(1)) setIds.Add(r.GetInt32(1)); + } + return (packageIds, setIds); + } + + private static async Task CollectPackageItemDefsAsync( + HashSet packageIds, HashSet defIds, SqlConnection cn) + { + if (packageIds.Count == 0) return; + var list = string.Join(",", packageIds); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = $"SELECT DISTINCT definition FROM packageitems WHERE packageid IN ({list})"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) defIds.Add(r.GetInt32(0)); + } + + private static async Task CollectSetMemberDefsAsync( + HashSet setIds, HashSet defIds, SqlConnection cn) + { + if (setIds.Count == 0) return; + var list = string.Join(",", setIds); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = $"SELECT DISTINCT definition FROM equipment_set_members WHERE set_id IN ({list})"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) defIds.Add(r.GetInt32(0)); + } + + private static async Task AddPackagesMergeAsync( + List changes, HashSet packageIds, SqlConnection cn) + { + if (packageIds.Count == 0) return; + var list = string.Join(",", packageIds); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = $"SELECT id, name FROM packages WHERE id IN ({list}) ORDER BY id"; + await using var r = await cmd.ExecuteReaderAsync(); + var sb = new StringBuilder(); + while (await r.ReadAsync()) + { + var name = r.IsDBNull(1) ? "" : r.GetString(1); + sb.AppendLine( + $"MERGE packages AS target " + + $"USING (SELECT {SqlLiteral.Of(name)} AS name) AS src " + + $"ON target.name = src.name " + + $"WHEN NOT MATCHED THEN INSERT (name) VALUES (src.name);"); + } + if (sb.Length > 0) + changes.Add(new RawSqlChange("packages: merge reward packages", sb.ToString().TrimEnd())); + } + + private static async Task AddPackageItemsMergeAsync( + List changes, HashSet packageIds, SqlConnection cn) + { + if (packageIds.Count == 0) return; + var list = string.Join(",", packageIds); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + $"SELECT p.name, e.definitionname, pi.quantity " + + $"FROM packageitems pi " + + $"JOIN packages p ON p.id = pi.packageid " + + $"JOIN entitydefaults e ON e.definition = pi.definition " + + $"WHERE pi.packageid IN ({list}) ORDER BY p.name, e.definitionname"; + await using var r = await cmd.ExecuteReaderAsync(); + var sb = new StringBuilder(); + while (await r.ReadAsync()) + { + var pkgName = r.GetString(0); + var defName = r.GetString(1); + var quantity = r.GetInt32(2); + sb.AppendLine( + $"MERGE packageitems AS target " + + $"USING (SELECT (SELECT id FROM packages WHERE name = {SqlLiteral.Of(pkgName)}) AS packageid, " + + $"(SELECT definition FROM entitydefaults WHERE definitionname = {SqlLiteral.Of(defName)}) AS definition) AS src " + + $"ON target.packageid = src.packageid AND target.definition = src.definition " + + $"WHEN MATCHED THEN UPDATE SET quantity = {quantity} " + + $"WHEN NOT MATCHED THEN INSERT (packageid, definition, quantity) " + + $"VALUES (src.packageid, src.definition, {quantity});"); + } + if (sb.Length > 0) + changes.Add(new RawSqlChange("packageitems: merge reward package items", sb.ToString().TrimEnd())); + } + + private static async Task AddEquipmentSetsMergeAsync( + List changes, HashSet setIds, SqlConnection cn) + { + if (setIds.Count == 0) return; + var list = string.Join(",", setIds); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = $"SELECT set_id, name FROM equipment_sets WHERE set_id IN ({list}) ORDER BY set_id"; + await using var r = await cmd.ExecuteReaderAsync(); + var sb = new StringBuilder(); + while (await r.ReadAsync()) + { + var name = r.IsDBNull(1) ? "" : r.GetString(1); + sb.AppendLine( + $"MERGE equipment_sets AS target " + + $"USING (SELECT {SqlLiteral.Of(name)} AS name) AS src " + + $"ON target.name = src.name " + + $"WHEN NOT MATCHED THEN INSERT (name) VALUES (src.name);"); + } + if (sb.Length > 0) + changes.Add(new RawSqlChange("equipment_sets: merge reward sets", sb.ToString().TrimEnd())); + } + + private static async Task AddEquipmentSetMembersAsync( + List changes, HashSet setIds, SqlConnection cn) + { + if (setIds.Count == 0) return; + var list = string.Join(",", setIds); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + $"SELECT es.name, e.definitionname " + + $"FROM equipment_set_members m " + + $"JOIN equipment_sets es ON es.set_id = m.set_id " + + $"JOIN entitydefaults e ON e.definition = m.definition " + + $"WHERE m.set_id IN ({list}) ORDER BY es.name, e.definitionname"; + await using var r = await cmd.ExecuteReaderAsync(); + var sb = new StringBuilder(); + while (await r.ReadAsync()) + { + var setName = r.GetString(0); + var defName = r.GetString(1); + sb.AppendLine( + $"MERGE equipment_set_members AS target " + + $"USING (SELECT (SELECT set_id FROM equipment_sets WHERE name = {SqlLiteral.Of(setName)}) AS set_id, " + + $"(SELECT definition FROM entitydefaults WHERE definitionname = {SqlLiteral.Of(defName)}) AS definition) AS src " + + $"ON target.set_id = src.set_id AND target.definition = src.definition " + + $"WHEN NOT MATCHED THEN INSERT (set_id, definition) VALUES (src.set_id, src.definition);"); + } + if (sb.Length > 0) + changes.Add(new RawSqlChange("equipment_set_members: merge set assignments", sb.ToString().TrimEnd())); + } + + private static async Task AddEquipmentSetThresholdsAsync( + List changes, HashSet setIds, SqlConnection cn) + { + if (setIds.Count == 0) return; + var list = string.Join(",", setIds); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + $"SELECT es.name, t.required_pieces, af.name AS field_name, t.bonus_value " + + $"FROM equipment_set_bonus_thresholds t " + + $"JOIN equipment_sets es ON es.set_id = t.set_id " + + $"JOIN aggregatefields af ON af.id = t.aggregate_field " + + $"WHERE t.set_id IN ({list}) ORDER BY es.name, t.required_pieces"; + await using var r = await cmd.ExecuteReaderAsync(); + var sb = new StringBuilder(); + while (await r.ReadAsync()) + { + var setName = r.GetString(0); + var reqPieces = r.GetInt32(1).ToString(); + var fieldName = r.GetString(2); + var bonusVal = r.GetDouble(3).ToString("R", CultureInfo.InvariantCulture); + var setVar = $"(SELECT set_id FROM equipment_sets WHERE name = {SqlLiteral.Of(setName)})"; + var fieldVar = $"(SELECT id FROM aggregatefields WHERE name = {SqlLiteral.Of(fieldName)})"; + sb.AppendLine( + $"MERGE equipment_set_bonus_thresholds AS target " + + $"USING (SELECT {setVar} AS set_id, {reqPieces} AS required_pieces) AS src " + + $"ON target.set_id = src.set_id AND target.required_pieces = src.required_pieces " + + $"WHEN MATCHED THEN UPDATE SET aggregate_field = {fieldVar}, bonus_value = {bonusVal} " + + $"WHEN NOT MATCHED THEN INSERT (set_id, required_pieces, aggregate_field, bonus_value) " + + $"VALUES (src.set_id, src.required_pieces, {fieldVar}, {bonusVal});"); + } + if (sb.Length > 0) + changes.Add(new RawSqlChange("equipment_set_bonus_thresholds: merge set thresholds", sb.ToString().TrimEnd())); + } + + private static async Task AddSeasonMergeAsync( + List changes, int seasonId, string seasonName, string seasonDesc, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT start_time, end_time, is_active, is_recurring, recurrence_gap_days, " + + "recurrence_iteration, recurrence_base_name, scoring_mode, daily_objectives_per_day " + + "FROM seasons WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", seasonId); + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return; + + var start = $"'{r.GetDateTime(0):yyyy-MM-ddTHH:mm:ss}'"; + var end = $"'{r.GetDateTime(1):yyyy-MM-ddTHH:mm:ss}'"; + var isRecur = r.IsDBNull(3) ? "0" : (r.GetBoolean(3) ? "1" : "0"); + var gapDays = r.IsDBNull(4) ? "NULL" : r.GetInt32(4).ToString(); + var recurIter = r.IsDBNull(5) ? "1" : r.GetInt32(5).ToString(); + var baseName = r.IsDBNull(6) ? "NULL" : SqlLiteral.Of(r.GetString(6)); + var scoreMode = r.IsDBNull(7) ? "0" : r.GetByte(7).ToString(); + var dailyObjPD = r.IsDBNull(8) ? "NULL" : ((int)(short)r.GetInt16(8)).ToString(); + + var name = SqlLiteral.Of(seasonName); + var desc = SqlLiteral.Of(seasonDesc); + + var sql = + $"MERGE seasons AS target " + + $"USING (SELECT {name} AS name) AS src " + + $"ON target.name = src.name " + + $"WHEN MATCHED THEN UPDATE SET description = {desc}, start_time = {start}, end_time = {end}, " + + $"is_recurring = {isRecur}, recurrence_gap_days = {gapDays}, recurrence_base_name = {baseName}, " + + $"scoring_mode = {scoreMode}, daily_objectives_per_day = {dailyObjPD} " + + $"WHEN NOT MATCHED THEN INSERT (name, description, start_time, end_time, is_active, is_recurring, " + + $"recurrence_gap_days, recurrence_iteration, recurrence_base_name, scoring_mode, daily_objectives_per_day) " + + $"VALUES ({name}, {desc}, {start}, {end}, 0 /*is_active: exported seasons start inactive*/, {isRecur}, {gapDays}, {recurIter}, {baseName}, {scoreMode}, {dailyObjPD})"; + changes.Add(new RawSqlChange($"seasons: merge '{seasonName}'", sql)); + } + + private static async Task AddActivityRatesAsync( + List changes, int seasonId, string seasonVar, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT activity_type, points_per_unit, unit_scale FROM season_activity_rates WHERE season_id = @id"; + cmd.Parameters.AddWithValue("@id", seasonId); + + var rows = new List<(int Type, double Pts, int Scale)>(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + rows.Add((r.GetInt32(0), r.GetDouble(1), r.GetInt32(2))); + if (rows.Count == 0) return; + + var sb = new StringBuilder(); + sb.AppendLine($"DELETE FROM season_activity_rates WHERE season_id = {seasonVar};"); + foreach (var (type, pts, scale) in rows) + sb.AppendLine( + $"INSERT INTO season_activity_rates (season_id, activity_type, points_per_unit, unit_scale) " + + $"VALUES ({seasonVar}, {type}, {pts.ToString("R", CultureInfo.InvariantCulture)}, {scale});"); + changes.Add(new RawSqlChange($"season_activity_rates: {rows.Count} rate(s)", sb.ToString().TrimEnd())); + } + + private static async Task AddObjectivesAsync( + List changes, int seasonId, string seasonVar, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT o.name, o.description, o.activity_type, o.target_value, o.bonus_points, " + + "o.display_order, o.is_daily, p.name AS pkg_name, ed.definitionname AS target_def, es.name AS set_name " + + "FROM season_objectives o " + + "LEFT JOIN packages p ON p.id = o.package_id " + + "LEFT JOIN entitydefaults ed ON ed.definition = o.target_definition_id " + + "LEFT JOIN equipment_sets es ON es.set_id = o.equipment_set_id " + + "WHERE o.season_id = @id ORDER BY o.display_order"; + cmd.Parameters.AddWithValue("@id", seasonId); + + var sb = new StringBuilder(); + int count = 0; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + count++; + var name = SqlLiteral.Of(r.IsDBNull(0) ? "" : r.GetString(0)); + var desc = SqlLiteral.Of(r.IsDBNull(1) ? "" : r.GetString(1)); + var actType = r.GetInt32(2).ToString(); + var targetVal = r.GetInt64(3).ToString(); + var bonus = r.GetInt32(4).ToString(); + var order = r.GetInt32(5).ToString(); + var isDaily = r.IsDBNull(6) ? "0" : (r.GetBoolean(6) ? "1" : "0"); + var pkgId = r.IsDBNull(7) ? "NULL" : $"(SELECT id FROM packages WHERE name = {SqlLiteral.Of(r.GetString(7))})"; + var targetDef = r.IsDBNull(8) ? "NULL" : $"(SELECT definition FROM entitydefaults WHERE definitionname = {SqlLiteral.Of(r.GetString(8))})"; + var setId = r.IsDBNull(9) ? "NULL" : $"(SELECT set_id FROM equipment_sets WHERE name = {SqlLiteral.Of(r.GetString(9))})"; + + sb.AppendLine( + $"MERGE season_objectives AS target " + + $"USING (SELECT {seasonVar} AS season_id, {name} AS name) AS src " + + $"ON target.season_id = src.season_id AND target.name = src.name " + + $"WHEN MATCHED THEN UPDATE SET description={desc}, activity_type={actType}, target_value={targetVal}, " + + $"bonus_points={bonus}, display_order={order}, is_daily={isDaily}, " + + $"package_id={pkgId}, target_definition_id={targetDef}, equipment_set_id={setId} " + + $"WHEN NOT MATCHED THEN INSERT (season_id, name, description, activity_type, target_value, bonus_points, " + + $"display_order, is_daily, package_id, target_definition_id, equipment_set_id) " + + $"VALUES (src.season_id, {name}, {desc}, {actType}, {targetVal}, {bonus}, {order}, {isDaily}, {pkgId}, {targetDef}, {setId});"); + } + if (count > 0) + changes.Add(new RawSqlChange($"season_objectives: {count} objective(s)", sb.ToString().TrimEnd())); + } + + private static async Task AddTiersAsync( + List changes, int seasonId, string seasonVar, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT t.tier_number, t.tier_name, t.points_required, p.name, es.name " + + "FROM season_tiers t " + + "LEFT JOIN packages p ON p.id = t.package_id " + + "LEFT JOIN equipment_sets es ON es.set_id = t.equipment_set_id " + + "WHERE t.season_id = @id ORDER BY t.tier_number"; + cmd.Parameters.AddWithValue("@id", seasonId); + + var sb = new StringBuilder(); + int count = 0; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + count++; + var tierNum = r.GetInt32(0).ToString(); + var tierName = SqlLiteral.Of(r.IsDBNull(1) ? "" : r.GetString(1)); + var pts = r.GetInt32(2).ToString(); + var pkgId = r.IsDBNull(3) ? "NULL" : $"(SELECT id FROM packages WHERE name = {SqlLiteral.Of(r.GetString(3))})"; + var setId = r.IsDBNull(4) ? "NULL" : $"(SELECT set_id FROM equipment_sets WHERE name = {SqlLiteral.Of(r.GetString(4))})"; + + sb.AppendLine( + $"MERGE season_tiers AS target " + + $"USING (SELECT {seasonVar} AS season_id, {tierNum} AS tier_number) AS src " + + $"ON target.season_id = src.season_id AND target.tier_number = src.tier_number " + + $"WHEN MATCHED THEN UPDATE SET tier_name={tierName}, points_required={pts}, package_id={pkgId}, equipment_set_id={setId} " + + $"WHEN NOT MATCHED THEN INSERT (season_id, tier_number, tier_name, points_required, package_id, equipment_set_id) " + + $"VALUES (src.season_id, src.tier_number, {tierName}, {pts}, {pkgId}, {setId});"); + } + if (count > 0) + changes.Add(new RawSqlChange($"season_tiers: {count} tier(s)", sb.ToString().TrimEnd())); + } + + private static async Task AddLeaderboardRewardsAsync( + List changes, int seasonId, string seasonVar, SqlConnection cn) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT lr.rank_min, lr.rank_max, p.name, es.name " + + "FROM season_leaderboard_rewards lr " + + "LEFT JOIN packages p ON p.id = lr.package_id " + + "LEFT JOIN equipment_sets es ON es.set_id = lr.equipment_set_id " + + "WHERE lr.season_id = @id ORDER BY lr.rank_min"; + cmd.Parameters.AddWithValue("@id", seasonId); + + var sb = new StringBuilder(); + int count = 0; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + count++; + var rMin = r.GetInt32(0).ToString(); + var rMax = r.GetInt32(1).ToString(); + var pkgId = r.IsDBNull(2) ? "NULL" : $"(SELECT id FROM packages WHERE name = {SqlLiteral.Of(r.GetString(2))})"; + var setId = r.IsDBNull(3) ? "NULL" : $"(SELECT set_id FROM equipment_sets WHERE name = {SqlLiteral.Of(r.GetString(3))})"; + + sb.AppendLine( + $"MERGE season_leaderboard_rewards AS target " + + $"USING (SELECT {seasonVar} AS season_id, {rMin} AS rank_min, {rMax} AS rank_max) AS src " + + $"ON target.season_id = src.season_id AND target.rank_min = src.rank_min AND target.rank_max = src.rank_max " + + $"WHEN MATCHED THEN UPDATE SET package_id={pkgId}, equipment_set_id={setId} " + + $"WHEN NOT MATCHED THEN INSERT (season_id, rank_min, rank_max, package_id, equipment_set_id) " + + $"VALUES (src.season_id, src.rank_min, src.rank_max, {pkgId}, {setId});"); + } + if (count > 0) + changes.Add(new RawSqlChange($"season_leaderboard_rewards: {count} reward(s)", sb.ToString().TrimEnd())); + } + } +} diff --git a/src/Perpetuum.AdminTool/Export/SqlExportBuilder.cs b/src/Perpetuum.AdminTool/Export/SqlExportBuilder.cs new file mode 100644 index 00000000..3ae402ce --- /dev/null +++ b/src/Perpetuum.AdminTool/Export/SqlExportBuilder.cs @@ -0,0 +1,19 @@ +using Perpetuum.AdminTool.Editing; + +namespace Perpetuum.AdminTool.Export +{ + internal static class SqlExportBuilder + { + /// DECLARE @varName INT = (SELECT definition FROM entitydefaults WHERE definitionname = 'defName'); + internal static string DeclareIdVar(string varName, string definitionName) => + $"DECLARE {varName} INT = (SELECT definition FROM entitydefaults WHERE definitionname = {SqlLiteral.Of(definitionName)})"; + + /// IF NOT EXISTS guard before inserting a row with no natural update key. + internal static string IfNotExistsInsert( + string table, string checkWhere, string insertCols, string insertVals) => + $"IF NOT EXISTS (SELECT 1 FROM {table} WHERE {checkWhere})\n" + + $"BEGIN\n" + + $" INSERT INTO {table} ({insertCols}) VALUES ({insertVals});\n" + + $"END"; + } +} diff --git a/src/Perpetuum.AdminTool/Loot/NpcLootRepository.cs b/src/Perpetuum.AdminTool/Loot/NpcLootRepository.cs new file mode 100644 index 00000000..094f8801 --- /dev/null +++ b/src/Perpetuum.AdminTool/Loot/NpcLootRepository.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Loot +{ + public class NpcLootRepository + { + private readonly ConnectionSettings _connection; + + public NpcLootRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task> LoadAllAsync() + { + var rows = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "select id, definition, lootdefinition, minquantity, quantity, probability, dontdamage, repackaged " + + "from npcloot order by definition, lootdefinition, id"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var snap = new NpcLootSnapshot + { + Id = reader.GetInt32(0), + Definition = reader.GetInt32(1), + LootDefinition = reader.GetInt32(2), + MinQuantity = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + Quantity = reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + Probability = reader.IsDBNull(5) ? 0 : reader.GetDouble(5), + DontDamage = !reader.IsDBNull(6) && reader.GetBoolean(6), + Repackaged = !reader.IsDBNull(7) && reader.GetBoolean(7) + }; + rows.Add(new NpcLootRow(snap)); + } + return rows; + } + } +} diff --git a/src/Perpetuum.AdminTool/Loot/NpcLootRow.cs b/src/Perpetuum.AdminTool/Loot/NpcLootRow.cs new file mode 100644 index 00000000..4f9ce68f --- /dev/null +++ b/src/Perpetuum.AdminTool/Loot/NpcLootRow.cs @@ -0,0 +1,88 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.Loot +{ + public partial class NpcLootRow : ObservableObject + { + // PK is the identity column `id`. 0 means a new (unsaved) row. + public int Id { get; } + + public bool IsNew { get; set; } + public NpcLootSnapshot Original { get; private set; } + + // Resolved display labels (not persisted) — kept in sync with current Definition / LootDefinition + // by NpcLootViewModel using the entitydefaults pick list. + [ObservableProperty] private string _definitionName = ""; + [ObservableProperty] private string _lootDefinitionName = ""; + + [ObservableProperty] private int _definition; + [ObservableProperty] private int _lootDefinition; + [ObservableProperty] private int _minQuantity; + [ObservableProperty] private int _quantity; + [ObservableProperty] private double _probability; + [ObservableProperty] private bool _dontDamage; + [ObservableProperty] private bool _repackaged; + + public NpcLootRow(NpcLootSnapshot snapshot) + { + Id = snapshot.Id; + Original = snapshot; + ApplySnapshot(snapshot); + } + + public void ApplySnapshot(NpcLootSnapshot snapshot) + { + Original = snapshot; + Definition = snapshot.Definition; + LootDefinition = snapshot.LootDefinition; + MinQuantity = snapshot.MinQuantity; + Quantity = snapshot.Quantity; + Probability = snapshot.Probability; + DontDamage = snapshot.DontDamage; + Repackaged = snapshot.Repackaged; + } + + public void RefreshOriginalFromCurrent() + { + Original = new NpcLootSnapshot + { + Id = Id, + Definition = Definition, + LootDefinition = LootDefinition, + MinQuantity = MinQuantity, + Quantity = Quantity, + Probability = Probability, + DontDamage = DontDamage, + Repackaged = Repackaged + }; + } + + public static NpcLootRow CreateNew(int definition, int lootDefinition, int minQuantity, int quantity, double probability, bool dontDamage, bool repackaged) + { + var snap = new NpcLootSnapshot + { + Id = 0, + Definition = definition, + LootDefinition = lootDefinition, + MinQuantity = minQuantity, + Quantity = quantity, + Probability = probability, + DontDamage = dontDamage, + Repackaged = repackaged + }; + return new NpcLootRow(snap) { IsNew = true }; + } + } + + public class NpcLootSnapshot + { + public int Id { get; init; } + public int Definition { get; init; } + public int LootDefinition { get; init; } + public int MinQuantity { get; init; } + public int Quantity { get; init; } + public double Probability { get; init; } + public bool DontDamage { get; init; } + public bool Repackaged { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/NewItem/BasicPanelMode.cs b/src/Perpetuum.AdminTool/NewItem/BasicPanelMode.cs new file mode 100644 index 00000000..284363e0 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/BasicPanelMode.cs @@ -0,0 +1,9 @@ +namespace Perpetuum.AdminTool.NewItem; + +public enum BasicPanelMode +{ + Main, + CalibrationTemplate, + Prototype, + RobotPart +} diff --git a/src/Perpetuum.AdminTool/NewItem/BasicPanelViewModel.cs b/src/Perpetuum.AdminTool/NewItem/BasicPanelViewModel.cs new file mode 100644 index 00000000..06b455ab --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/BasicPanelViewModel.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Entities; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class BasicPanelViewModel : ObservableObject +{ + private readonly BasicPanelMode _mode; + private readonly IReadOnlyCollection _existingDefNames; + + public BasicPanelMode Mode => _mode; + + [ObservableProperty] private string _definitionName = ""; + [ObservableProperty] private long _categoryFlags; + [ObservableProperty] private long _attributeFlags; + [ObservableProperty] private bool _enabled = true; + [ObservableProperty] private bool _purchasable; + [ObservableProperty] private bool _hidden; + [ObservableProperty] private int _quantity = 1; + [ObservableProperty] private double _mass; + [ObservableProperty] private double _volume; + [ObservableProperty] private double _health = 100.0; + [ObservableProperty] private int? _tierType; + [ObservableProperty] private int? _tierLevel; + [ObservableProperty] private string _descriptionToken = ""; + [ObservableProperty] private string _note = ""; + + // Only active in Main mode; gate tabs 2, 3, 6, 7 + [ObservableProperty] private bool _isCraftable; + // Only active in Main mode; gates tab 3 + [ObservableProperty] private bool _hasPrototype; + + // Only active in Main mode; gates tabs 9–14 + [ObservableProperty] private bool _isRobot; + + // Clone source original values for display (null if no clone) + public EntityDefaultRow? CloneSource { get; private set; } + + public string? DefinitionNameError + { + get + { + if (string.IsNullOrWhiteSpace(DefinitionName)) return "Required"; + if (!DefinitionName.StartsWith("def_", StringComparison.Ordinal)) return "Must start with def_"; + if (_mode == BasicPanelMode.CalibrationTemplate && !DefinitionName.EndsWith("_cprg", StringComparison.Ordinal)) + return "Must end with _cprg"; + if (_mode == BasicPanelMode.Prototype && !DefinitionName.EndsWith("_pr", StringComparison.Ordinal)) + return "Must end with _pr"; + if (_existingDefNames.Contains(DefinitionName)) return "Name already exists"; + return null; + } + } + + public bool HasErrors => + DefinitionNameError != null + || (_mode == BasicPanelMode.Main && CategoryFlags == 0); + + public BasicPanelViewModel(BasicPanelMode mode, IReadOnlyCollection existingDefNames) + { + _mode = mode; + _existingDefNames = existingDefNames; + + Purchasable = mode switch + { + BasicPanelMode.CalibrationTemplate => false, + _ => true + }; + } + + // Called by the dialog VM when BasicPanel.DefinitionName changes + public void SuggestName(string mainDefinitionName, string suffix) + { + var stripped = mainDefinitionName.StartsWith("def_", StringComparison.Ordinal) + ? mainDefinitionName : "def_" + mainDefinitionName; + DefinitionName = stripped + suffix; + } + + public void LoadFromClone(EntityDefaultRow source, string nameSuffix = "") + { + CloneSource = source; + var baseName = source.DefinitionName.StartsWith("def_", StringComparison.Ordinal) + ? source.DefinitionName : "def_" + source.DefinitionName; + DefinitionName = baseName + nameSuffix; + CategoryFlags = source.CategoryFlags; + AttributeFlags = source.AttributeFlags; + Enabled = source.Enabled; + Hidden = source.Hidden; + Quantity = source.Quantity; + Mass = source.Mass; + Volume = source.Volume; + Health = source.Health; + TierType = source.TierType; + TierLevel = source.TierLevel; + if (_mode != BasicPanelMode.CalibrationTemplate) + Purchasable = source.Purchasable; + + OnPropertyChanged(nameof(CloneSource)); + } + + partial void OnDefinitionNameChanged(string value) + { + DescriptionToken = SuggestDescriptionToken(value); + OnPropertyChanged(nameof(DefinitionNameError)); + OnPropertyChanged(nameof(HasErrors)); + } + + partial void OnCategoryFlagsChanged(long value) + { + OnPropertyChanged(nameof(HasErrors)); + } + + private string SuggestDescriptionToken(string defName) + { + if (defName.EndsWith("_desc", StringComparison.OrdinalIgnoreCase)) + return defName; + return defName + "_desc"; + } +} diff --git a/src/Perpetuum.AdminTool/NewItem/CloneExtendedData.cs b/src/Perpetuum.AdminTool/NewItem/CloneExtendedData.cs new file mode 100644 index 00000000..38e703fa --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/CloneExtendedData.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Perpetuum.AdminTool.NewItem; + +public class CloneExtendedData +{ + public IReadOnlyList<(int ComponentDef, int Amount)> Components { get; init; } = []; + public (int ResearchLevel, int? CalibrationProgram, bool Enabled)? ResearchLevel { get; init; } + public IReadOnlyList<(int ParentDef, int GroupId, int X, int Y, int? EnablerExtId)> TechTree { get; init; } = []; + public IReadOnlyList<(int PointTypeId, int Amount)> ResearchCosts { get; init; } = []; + public IReadOnlyList<(int ExtensionId, int Level)> EnablerExtensions { get; init; } = []; + public IReadOnlyDictionary DefinitionConfig { get; init; } = new Dictionary(); +} diff --git a/src/Perpetuum.AdminTool/NewItem/DefinitionConfigColumnInfo.cs b/src/Perpetuum.AdminTool/NewItem/DefinitionConfigColumnInfo.cs new file mode 100644 index 00000000..ca6bb2f7 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/DefinitionConfigColumnInfo.cs @@ -0,0 +1,10 @@ +namespace Perpetuum.AdminTool.NewItem; + +public record DefinitionConfigColumnInfo(string Name, string SqlType) +{ + public bool IsFloat => SqlType.StartsWith("float", StringComparison.OrdinalIgnoreCase); + public bool IsInt => SqlType.StartsWith("int", StringComparison.OrdinalIgnoreCase); + public bool IsBit => string.Equals(SqlType, "bit", StringComparison.OrdinalIgnoreCase); + public bool IsVarchar => SqlType.StartsWith("varchar", StringComparison.OrdinalIgnoreCase) + || SqlType.StartsWith("nvarchar", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Perpetuum.AdminTool/NewItem/DefinitionConfigRow.cs b/src/Perpetuum.AdminTool/NewItem/DefinitionConfigRow.cs new file mode 100644 index 00000000..cda8e0c8 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/DefinitionConfigRow.cs @@ -0,0 +1,11 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class DefinitionConfigRow : ObservableObject +{ + [ObservableProperty] private string _columnName = ""; + [ObservableProperty] private string _rawValue = ""; + public string? OriginalValue { get; init; } + public string? ValidationError { get; set; } +} diff --git a/src/Perpetuum.AdminTool/NewItem/EnablerExtensionRow.cs b/src/Perpetuum.AdminTool/NewItem/EnablerExtensionRow.cs new file mode 100644 index 00000000..8d643ffc --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/EnablerExtensionRow.cs @@ -0,0 +1,11 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class EnablerExtensionRow : ObservableObject +{ + [ObservableProperty] private int _extensionId; + [ObservableProperty] private int _extensionLevel = 1; + public int? OriginalExtensionId { get; init; } + public int? OriginalExtensionLevel { get; init; } +} diff --git a/src/Perpetuum.AdminTool/NewItem/ExtensionPickItem.cs b/src/Perpetuum.AdminTool/NewItem/ExtensionPickItem.cs new file mode 100644 index 00000000..1cf8492f --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/ExtensionPickItem.cs @@ -0,0 +1,6 @@ +namespace Perpetuum.AdminTool.NewItem; + +public record ExtensionPickItem(int Id, string Name) +{ + public string Display => Name; +} diff --git a/src/Perpetuum.AdminTool/NewItem/ItemSqlBuilder.cs b/src/Perpetuum.AdminTool/NewItem/ItemSqlBuilder.cs new file mode 100644 index 00000000..da639190 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/ItemSqlBuilder.cs @@ -0,0 +1,120 @@ +using System.Linq; +using System.Text; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.ViewModels; + +namespace Perpetuum.AdminTool.NewItem; + +public static class ItemSqlBuilder +{ + public static RawSqlChange Build(NewItemDialogViewModel vm) + { + var sql = new StringBuilder(); + var basic = vm.BasicPanel; + var optVis = vm.OptionsVisualPanel; + + // 1. Main entity + sql.AppendLine("DECLARE @mainDef INT;"); + AppendEntityInsert(sql, basic, optVis.OptionsText); + sql.AppendLine("SET @mainDef = SCOPE_IDENTITY();"); + + if (basic.IsCraftable) + { + // 2. Calibration Template entity + sql.AppendLine("DECLARE @cprgDef INT;"); + AppendEntityInsert(sql, vm.CalibrationPanel, null); + sql.AppendLine("SET @cprgDef = SCOPE_IDENTITY();"); + + if (basic.HasPrototype) + { + // 3. Prototype entity + sql.AppendLine("DECLARE @prDef INT;"); + AppendEntityInsert(sql, vm.PrototypePanel, null); + sql.AppendLine("SET @prDef = SCOPE_IDENTITY();"); + } + } + + // 4. aggregatevalues + foreach (var row in vm.StatsPanel.Rows) + sql.AppendLine($"INSERT INTO aggregatevalues (definition, field, value) VALUES (@mainDef, {row.FieldId}, {SqlLiteral.Of(row.NewValue)});"); + + // 5. modulepropertymodifiers (new rows only, keyed by main item's categoryflags) + foreach (var row in vm.PropertyModifiersPanel.ModulePropertyModifierRows) + sql.AppendLine($"INSERT INTO modulepropertymodifiers (categoryflags, basefield, modifierfield) VALUES ({SqlLiteral.Of(basic.CategoryFlags)}, {row.BaseFieldId}, {row.ModifierFieldId});"); + + // 6. aggregatemodifiers (new rows only) + foreach (var row in vm.PropertyModifiersPanel.AggregateModifierRows) + sql.AppendLine($"INSERT INTO aggregatemodifiers (categoryflag, basefield, modifierfield) VALUES ({SqlLiteral.Of(basic.CategoryFlags)}, {row.BaseFieldId}, {row.ModifierFieldId});"); + + if (basic.IsCraftable) + { + // 7. components + foreach (var row in vm.ProductionPanel.Components) + sql.AppendLine($"INSERT INTO components (definition, componentdefinition, componentamount) VALUES (@mainDef, {row.IngredientDefinition}, {row.Amount});"); + + // 8. productionduration (only if category has no existing row) + if (vm.ProductionPanel.ShouldWriteProductionDuration) + sql.AppendLine($"INSERT INTO productionduration (category, durationmodifier) VALUES ({SqlLiteral.Of(basic.CategoryFlags)}, {SqlLiteral.Of(vm.ProductionPanel.DurationModifier)});"); + + // 9. itemresearchlevels + var rp = vm.ResearchPanel; + var cprgRef = rp.UseCprgRef ? "@cprgDef" : SqlLiteral.OfNullableInt(rp.ManualCalibrationProgramDefinition); + sql.AppendLine($"INSERT INTO itemresearchlevels (definition, researchlevel, calibrationprogram, enabled) VALUES (@mainDef, {rp.ResearchLevel}, {cprgRef}, {SqlLiteral.Of(rp.IsEnabled)});"); + + // 10. techtree rows + foreach (var row in rp.TechTreeRows) + { + var extRef = row.EnablerExtensionId.HasValue + ? row.EnablerExtensionId.Value.ToString() + : "NULL"; + sql.AppendLine($"INSERT INTO techtree (parentdefinition, childdefinition, groupID, x, y, enablerextensionid) VALUES ({row.ParentDefinition}, @mainDef, {row.GroupId}, {row.X}, {row.Y}, {extRef});"); + } + + // 11. techtreenodeprices + foreach (var row in rp.ResearchCostRows) + sql.AppendLine($"INSERT INTO techtreenodeprices (definition, pointtype, amount) VALUES (@mainDef, {row.PointTypeId}, {row.Amount});"); + + // 12. enablerextensions (DELETE + INSERT — full replacement) + sql.AppendLine("DELETE FROM enablerextensions WHERE definition = @mainDef;"); + foreach (var row in rp.EnablerExtensionRows) + sql.AppendLine($"INSERT INTO enablerextensions (definition, extensionid, extensionlevel) VALUES (@mainDef, {row.ExtensionId}, {row.ExtensionLevel});"); + + // 13. prototypes + if (basic.HasPrototype) + sql.AppendLine("INSERT INTO prototypes (definition, prototype) VALUES (@mainDef, @prDef);"); + } + + // 14. definitionconfig (optional) + if (optVis.HasDefinitionConfig && optVis.DefinitionConfigRows.Count > 0) + { + var cols = string.Join(", ", optVis.DefinitionConfigRows.Select(r => SqlLiteral.Identifier(r.ColumnName))); + var vals = string.Join(", ", optVis.DefinitionConfigRows.Select(r => + FormatConfigValue(r.RawValue, optVis.AvailableConfigColumns + .FirstOrDefault(c => c.Name == r.ColumnName)))); + sql.AppendLine($"INSERT INTO definitionconfig (definition, {cols}) VALUES (@mainDef, {vals});"); + } + + return new RawSqlChange($"Create new item: {basic.DefinitionName}", sql.ToString()); + } + + internal static void AppendEntityInsert(StringBuilder sql, BasicPanelViewModel panel, string? options) + { + var tierType = panel.TierType.HasValue ? SqlLiteral.Of((object)panel.TierType.Value) : "NULL"; + var tierLevel = SqlLiteral.OfNullableInt(panel.TierLevel); + var optSql = string.IsNullOrEmpty(options) ? "NULL" : SqlLiteral.Of(options); + + sql.AppendLine( + $"INSERT INTO entitydefaults (definitionname, quantity, attributeflags, categoryflags, options, note, enabled, volume, mass, hidden, health, descriptiontoken, purchasable, tiertype, tierlevel)" + + $" VALUES ({SqlLiteral.Of(panel.DefinitionName)}, {panel.Quantity}, {panel.AttributeFlags}, {panel.CategoryFlags}, {optSql}, {SqlLiteral.Of(panel.Note)}, {SqlLiteral.Of(panel.Enabled)}, {SqlLiteral.Of(panel.Volume)}, {SqlLiteral.Of(panel.Mass)}, {SqlLiteral.Of(panel.Hidden)}, {SqlLiteral.Of(panel.Health)}, {SqlLiteral.Of(panel.DescriptionToken)}, {SqlLiteral.Of(panel.Purchasable)}, {tierType}, {tierLevel});"); + } + + internal static string FormatConfigValue(string rawValue, DefinitionConfigColumnInfo? colInfo) + { + if (colInfo == null) return SqlLiteral.Of(rawValue); + if (colInfo.IsBit) + return rawValue.Trim() is "1" or "true" or "True" ? "1" : "0"; + if (colInfo.IsInt || colInfo.IsFloat) + return rawValue.Trim(); + return SqlLiteral.Of(rawValue); + } +} diff --git a/src/Perpetuum.AdminTool/NewItem/NewComponentRow.cs b/src/Perpetuum.AdminTool/NewItem/NewComponentRow.cs new file mode 100644 index 00000000..ff5beb95 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/NewComponentRow.cs @@ -0,0 +1,10 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class NewComponentRow : ObservableObject +{ + [ObservableProperty] private int _ingredientDefinition; + [ObservableProperty] private int _amount = 1; + public int? OriginalAmount { get; init; } +} diff --git a/src/Perpetuum.AdminTool/NewItem/NewItemLookups.cs b/src/Perpetuum.AdminTool/NewItem/NewItemLookups.cs new file mode 100644 index 00000000..3eee1d66 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/NewItemLookups.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Perpetuum.AdminTool.Entities; +using Perpetuum.AdminTool.Packages; + +namespace Perpetuum.AdminTool.NewItem; + +public class NewItemLookups +{ + public IReadOnlyList AggregateFields { get; init; } = []; + public IReadOnlyList Extensions { get; init; } = []; + public IReadOnlyList TechTreeGroups { get; init; } = []; + public IReadOnlyList PointTypes { get; init; } = []; + public IReadOnlyList EnabledItems { get; init; } = []; + public IReadOnlyList<(long CategoryFlags, int BaseField, int ModifierField)> ExistingModPropertyModifiers { get; init; } = []; + public IReadOnlyList<(long CategoryFlags, int BaseField, int ModifierField)> ExistingAggregateModifiers { get; init; } = []; + public IReadOnlyDictionary ExistingProductionDurations { get; init; } = new Dictionary(); + public IReadOnlyList DefinitionConfigColumns { get; init; } = []; +} diff --git a/src/Perpetuum.AdminTool/NewItem/NewItemRepository.cs b/src/Perpetuum.AdminTool/NewItem/NewItemRepository.cs new file mode 100644 index 00000000..21718063 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/NewItemRepository.cs @@ -0,0 +1,201 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Entities; +using Perpetuum.AdminTool.Packages; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.NewItem; + +public class NewItemRepository +{ + private readonly ConnectionSettings _connection; + + public NewItemRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task LoadAsync( + IReadOnlyList aggregateFields, + IReadOnlyList entities, + Dictionary? englishNames = null) + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + var extensions = new List(); + var groups = new List(); + var pointTypes = new List(); + var existingModProp = new List<(long CategoryFlags, int BaseField, int ModifierField)>(); + var existingAggMod = new List<(long CategoryFlags, int BaseField, int ModifierField)>(); + var existingProdDur = new Dictionary(); + var defConfigCols = new List(); + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT extensionid, extensionname FROM extensions ORDER BY extensionname"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + extensions.Add(new ExtensionPickItem(r.GetInt32(0), r.GetString(1))); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT id, name FROM techtreegroups ORDER BY name"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + groups.Add(new TechTreeGroupPickItem(r.GetInt32(0), r.GetString(1))); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT id, name FROM techtreepointtypes ORDER BY name"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + pointTypes.Add(new PointTypePickItem(r.GetInt32(0), r.GetString(1))); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT categoryflags, basefield, modifierfield FROM modulepropertymodifiers"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + existingModProp.Add((r.GetInt64(0), r.GetInt32(1), r.GetInt32(2))); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT categoryflag, basefield, modifierfield FROM aggregatemodifiers"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + existingAggMod.Add((r.GetInt64(0), r.GetInt32(1), r.GetInt32(2))); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT category, durationmodifier FROM productionduration"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + existingProdDur[r.GetInt64(0)] = r.GetDouble(1); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = @"SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'definitionconfig' + AND COLUMN_NAME NOT IN ('id','definition') + ORDER BY ORDINAL_POSITION"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + defConfigCols.Add(new DefinitionConfigColumnInfo(r.GetString(0), r.GetString(1))); + } + + return new NewItemLookups + { + AggregateFields = aggregateFields, + Extensions = extensions, + TechTreeGroups = groups, + PointTypes = pointTypes, + EnabledItems = PackageItemPickItem.BuildFilteredList(entities, englishNames), + ExistingModPropertyModifiers = existingModProp, + ExistingAggregateModifiers = existingAggMod, + ExistingProductionDurations = existingProdDur, + DefinitionConfigColumns = defConfigCols + }; + } + + public async Task LoadCloneExtendedAsync(int definition) + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + var components = new List<(int ComponentDef, int Amount)>(); + var techTree = new List<(int ParentDef, int GroupId, int X, int Y, int? EnablerExtId)>(); + var researchCosts = new List<(int PointTypeId, int Amount)>(); + var enablerExts = new List<(int ExtensionId, int Level)>(); + var defConfig = new Dictionary(); + (int, int?, bool)? researchLevel = null; + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT componentdefinition, componentamount FROM components WHERE definition = @def"; + cmd.Parameters.AddWithValue("@def", definition); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + components.Add((r.GetInt32(0), r.GetInt32(1))); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT researchlevel, calibrationprogram, enabled FROM itemresearchlevels WHERE definition = @def"; + cmd.Parameters.AddWithValue("@def", definition); + await using var r = await cmd.ExecuteReaderAsync(); + if (await r.ReadAsync()) + researchLevel = (r.GetInt32(0), r.IsDBNull(1) ? null : r.GetInt32(1), r.GetBoolean(2)); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT parentdefinition, groupID, x, y, enablerextensionid FROM techtree WHERE childdefinition = @def"; + cmd.Parameters.AddWithValue("@def", definition); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + techTree.Add((r.GetInt32(0), r.GetInt32(1), r.GetInt32(2), r.GetInt32(3), r.IsDBNull(4) ? null : r.GetInt32(4))); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT pointtype, amount FROM techtreenodeprices WHERE definition = @def"; + cmd.Parameters.AddWithValue("@def", definition); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + researchCosts.Add((r.GetInt32(0), r.GetInt32(1))); + } + + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT extensionid, extensionlevel FROM enablerextensions WHERE definition = @def"; + cmd.Parameters.AddWithValue("@def", definition); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + enablerExts.Add((r.GetInt32(0), r.GetInt32(1))); + } + + // Load definitionconfig columns dynamically, then fetch values for this definition + var colNames = new List(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = @"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'definitionconfig' AND COLUMN_NAME NOT IN ('id','definition') + ORDER BY ORDINAL_POSITION"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + colNames.Add(r.GetString(0)); + } + + if (colNames.Count > 0) + { + var colList = string.Join(", ", colNames.Select(c => "[" + c + "]")); + await using var cmd2 = cn.CreateCommand(); + cmd2.CommandText = $"SELECT {colList} FROM definitionconfig WHERE definition = @def"; + cmd2.Parameters.AddWithValue("@def", definition); + await using var r2 = await cmd2.ExecuteReaderAsync(); + if (await r2.ReadAsync()) + for (int i = 0; i < colNames.Count; i++) + defConfig[colNames[i]] = r2.IsDBNull(i) ? null : r2.GetValue(i)?.ToString(); + } + + return new CloneExtendedData + { + Components = components, + ResearchLevel = researchLevel, + TechTree = techTree, + ResearchCosts = researchCosts, + EnablerExtensions = enablerExts, + DefinitionConfig = defConfig + }; + } +} diff --git a/src/Perpetuum.AdminTool/NewItem/NewStatRow.cs b/src/Perpetuum.AdminTool/NewItem/NewStatRow.cs new file mode 100644 index 00000000..1c01053a --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/NewStatRow.cs @@ -0,0 +1,10 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class NewStatRow : ObservableObject +{ + [ObservableProperty] private int _fieldId; + [ObservableProperty] private double _newValue; + public double? OriginalValue { get; init; } +} diff --git a/src/Perpetuum.AdminTool/NewItem/OptionsVisualPanelViewModel.cs b/src/Perpetuum.AdminTool/NewItem/OptionsVisualPanelViewModel.cs new file mode 100644 index 00000000..8f028ea3 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/OptionsVisualPanelViewModel.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.RegularExpressions; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class OptionsVisualPanelViewModel : ObservableObject +{ + [ObservableProperty] private string _optionsText = ""; + [ObservableProperty] private string? _cloneOptionsText; + [ObservableProperty] private bool _hasDefinitionConfig; + [ObservableProperty] private IReadOnlyList _availableConfigColumns = []; + + public ObservableCollection DefinitionConfigRows { get; } = new(); + + public void Initialize(NewItemLookups lookups) + { + AvailableConfigColumns = lookups.DefinitionConfigColumns; + } + + [RelayCommand] private void AddConfigRow() => DefinitionConfigRows.Add(new DefinitionConfigRow()); + [RelayCommand] private void RemoveConfigRow(DefinitionConfigRow row) => DefinitionConfigRows.Remove(row); + + public void LoadFromClone(string? options, IReadOnlyDictionary configValues) + { + OptionsText = options ?? ""; + CloneOptionsText = options; + + DefinitionConfigRows.Clear(); + if (configValues.Count > 0) + { + HasDefinitionConfig = true; + foreach (var (col, val) in configValues) + if (val != null) + DefinitionConfigRows.Add(new DefinitionConfigRow + { + ColumnName = col, RawValue = val, OriginalValue = val + }); + } + } + + public bool HasDuplicateConfigColumns() + { + var cols = DefinitionConfigRows.Select(r => r.ColumnName).ToList(); + return cols.Count != cols.Distinct(StringComparer.OrdinalIgnoreCase).Count(); + } + + public string? ValidateTintValues() + { + foreach (var row in DefinitionConfigRows) + { + if (row.ColumnName == "tint" && !string.IsNullOrEmpty(row.RawValue)) + { + if (!Regex.IsMatch(row.RawValue, @"^#[0-9A-Fa-f]{6}$")) + return $"tint must be #RRGGBB, got: {row.RawValue}"; + } + } + return null; + } +} diff --git a/src/Perpetuum.AdminTool/NewItem/PointTypePickItem.cs b/src/Perpetuum.AdminTool/NewItem/PointTypePickItem.cs new file mode 100644 index 00000000..bb77f41e --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/PointTypePickItem.cs @@ -0,0 +1,6 @@ +namespace Perpetuum.AdminTool.NewItem; + +public record PointTypePickItem(int Id, string Name) +{ + public string Display => Name; +} diff --git a/src/Perpetuum.AdminTool/NewItem/ProductionPanelViewModel.cs b/src/Perpetuum.AdminTool/NewItem/ProductionPanelViewModel.cs new file mode 100644 index 00000000..bf39bffd --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/ProductionPanelViewModel.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Packages; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class ProductionPanelViewModel : ObservableObject +{ + private IReadOnlyDictionary _existingProductionDurations = new Dictionary(); + + public ObservableCollection Components { get; } = new(); + + [ObservableProperty] private IReadOnlyList _availableIngredients = []; + [ObservableProperty] private bool _hasExistingProductionDuration; + [ObservableProperty] private double _existingDurationValue; + [ObservableProperty] private double _durationModifier = 1.0; + + public bool ShouldWriteProductionDuration => !HasExistingProductionDuration; + + public void Initialize(NewItemLookups lookups) + { + AvailableIngredients = lookups.EnabledItems; + _existingProductionDurations = lookups.ExistingProductionDurations; + } + + public void UpdateCategory(long categoryFlags) + { + if (_existingProductionDurations.TryGetValue(categoryFlags, out var dur)) + { + HasExistingProductionDuration = true; + ExistingDurationValue = dur; + } + else + { + HasExistingProductionDuration = false; + } + } + + [RelayCommand] private void AddComponent() => Components.Add(new NewComponentRow()); + [RelayCommand] private void RemoveComponent(NewComponentRow row) => Components.Remove(row); + + public void LoadFromClone(IEnumerable<(int ComponentDef, int Amount)> cloneComponents) + { + Components.Clear(); + foreach (var (def, amt) in cloneComponents) + Components.Add(new NewComponentRow { IngredientDefinition = def, Amount = amt, OriginalAmount = amt }); + } + + public bool HasDuplicateIngredients() + { + var ids = Components.Select(c => c.IngredientDefinition).ToList(); + return ids.Count != ids.Distinct().Count(); + } +} diff --git a/src/Perpetuum.AdminTool/NewItem/PropertyModifierRow.cs b/src/Perpetuum.AdminTool/NewItem/PropertyModifierRow.cs new file mode 100644 index 00000000..67adf630 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/PropertyModifierRow.cs @@ -0,0 +1,11 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class PropertyModifierRow : ObservableObject +{ + [ObservableProperty] private int _baseFieldId; + [ObservableProperty] private int _modifierFieldId; + public int? OriginalBaseFieldId { get; init; } + public int? OriginalModifierFieldId { get; init; } +} diff --git a/src/Perpetuum.AdminTool/NewItem/PropertyModifiersPanelViewModel.cs b/src/Perpetuum.AdminTool/NewItem/PropertyModifiersPanelViewModel.cs new file mode 100644 index 00000000..960d6f87 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/PropertyModifiersPanelViewModel.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Entities; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class PropertyModifiersPanelViewModel : ObservableObject +{ + public ObservableCollection ModulePropertyModifierRows { get; } = new(); + public ObservableCollection AggregateModifierRows { get; } = new(); + + [ObservableProperty] private IReadOnlyList<(long CategoryFlags, int BaseField, int ModifierField)> _existingModPropertyModifiers = []; + [ObservableProperty] private IReadOnlyList<(long CategoryFlags, int BaseField, int ModifierField)> _existingAggregateModifiers = []; + [ObservableProperty] private IReadOnlyList _availableFields = []; + + public void Initialize(NewItemLookups lookups) + { + AvailableFields = lookups.AggregateFields; + ExistingModPropertyModifiers = lookups.ExistingModPropertyModifiers; + ExistingAggregateModifiers = lookups.ExistingAggregateModifiers; + } + + [RelayCommand] private void AddModPropertyRow() => ModulePropertyModifierRows.Add(new PropertyModifierRow()); + [RelayCommand] private void RemoveModPropertyRow(PropertyModifierRow row) => ModulePropertyModifierRows.Remove(row); + [RelayCommand] private void AddAggModRow() => AggregateModifierRows.Add(new PropertyModifierRow()); + [RelayCommand] private void RemoveAggModRow(PropertyModifierRow row) => AggregateModifierRows.Remove(row); + + public void LoadFromClone(long categoryFlags) + { + ModulePropertyModifierRows.Clear(); + AggregateModifierRows.Clear(); + foreach (var (cf, bf, mf) in ExistingModPropertyModifiers.Where(e => e.CategoryFlags == categoryFlags)) + ModulePropertyModifierRows.Add(new PropertyModifierRow { BaseFieldId = bf, ModifierFieldId = mf, OriginalBaseFieldId = bf, OriginalModifierFieldId = mf }); + foreach (var (cf, bf, mf) in ExistingAggregateModifiers.Where(e => e.CategoryFlags == categoryFlags)) + AggregateModifierRows.Add(new PropertyModifierRow { BaseFieldId = bf, ModifierFieldId = mf, OriginalBaseFieldId = bf, OriginalModifierFieldId = mf }); + } +} diff --git a/src/Perpetuum.AdminTool/NewItem/ResearchCostRow.cs b/src/Perpetuum.AdminTool/NewItem/ResearchCostRow.cs new file mode 100644 index 00000000..37532d3b --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/ResearchCostRow.cs @@ -0,0 +1,10 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class ResearchCostRow : ObservableObject +{ + [ObservableProperty] private int _pointTypeId; + [ObservableProperty] private int _amount; + public int? OriginalAmount { get; init; } +} diff --git a/src/Perpetuum.AdminTool/NewItem/ResearchPanelViewModel.cs b/src/Perpetuum.AdminTool/NewItem/ResearchPanelViewModel.cs new file mode 100644 index 00000000..992a01bf --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/ResearchPanelViewModel.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class ResearchPanelViewModel : ObservableObject +{ + [ObservableProperty] private int _researchLevel = 1; + [ObservableProperty] private bool _isEnabled = true; + + // When IsCraftable, the calibration program is @cprgDef (resolved in SQL). + // UseCprgRef = true means ItemSqlBuilder will use @cprgDef. + // UseCprgRef = false means ManualCalibrationProgramDefinition is used instead. + [ObservableProperty] private bool _useCprgRef = true; + [ObservableProperty] private int? _manualCalibrationProgramDefinition; + + [ObservableProperty] private IReadOnlyList _availableExtensions = []; + [ObservableProperty] private IReadOnlyList _availableTechTreeGroups = []; + [ObservableProperty] private IReadOnlyList _availablePointTypes = []; + + public ObservableCollection TechTreeRows { get; } = new(); + public ObservableCollection ResearchCostRows { get; } = new(); + public ObservableCollection EnablerExtensionRows { get; } = new(); + + public void Initialize(NewItemLookups lookups) + { + AvailableExtensions = lookups.Extensions; + AvailableTechTreeGroups = lookups.TechTreeGroups; + AvailablePointTypes = lookups.PointTypes; + } + + [RelayCommand] private void AddTechTreeRow() => TechTreeRows.Add(new TechTreePlacementRow()); + [RelayCommand] private void RemoveTechTreeRow(TechTreePlacementRow row) => TechTreeRows.Remove(row); + [RelayCommand] private void AddResearchCost() => ResearchCostRows.Add(new ResearchCostRow()); + [RelayCommand] private void RemoveResearchCost(ResearchCostRow row) => ResearchCostRows.Remove(row); + [RelayCommand] private void AddEnablerExtension() => EnablerExtensionRows.Add(new EnablerExtensionRow()); + [RelayCommand] private void RemoveEnablerExtension(EnablerExtensionRow row) => EnablerExtensionRows.Remove(row); + + public void LoadFromClone(CloneExtendedData clone) + { + if (clone.ResearchLevel.HasValue) + { + var (lvl, _, enabled) = clone.ResearchLevel.Value; + ResearchLevel = lvl; + IsEnabled = enabled; + // Leave UseCprgRef = true so the new _cprg entity is referenced automatically + } + + TechTreeRows.Clear(); + foreach (var (pd, grp, x, y, ext) in clone.TechTree) + TechTreeRows.Add(new TechTreePlacementRow + { + ParentDefinition = pd, GroupId = grp, X = x, Y = y, + EnablerExtensionId = ext, + OriginalParentDefinition = pd, OriginalX = x, OriginalY = y + }); + + ResearchCostRows.Clear(); + foreach (var (pt, amt) in clone.ResearchCosts) + ResearchCostRows.Add(new ResearchCostRow { PointTypeId = pt, Amount = amt, OriginalAmount = amt }); + + EnablerExtensionRows.Clear(); + foreach (var (extId, lvl) in clone.EnablerExtensions) + EnablerExtensionRows.Add(new EnablerExtensionRow + { + ExtensionId = extId, ExtensionLevel = lvl, + OriginalExtensionId = extId, OriginalExtensionLevel = lvl + }); + } + + public bool HasDuplicatePointTypes() + { + var ids = ResearchCostRows.Select(r => r.PointTypeId).ToList(); + return ids.Count != ids.Distinct().Count(); + } +} diff --git a/src/Perpetuum.AdminTool/NewItem/StatsPanelViewModel.cs b/src/Perpetuum.AdminTool/NewItem/StatsPanelViewModel.cs new file mode 100644 index 00000000..f4530338 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/StatsPanelViewModel.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Entities; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class StatsPanelViewModel : ObservableObject +{ + [ObservableProperty] private IReadOnlyList _availableFields = []; + + public ObservableCollection Rows { get; } = new(); + + public void Initialize(NewItemLookups lookups) + { + AvailableFields = lookups.AggregateFields; + } + + [RelayCommand] + private void AddRow() => Rows.Add(new NewStatRow()); + + [RelayCommand] + private void RemoveRow(NewStatRow row) => Rows.Remove(row); + + public void LoadFromClone(IEnumerable cloneStats) + { + Rows.Clear(); + foreach (var s in cloneStats) + Rows.Add(new NewStatRow { FieldId = (int)s.Field, NewValue = s.Value, OriginalValue = s.Value }); + } + + public bool HasDuplicateFields() + { + var ids = Rows.Select(r => r.FieldId).ToList(); + return ids.Count != ids.Distinct().Count(); + } +} diff --git a/src/Perpetuum.AdminTool/NewItem/TechTreeGroupPickItem.cs b/src/Perpetuum.AdminTool/NewItem/TechTreeGroupPickItem.cs new file mode 100644 index 00000000..647f13ed --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/TechTreeGroupPickItem.cs @@ -0,0 +1,6 @@ +namespace Perpetuum.AdminTool.NewItem; + +public record TechTreeGroupPickItem(int Id, string Name) +{ + public string Display => Name; +} diff --git a/src/Perpetuum.AdminTool/NewItem/TechTreePlacementRow.cs b/src/Perpetuum.AdminTool/NewItem/TechTreePlacementRow.cs new file mode 100644 index 00000000..744ae792 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewItem/TechTreePlacementRow.cs @@ -0,0 +1,15 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.NewItem; + +public partial class TechTreePlacementRow : ObservableObject +{ + [ObservableProperty] private int _parentDefinition; + [ObservableProperty] private int _groupId; + [ObservableProperty] private int _x; + [ObservableProperty] private int _y; + [ObservableProperty] private int? _enablerExtensionId; + public int? OriginalParentDefinition { get; init; } + public int? OriginalX { get; init; } + public int? OriginalY { get; init; } +} diff --git a/src/Perpetuum.AdminTool/NewRobot/BonusesPanelViewModel.cs b/src/Perpetuum.AdminTool/NewRobot/BonusesPanelViewModel.cs new file mode 100644 index 00000000..0208d7d7 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewRobot/BonusesPanelViewModel.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Entities; +using Perpetuum.AdminTool.NewItem; + +namespace Perpetuum.AdminTool.NewRobot; + +public partial class BonusesPanelViewModel : ObservableObject +{ + [ObservableProperty] private IReadOnlyList _availableExtensions = []; + [ObservableProperty] private IReadOnlyList _availableFields = []; + + public ObservableCollection Rows { get; } = new(); + + public void Initialize(NewItemLookups lookups, Dictionary? englishNames) + { + AvailableExtensions = lookups.Extensions + .Select(e => + { + var display = (englishNames != null && englishNames.TryGetValue(e.Name, out var eng) && !string.IsNullOrEmpty(eng)) + ? eng : e.Name; + return new ExtensionPickItem(e.Id, display); + }) + .ToList(); + AvailableFields = lookups.AggregateFields; + } + + [RelayCommand] + private void AddRow() => Rows.Add(new NewBonusRow()); + + [RelayCommand] + private void RemoveRow(NewBonusRow row) => Rows.Remove(row); + + public void LoadFromClone(IEnumerable rows) + { + Rows.Clear(); + foreach (var r in rows) + Rows.Add(new NewBonusRow + { + ExtensionId = r.ExtensionId, + NewBonus = r.Bonus, + OriginalBonus = r.Bonus, + TargetPropertyId = r.TargetPropertyId, + EffectEnhancer = r.EffectEnhancer, + Note = r.Note ?? "" + }); + } + + public bool HasDuplicates() + { + var keys = Rows.Select(r => (r.ExtensionId, r.TargetPropertyId)).ToList(); + return keys.Count != keys.Distinct().Count(); + } +} diff --git a/src/Perpetuum.AdminTool/NewRobot/NewBonusRow.cs b/src/Perpetuum.AdminTool/NewRobot/NewBonusRow.cs new file mode 100644 index 00000000..ce46d518 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewRobot/NewBonusRow.cs @@ -0,0 +1,13 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.NewRobot; + +public partial class NewBonusRow : ObservableObject +{ + [ObservableProperty] private int _extensionId; + [ObservableProperty] private double _newBonus; + [ObservableProperty] private int _targetPropertyId; + [ObservableProperty] private bool _effectEnhancer; + [ObservableProperty] private string _note = ""; + public double? OriginalBonus { get; init; } +} diff --git a/src/Perpetuum.AdminTool/NewRobot/NewRobotRepository.cs b/src/Perpetuum.AdminTool/NewRobot/NewRobotRepository.cs new file mode 100644 index 00000000..a55bd275 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewRobot/NewRobotRepository.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.NewRobot; + +public record ChassisBonusRow(int ExtensionId, double Bonus, int TargetPropertyId, bool EffectEnhancer, string? Note); + +public class NewRobotRepository +{ + private readonly ConnectionSettings _connection; + + public NewRobotRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task LoadTemplateRelationAsync(int robotDefinition) + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + await using var cmd = cn.CreateCommand(); + cmd.CommandText = @" + SELECT itemscoresum, raceid, missionlevel, missionleveloverride, killep, note + FROM robottemplaterelation + WHERE definition = @def"; + cmd.Parameters.AddWithValue("@def", robotDefinition); + await using var r = await cmd.ExecuteReaderAsync(); + if (!await r.ReadAsync()) return null; + + return new RobotTemplateRelationData( + ItemScoreSum: r.GetInt32(0), + RaceId: r.GetInt32(1), + MissionLevel: r.IsDBNull(2) ? 0 : r.GetInt32(2), + MissionLevelOverride: r.IsDBNull(3) ? 0 : r.GetInt32(3), + KillEp: r.IsDBNull(4) ? 0 : r.GetInt32(4), + Note: r.IsDBNull(5) ? null : r.GetString(5)); + } + + public async Task> LoadChassisBonusesAsync(int chassisDefinition) + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + await using var cmd = cn.CreateCommand(); + cmd.CommandText = @" + SELECT extension, bonus, targetpropertyID, effectenhancer, note + FROM chassisbonus + WHERE definition = @def"; + cmd.Parameters.AddWithValue("@def", chassisDefinition); + + var results = new List(); + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + results.Add(new ChassisBonusRow( + ExtensionId: r.GetInt32(0), + Bonus: r.GetDouble(1), + TargetPropertyId: r.GetInt32(2), + EffectEnhancer: r.GetBoolean(3), + Note: r.IsDBNull(4) ? null : r.GetString(4))); + return results; + } +} diff --git a/src/Perpetuum.AdminTool/NewRobot/RobotSqlBuilder.cs b/src/Perpetuum.AdminTool/NewRobot/RobotSqlBuilder.cs new file mode 100644 index 00000000..0e043601 --- /dev/null +++ b/src/Perpetuum.AdminTool/NewRobot/RobotSqlBuilder.cs @@ -0,0 +1,188 @@ +using System.Linq; +using System.Text; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.NewItem; +using Perpetuum.AdminTool.ViewModels; +using Perpetuum.GenXY; + +namespace Perpetuum.AdminTool.NewRobot; + +public static class RobotSqlBuilder +{ + public static RawSqlChange Build(NewRobotDialogViewModel vm) + { + var sql = new StringBuilder(); + var basic = vm.BasicPanel; + var optVis = vm.OptionsVisualPanel; + + // 1. Robot entity + sql.AppendLine("DECLARE @robotDef INT;"); + ItemSqlBuilder.AppendEntityInsert(sql, basic, optVis.OptionsText); + sql.AppendLine("SET @robotDef = SCOPE_IDENTITY();"); + + if (basic.IsCraftable) + { + // 2. Calibration Template entity + sql.AppendLine("DECLARE @cprgDef INT;"); + ItemSqlBuilder.AppendEntityInsert(sql, vm.CalibrationPanel, null); + sql.AppendLine("SET @cprgDef = SCOPE_IDENTITY();"); + + if (basic.HasPrototype) + { + // 3. Prototype entity + sql.AppendLine("DECLARE @prDef INT;"); + ItemSqlBuilder.AppendEntityInsert(sql, vm.PrototypePanel, null); + sql.AppendLine("SET @prDef = SCOPE_IDENTITY();"); + } + } + + // 4. Robot aggregatevalues + foreach (var row in vm.StatsPanel.Rows) + sql.AppendLine($"INSERT INTO aggregatevalues (definition, field, value) VALUES (@robotDef, {row.FieldId}, {SqlLiteral.Of(row.NewValue)});"); + + // 5. modulepropertymodifiers + foreach (var row in vm.PropertyModifiersPanel.ModulePropertyModifierRows) + sql.AppendLine($"INSERT INTO modulepropertymodifiers (categoryflags, basefield, modifierfield) VALUES ({SqlLiteral.Of(basic.CategoryFlags)}, {row.BaseFieldId}, {row.ModifierFieldId});"); + + // 6. aggregatemodifiers + foreach (var row in vm.PropertyModifiersPanel.AggregateModifierRows) + sql.AppendLine($"INSERT INTO aggregatemodifiers (categoryflag, basefield, modifierfield) VALUES ({SqlLiteral.Of(basic.CategoryFlags)}, {row.BaseFieldId}, {row.ModifierFieldId});"); + + if (basic.IsCraftable) + { + // 7. components + foreach (var row in vm.ProductionPanel.Components) + sql.AppendLine($"INSERT INTO components (definition, componentdefinition, componentamount) VALUES (@robotDef, {row.IngredientDefinition}, {row.Amount});"); + + // 8. productionduration (only if category has no existing row) + if (vm.ProductionPanel.ShouldWriteProductionDuration) + sql.AppendLine($"INSERT INTO productionduration (category, durationmodifier) VALUES ({SqlLiteral.Of(basic.CategoryFlags)}, {SqlLiteral.Of(vm.ProductionPanel.DurationModifier)});"); + + // 9. itemresearchlevels + var rp = vm.ResearchPanel; + var cprgRef = rp.UseCprgRef ? "@cprgDef" : SqlLiteral.OfNullableInt(rp.ManualCalibrationProgramDefinition); + sql.AppendLine($"INSERT INTO itemresearchlevels (definition, researchlevel, calibrationprogram, enabled) VALUES (@robotDef, {rp.ResearchLevel}, {cprgRef}, {SqlLiteral.Of(rp.IsEnabled)});"); + + // 10. techtree rows + foreach (var row in rp.TechTreeRows) + { + var extRef = row.EnablerExtensionId.HasValue ? row.EnablerExtensionId.Value.ToString() : "NULL"; + sql.AppendLine($"INSERT INTO techtree (parentdefinition, childdefinition, groupID, x, y, enablerextensionid) VALUES ({row.ParentDefinition}, @robotDef, {row.GroupId}, {row.X}, {row.Y}, {extRef});"); + } + + // 11. techtreenodeprices + foreach (var row in rp.ResearchCostRows) + sql.AppendLine($"INSERT INTO techtreenodeprices (definition, pointtype, amount) VALUES (@robotDef, {row.PointTypeId}, {row.Amount});"); + + // 12. enablerextensions (full replacement) + sql.AppendLine("DELETE FROM enablerextensions WHERE definition = @robotDef;"); + foreach (var row in rp.EnablerExtensionRows) + sql.AppendLine($"INSERT INTO enablerextensions (definition, extensionid, extensionlevel) VALUES (@robotDef, {row.ExtensionId}, {row.ExtensionLevel});"); + + // 13. prototypes + if (basic.HasPrototype) + sql.AppendLine("INSERT INTO prototypes (definition, prototype) VALUES (@robotDef, @prDef);"); + } + + // 14. definitionconfig (optional) + if (optVis.HasDefinitionConfig && optVis.DefinitionConfigRows.Count > 0) + { + var cols = string.Join(", ", optVis.DefinitionConfigRows.Select(r => SqlLiteral.Identifier(r.ColumnName))); + var vals = string.Join(", ", optVis.DefinitionConfigRows.Select(r => + ItemSqlBuilder.FormatConfigValue(r.RawValue, optVis.AvailableConfigColumns.FirstOrDefault(c => c.Name == r.ColumnName)))); + sql.AppendLine($"INSERT INTO definitionconfig (definition, {cols}) VALUES (@robotDef, {vals});"); + } + + if (basic.IsRobot) + { + // 15. Head entity + sql.AppendLine("DECLARE @headDef INT;"); + ItemSqlBuilder.AppendEntityInsert(sql, vm.HeadPanel, vm.HeadOptionsPanel.OptionsText); + sql.AppendLine("SET @headDef = SCOPE_IDENTITY();"); + + // 16. Chassis entity + sql.AppendLine("DECLARE @chassisDef INT;"); + ItemSqlBuilder.AppendEntityInsert(sql, vm.ChassisPanel, vm.ChassisOptionsPanel.OptionsText); + sql.AppendLine("SET @chassisDef = SCOPE_IDENTITY();"); + + // 17. Leg entity + sql.AppendLine("DECLARE @legDef INT;"); + ItemSqlBuilder.AppendEntityInsert(sql, vm.LegPanel, vm.LegOptionsPanel.OptionsText); + sql.AppendLine("SET @legDef = SCOPE_IDENTITY();"); + + // 18. Inventory entity + sql.AppendLine("DECLARE @inventoryDef INT;"); + ItemSqlBuilder.AppendEntityInsert(sql, vm.InventoryPanel, vm.InventoryOptionsPanel.OptionsText); + sql.AppendLine("SET @inventoryDef = SCOPE_IDENTITY();"); + + // 18a. Back-fill robot entity options with part definition references (GenXY decimal format). + // Strip any stale part-ref keys the user may have inherited via clone, preserve everything else. + var baseOptions = StripPartRefKeys(optVis.OptionsText); + sql.AppendLine( + "UPDATE entitydefaults" + + $" SET options = {SqlLiteral.Of(baseOptions)}" + + " + '#head=n' + CAST(@headDef AS VARCHAR(10))" + + " + '#chassis=n' + CAST(@chassisDef AS VARCHAR(10))" + + " + '#leg=n' + CAST(@legDef AS VARCHAR(10))" + + " + '#inventory=n' + CAST(@inventoryDef AS VARCHAR(10))" + + " WHERE definition = @robotDef;"); + + // 19. Part aggregatevalues + AppendPartStats(sql, "@headDef", vm.HeadStatsPanel); + AppendPartStats(sql, "@chassisDef", vm.ChassisStatsPanel); + AppendPartStats(sql, "@legDef", vm.LegStatsPanel); + AppendPartStats(sql, "@inventoryDef", vm.InventoryStatsPanel); + + // 19b. chassisbonus + foreach (var row in vm.BonusesPanel.Rows) + sql.AppendLine( + $"INSERT INTO chassisbonus (definition, extension, bonus, targetpropertyID, effectenhancer, note)" + + $" VALUES (@chassisDef, {row.ExtensionId}, {SqlLiteral.Of(row.NewBonus)}," + + $" {row.TargetPropertyId}, {SqlLiteral.Of(row.EffectEnhancer)}," + + $" {(string.IsNullOrEmpty(row.Note) ? "NULL" : SqlLiteral.Of(row.Note))});"); + + // 20. robottemplates (genxy auto-generated via FORMAT + SCOPE_IDENTITY vars) + sql.AppendLine("DECLARE @templateId INT;"); + sql.AppendLine( + $"INSERT INTO robottemplates (name, description, note)" + + $" VALUES ({SqlLiteral.Of(vm.TemplatePanelViewModel.Name)}," + + " '#robot=i' + FORMAT(@robotDef, 'x')" + + " + '#head=i' + FORMAT(@headDef, 'x')" + + " + '#chassis=i' + FORMAT(@chassisDef, 'x')" + + " + '#leg=i' + FORMAT(@legDef, 'x')" + + $" + '#container=i' + FORMAT(@inventoryDef, 'x')," + + $" {SqlLiteral.Of(vm.TemplatePanelViewModel.Note)});"); + sql.AppendLine("SET @templateId = SCOPE_IDENTITY();"); + + // 21. robottemplaterelation + var rel = vm.TemplateRelationPanelViewModel; + sql.AppendLine( + "INSERT INTO robottemplaterelation (definition, templateid, itemscoresum, raceid, missionlevel, missionleveloverride, killep, note)" + + $" VALUES (@robotDef, @templateId, {SqlLiteral.Of(rel.ItemScoreSum)}, {rel.RaceId}, {rel.MissionLevel}, {rel.MissionLevelOverride}, {rel.KillEp}, {SqlLiteral.Of(rel.Note)});"); + } + + return new RawSqlChange($"Create new robot: {basic.DefinitionName}", sql.ToString()); + } + + private static void AppendPartStats(StringBuilder sql, string defVar, StatsPanelViewModel stats) + { + foreach (var row in stats.Rows) + sql.AppendLine($"INSERT INTO aggregatevalues (definition, field, value) VALUES ({defVar}, {row.FieldId}, {SqlLiteral.Of(row.NewValue)});"); + } + + // Returns the options string with all robot-part-ref keys removed so they can be + // re-written with the new definition IDs without losing unrelated options. + private static string StripPartRefKeys(string optionsText) + { + if (string.IsNullOrEmpty(optionsText)) + return ""; + + var dict = GenxyConverter.Deserialize(optionsText); + dict.Remove("head"); + dict.Remove("chassis"); + dict.Remove("leg"); + dict.Remove("inventory"); + dict.Remove("container"); + return GenxyConverter.Serialize(dict); + } +} diff --git a/src/Perpetuum.AdminTool/NewRobot/RobotTemplatePanelViewModel.cs b/src/Perpetuum.AdminTool/NewRobot/RobotTemplatePanelViewModel.cs new file mode 100644 index 00000000..150f726b --- /dev/null +++ b/src/Perpetuum.AdminTool/NewRobot/RobotTemplatePanelViewModel.cs @@ -0,0 +1,13 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.NewRobot; + +public partial class RobotTemplatePanelViewModel : ObservableObject +{ + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _note = ""; + + public bool HasErrors => string.IsNullOrWhiteSpace(Name); + + partial void OnNameChanged(string value) => OnPropertyChanged(nameof(HasErrors)); +} diff --git a/src/Perpetuum.AdminTool/NewRobot/RobotTemplateRelationPanelViewModel.cs b/src/Perpetuum.AdminTool/NewRobot/RobotTemplateRelationPanelViewModel.cs new file mode 100644 index 00000000..b823579e --- /dev/null +++ b/src/Perpetuum.AdminTool/NewRobot/RobotTemplateRelationPanelViewModel.cs @@ -0,0 +1,31 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.NewRobot; + +public record RobotTemplateRelationData( + int ItemScoreSum, + int RaceId, + int MissionLevel, + int MissionLevelOverride, + int KillEp, + string? Note); + +public partial class RobotTemplateRelationPanelViewModel : ObservableObject +{ + [ObservableProperty] private int _itemScoreSum; + [ObservableProperty] private int _raceId; + [ObservableProperty] private int _missionLevel; + [ObservableProperty] private int _missionLevelOverride; + [ObservableProperty] private int _killEp; + [ObservableProperty] private string _note = ""; + + public void LoadFromClone(RobotTemplateRelationData data) + { + ItemScoreSum = data.ItemScoreSum; + RaceId = data.RaceId; + MissionLevel = data.MissionLevel; + MissionLevelOverride = data.MissionLevelOverride; + KillEp = data.KillEp; + Note = data.Note ?? ""; + } +} diff --git a/src/Perpetuum.AdminTool/Npc/FlockRepository.cs b/src/Perpetuum.AdminTool/Npc/FlockRepository.cs new file mode 100644 index 00000000..28352632 --- /dev/null +++ b/src/Perpetuum.AdminTool/Npc/FlockRepository.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Npc +{ + public class FlockRepository + { + private readonly ConnectionSettings _connection; + + public FlockRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task LoadAllAsync() + { + var result = new FlockLoad(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + await LoadPresencePicksAsync(cn, result.PresencePicks); + + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "select id, name, presenceid, flockmembercount, definition, " + + "spawnoriginX, spawnoriginY, spawnrangeMin, spawnrangeMax, " + + "respawnseconds, totalspawncount, homerange, note, respawnmultiplierlow, " + + "enabled, iscallforhelp, behaviorType, npcSpecialType " + + "from npcflock order by presenceid, name, id"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var snap = new FlockSnapshot + { + Id = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + PresenceId = reader.GetInt32(2), + FlockMemberCount = reader.GetInt32(3), + Definition = reader.GetInt32(4), + SpawnOriginX = reader.GetInt32(5), + SpawnOriginY = reader.GetInt32(6), + SpawnRangeMin = reader.GetInt32(7), + SpawnRangeMax = reader.GetInt32(8), + RespawnSeconds = reader.GetInt32(9), + TotalSpawnCount = reader.GetInt32(10), + HomeRange = reader.GetInt32(11), + Note = reader.IsDBNull(12) ? null : reader.GetString(12), + RespawnMultiplierLow = reader.IsDBNull(13) ? 0 : reader.GetDouble(13), + Enabled = !reader.IsDBNull(14) && reader.GetBoolean(14), + IsCallForHelp = !reader.IsDBNull(15) && reader.GetBoolean(15), + BehaviorType = reader.GetInt32(16), + NpcSpecialType = reader.GetInt32(17) + }; + result.Rows.Add(new FlockRow(snap)); + } + return result; + } + + public async Task> LoadByPresenceAsync(int presenceId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "select f.id, f.name, f.definition, e.definitionname, " + + "f.flockmembercount, f.enabled, f.behaviorType, f.npcSpecialType " + + "from npcflock f " + + "left join entitydefaults e on e.definition = f.definition " + + "where f.presenceid = @pid " + + "order by f.name, f.id"; + cmd.Parameters.AddWithValue("@pid", presenceId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new FlockSummary + { + Id = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + Definition = reader.GetInt32(2), + DefinitionName = reader.IsDBNull(3) ? "" : reader.GetString(3), + FlockMemberCount = reader.GetInt32(4), + Enabled = !reader.IsDBNull(5) && reader.GetBoolean(5), + BehaviorType = reader.GetInt32(6), + NpcSpecialType = reader.GetInt32(7) + }); + } + return result; + } + + private static async Task LoadPresencePicksAsync(SqlConnection cn, List sink) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "select id, name from npcpresence order by name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + sink.Add(new PresencePickItem + { + Id = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1) + }); + } + } + } + + public class FlockLoad + { + public List Rows { get; } = new(); + public List PresencePicks { get; } = new(); + } + + public class FlockSummary + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public int Definition { get; init; } + public string DefinitionName { get; init; } = ""; + public int FlockMemberCount { get; init; } + public bool Enabled { get; init; } + public int BehaviorType { get; init; } + public int NpcSpecialType { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Npc/FlockRow.cs b/src/Perpetuum.AdminTool/Npc/FlockRow.cs new file mode 100644 index 00000000..dfe99093 --- /dev/null +++ b/src/Perpetuum.AdminTool/Npc/FlockRow.cs @@ -0,0 +1,117 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.Npc +{ + public partial class FlockRow : ObservableObject + { + // PK is the identity column `id`. 0 means a new (unsaved) row. + public int Id { get; } + + public bool IsNew { get; set; } + public FlockSnapshot Original { get; private set; } + + // Resolved display labels (not persisted) — kept in sync with current PresenceId / Definition + // by FlocksViewModel using the pick lists. + [ObservableProperty] private string _presenceName = ""; + [ObservableProperty] private string _definitionName = ""; + + [ObservableProperty] private string _name = ""; + [ObservableProperty] private int _presenceId; + [ObservableProperty] private int _flockMemberCount; + [ObservableProperty] private int _definition; + [ObservableProperty] private int _spawnOriginX; + [ObservableProperty] private int _spawnOriginY; + [ObservableProperty] private int _spawnRangeMin; + [ObservableProperty] private int _spawnRangeMax; + [ObservableProperty] private int _respawnSeconds; + [ObservableProperty] private int _totalSpawnCount; + [ObservableProperty] private int _homeRange; + [ObservableProperty] private string? _note; + [ObservableProperty] private double _respawnMultiplierLow; + [ObservableProperty] private bool _enabled; + [ObservableProperty] private bool _isCallForHelp; + [ObservableProperty] private int _behaviorType; + [ObservableProperty] private int _npcSpecialType; + + public FlockRow(FlockSnapshot snapshot) + { + Id = snapshot.Id; + Original = snapshot; + ApplySnapshot(snapshot); + } + + public void ApplySnapshot(FlockSnapshot s) + { + Original = s; + Name = s.Name; + PresenceId = s.PresenceId; + FlockMemberCount = s.FlockMemberCount; + Definition = s.Definition; + SpawnOriginX = s.SpawnOriginX; + SpawnOriginY = s.SpawnOriginY; + SpawnRangeMin = s.SpawnRangeMin; + SpawnRangeMax = s.SpawnRangeMax; + RespawnSeconds = s.RespawnSeconds; + TotalSpawnCount = s.TotalSpawnCount; + HomeRange = s.HomeRange; + Note = s.Note; + RespawnMultiplierLow = s.RespawnMultiplierLow; + Enabled = s.Enabled; + IsCallForHelp = s.IsCallForHelp; + BehaviorType = s.BehaviorType; + NpcSpecialType = s.NpcSpecialType; + } + + public void RefreshOriginalFromCurrent() + { + Original = new FlockSnapshot + { + Id = Id, + Name = Name, + PresenceId = PresenceId, + FlockMemberCount = FlockMemberCount, + Definition = Definition, + SpawnOriginX = SpawnOriginX, + SpawnOriginY = SpawnOriginY, + SpawnRangeMin = SpawnRangeMin, + SpawnRangeMax = SpawnRangeMax, + RespawnSeconds = RespawnSeconds, + TotalSpawnCount = TotalSpawnCount, + HomeRange = HomeRange, + Note = Note, + RespawnMultiplierLow = RespawnMultiplierLow, + Enabled = Enabled, + IsCallForHelp = IsCallForHelp, + BehaviorType = BehaviorType, + NpcSpecialType = NpcSpecialType + }; + } + + public static FlockRow CreateNew(FlockSnapshot seed) + { + return new FlockRow(seed) { IsNew = true }; + } + } + + public class FlockSnapshot + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public int PresenceId { get; init; } + public int FlockMemberCount { get; init; } + public int Definition { get; init; } + public int SpawnOriginX { get; init; } + public int SpawnOriginY { get; init; } + public int SpawnRangeMin { get; init; } + public int SpawnRangeMax { get; init; } + public int RespawnSeconds { get; init; } + public int TotalSpawnCount { get; init; } + public int HomeRange { get; init; } + public string? Note { get; init; } + public double RespawnMultiplierLow { get; init; } + public bool Enabled { get; init; } + public bool IsCallForHelp { get; init; } + public int BehaviorType { get; init; } + public int NpcSpecialType { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Npc/PresenceRepository.cs b/src/Perpetuum.AdminTool/Npc/PresenceRepository.cs new file mode 100644 index 00000000..f5d68a90 --- /dev/null +++ b/src/Perpetuum.AdminTool/Npc/PresenceRepository.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Npc +{ + public class PresenceRepository + { + private readonly ConnectionSettings _connection; + + public PresenceRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task LoadAllAsync() + { + var result = new PresenceLoad(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + await LoadZoneSpawnPicksAsync(cn, result.ZoneSpawnPicks); + + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "select id, name, topx, topy, bottomx, bottomy, note, spawnid, enabled, " + + "roaming, roamingrespawnseconds, presencetype, maxrandomflock, " + + "randomcenterx, randomcentery, randomradius, dynamiclifetime, " + + "isbodypull, isrespawnallowed, safebodypull, izgroupid, growthseconds " + + "from npcpresence order by name, id"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var snap = new PresenceSnapshot + { + Id = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + TopX = reader.GetInt32(2), + TopY = reader.GetInt32(3), + BottomX = reader.GetInt32(4), + BottomY = reader.GetInt32(5), + Note = reader.IsDBNull(6) ? null : reader.GetString(6), + SpawnId = reader.IsDBNull(7) ? null : reader.GetInt32(7), + Enabled = !reader.IsDBNull(8) && reader.GetBoolean(8), + Roaming = !reader.IsDBNull(9) && reader.GetBoolean(9), + RoamingRespawnSeconds = reader.GetInt32(10), + PresenceType = reader.GetInt32(11), + MaxRandomFlock = reader.IsDBNull(12) ? null : reader.GetInt32(12), + RandomCenterX = reader.IsDBNull(13) ? null : reader.GetInt32(13), + RandomCenterY = reader.IsDBNull(14) ? null : reader.GetInt32(14), + RandomRadius = reader.IsDBNull(15) ? null : reader.GetInt32(15), + DynamicLifetime = reader.IsDBNull(16) ? null : reader.GetInt32(16), + IsBodyPull = !reader.IsDBNull(17) && reader.GetBoolean(17), + IsRespawnAllowed = !reader.IsDBNull(18) && reader.GetBoolean(18), + SafeBodyPull = !reader.IsDBNull(19) && reader.GetBoolean(19), + IzGroupId = reader.IsDBNull(20) ? null : reader.GetInt32(20), + GrowthSeconds = reader.IsDBNull(21) ? null : reader.GetInt32(21) + }; + result.Rows.Add(new PresenceRow(snap)); + } + return result; + } + + private static async Task LoadZoneSpawnPicksAsync(SqlConnection cn, List sink) + { + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "select spawnid, name from zones where spawnid is not null order by name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + sink.Add(new ZoneSpawnPickItem + { + SpawnId = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1) + }); + } + } + } + + public class PresenceLoad + { + public List Rows { get; } = new(); + public List ZoneSpawnPicks { get; } = new(); + } +} diff --git a/src/Perpetuum.AdminTool/Npc/PresenceRow.cs b/src/Perpetuum.AdminTool/Npc/PresenceRow.cs new file mode 100644 index 00000000..8d693b8f --- /dev/null +++ b/src/Perpetuum.AdminTool/Npc/PresenceRow.cs @@ -0,0 +1,132 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.Npc +{ + public partial class PresenceRow : ObservableObject + { + // PK is the identity column `id`. 0 means a new (unsaved) row. + public int Id { get; } + + public bool IsNew { get; set; } + public PresenceSnapshot Original { get; private set; } + + // Resolved display label (not persisted) — kept in sync with current SpawnId + // by PresencesViewModel using the zones pick list. + [ObservableProperty] private string _spawnName = ""; + + [ObservableProperty] private string _name = ""; + [ObservableProperty] private int _topX; + [ObservableProperty] private int _topY; + [ObservableProperty] private int _bottomX; + [ObservableProperty] private int _bottomY; + [ObservableProperty] private string? _note; + [ObservableProperty] private int? _spawnId; + [ObservableProperty] private bool _enabled; + [ObservableProperty] private bool _roaming; + [ObservableProperty] private int _roamingRespawnSeconds; + [ObservableProperty] private int _presenceType; + [ObservableProperty] private int? _maxRandomFlock; + [ObservableProperty] private int? _randomCenterX; + [ObservableProperty] private int? _randomCenterY; + [ObservableProperty] private int? _randomRadius; + [ObservableProperty] private int? _dynamicLifetime; + [ObservableProperty] private bool _isBodyPull; + [ObservableProperty] private bool _isRespawnAllowed; + [ObservableProperty] private bool _safeBodyPull; + [ObservableProperty] private int? _izGroupId; + [ObservableProperty] private int? _growthSeconds; + + public PresenceRow(PresenceSnapshot snapshot) + { + Id = snapshot.Id; + Original = snapshot; + ApplySnapshot(snapshot); + } + + public void ApplySnapshot(PresenceSnapshot s) + { + Original = s; + Name = s.Name; + TopX = s.TopX; + TopY = s.TopY; + BottomX = s.BottomX; + BottomY = s.BottomY; + Note = s.Note; + SpawnId = s.SpawnId; + Enabled = s.Enabled; + Roaming = s.Roaming; + RoamingRespawnSeconds = s.RoamingRespawnSeconds; + PresenceType = s.PresenceType; + MaxRandomFlock = s.MaxRandomFlock; + RandomCenterX = s.RandomCenterX; + RandomCenterY = s.RandomCenterY; + RandomRadius = s.RandomRadius; + DynamicLifetime = s.DynamicLifetime; + IsBodyPull = s.IsBodyPull; + IsRespawnAllowed = s.IsRespawnAllowed; + SafeBodyPull = s.SafeBodyPull; + IzGroupId = s.IzGroupId; + GrowthSeconds = s.GrowthSeconds; + } + + public void RefreshOriginalFromCurrent() + { + Original = new PresenceSnapshot + { + Id = Id, + Name = Name, + TopX = TopX, + TopY = TopY, + BottomX = BottomX, + BottomY = BottomY, + Note = Note, + SpawnId = SpawnId, + Enabled = Enabled, + Roaming = Roaming, + RoamingRespawnSeconds = RoamingRespawnSeconds, + PresenceType = PresenceType, + MaxRandomFlock = MaxRandomFlock, + RandomCenterX = RandomCenterX, + RandomCenterY = RandomCenterY, + RandomRadius = RandomRadius, + DynamicLifetime = DynamicLifetime, + IsBodyPull = IsBodyPull, + IsRespawnAllowed = IsRespawnAllowed, + SafeBodyPull = SafeBodyPull, + IzGroupId = IzGroupId, + GrowthSeconds = GrowthSeconds + }; + } + + public static PresenceRow CreateNew(PresenceSnapshot seed) + { + return new PresenceRow(seed) { IsNew = true }; + } + } + + public class PresenceSnapshot + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public int TopX { get; init; } + public int TopY { get; init; } + public int BottomX { get; init; } + public int BottomY { get; init; } + public string? Note { get; init; } + public int? SpawnId { get; init; } + public bool Enabled { get; init; } + public bool Roaming { get; init; } + public int RoamingRespawnSeconds { get; init; } + public int PresenceType { get; init; } + public int? MaxRandomFlock { get; init; } + public int? RandomCenterX { get; init; } + public int? RandomCenterY { get; init; } + public int? RandomRadius { get; init; } + public int? DynamicLifetime { get; init; } + public bool IsBodyPull { get; init; } + public bool IsRespawnAllowed { get; init; } + public bool SafeBodyPull { get; init; } + public int? IzGroupId { get; init; } + public int? GrowthSeconds { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Packages/PackageChanges.cs b/src/Perpetuum.AdminTool/Packages/PackageChanges.cs new file mode 100644 index 00000000..c3c9decd --- /dev/null +++ b/src/Perpetuum.AdminTool/Packages/PackageChanges.cs @@ -0,0 +1,60 @@ +using Perpetuum.AdminTool.Editing; + +namespace Perpetuum.AdminTool.Packages +{ + public static class PackageChanges + { + public static IPendingChange BuildInsertPackage(string name) + { + return new RawSqlChange( + $"packages: insert '{name}'", + $"INSERT INTO packages (name) VALUES ({SqlLiteral.Of(name)})"); + } + + public static IPendingChange BuildInsertPackageWithItems(string name, System.Collections.Generic.IReadOnlyList items) + { + var varName = "@pkgId_" + System.Guid.NewGuid().ToString("N").Substring(0, 8); + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"DECLARE {varName} INT;"); + sb.AppendLine($"INSERT INTO packages (name) VALUES ({SqlLiteral.Of(name)});"); + sb.AppendLine($"SET {varName} = SCOPE_IDENTITY();"); + foreach (var it in items) + sb.AppendLine($"INSERT INTO packageitems (packageid, definition, quantity) VALUES ({varName}, {it.Definition}, {it.Quantity});"); + var desc = items.Count > 0 + ? $"packages: insert '{name}' with {items.Count} item(s)" + : $"packages: insert '{name}'"; + return new RawSqlChange(desc, sb.ToString()); + } + + public static IPendingChange BuildUpdatePackage(int id, string name) + { + return new RawSqlChange( + $"packages: update id {id} (name '{name}')", + $"UPDATE packages SET name = {SqlLiteral.Of(name)} WHERE id = {id}"); + } + + public static IPendingChange BuildDeletePackage(int id) + { + return new RawSqlChange( + $"packages: delete id {id}", + $"DELETE FROM packages WHERE id = {id}", + isDestructive: true); + } + + public static IPendingChange BuildInsertPackageItem(int packageId, int definition, int quantity) + { + return new RawSqlChange( + $"packageitems: insert package {packageId} def {definition} qty {quantity}", + "INSERT INTO packageitems (packageid, definition, quantity) " + + $"VALUES ({packageId}, {definition}, {quantity})"); + } + + public static IPendingChange BuildDeletePackageItem(int id) + { + return new RawSqlChange( + $"packageitems: delete id {id}", + $"DELETE FROM packageitems WHERE id = {id}", + isDestructive: true); + } + } +} diff --git a/src/Perpetuum.AdminTool/Packages/PackageItemPickItem.cs b/src/Perpetuum.AdminTool/Packages/PackageItemPickItem.cs new file mode 100644 index 00000000..e002d166 --- /dev/null +++ b/src/Perpetuum.AdminTool/Packages/PackageItemPickItem.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Linq; +using Perpetuum.AdminTool.Common; +using Perpetuum.ExportedTypes; + +namespace Perpetuum.AdminTool.Packages +{ + public record PackageItemPickItem(int Definition, string DisplayName) + { + public string Display => $"{Definition} — {DisplayName}"; + + private static readonly long[] AllowedRoots = + { + (long)CategoryFlags.cf_robots, + (long)CategoryFlags.cf_ammo, + (long)CategoryFlags.cf_robot_equipment, + (long)CategoryFlags.cf_material, + (long)CategoryFlags.cf_production_items, + (long)CategoryFlags.cf_gift_packages, + (long)CategoryFlags.cf_consumable_items, + (long)CategoryFlags.cf_consumable_boosters, + (long)CategoryFlags.cf_field_accessories, + (long)CategoryFlags.cf_pbs_capsules, + (long)CategoryFlags.cf_redeemables, + }; + + public static List BuildFilteredList( + IEnumerable all, + Dictionary? englishNames = null) + { + var result = new List(); + foreach (var e in all) + { + if (!e.Enabled) continue; + if (e.Hidden) continue; + if (e.CategoryFlags == 0) continue; + if (!MatchesAnyRoot(e.CategoryFlags)) continue; + var baseName = (englishNames != null && englishNames.TryGetValue(e.Name, out var eng) && !string.IsNullOrEmpty(eng)) + ? eng + : e.Name; + var tierLabel = GetTierLabel(e.CategoryFlags, e.TierType, e.TierLevel); + var displayName = tierLabel.Length > 0 ? $"{baseName} ({tierLabel})" : baseName; + result.Add(new PackageItemPickItem(e.Definition, displayName)); + } + return result.OrderBy(p => p.DisplayName, System.StringComparer.OrdinalIgnoreCase).ToList(); + } + + internal static string GetTierLabel(long categoryFlags, int tierType, int tierLevel) + { + var tt = (TierType)tierType; + bool isRobot = (categoryFlags & CategoryFlagsMask((long)CategoryFlags.cf_robots)) == (long)CategoryFlags.cf_robots; + + if (isRobot) + { + return tt switch + { + TierType.Prototype => "P", + TierType.Normal when tierLevel >= 2 => $"Mk{tierLevel}", + _ => "" + }; + } + return (tt, tierLevel) switch + { + (TierType.Undefined, _) => "", + (_, 0) => "", + (TierType.Normal, 1) => "", + (TierType.Normal, int l) => $"T{l}", + (TierType.Prototype, int l) => $"T{l}P", + (TierType.Special, int l) => $"T{l}+", + _ => "" + }; + } + + private static bool MatchesAnyRoot(long entityFlags) + { + foreach (var root in AllowedRoots) + { + var mask = CategoryFlagsMask(root); + if ((entityFlags & mask) == root) return true; + } + return false; + } + + internal static long CategoryFlagsMask(long target) + { + var mask = unchecked((long)0xFFFFFFFFFFFFFFFFUL); + while (((ulong)target & (ulong)mask) > 0) + mask <<= 8; + return ~mask; + } + } +} diff --git a/src/Perpetuum.AdminTool/Packages/PackageItemRow.cs b/src/Perpetuum.AdminTool/Packages/PackageItemRow.cs new file mode 100644 index 00000000..a69ad067 --- /dev/null +++ b/src/Perpetuum.AdminTool/Packages/PackageItemRow.cs @@ -0,0 +1,23 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.Packages +{ + public partial class PackageItemRow : ObservableObject + { + public int Id { get; set; } + public int PackageId { get; set; } + public bool IsNew { get; set; } + + [ObservableProperty] private int _definition; + [ObservableProperty] private int _quantity = 1; + [ObservableProperty] private string _displayName = ""; + [ObservableProperty] private PackageItemPickItem? _selectedPickItem; + + partial void OnSelectedPickItemChanged(PackageItemPickItem? value) + { + if (value == null) return; + Definition = value.Definition; + DisplayName = value.DisplayName; + } + } +} diff --git a/src/Perpetuum.AdminTool/Packages/PackageRepository.cs b/src/Perpetuum.AdminTool/Packages/PackageRepository.cs new file mode 100644 index 00000000..1acbad8c --- /dev/null +++ b/src/Perpetuum.AdminTool/Packages/PackageRepository.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Packages +{ + public class PackageRepository + { + private readonly ConnectionSettings _connection; + + public PackageRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task> LoadAllPackagesAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT " + + " p.id, " + + " p.name, " + + " (SELECT COUNT(*) FROM packageitems pi WHERE pi.packageid = p.id) AS item_count, " + + " (SELECT COUNT(DISTINCT season_id) " + + " FROM ( " + + " SELECT season_id FROM season_tiers WHERE package_id = p.id " + + " UNION ALL " + + " SELECT season_id FROM season_leaderboard_rewards WHERE package_id = p.id " + + " ) refs " + + " ) AS season_count " + + "FROM packages p " + + "ORDER BY p.name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new PackageRow + { + Id = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + ItemCount = reader.GetInt32(2), + SeasonCount = reader.GetInt32(3) + }); + } + return result; + } + + public async Task> LoadPackageItemsAsync(int packageId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT id, packageid, definition, quantity " + + "FROM packageitems WHERE packageid = @packageId"; + cmd.Parameters.AddWithValue("@packageId", packageId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new PackageItemRow + { + Id = reader.GetInt32(0), + PackageId = reader.GetInt32(1), + Definition = reader.GetInt32(2), + Quantity = reader.GetInt32(3) + }); + } + return result; + } + + public async Task> LoadSeasonUsageAsync(int packageId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT s.id AS season_id, s.name AS season_name, s.is_active, " + + " 'Tier' AS context, t.tier_name AS detail " + + "FROM season_tiers t " + + "JOIN seasons s ON s.id = t.season_id " + + "WHERE t.package_id = @packageId " + + "UNION ALL " + + "SELECT s.id, s.name, s.is_active, " + + " 'Leaderboard' AS context, " + + " 'Rank ' + CAST(lr.rank_min AS varchar) + '-' + CAST(lr.rank_max AS varchar) AS detail " + + "FROM season_leaderboard_rewards lr " + + "JOIN seasons s ON s.id = lr.season_id " + + "WHERE lr.package_id = @packageId " + + "ORDER BY season_name, context"; + cmd.Parameters.AddWithValue("@packageId", packageId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new PackageUsageRow( + reader.GetInt32(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + !reader.IsDBNull(2) && reader.GetBoolean(2), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? "" : reader.GetString(4))); + } + return result; + } + } + + public record PackageUsageRow(int SeasonId, string SeasonName, bool IsActive, string Context, string Detail); +} diff --git a/src/Perpetuum.AdminTool/Packages/PackageRow.cs b/src/Perpetuum.AdminTool/Packages/PackageRow.cs new file mode 100644 index 00000000..6e985705 --- /dev/null +++ b/src/Perpetuum.AdminTool/Packages/PackageRow.cs @@ -0,0 +1,30 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.Packages +{ + public partial class PackageRow : ObservableObject + { + public int Id { get; set; } + public bool IsNew { get; set; } + + [ObservableProperty] private string _name = ""; + [ObservableProperty] private int _itemCount; + [ObservableProperty] private int _seasonCount; + + public bool IsUnused => SeasonCount == 0; + public string Display => Name; + + public string SubtitleText => SeasonCount == 0 + ? $"{ItemCount} item(s) — Not used" + : $"{ItemCount} item(s) — Used by {SeasonCount} season(s)"; + + partial void OnSeasonCountChanged(int value) + { + OnPropertyChanged(nameof(IsUnused)); + OnPropertyChanged(nameof(SubtitleText)); + } + + partial void OnItemCountChanged(int value) => OnPropertyChanged(nameof(SubtitleText)); + partial void OnNameChanged(string value) => OnPropertyChanged(nameof(Display)); + } +} diff --git a/src/Perpetuum.AdminTool/Perpetuum.AdminTool.csproj b/src/Perpetuum.AdminTool/Perpetuum.AdminTool.csproj new file mode 100644 index 00000000..fc782ee0 --- /dev/null +++ b/src/Perpetuum.AdminTool/Perpetuum.AdminTool.csproj @@ -0,0 +1,31 @@ + + + + WinExe + net8.0-windows + true + enable + annotations + x64 + Perpetuum.AdminTool + Perpetuum.AdminTool + app.manifest + win-x64 + + + + true + win-x64 + + + + + + + + + + + + + diff --git a/src/Perpetuum.AdminTool/Seasons/MaterialPickItem.cs b/src/Perpetuum.AdminTool/Seasons/MaterialPickItem.cs new file mode 100644 index 00000000..b352fc2c --- /dev/null +++ b/src/Perpetuum.AdminTool/Seasons/MaterialPickItem.cs @@ -0,0 +1,7 @@ +namespace Perpetuum.AdminTool.Seasons +{ + public record MaterialPickItem(int Definition, string DisplayName) + { + public string Display => $"{Definition} — {DisplayName}"; + } +} diff --git a/src/Perpetuum.AdminTool/Seasons/SeasonActivityRateRow.cs b/src/Perpetuum.AdminTool/Seasons/SeasonActivityRateRow.cs new file mode 100644 index 00000000..b73b1d55 --- /dev/null +++ b/src/Perpetuum.AdminTool/Seasons/SeasonActivityRateRow.cs @@ -0,0 +1,97 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.Services.Seasons; +using System.Globalization; + +namespace Perpetuum.AdminTool.Seasons +{ + public partial class SeasonActivityRateRow : ObservableObject + { + public int Id { get; set; } + public int SeasonId { get; set; } + + [ObservableProperty] private SeasonActivityType _activityType; + [ObservableProperty] private double _pointsPerUnit; + [ObservableProperty] private int _unitScale = 1; + + public string ActivityTypeLabel => ActivityType switch + { + SeasonActivityType.NpcKill => "NPC Kill", + SeasonActivityType.PvpKill => "PvP Kill", + SeasonActivityType.MissionComplete => "Mission Complete", + SeasonActivityType.MineralMined => "Mineral Mined", + SeasonActivityType.EpSpent => "EP Spent", + SeasonActivityType.NicEarned => "NIC Earned", + SeasonActivityType.NicSpent => "NIC Spent", + SeasonActivityType.IntrusionPoint => "Intrusion Point", + SeasonActivityType.PlantHarvested => "Plant Harvested", + + // Phase 1 — non-combat + SeasonActivityType.Prototyping => "Prototyping", + SeasonActivityType.ReverseEngineering => "Reverse Engineering", + SeasonActivityType.Production => "Production", + SeasonActivityType.ArtifactFound => "Artifact Found", + SeasonActivityType.EpEarned => "EP Earned", + // Phase 2 — combat + SeasonActivityType.DamageDone => "Damage Done", + SeasonActivityType.DamageReceived => "Damage Received", + SeasonActivityType.ArmorRestored => "Armor Restored", + SeasonActivityType.EnergyDrainDealt => "Energy Drain Dealt", + SeasonActivityType.EnergyDrainReceived => "Energy Drain Received", + SeasonActivityType.EnergyTransferDealt => "Energy Transfer Dealt", + SeasonActivityType.EnergyTransferReceived => "Energy Transfer Received", + _ => ActivityType.ToString() + }; + + public string EffectiveRate => GetEffectiveRateLabel(ActivityType, PointsPerUnit, UnitScale); + + public bool CanQueueSave => Id > 0 || PointsPerUnit > 0; + + partial void OnPointsPerUnitChanged(double value) + { + OnPropertyChanged(nameof(EffectiveRate)); + OnPropertyChanged(nameof(CanQueueSave)); + } + partial void OnUnitScaleChanged(int value) => OnPropertyChanged(nameof(EffectiveRate)); + partial void OnActivityTypeChanged(SeasonActivityType value) => OnPropertyChanged(nameof(EffectiveRate)); + + public static string GetEffectiveRateLabel(SeasonActivityType type, double pointsPerUnit, int unitScale) + { + if (pointsPerUnit == 0) return "Disabled"; + + var pts = pointsPerUnit.ToString("0.##", CultureInfo.InvariantCulture); + var scale = unitScale.ToString("N0", CultureInfo.InvariantCulture); + + return type switch + { + SeasonActivityType.NpcKill => unitScale > 1 + ? $"{pts} pts per {scale} kills" + : $"{pts} pts per kill", + SeasonActivityType.PvpKill => unitScale > 1 + ? $"{pts} pts per {scale} kills" + : $"{pts} pts per kill", + SeasonActivityType.MissionComplete => unitScale > 1 + ? $"{pts} pts per {scale} completions" + : $"{pts} pts per completion", + SeasonActivityType.IntrusionPoint => unitScale > 1 + ? $"{pts} pts per {scale} intrusion points" + : $"{pts} pts per intrusion point", + SeasonActivityType.MineralMined => unitScale > 1 + ? $"{pts} pts per {scale} units mined" + : $"{pts} pts per unit mined", + SeasonActivityType.EpSpent => unitScale > 1 + ? $"{pts} pts per {scale} EP spent" + : $"{pts} pts per EP spent", + SeasonActivityType.NicEarned => unitScale > 1 + ? $"{pts} pts per {scale} NIC earned" + : $"{pts} pts per NIC earned", + SeasonActivityType.NicSpent => unitScale > 1 + ? $"{pts} pts per {scale} NIC spent" + : $"{pts} pts per NIC spent", + SeasonActivityType.PlantHarvested => unitScale > 1 + ? $"{pts} pts per {scale} units harvested" + : $"{pts} pts per unit harvested", + _ => $"{pts} pts" + }; + } + } +} diff --git a/src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs b/src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs new file mode 100644 index 00000000..297241db --- /dev/null +++ b/src/Perpetuum.AdminTool/Seasons/SeasonChanges.cs @@ -0,0 +1,136 @@ +using System; +using Perpetuum.AdminTool.Editing; +using Perpetuum.Services.Seasons; + +namespace Perpetuum.AdminTool.Seasons +{ + public static class SeasonChanges + { + public static IPendingChange BuildInsert(SeasonRow row) + { + string gapSql = row.IsRecurring && row.RecurrenceGapDays.HasValue + ? row.RecurrenceGapDays.Value.ToString() + : "NULL"; + string baseNameSql = row.IsRecurring && row.RecurrenceBaseName != null + ? SqlLiteral.Of(row.RecurrenceBaseName) + : "NULL"; + string dailyObjSql = SqlLiteral.OfNullableInt(row.DailyObjectivesPerDay > 0 ? row.DailyObjectivesPerDay : null); + return new RawSqlChange( + $"seasons: insert '{row.Name}'", + $"INSERT INTO seasons (name, description, start_time, end_time, is_active, " + + $"is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name, scoring_mode, " + + $"daily_objectives_per_day) VALUES (" + + $"{SqlLiteral.Of(row.Name)}, {SqlLiteral.Of(row.Description)}, " + + $"'{DateTime.SpecifyKind(row.StartTime, DateTimeKind.Utc):yyyy-MM-ddTHH:mm:ss}', " + + $"'{DateTime.SpecifyKind(row.EndTime, DateTimeKind.Utc):yyyy-MM-ddTHH:mm:ss}', 0, " + + $"{(row.IsRecurring ? 1 : 0)}, {gapSql}, 1, {baseNameSql}, {(int)row.ScoringMode}, " + + $"{dailyObjSql})"); + } + + public static IPendingChange BuildUpdate(SeasonRow row) + { + string gapSql = row.IsRecurring && row.RecurrenceGapDays.HasValue + ? row.RecurrenceGapDays.Value.ToString() + : "NULL"; + string baseNameSql = row.IsRecurring && row.RecurrenceBaseName != null + ? SqlLiteral.Of(row.RecurrenceBaseName) + : "NULL"; + string dailyObjSql = SqlLiteral.OfNullableInt(row.DailyObjectivesPerDay > 0 ? row.DailyObjectivesPerDay : null); + var sets = $"name = {SqlLiteral.Of(row.Name)}, description = {SqlLiteral.Of(row.Description)}, " + + $"start_time = '{DateTime.SpecifyKind(row.StartTime, DateTimeKind.Utc):yyyy-MM-ddTHH:mm:ss}', " + + $"end_time = '{DateTime.SpecifyKind(row.EndTime, DateTimeKind.Utc):yyyy-MM-ddTHH:mm:ss}', " + + $"is_recurring = {(row.IsRecurring ? 1 : 0)}, " + + $"recurrence_gap_days = {gapSql}, " + + $"recurrence_base_name = {baseNameSql}, " + + $"scoring_mode = {(int)row.ScoringMode}, " + + $"daily_objectives_per_day = {dailyObjSql}"; + return new RawSqlChange( + $"seasons: update id {row.Id} ('{row.Name}')", + $"UPDATE seasons SET {sets} WHERE id = {row.Id}"); + } + + public static IPendingChange BuildActivate(int seasonId) => + new RawSqlChange($"seasons: activate id {seasonId}", + $"UPDATE seasons SET is_active = 1 WHERE id = {seasonId}"); + + public static IPendingChange BuildDeactivate(int seasonId) => + new RawSqlChange($"seasons: deactivate id {seasonId}", + $"UPDATE seasons SET is_active = 0 WHERE id = {seasonId}"); + + public static IPendingChange BuildUpsertActivityRate(SeasonActivityRateRow row) => + new RawSqlChange( + $"season_activity_rates: upsert season {row.SeasonId} type {row.ActivityType}", + $"MERGE season_activity_rates AS target " + + $"USING (SELECT {row.SeasonId} AS season_id, {(int)row.ActivityType} AS activity_type) AS src " + + $"ON target.season_id = src.season_id AND target.activity_type = src.activity_type " + + $"WHEN MATCHED THEN UPDATE SET points_per_unit = {SqlLiteral.Of(row.PointsPerUnit)}, unit_scale = {row.UnitScale} " + + $"WHEN NOT MATCHED THEN INSERT (season_id, activity_type, points_per_unit, unit_scale) " + + $"VALUES ({row.SeasonId}, {(int)row.ActivityType}, {SqlLiteral.Of(row.PointsPerUnit)}, {row.UnitScale});"); + + public static IPendingChange BuildInsertObjective(SeasonObjectiveRow row) + { + return new RawSqlChange( + $"season_objectives: insert '{row.Name}' in season {row.SeasonId}", + $"INSERT INTO season_objectives (season_id, name, description, activity_type, " + + $"target_value, bonus_points, display_order, is_daily, package_id, target_definition_id, equipment_set_id) VALUES (" + + $"{row.SeasonId}, {SqlLiteral.Of(row.Name)}, {SqlLiteral.Of(row.Description)}, " + + $"{(int)row.ActivityType}, {row.TargetValue}, {row.BonusPoints}, {row.DisplayOrder}, " + + $"{(row.IsDaily ? 1 : 0)}, {SqlLiteral.OfNullableInt(row.PackageId)}, " + + $"{SqlLiteral.OfNullableInt(row.TargetDefinitionId)}, {SqlLiteral.OfNullableInt(row.EquipmentSetId)})"); + } + + public static IPendingChange BuildUpdateObjective(SeasonObjectiveRow row) + { + return new RawSqlChange( + $"season_objectives: update id {row.Id}", + $"UPDATE season_objectives SET name = {SqlLiteral.Of(row.Name)}, " + + $"description = {SqlLiteral.Of(row.Description)}, " + + $"activity_type = {(int)row.ActivityType}, target_value = {row.TargetValue}, " + + $"bonus_points = {row.BonusPoints}, display_order = {row.DisplayOrder}, " + + $"is_daily = {(row.IsDaily ? 1 : 0)}, package_id = {SqlLiteral.OfNullableInt(row.PackageId)}, " + + $"target_definition_id = {SqlLiteral.OfNullableInt(row.TargetDefinitionId)}, " + + $"equipment_set_id = {SqlLiteral.OfNullableInt(row.EquipmentSetId)} " + + $"WHERE id = {row.Id}"); + } + + public static IPendingChange BuildDeleteObjective(SeasonObjectiveRow row) => + new RawSqlChange($"season_objectives: delete id {row.Id}", + $"DELETE FROM season_objectives WHERE id = {row.Id}", isDestructive: true); + + public static IPendingChange BuildInsertTier(SeasonTierRow row) => + new RawSqlChange( + $"season_tiers: insert tier {row.TierNumber} ('{row.TierName}') in season {row.SeasonId}", + $"INSERT INTO season_tiers (season_id, tier_number, tier_name, points_required, package_id, equipment_set_id) VALUES (" + + $"{row.SeasonId}, {row.TierNumber}, {SqlLiteral.Of(row.TierName)}, {row.PointsRequired}, " + + $"{SqlLiteral.OfNullableInt(row.PackageId)}, {SqlLiteral.OfNullableInt(row.EquipmentSetId)})"); + + public static IPendingChange BuildUpdateTier(SeasonTierRow row) => + new RawSqlChange( + $"season_tiers: update id {row.Id}", + $"UPDATE season_tiers SET tier_number = {row.TierNumber}, tier_name = {SqlLiteral.Of(row.TierName)}, " + + $"points_required = {row.PointsRequired}, package_id = {SqlLiteral.OfNullableInt(row.PackageId)}, " + + $"equipment_set_id = {SqlLiteral.OfNullableInt(row.EquipmentSetId)} WHERE id = {row.Id}"); + + public static IPendingChange BuildDeleteTier(SeasonTierRow row) => + new RawSqlChange($"season_tiers: delete id {row.Id}", + $"DELETE FROM season_tiers WHERE id = {row.Id}", isDestructive: true); + + public static IPendingChange BuildInsertLeaderboardReward(SeasonLeaderboardRewardRow row) => + new RawSqlChange( + $"season_leaderboard_rewards: insert ranks {row.RankMin}-{row.RankMax} in season {row.SeasonId}", + $"INSERT INTO season_leaderboard_rewards (season_id, rank_min, rank_max, package_id, equipment_set_id) VALUES (" + + $"{row.SeasonId}, {row.RankMin}, {row.RankMax}, " + + $"{SqlLiteral.OfNullableInt(row.PackageId)}, {SqlLiteral.OfNullableInt(row.EquipmentSetId)})"); + + public static IPendingChange BuildUpdateLeaderboardReward(SeasonLeaderboardRewardRow row) => + new RawSqlChange( + $"season_leaderboard_rewards: update id {row.Id}", + $"UPDATE season_leaderboard_rewards SET rank_min = {row.RankMin}, rank_max = {row.RankMax}, " + + $"package_id = {SqlLiteral.OfNullableInt(row.PackageId)}, " + + $"equipment_set_id = {SqlLiteral.OfNullableInt(row.EquipmentSetId)} WHERE id = {row.Id}"); + + public static IPendingChange BuildDeleteLeaderboardReward(SeasonLeaderboardRewardRow row) => + new RawSqlChange($"season_leaderboard_rewards: delete id {row.Id}", + $"DELETE FROM season_leaderboard_rewards WHERE id = {row.Id}", isDestructive: true); + } +} diff --git a/src/Perpetuum.AdminTool/Seasons/SeasonLeaderboardRewardRow.cs b/src/Perpetuum.AdminTool/Seasons/SeasonLeaderboardRewardRow.cs new file mode 100644 index 00000000..4fc089fd --- /dev/null +++ b/src/Perpetuum.AdminTool/Seasons/SeasonLeaderboardRewardRow.cs @@ -0,0 +1,42 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.EquipmentSets; +using Perpetuum.AdminTool.Packages; + +namespace Perpetuum.AdminTool.Seasons +{ + public partial class SeasonLeaderboardRewardRow : ObservableObject + { + public int Id { get; set; } + public int SeasonId { get; set; } + public bool IsNew { get; set; } + + [ObservableProperty] private int _rankMin = 1; + [ObservableProperty] private int _rankMax = 1; + [ObservableProperty] private int? _packageId; + [ObservableProperty] private PackageRow? _selectedPackage; + [ObservableProperty] private int? _equipmentSetId; + [ObservableProperty] private EquipmentSetRow? _selectedEquipmentSet; + + partial void OnSelectedPackageChanged(PackageRow? value) + { + if (value != null) + { + PackageId = value.Id; + EquipmentSetId = null; + _selectedEquipmentSet = null; + OnPropertyChanged(nameof(SelectedEquipmentSet)); + } + } + + partial void OnSelectedEquipmentSetChanged(EquipmentSetRow? value) + { + if (value != null) + { + EquipmentSetId = value.SetId; + PackageId = null; + _selectedPackage = null; + OnPropertyChanged(nameof(SelectedPackage)); + } + } + } +} diff --git a/src/Perpetuum.AdminTool/Seasons/SeasonObjectiveRow.cs b/src/Perpetuum.AdminTool/Seasons/SeasonObjectiveRow.cs new file mode 100644 index 00000000..7d72b0b0 --- /dev/null +++ b/src/Perpetuum.AdminTool/Seasons/SeasonObjectiveRow.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.EquipmentSets; +using Perpetuum.AdminTool.Packages; +using Perpetuum.Services.Seasons; + +namespace Perpetuum.AdminTool.Seasons +{ + public partial class SeasonObjectiveRow : ObservableObject + { + public int Id { get; set; } + public int SeasonId { get; set; } + public bool IsNew { get; set; } + + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _description = ""; + [ObservableProperty] private SeasonActivityType _activityType = SeasonActivityType.NpcKill; + [ObservableProperty] private long _targetValue; + [ObservableProperty] private int _bonusPoints; + [ObservableProperty] private int _displayOrder; + [ObservableProperty] private bool _isDaily; + [ObservableProperty] private int? _packageId; + [ObservableProperty] private PackageRow? _selectedPackage; + + partial void OnSelectedPackageChanged(PackageRow? value) + { + if (value != null) + { + PackageId = value.Id; + EquipmentSetId = null; + _selectedEquipmentSet = null; + OnPropertyChanged(nameof(SelectedEquipmentSet)); + } + else + { + PackageId = value?.Id; + } + } + + [ObservableProperty] private int? _equipmentSetId; + [ObservableProperty] private EquipmentSetRow? _selectedEquipmentSet; + + partial void OnSelectedEquipmentSetChanged(EquipmentSetRow? value) + { + if (value != null) + { + EquipmentSetId = value.SetId; + PackageId = null; + _selectedPackage = null; + OnPropertyChanged(nameof(SelectedPackage)); + } + } + + [ObservableProperty] private int? _targetDefinitionId; + [ObservableProperty] private string? _targetDisplayName; + + private IReadOnlyList _oreAndLiquidMaterials = Array.Empty(); + private IReadOnlyList _organicMaterials = Array.Empty(); + + [ObservableProperty] private IReadOnlyList _availableMaterials = Array.Empty(); + + public void InitializeMaterialLists( + IReadOnlyList oreAndLiquid, + IReadOnlyList organics) + { + _oreAndLiquidMaterials = oreAndLiquid; + _organicMaterials = organics; + RefreshAvailableMaterials(); + } + + partial void OnActivityTypeChanged(SeasonActivityType value) => RefreshAvailableMaterials(); + + partial void OnTargetDefinitionIdChanged(int? value) + { + TargetDisplayName = AvailableMaterials + .FirstOrDefault(m => m.Definition == value)?.DisplayName; + } + + public bool HasTargetMaterials => AvailableMaterials.Count > 0; + + private void RefreshAvailableMaterials() + { + AvailableMaterials = ActivityType switch + { + SeasonActivityType.MineralMined => _oreAndLiquidMaterials, + SeasonActivityType.PlantHarvested => _organicMaterials, + _ => Array.Empty() + }; + if (TargetDefinitionId.HasValue && + !AvailableMaterials.Any(m => m.Definition == TargetDefinitionId)) + TargetDefinitionId = null; + TargetDisplayName = AvailableMaterials + .FirstOrDefault(m => m.Definition == TargetDefinitionId)?.DisplayName; + OnPropertyChanged(nameof(HasTargetMaterials)); + } + } +} diff --git a/src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs b/src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs new file mode 100644 index 00000000..d20f3043 --- /dev/null +++ b/src/Perpetuum.AdminTool/Seasons/SeasonRepository.cs @@ -0,0 +1,406 @@ +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.EquipmentSets; +using Perpetuum.AdminTool.Settings; +using Perpetuum.Services.Seasons; + +namespace Perpetuum.AdminTool.Seasons +{ + public class SeasonRepository + { + private readonly ConnectionSettings _connection; + + public SeasonRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task> LoadAllSeasonsAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT id, name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name, scoring_mode, " + + "daily_objectives_per_day " + + "FROM seasons ORDER BY start_time DESC"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var snap = new SeasonSnapshot + { + Id = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + Description = reader.IsDBNull(2) ? "" : reader.GetString(2), + StartTime = DateTime.SpecifyKind(reader.GetDateTime(3), DateTimeKind.Utc), + EndTime = DateTime.SpecifyKind(reader.GetDateTime(4), DateTimeKind.Utc), + IsActive = !reader.IsDBNull(5) && reader.GetBoolean(5), + IsRecurring = !reader.IsDBNull(6) && reader.GetBoolean(6), + RecurrenceGapDays = reader.IsDBNull(7) ? (int?)null : reader.GetInt32(7), + RecurrenceIteration = reader.IsDBNull(8) ? 1 : reader.GetInt32(8), + RecurrenceBaseName = reader.IsDBNull(9) ? null : reader.GetString(9), + ScoringMode = reader.IsDBNull(10) ? SeasonScoringMode.ActivityAndGlobal + : (SeasonScoringMode)reader.GetByte(10), + DailyObjectivesPerDay = reader.IsDBNull(11) ? (int?)null : (int)reader.GetInt16(11), + }; + result.Add(new SeasonRow(snap)); + } + return result; + } + + public async Task> LoadActivityRatesAsync(int seasonId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT id, season_id, activity_type, points_per_unit, unit_scale " + + "FROM season_activity_rates WHERE season_id = @seasonId"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new SeasonActivityRateRow + { + Id = reader.GetInt32(0), + SeasonId = reader.GetInt32(1), + ActivityType = (SeasonActivityType)reader.GetInt32(2), + PointsPerUnit = reader.GetDouble(3), + UnitScale = reader.GetInt32(4) + }); + } + return result; + } + + public async Task> LoadObjectivesAsync(int seasonId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT id, season_id, name, description, activity_type, " + + "target_value, bonus_points, display_order, is_daily, package_id, " + + "target_definition_id, equipment_set_id " + + "FROM season_objectives WHERE season_id = @seasonId ORDER BY display_order"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new SeasonObjectiveRow + { + Id = reader.GetInt32(0), + SeasonId = reader.GetInt32(1), + Name = reader.IsDBNull(2) ? "" : reader.GetString(2), + Description = reader.IsDBNull(3) ? "" : reader.GetString(3), + ActivityType = (SeasonActivityType)reader.GetInt32(4), + TargetValue = reader.GetInt64(5), + BonusPoints = reader.GetInt32(6), + DisplayOrder = reader.GetInt32(7), + IsDaily = !reader.IsDBNull(8) && reader.GetBoolean(8), + PackageId = reader.IsDBNull(9) ? (int?)null : reader.GetInt32(9), + TargetDefinitionId = reader.IsDBNull(10) ? (int?)null : reader.GetInt32(10), + EquipmentSetId = reader.IsDBNull(11) ? (int?)null : reader.GetInt32(11), + }); + } + return result; + } + + public async Task> LoadTiersAsync(int seasonId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT id, season_id, tier_number, tier_name, points_required, " + + "package_id, equipment_set_id " + + "FROM season_tiers WHERE season_id = @seasonId ORDER BY tier_number"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new SeasonTierRow + { + Id = reader.GetInt32(0), + SeasonId = reader.GetInt32(1), + TierNumber = reader.GetInt32(2), + TierName = reader.IsDBNull(3) ? "" : reader.GetString(3), + PointsRequired = reader.GetInt32(4), + PackageId = reader.IsDBNull(5) ? (int?)null : reader.GetInt32(5), + EquipmentSetId = reader.IsDBNull(6) ? (int?)null : reader.GetInt32(6), + }); + } + return result; + } + + public async Task> LoadLeaderboardRewardsAsync(int seasonId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT id, season_id, rank_min, rank_max, package_id, equipment_set_id " + + "FROM season_leaderboard_rewards WHERE season_id = @seasonId ORDER BY rank_min"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new SeasonLeaderboardRewardRow + { + Id = reader.GetInt32(0), + SeasonId = reader.GetInt32(1), + RankMin = reader.GetInt32(2), + RankMax = reader.GetInt32(3), + PackageId = reader.IsDBNull(4) ? (int?)null : reader.GetInt32(4), + EquipmentSetId = reader.IsDBNull(5) ? (int?)null : reader.GetInt32(5), + }); + } + return result; + } + + public async Task LoadParticipantCountAsync(int seasonId) + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM season_character_points WHERE season_id = @seasonId"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + var v = await cmd.ExecuteScalarAsync(); + return v == null ? 0 : System.Convert.ToInt32(v); + } + + public async Task LoadActiveLast7DaysAsync(int seasonId) + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT COUNT(*) FROM season_character_points " + + "WHERE season_id = @seasonId AND last_updated >= DATEADD(day, -7, GETUTCDATE())"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + var v = await cmd.ExecuteScalarAsync(); + return v == null ? 0 : System.Convert.ToInt32(v); + } + + public async Task> LoadTierDistributionAsync(int seasonId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT t.tier_number, t.tier_name, COUNT(c.character_id) AS claim_count " + + "FROM season_tiers t " + + "LEFT JOIN season_tier_claims c ON c.tier_id = t.id AND c.season_id = @seasonId " + + "WHERE t.season_id = @seasonId " + + "GROUP BY t.id, t.tier_number, t.tier_name " + + "ORDER BY t.tier_number"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new TierDistributionRow( + reader.GetInt32(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.GetInt32(2))); + } + return result; + } + + public async Task> LoadTop10LeaderboardAsync(int seasonId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT TOP 10 scp.character_id, ch.nick AS character_name, scp.total_points " + + "FROM season_character_points scp " + + "JOIN characters ch ON ch.characterID = scp.character_id " + + "WHERE scp.season_id = @seasonId " + + "ORDER BY scp.total_points DESC"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + await using var reader = await cmd.ExecuteReaderAsync(); + int rank = 1; + while (await reader.ReadAsync()) + { + var nick = reader.IsDBNull(1) ? $"(char {reader.GetInt32(0)})" : reader.GetString(1); + result.Add(new LeaderboardEntryRow(rank++, nick, Math.Round(reader.GetDouble(2), 2))); + } + return result; + } + + public async Task> LoadObjectiveCompletionAsync(int seasonId) + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT o.id, o.name, COUNT(DISTINCT p.character_id) AS completed_count " + + "FROM season_objectives o " + + "LEFT JOIN season_objective_progress p ON p.objective_id = o.id " + + " AND p.season_id = @seasonId AND p.completed = 1 " + + "WHERE o.season_id = @seasonId " + + "GROUP BY o.id, o.name, o.display_order " + + "ORDER BY o.display_order"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new ObjectiveCompletionRow( + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.GetInt32(2))); + } + return result; + } + + public async Task LoadAvgPointsPerDayAsync(int seasonId) + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "SELECT " + + " CAST(SUM(total_points) AS float) / " + + " NULLIF(COUNT(*), 0) / " + + " NULLIF(DATEDIFF(day, s.start_time, GETUTCDATE()), 0) AS avg_points_per_day " + + "FROM season_character_points scp " + + "JOIN seasons s ON s.id = scp.season_id " + + "WHERE scp.season_id = @seasonId " + + "GROUP BY s.start_time"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + var v = await cmd.ExecuteScalarAsync(); + if (v == null || v == System.DBNull.Value) return 0.0; + return System.Convert.ToDouble(v); + } + + public async Task> LoadEquipmentSetsAsync() + { + var result = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT set_id, name FROM equipment_sets ORDER BY name"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + result.Add(new EquipmentSetRow + { + SetId = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + }); + return result; + } + + public async Task> LoadTodaysDailyObjectivesAsync(int seasonId) + { + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + + // 1. Load daily_objectives_per_day for the season + int? dailyPerDay; + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = "SELECT daily_objectives_per_day FROM seasons WHERE id = @seasonId"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + var raw = await cmd.ExecuteScalarAsync(); + dailyPerDay = raw == null || raw == DBNull.Value ? (int?)null : (int)(short)raw; + } + + // 2. Load all is_daily objectives ordered by display_order + var daily = new List<(int Id, string Name, SeasonActivityType ActivityType, long TargetValue)>(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "SELECT id, name, activity_type, target_value " + + "FROM season_objectives " + + "WHERE season_id = @seasonId AND is_daily = 1 " + + "ORDER BY display_order"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + daily.Add(( + reader.GetInt32(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + (SeasonActivityType)reader.GetInt32(2), + reader.GetInt64(3) + )); + } + } + + if (daily.Count == 0) + return []; + + // 3. Compute pool using same seeded Fisher-Yates as SeasonService.SelectDailyPool + List<(int Id, string Name, SeasonActivityType ActivityType, long TargetValue)> pool; + if (!dailyPerDay.HasValue || dailyPerDay.Value >= daily.Count) + { + pool = daily; + } + else + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + // Precedence: * binds tighter than ^ — result is (seasonId * 397) XOR today.DayNumber + int seed = seasonId * 397 ^ today.DayNumber; + var rng = new Random(seed); + var shuffled = daily.ToList(); + for (int i = shuffled.Count - 1; i > 0; i--) + { + int j = rng.Next(i + 1); + (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); + } + pool = shuffled.Take(dailyPerDay.Value).ToList(); + } + + if (pool.Count == 0) + return []; + + // 4. Query completion counts for pool IDs scoped to today's day_window + var ids = pool.Select(o => o.Id).ToList(); + var idParams = string.Join(",", ids.Select((_, i) => $"@id{i}")); + var counts = new Dictionary(); + await using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + $"SELECT o.id, COUNT(DISTINCT p.character_id) AS completions_today " + + $"FROM season_objectives o " + + $"LEFT JOIN season_objective_progress p " + + $" ON p.objective_id = o.id " + + $" AND p.season_id = @seasonId " + + $" AND p.day_window = CAST(GETUTCDATE() AS date) " + + $" AND p.completed = 1 " + + $"WHERE o.season_id = @seasonId AND o.id IN ({idParams}) " + + $"GROUP BY o.id"; + cmd.Parameters.AddWithValue("@seasonId", seasonId); + for (int i = 0; i < ids.Count; i++) + cmd.Parameters.AddWithValue($"@id{i}", ids[i]); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + counts[reader.GetInt32(0)] = reader.GetInt32(1); + } + + // Restore display_order (shuffle randomised pool; daily is ordered by display_order) + var displayIndex = daily + .Select((o, idx) => (o.Id, idx)) + .ToDictionary(x => x.Id, x => x.idx); + + return pool + .OrderBy(o => displayIndex.GetValueOrDefault(o.Id, int.MaxValue)) + .Select(o => new TodaysDailyObjectiveRow( + o.Name, + o.ActivityType, + o.TargetValue, + counts.GetValueOrDefault(o.Id, 0))) + .ToList(); + } + } + + public record TierDistributionRow(int TierNumber, string TierName, int ClaimCount); + public record LeaderboardEntryRow(int Rank, string CharacterName, double TotalPoints); + public record ObjectiveCompletionRow(string Name, int CompletedCount); +} diff --git a/src/Perpetuum.AdminTool/Seasons/SeasonRow.cs b/src/Perpetuum.AdminTool/Seasons/SeasonRow.cs new file mode 100644 index 00000000..640e3fb7 --- /dev/null +++ b/src/Perpetuum.AdminTool/Seasons/SeasonRow.cs @@ -0,0 +1,96 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.Services.Seasons; + +namespace Perpetuum.AdminTool.Seasons +{ + public partial class SeasonRow : ObservableObject + { + public int Id { get; } + public bool IsNew { get; set; } + public SeasonSnapshot Original { get; private set; } + + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _description = ""; + [ObservableProperty] private DateTime _startTime; + [ObservableProperty] private DateTime _endTime; + [ObservableProperty] private bool _isActive; + [ObservableProperty] private bool _isRecurring; + [ObservableProperty] private int? _recurrenceGapDays; + [ObservableProperty] private int _recurrenceIteration = 1; + [ObservableProperty] private string? _recurrenceBaseName; + [ObservableProperty] private SeasonScoringMode _scoringMode; + [ObservableProperty] private int? _dailyObjectivesPerDay; + + public SeasonRow(SeasonSnapshot snapshot) + { + Id = snapshot.Id; + Original = snapshot; + ApplySnapshot(snapshot); + } + + public void ApplySnapshot(SeasonSnapshot s) + { + Original = s; + Name = s.Name; + Description = s.Description; + StartTime = s.StartTime; + EndTime = s.EndTime; + IsActive = s.IsActive; + IsRecurring = s.IsRecurring; + RecurrenceGapDays = s.RecurrenceGapDays; + RecurrenceIteration = s.RecurrenceIteration; + RecurrenceBaseName = s.RecurrenceBaseName; + ScoringMode = s.ScoringMode; + DailyObjectivesPerDay = s.DailyObjectivesPerDay; + } + + public void RefreshOriginalFromCurrent() + { + Original = new SeasonSnapshot + { + Id = Id, + Name = Name, + Description = Description, + StartTime = StartTime, + EndTime = EndTime, + IsActive = IsActive, + IsRecurring = IsRecurring, + RecurrenceGapDays = RecurrenceGapDays, + RecurrenceIteration = RecurrenceIteration, + RecurrenceBaseName = RecurrenceBaseName, + ScoringMode = ScoringMode, + DailyObjectivesPerDay = DailyObjectivesPerDay, + }; + } + + public static SeasonRow CreateNew(SeasonSnapshot seed) => new SeasonRow(seed) { IsNew = true }; + + public SeasonCardState CardState + { + get + { + if (IsActive) return SeasonCardState.Active; + return EndTime > DateTime.UtcNow ? SeasonCardState.Draft : SeasonCardState.Ended; + } + } + } + + public class SeasonSnapshot + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public string Description { get; init; } = ""; + public DateTime StartTime { get; init; } + public DateTime EndTime { get; init; } + public bool IsActive { get; init; } + public bool IsRecurring { get; init; } + public int? RecurrenceGapDays { get; init; } + public int RecurrenceIteration { get; init; } = 1; + public string? RecurrenceBaseName { get; init; } + public SeasonScoringMode ScoringMode { get; init; } + public int? DailyObjectivesPerDay { get; init; } + } + + public enum SeasonCardState { Active, Draft, Ended } +} diff --git a/src/Perpetuum.AdminTool/Seasons/SeasonTierRow.cs b/src/Perpetuum.AdminTool/Seasons/SeasonTierRow.cs new file mode 100644 index 00000000..ba7d83b5 --- /dev/null +++ b/src/Perpetuum.AdminTool/Seasons/SeasonTierRow.cs @@ -0,0 +1,43 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.EquipmentSets; +using Perpetuum.AdminTool.Packages; + +namespace Perpetuum.AdminTool.Seasons +{ + public partial class SeasonTierRow : ObservableObject + { + public int Id { get; set; } + public int SeasonId { get; set; } + public bool IsNew { get; set; } + + [ObservableProperty] private int _tierNumber; + [ObservableProperty] private string _tierName = ""; + [ObservableProperty] private int _pointsRequired; + [ObservableProperty] private int? _packageId; + [ObservableProperty] private PackageRow? _selectedPackage; + [ObservableProperty] private int? _equipmentSetId; + [ObservableProperty] private EquipmentSetRow? _selectedEquipmentSet; + + partial void OnSelectedPackageChanged(PackageRow? value) + { + if (value != null) + { + PackageId = value.Id; + EquipmentSetId = null; + _selectedEquipmentSet = null; + OnPropertyChanged(nameof(SelectedEquipmentSet)); + } + } + + partial void OnSelectedEquipmentSetChanged(EquipmentSetRow? value) + { + if (value != null) + { + EquipmentSetId = value.SetId; + PackageId = null; + _selectedPackage = null; + OnPropertyChanged(nameof(SelectedPackage)); + } + } + } +} diff --git a/src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs b/src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs new file mode 100644 index 00000000..11e9408f --- /dev/null +++ b/src/Perpetuum.AdminTool/Seasons/TodaysDailyObjectiveRow.cs @@ -0,0 +1,10 @@ +using Perpetuum.Services.Seasons; + +namespace Perpetuum.AdminTool.Seasons +{ + public record TodaysDailyObjectiveRow( + string Name, + SeasonActivityType ActivityType, + long TargetValue, + int CompletionsToday); +} diff --git a/src/Perpetuum.AdminTool/Settings/AppSettings.cs b/src/Perpetuum.AdminTool/Settings/AppSettings.cs new file mode 100644 index 00000000..d131c025 --- /dev/null +++ b/src/Perpetuum.AdminTool/Settings/AppSettings.cs @@ -0,0 +1,11 @@ +namespace Perpetuum.AdminTool.Settings +{ + public class AppSettings + { + public ConnectionSettings Connection { get; set; } = new ConnectionSettings(); + public string GameRootPath { get; set; } = ""; + public string SqlOutputDirectory { get; set; } = ""; + public ApplyMode DefaultApplyMode { get; set; } = ApplyMode.SqlScript; + public string LastLoginEmail { get; set; } = ""; + } +} diff --git a/src/Perpetuum.AdminTool/Settings/AppSettingsStore.cs b/src/Perpetuum.AdminTool/Settings/AppSettingsStore.cs new file mode 100644 index 00000000..b0dcab21 --- /dev/null +++ b/src/Perpetuum.AdminTool/Settings/AppSettingsStore.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using Newtonsoft.Json; + +namespace Perpetuum.AdminTool.Settings +{ + public class AppSettingsStore + { + private const string FolderName = "PerpetuumAdminTool"; + private const string FileName = "settings.json"; + + public AppSettings Settings { get; private set; } = new AppSettings(); + + public string FilePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + FolderName, + FileName); + + public void Load() + { + try + { + if (!File.Exists(FilePath)) + { + Settings = new AppSettings(); + return; + } + + var json = File.ReadAllText(FilePath); + var loaded = JsonConvert.DeserializeObject(json); + Settings = loaded ?? new AppSettings(); + } + catch + { + Settings = new AppSettings(); + } + } + + public void Save() + { + var dir = Path.GetDirectoryName(FilePath)!; + Directory.CreateDirectory(dir); + + var json = JsonConvert.SerializeObject(Settings, Formatting.Indented); + File.WriteAllText(FilePath, json); + } + } +} diff --git a/src/Perpetuum.AdminTool/Settings/ApplyMode.cs b/src/Perpetuum.AdminTool/Settings/ApplyMode.cs new file mode 100644 index 00000000..4d28c464 --- /dev/null +++ b/src/Perpetuum.AdminTool/Settings/ApplyMode.cs @@ -0,0 +1,8 @@ +namespace Perpetuum.AdminTool.Settings +{ + public enum ApplyMode + { + DirectDb = 0, + SqlScript = 1 + } +} diff --git a/src/Perpetuum.AdminTool/Settings/ConnectionSettings.cs b/src/Perpetuum.AdminTool/Settings/ConnectionSettings.cs new file mode 100644 index 00000000..921021ce --- /dev/null +++ b/src/Perpetuum.AdminTool/Settings/ConnectionSettings.cs @@ -0,0 +1,39 @@ +using Microsoft.Data.SqlClient; + +namespace Perpetuum.AdminTool.Settings +{ + public class ConnectionSettings + { + public string Server { get; set; } = "localhost\\MSSQLSERVER2019"; + public string Database { get; set; } = "perpetuumsa"; + public bool IntegratedSecurity { get; set; } = true; + public string SqlUser { get; set; } = "sa"; + public string SqlPassword { get; set; } = ""; + public bool TrustServerCertificate { get; set; } = true; + public int ConnectTimeoutSeconds { get; set; } = 15; + + public string BuildConnectionString() + { + var b = new SqlConnectionStringBuilder + { + DataSource = Server, + InitialCatalog = Database, + TrustServerCertificate = TrustServerCertificate, + ConnectTimeout = ConnectTimeoutSeconds, + ApplicationName = "Perpetuum.AdminTool" + }; + + if (IntegratedSecurity) + { + b.IntegratedSecurity = true; + } + else + { + b.UserID = SqlUser; + b.Password = SqlPassword; + } + + return b.ConnectionString; + } + } +} diff --git a/src/Perpetuum.AdminTool/Templates/RobotTemplateEditorEntity.cs b/src/Perpetuum.AdminTool/Templates/RobotTemplateEditorEntity.cs new file mode 100644 index 00000000..97fd0d17 --- /dev/null +++ b/src/Perpetuum.AdminTool/Templates/RobotTemplateEditorEntity.cs @@ -0,0 +1,46 @@ +using Perpetuum.ExportedTypes; + +namespace Perpetuum.AdminTool.Templates +{ + /// + /// Lookup record used by the structured robot-template editor. Carries the + /// entitydefaults columns plus three values parsed from `options`: + /// `moduleFlag` (filter modules into slots), `ammoCategoryFlags` (filter ammo + /// into modules), and `slotFlags` (per-part slot list). + /// + public class RobotTemplateEditorEntity + { + public int Definition { get; init; } + public string Name { get; init; } = ""; + public long CategoryFlags { get; init; } + public long AttributeFlags { get; init; } + + /// Mirrors entitydefaults.enabled. Disabled rows are hidden from selectors. + public bool Enabled { get; init; } + + /// options[moduleFlag] — int, may be 0 if absent. + public long ModuleFlag { get; init; } + + /// + /// options[ammoType] — a CategoryFlags value (stored as Genxy long, e.g. `L3120a`) + /// rooting the subtree of ammo that fits the module. May be 0 if absent. + /// + public long AmmoType { get; init; } + + /// options[ammoCapacity] — int, may be 0. + public int AmmoCapacity { get; init; } + + /// options[slotFlags] — int[], empty if not a robot component. + public int[] SlotFlags { get; init; } = System.Array.Empty(); + + public string Display => $"{Definition} — {Name}"; + + // Server-side `ActiveModule.IsAmmoable` checks options at runtime, but the + // authoritative flag for "this module needs ammo" on the data side is the + // `ammo_required` attribute flag (bit 18). Use that here so modules that + // declare ammo support but don't expose it via options are still ammo-able + // in the editor. + public bool IsAmmoable => + new EntityAttributeFlags((ulong)AttributeFlags).HasFlag(Perpetuum.ExportedTypes.AttributeFlags.ammo_required); + } +} diff --git a/src/Perpetuum.AdminTool/Templates/RobotTemplateEditorRepository.cs b/src/Perpetuum.AdminTool/Templates/RobotTemplateEditorRepository.cs new file mode 100644 index 00000000..83806e24 --- /dev/null +++ b/src/Perpetuum.AdminTool/Templates/RobotTemplateEditorRepository.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; +using Perpetuum.GenXY; + +namespace Perpetuum.AdminTool.Templates +{ + /// + /// One-shot loader for the structured robot-template editor. Reads every + /// entitydefaults row and parses its `options` to extract the few fields + /// the editor needs for filtering. Heavy on the wire (full table + Genxy + /// parse per row), but the modal opens infrequently and we don't keep this + /// data around between opens. + /// + public class RobotTemplateEditorRepository + { + private readonly ConnectionSettings _connection; + + public RobotTemplateEditorRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task> LoadAllAsync() + { + var rows = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "select definition, definitionname, categoryflags, attributeflags, enabled, options " + + "from entitydefaults order by definitionname"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var definition = reader.GetInt32(0); + var name = reader.IsDBNull(1) ? "" : reader.GetString(1); + var categoryFlags = reader.GetInt64(2); + var attributeFlags = reader.GetInt64(3); + var enabled = !reader.IsDBNull(4) && reader.GetBoolean(4); + var optionsString = reader.IsDBNull(5) ? "" : reader.GetString(5); + + long moduleFlag = 0; + long ammoType = 0; + int ammoCapacity = 0; + int[] slotFlags = System.Array.Empty(); + + if (!string.IsNullOrEmpty(optionsString)) + { + var dict = GenxyConverter.Deserialize(optionsString); + moduleFlag = ToLong(dict.GetValueOrDefault("moduleFlag")); + ammoType = ToLong(dict.GetValueOrDefault("ammoType")); + ammoCapacity = ToInt(dict.GetValueOrDefault("ammoCapacity")); + slotFlags = ToIntArray(dict.GetValueOrDefault("slotFlags")); + } + + rows.Add(new RobotTemplateEditorEntity + { + Definition = definition, + Name = name, + CategoryFlags = categoryFlags, + AttributeFlags = attributeFlags, + Enabled = enabled, + ModuleFlag = moduleFlag, + AmmoType = ammoType, + AmmoCapacity = ammoCapacity, + SlotFlags = slotFlags + }); + } + return rows; + } + + private static long ToLong(object? v) => v switch + { + null => 0L, + long l => l, + int i => i, + _ => 0L + }; + + private static int ToInt(object? v) => v switch + { + null => 0, + int i => i, + long l => (int)l, + _ => 0 + }; + + private static int[] ToIntArray(object? v) => v switch + { + int[] a => a, + _ => System.Array.Empty() + }; + } +} diff --git a/src/Perpetuum.AdminTool/Templates/RobotTemplateRelationRepository.cs b/src/Perpetuum.AdminTool/Templates/RobotTemplateRelationRepository.cs new file mode 100644 index 00000000..e64113bd --- /dev/null +++ b/src/Perpetuum.AdminTool/Templates/RobotTemplateRelationRepository.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Templates +{ + public class RobotTemplateRelationRepository + { + private readonly ConnectionSettings _connection; + + public RobotTemplateRelationRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task> LoadAllAsync() + { + var rows = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = + "select definition, templateid, itemscoresum, raceid, " + + "missionlevel, missionleveloverride, killep, note " + + "from robottemplaterelation order by definition"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var snap = new RobotTemplateRelationSnapshot + { + Definition = reader.GetInt32(0), + TemplateId = reader.GetInt32(1), + ItemScoreSum = reader.GetInt32(2), + RaceId = reader.GetInt32(3), + MissionLevel = reader.IsDBNull(4) ? null : (int?)reader.GetInt32(4), + MissionLevelOverride = reader.IsDBNull(5) ? null : (int?)reader.GetInt32(5), + KillEp = reader.IsDBNull(6) ? null : (int?)reader.GetInt32(6), + Note = reader.IsDBNull(7) ? null : reader.GetString(7) + }; + rows.Add(new RobotTemplateRelationRow(snap)); + } + return rows; + } + } +} diff --git a/src/Perpetuum.AdminTool/Templates/RobotTemplateRelationRow.cs b/src/Perpetuum.AdminTool/Templates/RobotTemplateRelationRow.cs new file mode 100644 index 00000000..81750b50 --- /dev/null +++ b/src/Perpetuum.AdminTool/Templates/RobotTemplateRelationRow.cs @@ -0,0 +1,89 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.Templates +{ + public partial class RobotTemplateRelationRow : ObservableObject + { + public bool IsNew { get; set; } + public RobotTemplateRelationSnapshot Original { get; private set; } + + // Resolved labels (not persisted) — populated from lookup tables for display. + [ObservableProperty] private string _definitionName = ""; + [ObservableProperty] private string _templateName = ""; + + // PK column. Editable; the diff layer keys baselines by Original.Definition so a user + // can change this value and the resulting UPDATE writes WHERE definition = old, SET definition = new. + [ObservableProperty] private int _definition; + [ObservableProperty] private int _templateId; + [ObservableProperty] private int _itemScoreSum; + [ObservableProperty] private int _raceId; + [ObservableProperty] private int? _missionLevel; + [ObservableProperty] private int? _missionLevelOverride; + [ObservableProperty] private int? _killEp; + [ObservableProperty] private string? _note; + + public RobotTemplateRelationRow(RobotTemplateRelationSnapshot snapshot) + { + Original = snapshot; + ApplySnapshot(snapshot); + } + + public void ApplySnapshot(RobotTemplateRelationSnapshot snapshot) + { + Original = snapshot; + Definition = snapshot.Definition; + TemplateId = snapshot.TemplateId; + ItemScoreSum = snapshot.ItemScoreSum; + RaceId = snapshot.RaceId; + MissionLevel = snapshot.MissionLevel; + MissionLevelOverride = snapshot.MissionLevelOverride; + KillEp = snapshot.KillEp; + Note = snapshot.Note; + } + + public void RefreshOriginalFromCurrent() + { + Original = new RobotTemplateRelationSnapshot + { + Definition = Definition, + TemplateId = TemplateId, + ItemScoreSum = ItemScoreSum, + RaceId = RaceId, + MissionLevel = MissionLevel, + MissionLevelOverride = MissionLevelOverride, + KillEp = KillEp, + Note = Note + }; + } + + public static RobotTemplateRelationRow CreateNew( + int definition, int templateId, int itemScoreSum, int raceId, + int? missionLevel, int? missionLevelOverride, int? killEp, string? note) + { + var snap = new RobotTemplateRelationSnapshot + { + Definition = definition, + TemplateId = templateId, + ItemScoreSum = itemScoreSum, + RaceId = raceId, + MissionLevel = missionLevel, + MissionLevelOverride = missionLevelOverride, + KillEp = killEp, + Note = note + }; + return new RobotTemplateRelationRow(snap) { IsNew = true }; + } + } + + public class RobotTemplateRelationSnapshot + { + public int Definition { get; init; } + public int TemplateId { get; init; } + public int ItemScoreSum { get; init; } + public int RaceId { get; init; } + public int? MissionLevel { get; init; } + public int? MissionLevelOverride { get; init; } + public int? KillEp { get; init; } + public string? Note { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Templates/RobotTemplateRepository.cs b/src/Perpetuum.AdminTool/Templates/RobotTemplateRepository.cs new file mode 100644 index 00000000..54914983 --- /dev/null +++ b/src/Perpetuum.AdminTool/Templates/RobotTemplateRepository.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.Templates +{ + public class RobotTemplateRepository + { + private readonly ConnectionSettings _connection; + + public RobotTemplateRepository(ConnectionSettings connection) + { + _connection = connection; + } + + public async Task> LoadAllAsync() + { + var rows = new List(); + await using var cn = new SqlConnection(_connection.BuildConnectionString()); + await cn.OpenAsync(); + await using var cmd = cn.CreateCommand(); + cmd.CommandText = "select id, name, description, note from robottemplates order by id"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var snap = new RobotTemplateSnapshot + { + Id = reader.GetInt32(0), + Name = reader.IsDBNull(1) ? "" : reader.GetString(1), + Description = reader.IsDBNull(2) ? "" : reader.GetString(2), + Note = reader.IsDBNull(3) ? null : reader.GetString(3) + }; + rows.Add(new RobotTemplateRow(snap)); + } + return rows; + } + } +} diff --git a/src/Perpetuum.AdminTool/Templates/RobotTemplateRow.cs b/src/Perpetuum.AdminTool/Templates/RobotTemplateRow.cs new file mode 100644 index 00000000..cf06096a --- /dev/null +++ b/src/Perpetuum.AdminTool/Templates/RobotTemplateRow.cs @@ -0,0 +1,79 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.Templates +{ + public partial class RobotTemplateRow : ObservableObject + { + private static int _placeholderCounter = 0; + + public int Id { get; } + public bool IsNew { get; set; } + public string NewIdToken { get; init; } = ""; + + [ObservableProperty] private bool _isQueued; + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _description = ""; + [ObservableProperty] private string? _note; + + public RobotTemplateSnapshot Original { get; private set; } + + partial void OnIsQueuedChanged(bool value) => + OnPropertyChanged(nameof(IdDisplay)); + + public string IdDisplay => + IsNew + ? (IsQueued ? "(pending insert — reload after commit)" : "(autoincrement on save)") + : Id.ToString(); + + public RobotTemplateRow(RobotTemplateSnapshot snapshot) + { + Id = snapshot.Id; + Original = snapshot; + ApplySnapshot(snapshot); + } + + public void ApplySnapshot(RobotTemplateSnapshot snapshot) + { + Original = snapshot; + Name = snapshot.Name; + Description = snapshot.Description; + Note = snapshot.Note; + } + + public void RefreshOriginalFromCurrent() + { + Original = new RobotTemplateSnapshot + { + Id = Id, + Name = Name, + Description = Description, + Note = Note + }; + } + + public static RobotTemplateRow CreateNew(string name) + { + var placeholder = System.Threading.Interlocked.Decrement(ref _placeholderCounter); + var snap = new RobotTemplateSnapshot + { + Id = placeholder, + Name = name, + Description = "", + Note = null + }; + return new RobotTemplateRow(snap) + { + IsNew = true, + NewIdToken = System.Guid.NewGuid().ToString("N").Substring(0, 8) + }; + } + } + + public class RobotTemplateSnapshot + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public string Description { get; init; } = ""; + public string? Note { get; init; } + } +} diff --git a/src/Perpetuum.AdminTool/Translations/LanguageCatalog.cs b/src/Perpetuum.AdminTool/Translations/LanguageCatalog.cs new file mode 100644 index 00000000..e5bf8768 --- /dev/null +++ b/src/Perpetuum.AdminTool/Translations/LanguageCatalog.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Perpetuum.AdminTool.Translations +{ + public record LanguageInfo(int Id, string Name); + + public static class LanguageCatalog + { + // Mirrors the comment in Perpetuum/CustomDictionary.cs. + public static readonly IReadOnlyList All = new[] + { + new LanguageInfo(0, "English"), + new LanguageInfo(1, "Hungarian"), + new LanguageInfo(2, "German"), + new LanguageInfo(3, "Portuguese"), + new LanguageInfo(4, "Russian"), + new LanguageInfo(5, "French"), + new LanguageInfo(6, "Spanish"), + new LanguageInfo(7, "Polish"), + new LanguageInfo(8, "Slovenian"), + new LanguageInfo(9, "Romanian"), + new LanguageInfo(10, "Norwegian"), + new LanguageInfo(11, "Greek"), + new LanguageInfo(12, "Finnish"), + new LanguageInfo(13, "Italian"), + new LanguageInfo(14, "Turkish"), + new LanguageInfo(15, "Estonian"), + new LanguageInfo(16, "Swedish"), + new LanguageInfo(17, "Dutch"), + }; + + public static string NameOf(int id) => + All.FirstOrDefault(l => l.Id == id)?.Name ?? $"Lang {id}"; + } +} diff --git a/src/Perpetuum.AdminTool/Translations/TranslationRow.cs b/src/Perpetuum.AdminTool/Translations/TranslationRow.cs new file mode 100644 index 00000000..d6ee91c2 --- /dev/null +++ b/src/Perpetuum.AdminTool/Translations/TranslationRow.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.Translations +{ + public class TranslationRow : ObservableObject + { + private string _key = ""; + private readonly Dictionary _values = new(); + + public string Key + { + get => _key; + set => SetProperty(ref _key, value); + } + + // Indexer so DataGrid columns can bind via Path=[langId]. + public string this[int langId] + { + get => _values.TryGetValue(langId, out var v) ? v : ""; + set + { + var current = this[langId]; + var normalized = value ?? ""; + if (current == normalized) return; + + if (string.IsNullOrEmpty(normalized)) + { + _values.Remove(langId); + } + else + { + _values[langId] = normalized; + } + // WPF binding listens for "Item[]" to refresh indexer-bound paths. + OnPropertyChanged("Item[]"); + } + } + + public IReadOnlyDictionary Values => _values; + + public bool HasValue(int langId) => _values.ContainsKey(langId); + } +} diff --git a/src/Perpetuum.AdminTool/Translations/TranslationStore.cs b/src/Perpetuum.AdminTool/Translations/TranslationStore.cs new file mode 100644 index 00000000..1f2cfd04 --- /dev/null +++ b/src/Perpetuum.AdminTool/Translations/TranslationStore.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text; +using Newtonsoft.Json; + +namespace Perpetuum.AdminTool.Translations +{ + public class TranslationStore + { + public const string DictionaryDirName = "customDictionary"; + + public string GameRoot { get; } + public ObservableCollection Languages { get; } = new(); + public ObservableCollection Rows { get; } = new(); + + private readonly Dictionary _byKey = new(StringComparer.Ordinal); + + public TranslationStore(string gameRoot) + { + GameRoot = gameRoot ?? ""; + } + + public string DictionaryDirectory => Path.Combine(GameRoot, DictionaryDirName); + + public bool DirectoryExists => + !string.IsNullOrWhiteSpace(GameRoot) && Directory.Exists(DictionaryDirectory); + + public void Load() + { + Languages.Clear(); + Rows.Clear(); + _byKey.Clear(); + + if (!DirectoryExists) return; + + var files = Directory + .GetFiles(DictionaryDirectory, "*.json") + .OrderBy(f => f, StringComparer.Ordinal); + + var langs = new List(); + + foreach (var file in files) + { + var name = Path.GetFileNameWithoutExtension(file); + if (!int.TryParse(name, out var langId)) continue; + + var json = File.ReadAllText(file); + Dictionary? dict; + try + { + dict = JsonConvert.DeserializeObject>(json); + } + catch + { + // Skip malformed file rather than abort. + continue; + } + if (dict == null) continue; + + langs.Add(langId); + + foreach (var kv in dict) + { + if (!_byKey.TryGetValue(kv.Key, out var row)) + { + row = new TranslationRow { Key = kv.Key }; + _byKey[kv.Key] = row; + Rows.Add(row); + } + row[langId] = kv.Value?.ToString() ?? ""; + } + } + + foreach (var id in langs.OrderBy(x => x)) + { + Languages.Add(id); + } + } + + public bool TryAddKey(string key, out string error) + { + error = ""; + if (string.IsNullOrWhiteSpace(key)) + { + error = "Key cannot be empty."; + return false; + } + if (_byKey.ContainsKey(key)) + { + error = "A row with that key already exists."; + return false; + } + var row = new TranslationRow { Key = key }; + _byKey[key] = row; + Rows.Insert(0, row); + return true; + } + + public void RemoveRow(TranslationRow row) + { + if (row == null) return; + _byKey.Remove(row.Key); + Rows.Remove(row); + } + + public bool TryAddLanguage(int langId, out string error) + { + error = ""; + if (Languages.Contains(langId)) + { + error = "Language is already present."; + return false; + } + + // Insert keeping the list sorted. + var index = 0; + while (index < Languages.Count && Languages[index] < langId) index++; + Languages.Insert(index, langId); + return true; + } + + public IEnumerable UnusedLanguages() => + LanguageCatalog.All + .Select(l => l.Id) + .Where(id => !Languages.Contains(id)); + + public void Save() + { + if (string.IsNullOrWhiteSpace(GameRoot)) + { + throw new InvalidOperationException("GameRoot path is not configured."); + } + Directory.CreateDirectory(DictionaryDirectory); + + var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + foreach (var langId in Languages) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var row in Rows) + { + if (string.IsNullOrEmpty(row.Key)) continue; + if (!row.HasValue(langId)) continue; + + var val = row[langId]; + if (string.IsNullOrEmpty(val)) continue; + + dict[row.Key] = val; + } + + var json = JsonConvert.SerializeObject(dict, Formatting.Indented); + var path = Path.Combine(DictionaryDirectory, $"{langId}.json"); + File.WriteAllText(path, json, encoding); + } + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AddAutoMarketItemViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AddAutoMarketItemViewModel.cs new file mode 100644 index 00000000..b3d0c3ac --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AddAutoMarketItemViewModel.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AddAutoMarketItemViewModel : ObservableObject + { + private const int EnglishLangId = 0; + + [ObservableProperty] private string _filterText = ""; + [ObservableProperty] private AddAutoMarketItemPickItem? _selectedItem; + [ObservableProperty] private string _errorMessage = ""; + + public ObservableCollection Items { get; } = new(); + public ICollectionView View { get; } + + public AddAutoMarketItemViewModel( + LookupCache lookups, + TranslationsViewModel? translations, + IReadOnlySet alreadyInList) + { + var store = translations?.Store; + foreach (var e in lookups.Entities) + { + if (!e.Enabled) continue; + if (alreadyInList.Contains(e.Name)) continue; + + var translated = ""; + if (store != null) + { + var row = store.Rows.FirstOrDefault(r => r.Key == e.Name); + translated = row?[EnglishLangId] ?? ""; + } + + Items.Add(new AddAutoMarketItemPickItem + { + Definition = e.Definition, + DefinitionName = e.Name, + DisplayName = string.IsNullOrEmpty(translated) ? e.Name : translated, + }); + } + + View = CollectionViewSource.GetDefaultView(Items); + View.Filter = MatchesFilter; + } + + partial void OnFilterTextChanged(string value) => View.Refresh(); + + private bool MatchesFilter(object obj) + { + if (obj is not AddAutoMarketItemPickItem item) return false; + if (string.IsNullOrWhiteSpace(FilterText)) return true; + var f = FilterText.Trim(); + return item.DefinitionName.Contains(f, StringComparison.OrdinalIgnoreCase) + || item.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AddFlockRowViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AddFlockRowViewModel.cs new file mode 100644 index 00000000..b7c3d1a7 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AddFlockRowViewModel.cs @@ -0,0 +1,36 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Common; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AddFlockRowViewModel : ObservableObject + { + [ObservableProperty] private string _name = ""; + [ObservableProperty] private int _presenceId; + [ObservableProperty] private int _flockMemberCount = 1; + [ObservableProperty] private int _definition; + [ObservableProperty] private int _spawnOriginX; + [ObservableProperty] private int _spawnOriginY; + [ObservableProperty] private int _spawnRangeMin; + [ObservableProperty] private int _spawnRangeMax = 10; + [ObservableProperty] private int _respawnSeconds = 1; + [ObservableProperty] private int _totalSpawnCount; + [ObservableProperty] private int _homeRange = 70; + [ObservableProperty] private string? _note; + [ObservableProperty] private double _respawnMultiplierLow = 0.9; + [ObservableProperty] private bool _enabled = true; + [ObservableProperty] private bool _isCallForHelp = true; + [ObservableProperty] private int _behaviorType = 1; + [ObservableProperty] private int _npcSpecialType; + [ObservableProperty] private string _errorMessage = ""; + + public LookupCache Lookups { get; } + public ObservableCollection PresencePicks { get; } = new(); + + public AddFlockRowViewModel(LookupCache lookups) + { + Lookups = lookups; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AddKeyViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AddKeyViewModel.cs new file mode 100644 index 00000000..0679f105 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AddKeyViewModel.cs @@ -0,0 +1,10 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AddKeyViewModel : ObservableObject + { + [ObservableProperty] private string _key = ""; + [ObservableProperty] private string _errorMessage = ""; + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AddLanguageViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AddLanguageViewModel.cs new file mode 100644 index 00000000..43d6dfb2 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AddLanguageViewModel.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AddLanguageViewModel : ObservableObject + { + [ObservableProperty] private LanguageInfo? _selected; + [ObservableProperty] private string _errorMessage = ""; + + public ObservableCollection Available { get; } + + public AddLanguageViewModel(IEnumerable unusedLanguageIds) + { + Available = new ObservableCollection( + unusedLanguageIds + .Select(id => LanguageCatalog.All.FirstOrDefault(l => l.Id == id) + ?? new LanguageInfo(id, $"Lang {id}")) + .OrderBy(l => l.Id)); + + Selected = Available.FirstOrDefault(); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AddNpcLootRowViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AddNpcLootRowViewModel.cs new file mode 100644 index 00000000..215b8f1a --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AddNpcLootRowViewModel.cs @@ -0,0 +1,24 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Common; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AddNpcLootRowViewModel : ObservableObject + { + [ObservableProperty] private int _definition; + [ObservableProperty] private int _lootDefinition; + [ObservableProperty] private int _minQuantity = 1; + [ObservableProperty] private int _quantity = 1; + [ObservableProperty] private double _probability = 1.0; + [ObservableProperty] private bool _dontDamage; + [ObservableProperty] private bool _repackaged; + [ObservableProperty] private string _errorMessage = ""; + + public LookupCache Lookups { get; } + + public AddNpcLootRowViewModel(LookupCache lookups) + { + Lookups = lookups; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AddPresenceRowViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AddPresenceRowViewModel.cs new file mode 100644 index 00000000..ab25bdf6 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AddPresenceRowViewModel.cs @@ -0,0 +1,34 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Common; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AddPresenceRowViewModel : ObservableObject + { + [ObservableProperty] private string _name = ""; + [ObservableProperty] private int _topX; + [ObservableProperty] private int _topY; + [ObservableProperty] private int _bottomX; + [ObservableProperty] private int _bottomY; + [ObservableProperty] private string? _note; + [ObservableProperty] private int? _spawnId; + [ObservableProperty] private bool _enabled = true; + [ObservableProperty] private bool _roaming; + [ObservableProperty] private int _roamingRespawnSeconds; + [ObservableProperty] private int _presenceType; + [ObservableProperty] private int? _maxRandomFlock; + [ObservableProperty] private int? _randomCenterX; + [ObservableProperty] private int? _randomCenterY; + [ObservableProperty] private int? _randomRadius; + [ObservableProperty] private int? _dynamicLifetime; + [ObservableProperty] private bool _isBodyPull = true; + [ObservableProperty] private bool _isRespawnAllowed = true; + [ObservableProperty] private bool _safeBodyPull; + [ObservableProperty] private int? _izGroupId; + [ObservableProperty] private int? _growthSeconds; + [ObservableProperty] private string _errorMessage = ""; + + public ObservableCollection ZoneSpawnPicks { get; } = new(); + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AddSetMemberViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AddSetMemberViewModel.cs new file mode 100644 index 00000000..16456285 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AddSetMemberViewModel.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.EquipmentSets; +using Perpetuum.AdminTool.Packages; +using Perpetuum.AdminTool.Translations; +using Perpetuum.ExportedTypes; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AddSetMemberViewModel : ObservableObject + { + private static readonly long _equipmentRoot = (long)CategoryFlags.cf_robot_equipment; + private static readonly long _equipmentMask = PackageItemPickItem.CategoryFlagsMask(_equipmentRoot); + private const int EnglishLangId = 0; + + [ObservableProperty] private string _filterText = ""; + [ObservableProperty] private SetMemberPickItem? _selectedItem; + [ObservableProperty] private string _errorMessage = ""; + + public ObservableCollection Items { get; } = new(); + public ICollectionView View { get; } + + public AddSetMemberViewModel( + LookupCache lookups, + TranslationsViewModel translations, + IReadOnlySet alreadyAssigned) + { + var store = translations.Store; + foreach (var e in lookups.Entities) + { + if (!e.Enabled) continue; + if (e.Hidden) continue; + if ((e.CategoryFlags & _equipmentMask) != _equipmentRoot) continue; + if (alreadyAssigned.Contains(e.Definition)) continue; + + var translated = ""; + if (store != null) + { + var row = store.Rows.FirstOrDefault(r => r.Key == e.Name); + translated = row?[EnglishLangId] ?? ""; + } + + Items.Add(new SetMemberPickItem + { + Definition = e.Definition, + DefinitionName = e.Name, + TranslatedName = translated, + }); + } + + View = CollectionViewSource.GetDefaultView(Items); + View.Filter = MatchesFilter; + } + + partial void OnFilterTextChanged(string value) => View.Refresh(); + + private bool MatchesFilter(object obj) + { + if (obj is not SetMemberPickItem item) return false; + if (string.IsNullOrWhiteSpace(FilterText)) return true; + var f = FilterText.Trim(); + if (int.TryParse(f, out var id)) return item.Definition == id; + return item.DefinitionName.Contains(f, StringComparison.OrdinalIgnoreCase) + || item.TranslatedName.Contains(f, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AddSetThresholdViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AddSetThresholdViewModel.cs new file mode 100644 index 00000000..b9b51fab --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AddSetThresholdViewModel.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.EquipmentSets; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AddSetThresholdViewModel : ObservableObject + { + [ObservableProperty] private string _filterText = ""; + [ObservableProperty] private AggregateFieldPickItem? _selectedField; + [ObservableProperty] private int _requiredPieces; + [ObservableProperty] private double _bonusValue; + [ObservableProperty] private string _errorMessage = ""; + + public ObservableCollection Items { get; } = new(); + public ICollectionView View { get; } + + public AddSetThresholdViewModel(System.Collections.Generic.IEnumerable fields) + { + foreach (var f in fields) + Items.Add(f); + + View = CollectionViewSource.GetDefaultView(Items); + View.Filter = MatchesFilter; + } + + partial void OnFilterTextChanged(string value) => View.Refresh(); + + private bool MatchesFilter(object obj) + { + if (obj is not AggregateFieldPickItem item) return false; + if (string.IsNullOrWhiteSpace(FilterText)) return true; + var f = FilterText.Trim(); + return item.Name.Contains(f, StringComparison.OrdinalIgnoreCase) + || item.TranslatedName.Contains(f, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AddTemplateRelationRowViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AddTemplateRelationRowViewModel.cs new file mode 100644 index 00000000..215fe4cb --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AddTemplateRelationRowViewModel.cs @@ -0,0 +1,25 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Common; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AddTemplateRelationRowViewModel : ObservableObject + { + [ObservableProperty] private int _definition; + [ObservableProperty] private int _templateId; + [ObservableProperty] private int _itemScoreSum; + [ObservableProperty] private int _raceId; + [ObservableProperty] private int? _missionLevel; + [ObservableProperty] private int? _missionLevelOverride; + [ObservableProperty] private int? _killEp; + [ObservableProperty] private string? _note; + [ObservableProperty] private string _errorMessage = ""; + + public LookupCache Lookups { get; } + + public AddTemplateRelationRowViewModel(LookupCache lookups) + { + Lookups = lookups; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AttributeFlagsPickerViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AttributeFlagsPickerViewModel.cs new file mode 100644 index 00000000..2fe53142 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AttributeFlagsPickerViewModel.cs @@ -0,0 +1,44 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Entities; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AttributeFlagsPickerViewModel : ObservableObject + { + public ObservableCollection Items { get; } = new(); + + public AttributeFlagsPickerViewModel(ulong initialValue) + { + foreach (var bit in AttributeFlagsCatalog.Bits) + { + Items.Add(new BitItem(bit, AttributeFlagsCatalog.IsSet(initialValue, bit.Position))); + } + } + + public ulong ComposeValue() + { + ulong value = 0; + foreach (var item in Items) + { + if (item.IsChecked) value |= item.Bit.Mask; + } + return value; + } + + public partial class BitItem : ObservableObject + { + public AttributeFlagsCatalog.Bit Bit { get; } + + [ObservableProperty] private bool _isChecked; + + public BitItem(AttributeFlagsCatalog.Bit bit, bool isChecked) + { + Bit = bit; + _isChecked = isChecked; + } + + public string Display => Bit.Display; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AutoMarketConfigViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AutoMarketConfigViewModel.cs new file mode 100644 index 00000000..f119a6fc --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AutoMarketConfigViewModel.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Editing; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketConfigViewModel : ObservableObject + { + private readonly AutoMarketRepository _repo; + private readonly ChangeQueue _queue; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public ObservableCollection Rows { get; } = new(); + + public AutoMarketConfigViewModel(AutoMarketRepository repo, ChangeQueue queue) + { + _repo = repo; + _queue = queue; + } + + public async Task LoadAsync() + { + IsLoading = true; + StatusMessage = ""; + StatusIsError = false; + try + { + var rows = await _repo.LoadConfigAsync(); + Rows.Clear(); + foreach (var r in rows) Rows.Add(r); + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + [RelayCommand] + private void QueueSave(AutoMarketConfigRow row) + { + var description = $"automarket_config: update {row.ParamName}"; + var existing = _queue.Items.FirstOrDefault(c => c.Description == description); + if (existing != null) _queue.Items.Remove(existing); + _queue.Add(new RawSqlChange( + description, + $"UPDATE automarket_config SET param_value = {SqlLiteral.Of(row.ParamValue)} " + + $"WHERE param_name = {SqlLiteral.Of(row.ParamName)}")); + row.OriginalValue = row.ParamValue; + StatusMessage = $"{row.Label} queued."; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AutoMarketOrdersViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AutoMarketOrdersViewModel.cs new file mode 100644 index 00000000..6fcad5ab --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AutoMarketOrdersViewModel.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketOrdersViewModel : ObservableObject + { + private static readonly HashSet PlasmaIds = new() { 3271, 3272, 3273, 3274 }; + private const int EnglishLangId = 0; + + private readonly AutoMarketRepository _repo; + private readonly TranslationsViewModel? _translations; + private List _allOrders = new(); + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private string? _orderTypeFilter; + [ObservableProperty] private string? _categoryFilter; + + public ObservableCollection FilteredOrders { get; } = new(); + + public static IReadOnlyList OrderTypeOptions { get; } = + new List { null, "Buy", "Sell", "Buyback" }; + public static IReadOnlyList CategoryOptions { get; } = + new List { null, "Plasma", "Raw Material", "Production Item" }; + + // Instance forwarders for WPF binding (avoids XAML build-time x:Static resolution issues) + public IReadOnlyList OrderTypeOptionsList => OrderTypeOptions; + public IReadOnlyList CategoryOptionsList => CategoryOptions; + + public AutoMarketOrdersViewModel(AutoMarketRepository repo, TranslationsViewModel? translations) + { + _repo = repo; + _translations = translations; + } + + [RelayCommand(CanExecute = nameof(CanRefresh))] + public async Task RefreshAsync() + { + IsLoading = true; + StatusMessage = "Loading orders..."; + StatusIsError = false; + try + { + var raw = await _repo.LoadOrdersAsync(); + var store = _translations?.Store; + + string Translate(string defName) + { + if (string.IsNullOrEmpty(defName) || store == null) return defName; + var row = store.Rows.FirstOrDefault(r => r.Key == defName); + var t = row?[EnglishLangId]; + return string.IsNullOrEmpty(t) ? defName : t; + } + + var prodItems = (await _repo.LoadTradeListAsync()) + .Select(r => r.DefinitionName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + _allOrders = raw.Select(d => + { + var category = PlasmaIds.Contains(d.ItemDefinition) ? "Plasma" + : prodItems.Contains(d.DefinitionName) ? "Production Item" + : "Raw Material"; + var orderType = d.IsSell ? "Sell" + : category == "Production Item" ? "Buyback" + : "Buy"; + return new AutoMarketOrderRow + { + DisplayName = Translate(d.DefinitionName), + OrderType = orderType, + Price = d.Price, + Amount = d.Quantity, + MarketName = Translate(d.MarketDefinitionName), + Category = category, + }; + }).ToList(); + + ApplyFilter(); + StatusMessage = $"Loaded {_allOrders.Count} order(s) at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + private bool CanRefresh() => !IsLoading; + partial void OnIsLoadingChanged(bool _) => RefreshCommand.NotifyCanExecuteChanged(); + partial void OnOrderTypeFilterChanged(string? _) => ApplyFilter(); + partial void OnCategoryFilterChanged(string? _) => ApplyFilter(); + + private void ApplyFilter() + { + var filtered = _allOrders.AsEnumerable(); + if (OrderTypeFilter != null) filtered = filtered.Where(r => r.OrderType == OrderTypeFilter); + if (CategoryFilter != null) filtered = filtered.Where(r => r.Category == CategoryFilter); + FilteredOrders.Clear(); + foreach (var r in filtered) FilteredOrders.Add(r); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AutoMarketRawMaterialsViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AutoMarketRawMaterialsViewModel.cs new file mode 100644 index 00000000..73df3a9a --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AutoMarketRawMaterialsViewModel.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketRawMaterialsViewModel : ObservableObject + { + private readonly AutoMarketRepository _repo; + private readonly ChangeQueue _queue; + private readonly TranslationsViewModel? _translations; + private const int EnglishLangId = 0; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredRows))] + private bool _showOverridesOnly; + + public ObservableCollection Rows { get; } = new(); + + public IEnumerable FilteredRows => + _showOverridesOnly ? Rows.Where(r => r.HasOverride) : (IEnumerable)Rows; + + public AutoMarketRawMaterialsViewModel( + AutoMarketRepository repo, + ChangeQueue queue, + TranslationsViewModel? translations = null) + { + _repo = repo; + _queue = queue; + _translations = translations; + } + + [RelayCommand(CanExecute = nameof(CanRefresh))] + public async Task RefreshAsync() + { + IsLoading = true; + StatusMessage = "Loading raw materials..."; + StatusIsError = false; + try + { + var rows = await _repo.LoadCoveredMaterialsAsync(); + var store = _translations?.Store; + + Rows.Clear(); + foreach (var r in rows) + { + if (store != null) + { + var tr = store.Rows.FirstOrDefault(x => x.Key == r.DefinitionName); + var t = tr?[EnglishLangId]; + if (!string.IsNullOrEmpty(t)) r.DisplayName = t; + } + if (string.IsNullOrEmpty(r.DisplayName)) r.DisplayName = r.DefinitionName; + Rows.Add(r); + } + + OnPropertyChanged(nameof(FilteredRows)); + StatusMessage = $"Loaded {Rows.Count} materials at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + private bool CanRefresh() => !IsLoading; + partial void OnIsLoadingChanged(bool value) => RefreshCommand.NotifyCanExecuteChanged(); + + [RelayCommand] + private void QueueSave(AutoMarketCoveredMaterialRow row) + { + var description = $"automarket_rawmat_overrides: {row.DefinitionName}"; + var existing = _queue.Items.FirstOrDefault(c => c.Description == description); + if (existing != null) _queue.Items.Remove(existing); + + string sql; + if (row.IsAtDefaults) + { + sql = $"DELETE FROM automarket_rawmat_overrides WHERE definitionname = {SqlLiteral.Of(row.DefinitionName)}"; + } + else + { + sql = + $"MERGE automarket_rawmat_overrides AS t " + + $"USING (VALUES ({SqlLiteral.Of(row.DefinitionName)}, {SqlLiteral.OfNullableInt(row.WeeklyCapOverride)}, " + + $"{(row.CreateBuyOrders ? 1 : 0)}, {(row.CreateSellOrders ? 1 : 0)})) " + + $"AS s (definitionname, weekly_cap_override, create_buy_orders, create_sell_orders) " + + $"ON t.definitionname = s.definitionname " + + $"WHEN MATCHED THEN UPDATE SET " + + $" weekly_cap_override = s.weekly_cap_override, " + + $" create_buy_orders = s.create_buy_orders, " + + $" create_sell_orders = s.create_sell_orders " + + $"WHEN NOT MATCHED THEN INSERT (definitionname, weekly_cap_override, create_buy_orders, create_sell_orders) " + + $"VALUES (s.definitionname, s.weekly_cap_override, s.create_buy_orders, s.create_sell_orders);"; + } + + _queue.Add(new RawSqlChange(description, sql)); + row.OriginalCapOverride = row.WeeklyCapOverride; + row.OriginalBuyOrders = row.CreateBuyOrders; + row.OriginalSellOrders = row.CreateSellOrders; + StatusMessage = $"{row.DisplayName} queued."; + OnPropertyChanged(nameof(FilteredRows)); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AutoMarketStatisticsViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AutoMarketStatisticsViewModel.cs new file mode 100644 index 00000000..c520b1fe --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AutoMarketStatisticsViewModel.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketStatisticsViewModel : ObservableObject + { + private readonly AutoMarketRepository _repo; + private readonly TranslationsViewModel? _translations; + private const int EnglishLangId = 0; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public ObservableCollection NicFlow { get; } = new(); + public ObservableCollection PricingTrace { get; } = new(); + public ObservableCollection GatherBreakdown { get; } = new(); + + public AutoMarketStatisticsViewModel(AutoMarketRepository repo, TranslationsViewModel? translations = null) + { + _repo = repo; + _translations = translations; + } + + [RelayCommand(CanExecute = nameof(CanRefresh))] + public async Task RefreshAsync() + { + IsLoading = true; + StatusMessage = "Loading statistics..."; + StatusIsError = false; + try + { + var nicTask = _repo.LoadNicFlowAsync(); + var priceTask = _repo.LoadPricingTraceAsync(); + var gatherTask = _repo.LoadGatherBreakdownAsync(); + await Task.WhenAll(nicTask, priceTask, gatherTask); + + var store = _translations?.Store; + + NicFlow.Clear(); + foreach (var r in nicTask.Result) NicFlow.Add(r); + + PricingTrace.Clear(); + foreach (var r in priceTask.Result) + { + r.DisplayName = Translate(store, r.ResourceName); + PricingTrace.Add(r); + } + + GatherBreakdown.Clear(); + foreach (var r in gatherTask.Result) + { + r.DisplayName = Translate(store, r.ResourceName); + GatherBreakdown.Add(r); + } + + StatusMessage = $"Loaded at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + private bool CanRefresh() => !IsLoading; + partial void OnIsLoadingChanged(bool value) => RefreshCommand.NotifyCanExecuteChanged(); + + private string Translate(TranslationStore? store, string defName) + { + if (store == null) return defName; + var t = store.Rows.FirstOrDefault(x => x.Key == defName)?[EnglishLangId]; + return string.IsNullOrEmpty(t) ? defName : t; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AutoMarketTradeListViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AutoMarketTradeListViewModel.cs new file mode 100644 index 00000000..74fb6b9d --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AutoMarketTradeListViewModel.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Translations; +using Perpetuum.AdminTool.Views; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketTradeListViewModel : ObservableObject + { + private readonly AutoMarketRepository _repo; + private readonly ChangeQueue _queue; + private readonly LookupCache _lookups; + private readonly TranslationsViewModel? _translations; + private const int EnglishLangId = 0; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public ObservableCollection Rows { get; } = new(); + public ObservableCollection DerivedMaterials { get; } = new(); + + public AutoMarketTradeListViewModel( + AutoMarketRepository repo, + ChangeQueue queue, + LookupCache lookups, + TranslationsViewModel? translations) + { + _repo = repo; + _queue = queue; + _lookups = lookups; + _translations = translations; + } + + public async Task LoadAsync() + { + IsLoading = true; + StatusMessage = ""; + StatusIsError = false; + try + { + var store = _translations?.Store; + var rows = await _repo.LoadTradeListAsync(); + Rows.Clear(); + foreach (var r in rows) + { + if (store != null) + { + var tr = store.Rows.FirstOrDefault(x => x.Key == r.DefinitionName); + var t = tr?[EnglishLangId]; + if (!string.IsNullOrEmpty(t)) r.DisplayName = t; + } + Rows.Add(r); + } + await RefreshDerivedAsync(); + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + private async Task RefreshDerivedAsync() + { + try + { + var mats = await _repo.LoadDerivedMaterialsAsync(); + DerivedMaterials.Clear(); + foreach (var m in mats) DerivedMaterials.Add(m); + } + catch { /* non-fatal — sub-panel stays empty */ } + } + + [RelayCommand] + private void QueueSave(AutoMarketTradeListRow row) + { + var description = $"market_orders_configuration: update {row.DefinitionName}"; + var existing = _queue.Items.FirstOrDefault(c => c.Description == description); + if (existing != null) _queue.Items.Remove(existing); + _queue.Add(new RawSqlChange( + description, + $"UPDATE market_orders_configuration " + + $"SET amount = {SqlLiteral.Of(row.Amount)}, " + + $"create_sell_orders = {SqlLiteral.Of(row.CreateSellOrders)}, " + + $"create_buyback_orders = {SqlLiteral.Of(row.CreateBuybackOrders)} " + + $"WHERE definitionname = {SqlLiteral.Of(row.DefinitionName)}")); + row.OriginalAmount = row.Amount; + row.OriginalCreateSellOrders = row.CreateSellOrders; + row.OriginalCreateBuybackOrders = row.CreateBuybackOrders; + StatusMessage = $"{row.DisplayName} amount queued."; + } + + [RelayCommand] + private void Remove(AutoMarketTradeListRow row) + { + var msg = $"Remove '{row.DisplayName}' from the trade list?\n\n" + + "AutoMarket will no longer place orders for this item."; + if (MessageBox.Show(msg, "Remove item", + MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) + != MessageBoxResult.Yes) return; + + // Cancel any pending save for this row + var saveDesc = $"market_orders_configuration: update {row.DefinitionName}"; + var existing = _queue.Items.FirstOrDefault(c => c.Description == saveDesc); + if (existing != null) _queue.Items.Remove(existing); + + _queue.Add(new RawSqlChange( + $"market_orders_configuration: delete {row.DefinitionName}", + $"DELETE FROM market_orders_configuration WHERE definitionname = {SqlLiteral.Of(row.DefinitionName)}", + isDestructive: true)); + Rows.Remove(row); + StatusMessage = $"'{row.DisplayName}' queued for removal."; + } + + public void AddItem(Window owner) + { + var existing = Rows.Select(r => r.DefinitionName).ToHashSet(); + var vm = new AddAutoMarketItemViewModel(_lookups, _translations, existing); + var win = new AddAutoMarketItemWindow(vm) { Owner = owner }; + if (win.ShowDialog() != true || vm.SelectedItem == null) return; + + var item = vm.SelectedItem; + _queue.Add(new RawSqlChange( + $"market_orders_configuration: insert {item.DefinitionName}", + $"INSERT INTO market_orders_configuration (definitionname, amount) " + + $"VALUES ({SqlLiteral.Of(item.DefinitionName)}, 1)")); + + Rows.Add(new AutoMarketTradeListRow + { + DefinitionName = item.DefinitionName, + DisplayName = item.DisplayName, + Amount = 1, + OriginalAmount = 1, + CreateSellOrders = true, + OriginalCreateSellOrders = true, + CreateBuybackOrders = true, + OriginalCreateBuybackOrders = true, + }); + StatusMessage = $"'{item.DisplayName}' queued for insert."; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/AutoMarketViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/AutoMarketViewModel.cs new file mode 100644 index 00000000..3cdbe5cd --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/AutoMarketViewModel.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class AutoMarketViewModel : ObservableObject + { + private readonly AutoMarketRepository _repo; + + [ObservableProperty] private bool _isRefreshing; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public AutoMarketConfigViewModel Config { get; } + public AutoMarketTradeListViewModel TradeList { get; } + public AutoMarketRawMaterialsViewModel RawMaterials { get; } + public AutoMarketStatisticsViewModel Statistics { get; } + public AutoMarketOrdersViewModel Orders { get; } + + public AutoMarketViewModel( + AutoMarketRepository repo, + ChangeQueue queue, + LookupCache lookups, + TranslationsViewModel? translations = null) + { + _repo = repo; + Config = new AutoMarketConfigViewModel(repo, queue); + TradeList = new AutoMarketTradeListViewModel(repo, queue, lookups, translations); + RawMaterials = new AutoMarketRawMaterialsViewModel(repo, queue, translations); + Statistics = new AutoMarketStatisticsViewModel(repo, translations); + Orders = new AutoMarketOrdersViewModel(repo, translations); + } + + public async Task LoadAsync() + { + await Task.WhenAll(Config.LoadAsync(), TradeList.LoadAsync()); + } + + [RelayCommand(CanExecute = nameof(CanRefreshNow))] + private async Task RefreshNow() + { + IsRefreshing = true; + StatusIsError = false; + StatusMessage = "Refreshing AutoMarket orders..."; + try + { + await _repo.RefreshNowAsync(); + StatusMessage = $"Refresh complete at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Refresh failed: {ex.Message}"; + } + finally { IsRefreshing = false; } + } + + private bool CanRefreshNow() => !IsRefreshing; + partial void OnIsRefreshingChanged(bool value) => RefreshNowCommand.NotifyCanExecuteChanged(); + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/CategoryFlagsPickerViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/CategoryFlagsPickerViewModel.cs new file mode 100644 index 00000000..e49e76f9 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/CategoryFlagsPickerViewModel.cs @@ -0,0 +1,35 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Entities; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class CategoryFlagsPickerViewModel : ObservableObject + { + [ObservableProperty] private string _filter = ""; + [ObservableProperty] private CategoryFlagsCatalog.Entry? _selected; + + public ICollectionView View { get; } + + public CategoryFlagsPickerViewModel(long initialValue) + { + View = CollectionViewSource.GetDefaultView(CategoryFlagsCatalog.Entries); + View.Filter = MatchesFilter; + Selected = CategoryFlagsCatalog.Entries.FirstOrDefault(e => e.Value == initialValue); + } + + partial void OnFilterChanged(string value) => View.Refresh(); + + private bool MatchesFilter(object obj) + { + if (obj is not CategoryFlagsCatalog.Entry entry) return false; + if (string.IsNullOrWhiteSpace(Filter)) return true; + var f = Filter.Trim(); + return entry.Name.Contains(f, StringComparison.OrdinalIgnoreCase) + || entry.Hex.Contains(f, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/ConfirmSqlViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/ConfirmSqlViewModel.cs new file mode 100644 index 00000000..cb059cad --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/ConfirmSqlViewModel.cs @@ -0,0 +1,16 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.ViewModels +{ + public class ConfirmSqlViewModel : ObservableObject + { + public string Sql { get; } + public string Header { get; } + + public ConfirmSqlViewModel(string header, string sql) + { + Header = header; + Sql = sql; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/ConnectionSettingsViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/ConnectionSettingsViewModel.cs new file mode 100644 index 00000000..7a72493a --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/ConnectionSettingsViewModel.cs @@ -0,0 +1,72 @@ +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Data; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class ConnectionSettingsViewModel : ObservableObject + { + private readonly AppSettingsStore _store; + + [ObservableProperty] private string _server; + [ObservableProperty] private string _database; + [ObservableProperty] private bool _integratedSecurity; + [ObservableProperty] private string _sqlUser; + [ObservableProperty] private string _sqlPassword; + [ObservableProperty] private bool _trustServerCertificate; + [ObservableProperty] private string _gameRootPath; + [ObservableProperty] private string _sqlOutputDirectory; + [ObservableProperty] private string _testStatus; + [ObservableProperty] private bool _testIsError; + + public ConnectionSettingsViewModel(AppSettingsStore store) + { + _store = store; + var s = store.Settings; + _server = s.Connection.Server; + _database = s.Connection.Database; + _integratedSecurity = s.Connection.IntegratedSecurity; + _sqlUser = s.Connection.SqlUser; + _sqlPassword = s.Connection.SqlPassword; + _trustServerCertificate = s.Connection.TrustServerCertificate; + _gameRootPath = s.GameRootPath; + _sqlOutputDirectory = s.SqlOutputDirectory; + _testStatus = ""; + } + + public async Task TestAsync() + { + TestStatus = "Connecting..."; + TestIsError = false; + var probe = BuildSnapshot(); + var (ok, msg) = await DbProbe.TestConnectionAsync(probe); + TestStatus = msg; + TestIsError = !ok; + } + + public void Save() + { + var s = _store.Settings; + s.Connection.Server = Server; + s.Connection.Database = Database; + s.Connection.IntegratedSecurity = IntegratedSecurity; + s.Connection.SqlUser = SqlUser; + s.Connection.SqlPassword = SqlPassword; + s.Connection.TrustServerCertificate = TrustServerCertificate; + s.GameRootPath = GameRootPath; + s.SqlOutputDirectory = SqlOutputDirectory; + _store.Save(); + } + + private ConnectionSettings BuildSnapshot() => new() + { + Server = Server, + Database = Database, + IntegratedSecurity = IntegratedSecurity, + SqlUser = SqlUser, + SqlPassword = SqlPassword, + TrustServerCertificate = TrustServerCertificate + }; + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/EconomyInsuranceViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/EconomyInsuranceViewModel.cs new file mode 100644 index 00000000..6f8ebc71 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/EconomyInsuranceViewModel.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Economy; +using Perpetuum.AdminTool.Editing; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class EconomyInsuranceViewModel : ObservableObject + { + private readonly EconomyInsuranceRepository _repo; + private readonly ChangeQueue _queue; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private bool _isRecalculating; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private bool _showSinkWarning; + + public ObservableCollection ConfigRows { get; } = new(); + public ObservableCollection PriceRows { get; } = new(); + + public EconomyInsuranceViewModel(EconomyInsuranceRepository repo, ChangeQueue queue) + { + _repo = repo; + _queue = queue; + } + + public async Task LoadAsync() + { + IsLoading = true; + StatusMessage = ""; + StatusIsError = false; + try + { + var config = await _repo.LoadConfigAsync(); + var prices = await _repo.LoadPricesAsync(); + + ConfigRows.Clear(); + foreach (var r in config) ConfigRows.Add(r); + + PriceRows.Clear(); + foreach (var r in prices) PriceRows.Add(r); + + UpdateSinkWarning(); + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + [RelayCommand] + private void QueueSave(InsuranceConfigRow row) + { + var description = $"insurance_config: update {row.ParamName}"; + var existing = _queue.Items.FirstOrDefault(c => c.Description == description); + if (existing != null) _queue.Items.Remove(existing); + _queue.Add(new RawSqlChange( + description, + $"UPDATE insurance_config SET param_value = {SqlLiteral.Of(row.ParamValue)} " + + $"WHERE param_name = {SqlLiteral.Of(row.ParamName)}")); + row.OriginalValue = row.ParamValue; + StatusMessage = $"{row.Label} queued."; + UpdateSinkWarning(); + } + + [RelayCommand(CanExecute = nameof(CanRecalculate))] + private async Task RecalculateNowAsync() + { + IsRecalculating = true; + StatusIsError = false; + StatusMessage = "Recalculating insurance prices..."; + try + { + await _repo.RecalculateAsync(); + var prices = await _repo.LoadPricesAsync(); + PriceRows.Clear(); + foreach (var r in prices) PriceRows.Add(r); + StatusMessage = $"Prices recalculated at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Recalculate failed: {ex.Message}"; + } + finally { IsRecalculating = false; } + } + + private bool CanRecalculate() => !IsRecalculating && !IsLoading; + + [RelayCommand(CanExecute = nameof(CanRecalculate))] + private async Task ReloadAsync() => await LoadAsync(); + + partial void OnIsRecalculatingChanged(bool value) => RecalculateNowCommand.NotifyCanExecuteChanged(); + partial void OnIsLoadingChanged(bool value) + { + RecalculateNowCommand.NotifyCanExecuteChanged(); + ReloadCommand.NotifyCanExecuteChanged(); + } + + private void UpdateSinkWarning() + { + var feePct = ConfigRows.FirstOrDefault(r => r.ParamName == "fee_pct")?.ParamValue ?? 0; + var payoutPct = ConfigRows.FirstOrDefault(r => r.ParamName == "payout_pct")?.ParamValue ?? 0; + ShowSinkWarning = payoutPct >= feePct; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/EconomyMarketHealthViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/EconomyMarketHealthViewModel.cs new file mode 100644 index 00000000..6e555077 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/EconomyMarketHealthViewModel.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Economy; +using Perpetuum.AdminTool.Editing; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class EconomyMarketHealthViewModel : ObservableObject + { + private readonly EconomyMarketHealthRepository _repo; + private readonly ChangeQueue _changes; + private readonly LookupCache _lookups; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + [ObservableProperty] private int _ageBucketToday; + [ObservableProperty] private int _ageBucketD1To7; + [ObservableProperty] private int _ageBucketD7To30; + [ObservableProperty] private int _ageBucketD30Plus; + [ObservableProperty] private int _autoMarketOrderCount; + [ObservableProperty] private int _playerOrderCount; + + [ObservableProperty] private EntityPickItem? _selectedNewItem; + + public ObservableCollection VelocityRows { get; } = new(); + public ObservableCollection PriceIndexRows { get; } = new(); + public ObservableCollection BasketItems { get; } = new(); + public ObservableCollection AvailableItems => _lookups.Entities; + + public EconomyMarketHealthViewModel( + EconomyMarketHealthRepository repo, + ChangeQueue changes, + LookupCache lookups) + { + _repo = repo; + _changes = changes; + _lookups = lookups; + } + + [RelayCommand(CanExecute = nameof(CanRefresh))] + public async Task RefreshAsync() + { + IsLoading = true; + StatusMessage = "Loading..."; + StatusIsError = false; + try + { + var marketData = await _repo.LoadMarketDataAsync(); + var basket = await _repo.LoadBasketAsync(); + + VelocityRows.Clear(); + foreach (var r in marketData.VelocityRows) VelocityRows.Add(r); + + PriceIndexRows.Clear(); + foreach (var r in marketData.PriceIndexRows) PriceIndexRows.Add(r); + + AgeBucketToday = marketData.AgeBuckets.Today; + AgeBucketD1To7 = marketData.AgeBuckets.D1To7; + AgeBucketD7To30 = marketData.AgeBuckets.D7To30; + AgeBucketD30Plus = marketData.AgeBuckets.D30Plus; + AutoMarketOrderCount = marketData.AutoMarketOrderCount; + PlayerOrderCount = marketData.PlayerOrderCount; + + BasketItems.Clear(); + foreach (var b in basket) BasketItems.Add(b); + + StatusMessage = $"Loaded at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + [RelayCommand] + private void QueueSaveBasketItem(EconomyPriceIndexBasketItem item) + { + var desc = $"economy_price_index_basket: update id={item.Id}"; + var existing = _changes.Items.FirstOrDefault(c => c.Description == desc); + if (existing != null) _changes.Items.Remove(existing); + + _changes.Add(new RawSqlChange(desc, + $"UPDATE economy_price_index_basket SET weight = {SqlLiteral.Of(item.Weight)} WHERE id = {SqlLiteral.Of(item.Id)}")); + StatusMessage = $"Weight change for '{item.DefinitionName}' queued."; + } + + [RelayCommand] + private void RemoveBasketItem(EconomyPriceIndexBasketItem item) + { + BasketItems.Remove(item); + if (item.Id > 0) + { + _changes.Add(new RawSqlChange( + $"economy_price_index_basket: delete id={item.Id}", + $"DELETE FROM economy_price_index_basket WHERE id = {SqlLiteral.Of(item.Id)}", + isDestructive: true)); + } + StatusMessage = $"'{item.DefinitionName}' removed from basket (queued)."; + } + + [RelayCommand] + private void AddBasketItem() + { + if (SelectedNewItem == null) return; + if (BasketItems.Any(b => b.Definition == SelectedNewItem.Definition)) + { + StatusMessage = $"'{SelectedNewItem.Name}' is already in the basket."; + StatusIsError = true; + return; + } + + var newItem = new EconomyPriceIndexBasketItem + { + Id = 0, + Definition = SelectedNewItem.Definition, + DefinitionName = SelectedNewItem.Name, + }; + newItem.Weight = 1.0; + BasketItems.Add(newItem); + + _changes.Add(new RawSqlChange( + $"economy_price_index_basket: insert {SelectedNewItem.Name}", + $"INSERT INTO economy_price_index_basket (definition, weight) VALUES ({SqlLiteral.Of(SelectedNewItem.Definition)}, 1.0)")); + + StatusMessage = $"'{SelectedNewItem.Name}' added to basket (queued)."; + SelectedNewItem = null; + StatusIsError = false; + } + + private bool CanRefresh() => !IsLoading; + partial void OnIsLoadingChanged(bool value) => RefreshCommand.NotifyCanExecuteChanged(); + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/EconomyMoneySupplyViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/EconomyMoneySupplyViewModel.cs new file mode 100644 index 00000000..84cc0172 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/EconomyMoneySupplyViewModel.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Economy; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class EconomyMoneySupplyViewModel : ObservableObject + { + private readonly EconomyMoneySupplyRepository _repo; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private long _totalNic; + [ObservableProperty] private long _medianNic; + [ObservableProperty] private double _top1PctShare; + [ObservableProperty] private long _idleNic; + + public ObservableCollection SnapshotRows { get; } = new(); + public ObservableCollection Top10Rows { get; } = new(); + public ObservableCollection Top10CorpRows { get; } = new(); + + public EconomyMoneySupplyViewModel(EconomyMoneySupplyRepository repo) => _repo = repo; + + [RelayCommand(CanExecute = nameof(CanRefresh))] + public async Task RefreshAsync() + { + IsLoading = true; + StatusMessage = "Loading..."; + StatusIsError = false; + try + { + var data = await _repo.LoadAsync(); + + TotalNic = data.TotalNic; + MedianNic = data.MedianNic; + Top1PctShare = data.Top1PctShare; + IdleNic = data.IdleNic; + + SnapshotRows.Clear(); + foreach (var r in data.SnapshotRows) SnapshotRows.Add(r); + + Top10Rows.Clear(); + foreach (var r in data.Top10Rows) Top10Rows.Add(r); + + Top10CorpRows.Clear(); + foreach (var r in data.Top10CorpRows) Top10CorpRows.Add(r); + + StatusMessage = $"Loaded at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + private bool CanRefresh() => !IsLoading; + partial void OnIsLoadingChanged(bool value) => RefreshCommand.NotifyCanExecuteChanged(); + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/EconomyNicFlowViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/EconomyNicFlowViewModel.cs new file mode 100644 index 00000000..f3d620b2 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/EconomyNicFlowViewModel.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Economy; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class EconomyNicFlowViewModel : ObservableObject + { + private readonly EconomyRepository _repo; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public ObservableCollection NicIn { get; } = new(); + public ObservableCollection NicOut { get; } = new(); + + public long NetToday => TotalIn(r => r.Today) - TotalOut(r => r.Today); + public long NetLast7Days => TotalIn(r => r.Last7Days) - TotalOut(r => r.Last7Days); + public long NetLast30Days => TotalIn(r => r.Last30Days) - TotalOut(r => r.Last30Days); + public long NetAllTime => TotalIn(r => r.AllTime) - TotalOut(r => r.AllTime); + + public EconomyNicFlowViewModel(EconomyRepository repo) => _repo = repo; + + [RelayCommand(CanExecute = nameof(CanRefresh))] + public async Task RefreshAsync() + { + IsLoading = true; + StatusMessage = "Loading..."; + StatusIsError = false; + try + { + var (nicIn, nicOut) = await _repo.LoadNicFlowAsync(); + + NicIn.Clear(); + foreach (var row in nicIn) NicIn.Add(row); + + NicOut.Clear(); + foreach (var row in nicOut) NicOut.Add(row); + + OnPropertyChanged(nameof(NetToday)); + OnPropertyChanged(nameof(NetLast7Days)); + OnPropertyChanged(nameof(NetLast30Days)); + OnPropertyChanged(nameof(NetAllTime)); + + StatusMessage = $"Loaded at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + private bool CanRefresh() => !IsLoading; + partial void OnIsLoadingChanged(bool value) => RefreshCommand.NotifyCanExecuteChanged(); + + private long TotalIn(Func sel) + => NicIn.Where(r => !r.IsTotal).Sum(sel); + + private long TotalOut(Func sel) + => NicOut.Where(r => !r.IsTotal).Sum(sel); + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/EconomySinkEffectivenessViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/EconomySinkEffectivenessViewModel.cs new file mode 100644 index 00000000..6687e679 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/EconomySinkEffectivenessViewModel.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Economy; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class EconomySinkEffectivenessViewModel : ObservableObject + { + private readonly EconomySinkRepository _repo; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private int _activePlayerCount; + [ObservableProperty] private double _insuranceCoveragePct; + + public ObservableCollection SinkRows { get; } = new(); + + public EconomySinkEffectivenessViewModel(EconomySinkRepository repo) => _repo = repo; + + [RelayCommand(CanExecute = nameof(CanRefresh))] + public async Task RefreshAsync() + { + IsLoading = true; + StatusMessage = "Loading..."; + StatusIsError = false; + try + { + var data = await _repo.LoadAsync(); + + ActivePlayerCount = data.ActivePlayerCount; + InsuranceCoveragePct = data.InsuranceCoveragePct; + + SinkRows.Clear(); + foreach (var r in data.SinkRows) SinkRows.Add(r); + + StatusMessage = $"Loaded at {DateTime.UtcNow:HH:mm:ss} UTC."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + private bool CanRefresh() => !IsLoading; + partial void OnIsLoadingChanged(bool value) => RefreshCommand.NotifyCanExecuteChanged(); + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/EconomyViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/EconomyViewModel.cs new file mode 100644 index 00000000..61c65771 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/EconomyViewModel.cs @@ -0,0 +1,32 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Economy; +using Perpetuum.AdminTool.Editing; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class EconomyViewModel : ObservableObject + { + public EconomyNicFlowViewModel NicFlow { get; } + public EconomyMoneySupplyViewModel MoneySupply { get; } + public EconomyMarketHealthViewModel MarketHealth { get; } + public EconomySinkEffectivenessViewModel SinkEffectiveness { get; } + public EconomyInsuranceViewModel Insurance { get; } + + public EconomyViewModel( + EconomyRepository nicFlowRepo, + EconomyMoneySupplyRepository moneySupplyRepo, + EconomyMarketHealthRepository marketHealthRepo, + EconomySinkRepository sinkRepo, + EconomyInsuranceRepository insuranceRepo, + ChangeQueue changes, + LookupCache lookups) + { + NicFlow = new EconomyNicFlowViewModel(nicFlowRepo); + MoneySupply = new EconomyMoneySupplyViewModel(moneySupplyRepo); + MarketHealth = new EconomyMarketHealthViewModel(marketHealthRepo, changes, lookups); + SinkEffectiveness = new EconomySinkEffectivenessViewModel(sinkRepo); + Insurance = new EconomyInsuranceViewModel(insuranceRepo, changes); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/EntitiesViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/EntitiesViewModel.cs new file mode 100644 index 00000000..3746a9e1 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/EntitiesViewModel.cs @@ -0,0 +1,336 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Data.SqlClient; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Entities; +using Perpetuum.AdminTool.Export; +using Perpetuum.AdminTool.NewItem; +using Perpetuum.AdminTool.NewRobot; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.Translations; +using Perpetuum.AdminTool.Views; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class EntitiesViewModel : ObservableObject + { + private readonly AppSettingsStore _settings; + private readonly AppSession _session; + private readonly ChangeQueue _queue; + private readonly TranslationsViewModel _translations; + private readonly LookupCache _lookups; + private const int EnglishLangId = 0; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private string _filterText = ""; + [ObservableProperty] private EntityDefaultRow? _selectedRow; + [ObservableProperty] private EntityDetailViewModel? _detail; + + public ObservableCollection AllRows { get; } = new(); + public ICollectionView View { get; } + public IReadOnlyDictionary Fields { get; private set; } + = new Dictionary(); + + // Static category-flag tree, built once from the enum. + public IReadOnlyList CategoryRoots { get; } = + CategoryFlagsHierarchy.BuildRoots(); + + // When non-null, the entities grid is filtered to rows whose CategoryFlags is + // this node's value or any descendant. + [ObservableProperty] private CategoryFlagsNode? _selectedCategoryNode; + + public EntitiesViewModel(AppSettingsStore settings, AppSession session, ChangeQueue queue, TranslationsViewModel translations, LookupCache lookups) + { + _settings = settings; + _session = session; + _queue = queue; + _translations = translations; + _lookups = lookups; + + View = CollectionViewSource.GetDefaultView(AllRows); + View.Filter = MatchesFilter; + } + + partial void OnFilterTextChanged(string value) => View.Refresh(); + + partial void OnSelectedCategoryNodeChanged(CategoryFlagsNode? value) => View.Refresh(); + + partial void OnSelectedRowChanged(EntityDefaultRow? value) + { + if (Detail == null && Fields.Count > 0) + { + Detail = new EntityDetailViewModel(_queue, Fields); + } + if (Detail != null) + { + Detail.Row = value; + } + ExportEntityCommand.NotifyCanExecuteChanged(); + } + + partial void OnIsLoadingChanged(bool _) => ExportEntityCommand.NotifyCanExecuteChanged(); + + private bool MatchesFilter(object obj) + { + if (obj is not EntityDefaultRow row) return false; + + // Category filter (if active) ANDs with the text filter. + if (SelectedCategoryNode != null && + !SelectedCategoryNode.ContainsOrEquals(row.CategoryFlags)) + { + return false; + } + + if (string.IsNullOrWhiteSpace(FilterText)) return true; + + var f = FilterText.Trim(); + + if (int.TryParse(f, out var defId) && row.Definition == defId) return true; + if (row.DefinitionName.Contains(f, StringComparison.OrdinalIgnoreCase)) return true; + + var translated = TranslatedName(row); + if (!string.IsNullOrEmpty(translated) && + translated.Contains(f, StringComparison.OrdinalIgnoreCase)) return true; + + return false; + } + + public bool ApplySelectedCategoryToCurrentRow(out string error) + { + error = ""; + if (SelectedCategoryNode == null) + { + error = "Select a category in the tree first."; + return false; + } + if (SelectedRow == null) + { + error = "Select an entity in the list first."; + return false; + } + if (SelectedRow.IsQueued) + { + error = "This row is already queued for INSERT — reload after Commit before editing."; + return false; + } + SelectedRow.CategoryFlags = SelectedCategoryNode.Value; + return true; + } + + public string TranslatedName(EntityDefaultRow row) + { + var key = row.DefinitionName; + if (string.IsNullOrEmpty(key) || _translations.Store == null) return ""; + var rowsByKey = _translations.Store.Rows; + var match = rowsByKey.FirstOrDefault(r => r.Key == key); + return match?[EnglishLangId] ?? ""; + } + + public bool TryAddNew(string definitionName, out string error) + { + error = ""; + if (string.IsNullOrWhiteSpace(definitionName)) + { + error = "definitionName is required."; + return false; + } + var trimmed = definitionName.Trim(); + if (AllRows.Any(r => string.Equals(r.DefinitionName, trimmed, StringComparison.Ordinal))) + { + error = $"definitionName '{trimmed}' is already used by another row."; + return false; + } + + var row = EntityDefaultRow.CreateNew(trimmed); + AllRows.Insert(0, row); + SelectedRow = row; + return true; + } + + public void RemoveRow(EntityDefaultRow row) + { + if (row == null) return; + if (ReferenceEquals(SelectedRow, row)) SelectedRow = null; + AllRows.Remove(row); + } + + [RelayCommand] + private async Task OpenNewItemDialogAsync() + { + if (AllRows.Count == 0 || Fields.Count == 0) + await ReloadAsync(); + + if (StatusIsError) + return; + + var connSettings = _settings.Settings.Connection; + var store = _translations.Store; + if (store == null) + { + StatusIsError = true; + StatusMessage = "Load translations first before creating a new item (needed for translation key seeding)."; + return; + } + + var repo = new NewItemRepository(connSettings); + var applier = new ChangeApplier(connSettings); + + var aggregateFields = Fields.Values.ToList(); + var englishNames = _translations.Store.Rows + .GroupBy(r => r.Key) + .ToDictionary(g => g.Key, g => g.First()[EnglishLangId]); + + var vm = new NewItemDialogViewModel( + connSettings, + applier, + store, + repo, + _lookups, + AllRows.ToList(), + _session, + _settings); + + await vm.InitializeAsync(aggregateFields, englishNames); + + var dialog = new Views.NewItemDialog(vm) + { + Owner = System.Windows.Application.Current.MainWindow + }; + + if (dialog.ShowDialog() == true) + { + StatusMessage = vm.SaveResultSummary; + await ReloadAsync(); + } + } + + [RelayCommand] + private async Task OpenNewRobotDialogAsync() + { + if (AllRows.Count == 0 || Fields.Count == 0) + await ReloadAsync(); + + if (StatusIsError) + return; + + var connSettings = _settings.Settings.Connection; + var store = _translations.Store; + if (store == null) + { + StatusIsError = true; + StatusMessage = "Load translations first before creating a new robot (needed for translation key seeding)."; + return; + } + + var repo = new NewItemRepository(connSettings); + var robotRepo = new NewRobotRepository(connSettings); + var applier = new ChangeApplier(connSettings); + + var aggregateFields = Fields.Values.ToList(); + var englishNames = _translations.Store.Rows + .GroupBy(r => r.Key) + .ToDictionary(g => g.Key, g => g.First()[EnglishLangId]); + + var vm = new NewRobotDialogViewModel( + connSettings, + applier, + store, + repo, + robotRepo, + _lookups, + AllRows.ToList(), + _session, + _settings); + + await vm.InitializeAsync(aggregateFields, englishNames); + + var dialog = new Views.NewRobotDialog(vm) + { + Owner = System.Windows.Application.Current.MainWindow + }; + + if (dialog.ShowDialog() == true) + { + StatusMessage = vm.SaveResultSummary; + await ReloadAsync(); + } + } + + [RelayCommand(CanExecute = nameof(CanExport))] + private async Task ExportEntityAsync() + { + if (SelectedRow == null) return; + IsLoading = true; + StatusMessage = "Generating export script..."; + StatusIsError = false; + try + { + var conn = _settings.Settings.Connection; + await using var cn = new SqlConnection(conn.BuildConnectionString()); + await cn.OpenAsync(); + var itemChanges = await ItemExporter.ExportAsync(SelectedRow.Definition, cn); + var script = SqlScriptBuilder.Build(itemChanges); + var vm = new ExportScriptViewModel($"Item: {SelectedRow.DefinitionName}", script); + var win = new ExportScriptWindow(vm) { Owner = Application.Current?.MainWindow }; + win.ShowDialog(); + StatusMessage = "Export complete."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Export failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + private bool CanExport() => SelectedRow != null && !IsLoading; + + public async Task ReloadAsync() + { + IsLoading = true; + StatusMessage = "Loading entities..."; + StatusIsError = false; + try + { + var repo = new EntityRepository(_settings.Settings.Connection); + var snapshot = await repo.LoadAsync(); + Fields = snapshot.Fields; + AllRows.Clear(); + foreach (var r in snapshot.Rows) AllRows.Add(r); + Detail = new EntityDetailViewModel(_queue, Fields); + SelectedRow = null; + + // Reload pulls the freshest entitydefaults — push that into the shared + // cache so other tabs' dropdowns reflect the new state. + try { await _lookups.RefreshEntitiesAsync(_settings.Settings.Connection); } + catch { /* non-fatal */ } + + StatusMessage = + $"Loaded {AllRows.Count} entity definition(s) and {Fields.Count} aggregate field definition(s)."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/EntityDetailViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/EntityDetailViewModel.cs new file mode 100644 index 00000000..4c920bf9 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/EntityDetailViewModel.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.ExportedTypes; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Entities; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class EntityDetailViewModel : ObservableObject + { + private readonly ChangeQueue _queue; + private readonly IReadOnlyDictionary _fields; + + [ObservableProperty] private EntityDefaultRow? _row; + [ObservableProperty] private StatRow? _selectedStat; + [ObservableProperty] private AggregateFieldInfo? _newStatField; + [ObservableProperty] private double _newStatValue; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public ObservableCollection AvailableFields { get; } = new(); + + public EntityDetailViewModel(ChangeQueue queue, IReadOnlyDictionary fields) + { + _queue = queue; + _fields = fields; + } + + partial void OnRowChanged(EntityDefaultRow? value) + { + RebuildAvailableFields(); + StatusMessage = ""; + StatusIsError = false; + } + + public void RebuildAvailableFields() + { + AvailableFields.Clear(); + if (Row == null) return; + + var used = Row.Stats.Select(s => (int)s.Field).ToHashSet(); + foreach (var info in _fields.Values + .Where(f => !used.Contains(f.Id)) + .OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase)) + { + AvailableFields.Add(info); + } + NewStatField = AvailableFields.FirstOrDefault(); + } + + public void AddStat() + { + if (Row == null || NewStatField == null) return; + var fieldId = NewStatField.Id; + if (Row.Stats.Any(s => (int)s.Field == fieldId)) + { + StatusIsError = true; + StatusMessage = "That field is already on this entity."; + return; + } + Row.Stats.Add(new StatRow(Row.Definition, (AggregateField)fieldId, NewStatValue, wasInDb: false)); + NewStatValue = 0d; + RebuildAvailableFields(); + StatusIsError = false; + StatusMessage = $"Added stat {NewStatField.Name}."; + } + + public void RemoveSelectedStat() + { + if (Row == null || SelectedStat == null) return; + Row.Stats.Remove(SelectedStat); + SelectedStat = null; + RebuildAvailableFields(); + } + + public void Save() + { + if (Row == null) return; + + // A new row that's already been queued cannot be edited further until Reload — + // the assigned identity isn't known to this client yet. + if (Row.IsNew && Row.IsQueued) + { + StatusIsError = true; + StatusMessage = "This row has been queued for INSERT. Reload after Commit to continue editing."; + return; + } + + var changes = EntityChanges.ComputeChanges(Row).ToList(); + if (changes.Count == 0) + { + StatusIsError = false; + StatusMessage = "No changes to save."; + return; + } + foreach (var c in changes) _queue.Add(c); + + if (Row.IsNew) + { + // Capture the definitionName so commit can auto-create a translation key for it. + if (!string.IsNullOrWhiteSpace(Row.DefinitionName)) + { + _queue.AddNewEntityName(Row.DefinitionName); + } + + // Lock the row. The real id will arrive via Reload after Commit. + Row.IsQueued = true; + StatusIsError = false; + StatusMessage = + $"Queued INSERT (+{Row.Stats.Count} stat(s)). Reload after Commit to refresh the assigned id."; + return; + } + + // Existing row: refresh snapshots so subsequent edits diff against new baseline. + Row.RefreshOriginalFromCurrent(); + Row.OriginalStats.Clear(); + foreach (var s in Row.Stats) + { + Row.OriginalStats[(int)s.Field] = s.Value; + } + var rebuilt = Row.Stats + .Select(s => new StatRow(Row.Definition, s.Field, s.Value, wasInDb: true)) + .ToList(); + Row.Stats.Clear(); + foreach (var s in rebuilt) Row.Stats.Add(s); + + StatusIsError = false; + StatusMessage = $"Queued {changes.Count} change(s). Use the main Commit button to apply."; + } + + public IReadOnlyList EnqueueDelete() + { + if (Row == null) return System.Array.Empty(); + + // For never-saved rows, just drop in-memory; emit no SQL. + if (Row.IsNew) return System.Array.Empty(); + + var changes = EntityChanges.ComputeDeleteChanges(Row).ToList(); + foreach (var c in changes) _queue.Add(c); + return changes; + } + + public void Discard() + { + if (Row == null) return; + // Revert primitive fields + Row.ApplySnapshot(Row.Original); + + // Revert stats: re-add only the originals + Row.Stats.Clear(); + foreach (var (fieldId, value) in Row.OriginalStats) + { + Row.Stats.Add(new StatRow(Row.Definition, (AggregateField)fieldId, value, wasInDb: true)); + } + RebuildAvailableFields(); + StatusIsError = false; + StatusMessage = "Reverted to last-saved state."; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/EquipmentSetsViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/EquipmentSetsViewModel.cs new file mode 100644 index 00000000..5767f453 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/EquipmentSetsViewModel.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.EquipmentSets; +using Perpetuum.AdminTool.Translations; +using Perpetuum.AdminTool.Views; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class EquipmentSetsViewModel : ObservableObject + { + private readonly EquipmentSetRepository _repo; + private readonly ChangeQueue _queue; + private readonly LookupCache _lookups; + private readonly TranslationsViewModel _translations; + private const int EnglishLangId = 0; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private string _newSetName = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasSelection))] + [NotifyPropertyChangedFor(nameof(CanRename))] + private EquipmentSetRow? _selectedSet; + + public bool HasSelection => SelectedSet != null; + public bool CanRename => SelectedSet is { IsNew: false }; + + public ObservableCollection Sets { get; } = new(); + public ObservableCollection Members { get; } = new(); + public ObservableCollection Thresholds { get; } = new(); + public ObservableCollection AggregateFieldOptions { get; } = new(); + + public EquipmentSetsViewModel( + EquipmentSetRepository repo, + ChangeQueue queue, + LookupCache lookups, + TranslationsViewModel translations) + { + _repo = repo; + _queue = queue; + _lookups = lookups; + _translations = translations; + } + + partial void OnSelectedSetChanged(EquipmentSetRow? value) + { + _ = LoadSetDetailAsync(value); + } + + // ── Reload ─────────────────────────────────────────────────────────── + + public async Task ReloadAsync() + { + IsLoading = true; + StatusMessage = ""; + StatusIsError = false; + try + { + var sets = await _repo.LoadAllSetsAsync(); + var fields = await _repo.LoadAggregateFieldsAsync(); + var store = _translations.Store; + + Sets.Clear(); + foreach (var s in sets) Sets.Add(s); + + AggregateFieldOptions.Clear(); + foreach (var f in fields + .Select(f => + { + var translated = ""; + if (store != null) + { + var row = store.Rows.FirstOrDefault(r => r.Key == f.Name); + translated = row?[EnglishLangId] ?? ""; + } + return new AggregateFieldPickItem { Id = f.Id, Name = f.Name, TranslatedName = translated }; + }) + .OrderBy(f => f.Display, StringComparer.OrdinalIgnoreCase)) + { + AggregateFieldOptions.Add(f); + } + + var reselect = SelectedSet != null + ? Sets.FirstOrDefault(s => s.SetId == SelectedSet.SetId) + : null; + SelectedSet = reselect; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Reload failed: {ex.Message}"; + } + finally { IsLoading = false; } + } + + // ── Set detail load ─────────────────────────────────────────────────── + + private async Task LoadSetDetailAsync(EquipmentSetRow? set) + { + Members.Clear(); + foreach (var t in Thresholds) t.PropertyChanged -= OnThresholdPropertyChanged; + Thresholds.Clear(); + + if (set == null || set.IsNew) return; + + IsLoading = true; + try + { + var store = _translations.Store; + var members = await _repo.LoadMembersAsync(set.SetId); + var thresholds = await _repo.LoadThresholdsAsync(set.SetId); + + foreach (var m in members) + { + var translated = ""; + if (store != null) + { + var row = store.Rows.FirstOrDefault(r => r.Key == m.DefinitionName); + translated = row?[EnglishLangId] ?? ""; + } + Members.Add(new EquipmentSetMemberRow + { + SetId = m.SetId, + Definition = m.Definition, + DefinitionName = m.DefinitionName, + TranslatedName = translated, + }); + } + + foreach (var t in thresholds) + { + var field = AggregateFieldOptions.FirstOrDefault(f => f.Id == t.AggregateFieldId); + var row = new EquipmentSetThresholdRow + { + SetId = t.SetId, + RequiredPieces = t.RequiredPieces, + OriginalRequiredPieces = t.RequiredPieces, + AggregateFieldId = t.AggregateFieldId, + FieldSystemName = field?.Name ?? t.AggregateFieldId.ToString(), + FieldDisplay = field?.Display ?? t.AggregateFieldId.ToString(), + BonusValue = t.BonusValue, + }; + row.PropertyChanged += OnThresholdPropertyChanged; + Thresholds.Add(row); + } + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Failed to load set detail: {ex.Message}"; + } + finally { IsLoading = false; } + } + + // ── Threshold PropertyChanged → auto-UPSERT ─────────────────────────── + + private void OnThresholdPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not EquipmentSetThresholdRow row) return; + if (SelectedSet == null) return; + + if (row.RequiredPieces > 0 && row.AggregateFieldId > 0) + { + if (Thresholds.Any(t => !ReferenceEquals(t, row) && t.RequiredPieces == row.RequiredPieces)) + { + SetStatus($"A threshold for {row.RequiredPieces} piece(s) already exists.", isError: true); + return; + } + + if (e.PropertyName == nameof(EquipmentSetThresholdRow.RequiredPieces) + && row.OriginalRequiredPieces > 0 + && row.OriginalRequiredPieces != row.RequiredPieces) + { + _queue.Add(EquipmentSetChanges.BuildDeleteThreshold( + SelectedSet.SetId, SelectedSet.Name, row.OriginalRequiredPieces)); + row.OriginalRequiredPieces = row.RequiredPieces; + } + + _queue.Add(EquipmentSetChanges.BuildUpsertThreshold( + SelectedSet.SetId, SelectedSet.Name, + row.RequiredPieces, row.AggregateFieldId, row.BonusValue)); + } + } + + // ── Create set ──────────────────────────────────────────────────────── + + [RelayCommand] + private void CreateSet() + { + var name = NewSetName.Trim(); + if (string.IsNullOrEmpty(name)) + { + SetStatus("Set name is required.", isError: true); + return; + } + if (Sets.Any(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase))) + { + SetStatus($"A set named '{name}' already exists.", isError: true); + return; + } + _queue.Add(EquipmentSetChanges.BuildInsertSet(name)); + var row = new EquipmentSetRow { SetId = 0, Name = name }; + Sets.Add(row); + SelectedSet = row; + NewSetName = ""; + SetStatus($"'{name}' queued for insert. Commit to persist.", isError: false); + } + + // ── Delete set ──────────────────────────────────────────────────────── + + [RelayCommand(CanExecute = nameof(HasSelection))] + private void DeleteSet() + { + if (SelectedSet == null) return; + var memberCount = Members.Count; + var thresholdCount = Thresholds.Count; + var msg = + $"Delete set '{SelectedSet.Name}'?\n\n" + + $"This will also remove {memberCount} member(s) and {thresholdCount} threshold row(s).\n\n" + + "Continue?"; + if (MessageBox.Show(msg, "Delete set", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) + != MessageBoxResult.Yes) return; + + _queue.Add(EquipmentSetChanges.BuildDeleteSet(SelectedSet.SetId, SelectedSet.Name)); + Sets.Remove(SelectedSet); + SelectedSet = null; + } + + // ── Rename set ──────────────────────────────────────────────────────── + + [RelayCommand(CanExecute = nameof(CanRename))] + private void RenameSet() + { + if (SelectedSet == null) return; + var newName = SelectedSet.Name.Trim(); + if (string.IsNullOrEmpty(newName)) + { + SetStatus("New name is required.", isError: true); + return; + } + if (Sets.Any(s => !ReferenceEquals(s, SelectedSet) && + string.Equals(s.Name, newName, StringComparison.OrdinalIgnoreCase))) + { + SetStatus($"A set named '{newName}' already exists.", isError: true); + return; + } + _queue.Add(EquipmentSetChanges.BuildRenameSet(SelectedSet.SetId, newName)); + SelectedSet.Name = newName; + SetStatus($"Rename to '{newName}' queued.", isError: false); + } + + // ── Add member ──────────────────────────────────────────────────────── + + public void AddMember(Window owner) + { + if (SelectedSet == null) return; + var assigned = Members.Select(m => m.Definition).ToHashSet(); + var vm = new AddSetMemberViewModel(_lookups, _translations, assigned); + var win = new AddSetMemberWindow(vm) { Owner = owner }; + if (win.ShowDialog() != true || vm.SelectedItem == null) return; + + var item = vm.SelectedItem; + _queue.Add(EquipmentSetChanges.BuildInsertMember(SelectedSet.SetId, SelectedSet.Name, item.Definition)); + Members.Add(new EquipmentSetMemberRow + { + SetId = SelectedSet.SetId, + Definition = item.Definition, + DefinitionName = item.DefinitionName, + TranslatedName = item.TranslatedName, + }); + } + + // ── Remove member ───────────────────────────────────────────────────── + + [RelayCommand] + private void RemoveMember(EquipmentSetMemberRow row) + { + if (SelectedSet == null) return; + _queue.Add(EquipmentSetChanges.BuildDeleteMember(SelectedSet.SetId, SelectedSet.Name, row.Definition)); + Members.Remove(row); + } + + // ── Add threshold ───────────────────────────────────────────────────── + + public void AddThreshold(Window owner) + { + if (SelectedSet == null) return; + var usedPieces = Thresholds.Select(t => t.RequiredPieces).ToHashSet(); + var vm = new AddSetThresholdViewModel(AggregateFieldOptions); + var win = new AddSetThresholdWindow(vm, usedPieces) { Owner = owner }; + if (win.ShowDialog() != true || vm.SelectedField == null) return; + + var row = new EquipmentSetThresholdRow + { + SetId = SelectedSet.SetId, + RequiredPieces = vm.RequiredPieces, + OriginalRequiredPieces = vm.RequiredPieces, + AggregateFieldId = vm.SelectedField.Id, + FieldSystemName = vm.SelectedField.Name, + FieldDisplay = vm.SelectedField.Display, + BonusValue = vm.BonusValue, + }; + row.PropertyChanged += OnThresholdPropertyChanged; + _queue.Add(EquipmentSetChanges.BuildUpsertThreshold( + SelectedSet.SetId, SelectedSet.Name, + row.RequiredPieces, row.AggregateFieldId, row.BonusValue)); + Thresholds.Add(row); + } + + // ── Remove threshold ────────────────────────────────────────────────── + + [RelayCommand] + private void RemoveThreshold(EquipmentSetThresholdRow row) + { + if (SelectedSet != null && row.RequiredPieces > 0 && row.AggregateFieldId > 0) + _queue.Add(EquipmentSetChanges.BuildDeleteThreshold( + SelectedSet.SetId, SelectedSet.Name, row.RequiredPieces)); + row.PropertyChanged -= OnThresholdPropertyChanged; + Thresholds.Remove(row); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private void SetStatus(string message, bool isError) + { + StatusMessage = message; + StatusIsError = isError; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/FlocksViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/FlocksViewModel.cs new file mode 100644 index 00000000..c0612db6 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/FlocksViewModel.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Npc; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class FlocksViewModel : ObservableObject + { + private readonly AppSettingsStore _settings; + private readonly ChangeQueue _queue; + private readonly LookupCache _lookups; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private string _filterText = ""; + [ObservableProperty] private FlockRow? _selectedRow; + + public ObservableCollection Rows { get; } = new(); + public ICollectionView View { get; } + + // Entity picks come from the shared cache; presence picks are flock-specific + // (npcpresence isn't shared with other tabs) and stay local. + public LookupCache Lookups => _lookups; + public ObservableCollection PresencePicks { get; } = new(); + private Dictionary _presencePicksById = new(); + + private Dictionary _originalById = new(); + + public FlocksViewModel(AppSettingsStore settings, ChangeQueue queue, LookupCache lookups) + { + _settings = settings; + _queue = queue; + _lookups = lookups; + View = CollectionViewSource.GetDefaultView(Rows); + View.Filter = MatchesFilter; + } + + partial void OnFilterTextChanged(string value) => View.Refresh(); + + partial void OnSelectedRowChanged(FlockRow? value) + { + if (value != null) + { + value.PropertyChanged -= OnRowPropertyChanged; + value.PropertyChanged += OnRowPropertyChanged; + } + } + + private void OnRowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not FlockRow row) return; + if (e.PropertyName == nameof(FlockRow.Definition)) + row.DefinitionName = ResolveEntityName(row.Definition); + else if (e.PropertyName == nameof(FlockRow.PresenceId)) + row.PresenceName = ResolvePresenceName(row.PresenceId); + } + + private string ResolveEntityName(int definition) => + _lookups.EntityNamesByDefinition.TryGetValue(definition, out var n) ? n : ""; + + private string ResolvePresenceName(int presenceId) => + _presencePicksById.TryGetValue(presenceId, out var p) ? p.Name : ""; + + private bool MatchesFilter(object obj) + { + if (obj is not FlockRow row) return false; + if (string.IsNullOrWhiteSpace(FilterText)) return true; + var f = FilterText.Trim(); + if (int.TryParse(f, out var n)) + return row.Id == n || row.PresenceId == n || row.Definition == n; + return row.Name.Contains(f, StringComparison.OrdinalIgnoreCase) + || row.PresenceName.Contains(f, StringComparison.OrdinalIgnoreCase) + || row.DefinitionName.Contains(f, StringComparison.OrdinalIgnoreCase); + } + + public async Task ReloadAsync() + { + IsLoading = true; + StatusMessage = "Loading npcflock..."; + StatusIsError = false; + try + { + try { await _lookups.RefreshEntitiesAsync(_settings.Settings.Connection); } + catch { /* non-fatal */ } + + var repo = new FlockRepository(_settings.Settings.Connection); + var load = await repo.LoadAllAsync(); + + PresencePicks.Clear(); + foreach (var p in load.PresencePicks) PresencePicks.Add(p); + _presencePicksById = PresencePicks.ToDictionary(p => p.Id, p => p); + + Rows.Clear(); + _originalById = new Dictionary(load.Rows.Count); + foreach (var r in load.Rows) + { + r.DefinitionName = ResolveEntityName(r.Definition); + r.PresenceName = ResolvePresenceName(r.PresenceId); + r.PropertyChanged -= OnRowPropertyChanged; + r.PropertyChanged += OnRowPropertyChanged; + Rows.Add(r); + _originalById[r.Id] = r.Original; + } + SelectedRow = null; + StatusMessage = $"Loaded {Rows.Count} flock(s)."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + public bool TryAddNew(FlockSnapshot seed, out string error) + { + error = ""; + if (string.IsNullOrWhiteSpace(seed.Name)) + { + error = "Name is required."; + return false; + } + if (seed.PresenceId <= 0 || !_presencePicksById.ContainsKey(seed.PresenceId)) + { + error = "Presence must be selected from the npcpresence list."; + return false; + } + if (seed.Definition <= 0 || !_lookups.EntityNamesByDefinition.ContainsKey(seed.Definition)) + { + error = "Definition must be selected from the entitydefaults list."; + return false; + } + var row = FlockRow.CreateNew(seed); + row.PresenceName = ResolvePresenceName(seed.PresenceId); + row.DefinitionName = ResolveEntityName(seed.Definition); + row.PropertyChanged -= OnRowPropertyChanged; + row.PropertyChanged += OnRowPropertyChanged; + Rows.Insert(0, row); + SelectedRow = row; + return true; + } + + public void RemoveSelected() + { + if (SelectedRow == null) return; + var row = SelectedRow; + SelectedRow = null; + Rows.Remove(row); + } + + public void SaveAll() + { + var changes = FlockChanges.ComputeBulkChanges(Rows, _originalById).ToList(); + if (changes.Count == 0) + { + StatusIsError = false; + StatusMessage = "No changes to save."; + return; + } + + foreach (var c in changes) _queue.Add(c); + + // New rows queued an INSERT but the IDENTITY hasn't returned a real id yet. + // Roll forward only existing edited rows. + var newOriginals = new Dictionary(Rows.Count); + foreach (var row in Rows) + { + if (row.IsNew) continue; + row.RefreshOriginalFromCurrent(); + newOriginals[row.Id] = row.Original; + } + _originalById = newOriginals; + + var destructive = changes.Count(c => c.IsDestructive); + StatusIsError = false; + StatusMessage = destructive > 0 + ? $"Queued {changes.Count} change(s), {destructive} destructive. Use the main Commit button to apply." + : $"Queued {changes.Count} change(s). Use the main Commit button to apply."; + } + + public int SuggestNextPresenceId() => + SelectedRow?.PresenceId ?? (Rows.FirstOrDefault()?.PresenceId ?? 0); + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/LoginViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/LoginViewModel.cs new file mode 100644 index 00000000..dbb8b64c --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/LoginViewModel.cs @@ -0,0 +1,95 @@ +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Data; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.ViewModels; +using Perpetuum.AdminTool.Views; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class LoginViewModel : ObservableObject + { + private readonly AppSettingsStore _store; + private readonly AppSession _session; + + [ObservableProperty] private string _email; + [ObservableProperty] private string _password = ""; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private bool _isBusy; + + public LoginViewModel(AppSettingsStore store, AppSession session) + { + _store = store; + _session = session; + _email = store.Settings.LastLoginEmail; + } + + [RelayCommand] + private void OpenSettings(Window owner) + { + var vm = new ConnectionSettingsViewModel(_store); + var w = new ConnectionSettingsWindow(vm) { Owner = owner }; + w.ShowDialog(); + } + + [RelayCommand] + private async Task LoginAsync(Window window) + { + IsBusy = true; + StatusIsError = false; + StatusMessage = "Authenticating..."; + + try + { + var auth = new Authenticator(_store.Settings.Connection); + var outcome = await auth.AuthenticateAsync(Email, Password); + + switch (outcome.Result) + { + case AuthResult.Success: + _session.AccountId = outcome.AccountId; + _session.Email = outcome.Email; + _session.AccessLevel = outcome.AccessLevel; + _session.CurrentMode = _store.Settings.DefaultApplyMode; + + _store.Settings.LastLoginEmail = Email; + _store.Save(); + + window.DialogResult = true; + window.Close(); + return; + + case AuthResult.InvalidCredentials: + StatusIsError = true; + StatusMessage = "Invalid email or password."; + break; + + case AuthResult.InsufficientAccess: + StatusIsError = true; + StatusMessage = + $"Account exists but lacks admin access (current: {outcome.AccessLevel}, required: gameAdmin or higher)."; + break; + + case AuthResult.ConnectionFailed: + StatusIsError = true; + StatusMessage = $"Database error: {outcome.ErrorMessage}"; + break; + } + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private void Cancel(Window window) + { + window.DialogResult = false; + window.Close(); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/MainViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/MainViewModel.cs new file mode 100644 index 00000000..dd71d4cc --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/MainViewModel.cs @@ -0,0 +1,296 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.AutoMarket; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Economy; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Packages; +using Perpetuum.AdminTool.EquipmentSets; +using Perpetuum.AdminTool.Seasons; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.Views; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class MainViewModel : ObservableObject + { + private readonly AppSettingsStore _store; + private readonly AppSession _session; + + [ObservableProperty] private ApplyMode _currentMode; + [ObservableProperty] private string _statusText = ""; + [ObservableProperty] private int _pendingChangeCount; + + public ChangeQueue Changes => _session.Changes; + public string ConnectedAs => _session.DisplayName; + public AppSettingsStore Store => _store; + public AppSession Session => _session; + public LookupCache Lookups => _session.Lookups; + public TranslationsViewModel Translations { get; } + public EntitiesViewModel Entities { get; } + public RobotTemplatesViewModel RobotTemplates { get; } + public RobotTemplateRelationsViewModel RobotTemplateRelations { get; } + public NpcLootViewModel NpcLoot { get; } + public PresencesViewModel Presences { get; } + public FlocksViewModel Flocks { get; } + public SeasonsViewModel Seasons { get; } + public EquipmentSetsViewModel EquipmentSets { get; } + public AutoMarketViewModel AutoMarket { get; } + public EconomyViewModel Economy { get; } + + public MainViewModel(AppSettingsStore store, AppSession session) + { + _store = store; + _session = session; + _currentMode = session.CurrentMode; + Translations = new TranslationsViewModel(store); + Translations.Load(); + Entities = new EntitiesViewModel(store, session, session.Changes, Translations, session.Lookups); + RobotTemplates = new RobotTemplatesViewModel(store, session.Changes, session.Lookups); + RobotTemplateRelations = new RobotTemplateRelationsViewModel(store, session.Changes, session.Lookups); + NpcLoot = new NpcLootViewModel(store, session.Changes, session.Lookups); + Presences = new PresencesViewModel(store, session.Changes); + Flocks = new FlocksViewModel(store, session.Changes, session.Lookups); + Seasons = new SeasonsViewModel( + new SeasonRepository(store.Settings.Connection), + new PackageRepository(store.Settings.Connection), + session.Changes, + session.Lookups, + store.Settings.Connection, + Translations); + EquipmentSets = new EquipmentSetsViewModel( + new EquipmentSetRepository(store.Settings.Connection), + session.Changes, + session.Lookups, + Translations); + AutoMarket = new AutoMarketViewModel( + new AutoMarketRepository(store.Settings.Connection), + session.Changes, + session.Lookups, + Translations); + Economy = new EconomyViewModel( + new EconomyRepository(store.Settings.Connection), + new EconomyMoneySupplyRepository(store.Settings.Connection), + new EconomyMarketHealthRepository(store.Settings.Connection), + new EconomySinkRepository(store.Settings.Connection), + new EconomyInsuranceRepository(store.Settings.Connection), + session.Changes, + session.Lookups); + UpdateStatus(); + + _ = InitializeLookupsAsync(); + + Changes.Items.CollectionChanged += (_, _) => + { + PendingChangeCount = Changes.Items.Count; + UpdateStatus(); + }; + } + + partial void OnCurrentModeChanged(ApplyMode value) + { + _session.CurrentMode = value; + _store.Settings.DefaultApplyMode = value; + _store.Save(); + UpdateStatus(); + } + + private void UpdateStatus() + { + StatusText = + $"Mode: {CurrentMode} | Pending changes: {Changes.Items.Count} | {ConnectedAs}"; + } + + private async Task InitializeLookupsAsync() + { + try + { + await _session.Lookups.RefreshAllAsync(_store.Settings.Connection); + } + catch + { + // Per-tab Reload buttons are the fallback if startup refresh fails + // (e.g. transient DB hiccup). Don't surface a modal here. + } + } + + // Drains queued new-entity definition names into the translation store. + // Each name becomes a key; English (lang 0) is pre-filled with the name itself + // so the user has a working English label out of the box. Existing keys are + // skipped silently so we never clobber user-entered values. Returns a short + // human-readable note appended to the commit-success dialog (or "" if nothing + // was done). + private string ApplyPendingTranslationKeys() + { + var pending = Changes.PendingNewEntityNames; + if (pending.Count == 0) return ""; + + var store = Translations.Store; + if (store == null) return ""; + + var added = 0; + try + { + foreach (var name in pending) + { + if (string.IsNullOrWhiteSpace(name)) continue; + if (!store.TryAddKey(name, out _)) continue; + var row = store.Rows.FirstOrDefault(r => r.Key == name); + if (row != null) row[0] = name; + added++; + } + + if (added == 0) return ""; + + store.Save(); + return $"\n\nAlso created {added} translation key(s) (English = definitionName)."; + } + catch (Exception ex) + { + return $"\n\nNote: failed to update translation files: {ex.Message}"; + } + } + + [RelayCommand] + private void AddStubChange() + { + // Phase 1 placeholder. Replaced by real edits in later phases. + var change = new RawSqlChange( + "Stub change (Phase 1 placeholder — does nothing).", + "-- no-op\nSELECT 1;"); + Changes.Add(change); + } + + [RelayCommand] + private void ClearChanges() + { + Changes.Clear(); + PendingChangeCount = 0; + UpdateStatus(); + } + + public void OpenSettings(Window owner) + { + var vm = new ConnectionSettingsViewModel(_store); + var w = new ConnectionSettingsWindow(vm) { Owner = owner }; + if (w.ShowDialog() == true) + { + // Reload translations so a freshly-set GameRoot path takes effect. + Translations.Load(); + } + UpdateStatus(); + } + + public async Task CommitAsync(Window owner) + { + if (!Changes.HasPending) + { + MessageBox.Show(owner, "No pending changes.", "Nothing to apply", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var script = SqlScriptBuilder.Build(Changes.Items, _session.Email); + + if (CurrentMode == ApplyMode.SqlScript) + { + var dir = _store.Settings.SqlOutputDirectory; + if (string.IsNullOrWhiteSpace(dir)) + { + MessageBox.Show(owner, + "SQL output directory is not configured. Open Connection settings to set one.", + "Cannot save script", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + try + { + Directory.CreateDirectory(dir); + var fileName = SqlScriptBuilder.BuildFileName("season", Seasons.DetailViewModel?.Season.Name); + var path = Path.Combine(dir, fileName); + await File.WriteAllTextAsync(path, script); + + var translationNote = ApplyPendingTranslationKeys(); + + MessageBox.Show(owner, + $"Wrote {Changes.Items.Count} change(s) to:\n{path}{translationNote}", + "Script saved", MessageBoxButton.OK, MessageBoxImage.Information); + + Changes.Clear(); + UpdateStatus(); + } + catch (Exception ex) + { + MessageBox.Show(owner, ex.Message, "Failed to write script", + MessageBoxButton.OK, MessageBoxImage.Error); + } + return; + } + + // Direct DB mode + + // Extra guard for destructive operations (DELETE). + var destructiveCount = Changes.Items.Count(c => c.IsDestructive); + if (destructiveCount > 0) + { + var warn = MessageBox.Show(owner, + $"{destructiveCount} of {Changes.Items.Count} pending change(s) are destructive (DELETE).\n\n" + + "Applying directly to the database is irreversible from this tool.\n\n" + + "Continue?", + "Destructive operations pending", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (warn != MessageBoxResult.Yes) return; + } + + var confirmVm = new ConfirmSqlViewModel( + $"About to apply {Changes.Items.Count} change(s) directly to the database" + + (destructiveCount > 0 ? $" ({destructiveCount} destructive)." : "."), + script); + var confirmWin = new ConfirmSqlWindow(confirmVm) { Owner = owner }; + if (confirmWin.ShowDialog() != true) + { + return; + } + + try + { + var applier = new ChangeApplier(_store.Settings.Connection); + await applier.ExecuteAsync(Changes.Items.ToArray()); + + // Commits may have touched entitydefaults / robottemplates — refresh both + // caches so dropdowns reflect the new state on the next edit. + try { await _session.Lookups.RefreshAllAsync(_store.Settings.Connection); } + catch { /* non-fatal — per-tab Reload still works */ } + + var translationNote = ApplyPendingTranslationKeys(); + + MessageBox.Show(owner, + $"Applied {Changes.Items.Count} change(s) successfully.{translationNote}", + "Done", MessageBoxButton.OK, MessageBoxImage.Information); + + Changes.Clear(); + UpdateStatus(); + } + catch (Exception ex) + { + MessageBox.Show(owner, ex.Message, "Failed to apply changes", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + public void Logout() + { + _session.AccountId = null; + _session.Email = null; + _session.AccessLevel = AccessLevel.notDefined; + _session.Changes.Clear(); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/NewEntityViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/NewEntityViewModel.cs new file mode 100644 index 00000000..805d7241 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/NewEntityViewModel.cs @@ -0,0 +1,10 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class NewEntityViewModel : ObservableObject + { + [ObservableProperty] private string _definitionName = ""; + [ObservableProperty] private string _errorMessage = ""; + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/NewItemDialogViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/NewItemDialogViewModel.cs new file mode 100644 index 00000000..daae03ca --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/NewItemDialogViewModel.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Entities; +using Perpetuum.AdminTool.NewItem; +using Perpetuum.AdminTool.Packages; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels; + +public partial class NewItemDialogViewModel : ObservableObject +{ + private readonly ConnectionSettings _connection; + private readonly ChangeApplier _changeApplier; + private readonly TranslationStore _translationStore; + private readonly NewItemRepository _repository; + private readonly LookupCache _lookupCache; + private readonly Dictionary _existingRowsById; + private readonly AppSession _session; + private readonly AppSettingsStore _store; + + [ObservableProperty] private PackageItemPickItem? _cloneSource; + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private string _saveResultSummary = ""; + [ObservableProperty] private IReadOnlyList _enabledItems = []; + + public BasicPanelViewModel BasicPanel { get; } + public BasicPanelViewModel CalibrationPanel { get; } + public BasicPanelViewModel PrototypePanel { get; } + public StatsPanelViewModel StatsPanel { get; } + public PropertyModifiersPanelViewModel PropertyModifiersPanel { get; } + public ProductionPanelViewModel ProductionPanel { get; } + public ResearchPanelViewModel ResearchPanel { get; } + public OptionsVisualPanelViewModel OptionsVisualPanel { get; } + + public bool IsCraftable => BasicPanel.IsCraftable; + public bool HasPrototype => BasicPanel.HasPrototype; + + public event EventHandler? CloseRequested; + + public NewItemDialogViewModel( + ConnectionSettings connection, + ChangeApplier changeApplier, + TranslationStore translationStore, + NewItemRepository repository, + LookupCache lookupCache, + IReadOnlyList existingRows, + AppSession session, + AppSettingsStore store) + { + _connection = connection; + _changeApplier = changeApplier; + _translationStore = translationStore; + _repository = repository; + _lookupCache = lookupCache; + _existingRowsById = existingRows.ToDictionary(r => r.Definition); + _session = session; + _store = store; + + var existingNames = existingRows.Select(r => r.DefinitionName) + .ToHashSet(StringComparer.Ordinal); + + BasicPanel = new BasicPanelViewModel(BasicPanelMode.Main, existingNames); + CalibrationPanel = new BasicPanelViewModel(BasicPanelMode.CalibrationTemplate, existingNames); + PrototypePanel = new BasicPanelViewModel(BasicPanelMode.Prototype, existingNames); + StatsPanel = new StatsPanelViewModel(); + PropertyModifiersPanel = new PropertyModifiersPanelViewModel(); + ProductionPanel = new ProductionPanelViewModel(); + ResearchPanel = new ResearchPanelViewModel(); + OptionsVisualPanel = new OptionsVisualPanelViewModel(); + + BasicPanel.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(BasicPanelViewModel.DefinitionName)) + { + CalibrationPanel.SuggestName(BasicPanel.DefinitionName, "_cprg"); + PrototypePanel.SuggestName(BasicPanel.DefinitionName, "_pr"); + } + if (e.PropertyName == nameof(BasicPanelViewModel.CategoryFlags)) + ProductionPanel.UpdateCategory(BasicPanel.CategoryFlags); + if (e.PropertyName is nameof(BasicPanelViewModel.IsCraftable) + or nameof(BasicPanelViewModel.HasPrototype)) + { + OnPropertyChanged(nameof(IsCraftable)); + OnPropertyChanged(nameof(HasPrototype)); + } + }; + } + + public async Task InitializeAsync( + IReadOnlyList aggregateFields, + Dictionary? englishNames = null) + { + IsLoading = true; + try + { + var lookups = await _repository.LoadAsync( + aggregateFields, + _lookupCache.Entities.ToList(), + englishNames); + + EnabledItems = lookups.EnabledItems; + StatsPanel.Initialize(lookups); + PropertyModifiersPanel.Initialize(lookups); + ProductionPanel.Initialize(lookups); + ResearchPanel.Initialize(lookups); + OptionsVisualPanel.Initialize(lookups); + } + finally + { + IsLoading = false; + } + } + + partial void OnCloneSourceChanged(PackageItemPickItem? value) + { + if (value == null || IsLoading) return; + _ = LoadCloneAsync(value.Definition); + } + + private async Task LoadCloneAsync(int definition) + { + if (!_existingRowsById.TryGetValue(definition, out var row)) return; + + BasicPanel.LoadFromClone(row); + CalibrationPanel.LoadFromClone(row, "_cprg"); + PrototypePanel.LoadFromClone(row, "_pr"); + StatsPanel.LoadFromClone(row.Stats); + PropertyModifiersPanel.LoadFromClone(row.CategoryFlags); + + IsLoading = true; + try + { + var extended = await _repository.LoadCloneExtendedAsync(definition); + ProductionPanel.LoadFromClone(extended.Components); + ResearchPanel.LoadFromClone(extended); + OptionsVisualPanel.LoadFromClone(row.Options, extended.DefinitionConfig); + } + catch (Exception ex) + { + StatusMessage = $"Failed to load clone data: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + [RelayCommand(CanExecute = nameof(CanSave))] + private async Task SaveAsync() + { + var error = Validate(); + if (error != null) + { + StatusMessage = error; + return; + } + + IsLoading = true; + StatusMessage = ""; + try + { + var change = ItemSqlBuilder.Build(this); + + if (_session.CurrentMode == ApplyMode.SqlScript) + { + var dir = _store.Settings.SqlOutputDirectory; + if (string.IsNullOrWhiteSpace(dir)) + { + StatusMessage = "SQL output directory is not configured. Open Connection settings to set one."; + return; + } + + var script = SqlScriptBuilder.Build([change], _session.Email); + Directory.CreateDirectory(dir); + var fileName = SqlScriptBuilder.BuildFileName("entity", BasicPanel.DefinitionName); + var path = Path.Combine(dir, fileName); + await File.WriteAllTextAsync(path, script); + + var seededKeys = SeedTranslations(); + SaveResultSummary = BuildSummary(seededKeys, path); + CloseRequested?.Invoke(this, true); + } + else + { + await _changeApplier.ExecuteAsync([change]); + var seededKeys = SeedTranslations(); + await _lookupCache.RefreshAllAsync(_connection); + SaveResultSummary = BuildSummary(seededKeys, null); + CloseRequested?.Invoke(this, true); + } + } + catch (Exception ex) + { + StatusMessage = $"Save failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + private bool CanSave() => !IsLoading; + + [RelayCommand] + private void Cancel() => CloseRequested?.Invoke(this, false); + + private string? Validate() + { + if (BasicPanel.HasErrors) return "Basic tab has errors — check Definition Name and Category Flags."; + if (IsCraftable && CalibrationPanel.HasErrors) return "Calibration Template tab has errors."; + if (IsCraftable && HasPrototype && PrototypePanel.HasErrors) return "Prototype tab has errors."; + if (StatsPanel.HasDuplicateFields()) return "Stats tab: duplicate aggregate field."; + if (IsCraftable && ProductionPanel.HasDuplicateIngredients()) return "Production tab: duplicate ingredient."; + if (IsCraftable && ResearchPanel.HasDuplicatePointTypes()) return "Research tab: duplicate point type."; + if (OptionsVisualPanel.HasDuplicateConfigColumns()) return "Options & Visual tab: duplicate config column."; + var tintError = OptionsVisualPanel.ValidateTintValues(); + if (tintError != null) return tintError; + return null; + } + + private List SeedTranslations() + { + var seeded = new List(); + if (!_translationStore.DirectoryExists) return seeded; + + void TryAdd(string key) + { + if (_translationStore.TryAddKey(key, out _)) seeded.Add(key); + } + + TryAdd(BasicPanel.DefinitionName); + TryAdd(BasicPanel.DescriptionToken); + if (IsCraftable) + { + TryAdd(CalibrationPanel.DefinitionName); + TryAdd(CalibrationPanel.DescriptionToken); + } + if (IsCraftable && HasPrototype) + { + TryAdd(PrototypePanel.DefinitionName); + TryAdd(PrototypePanel.DescriptionToken); + } + + _translationStore.Save(); + return seeded; + } + + private string BuildSummary(List seededKeys, string? scriptPath) + { + var sb = new StringBuilder(); + if (scriptPath != null) + sb.AppendLine($"Item '{BasicPanel.DefinitionName}' written to script: {scriptPath}"); + else + sb.AppendLine($"Item '{BasicPanel.DefinitionName}' created."); + if (!_translationStore.DirectoryExists) + sb.AppendLine("Warning: GameRoot not configured — translation keys were NOT seeded."); + else if (seededKeys.Count > 0) + { + sb.AppendLine("Translation keys seeded:"); + foreach (var k in seededKeys) sb.AppendLine($" • {k}"); + } + return sb.ToString().TrimEnd(); + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/NewRobotDialogViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/NewRobotDialogViewModel.cs new file mode 100644 index 00000000..1fc7ced9 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/NewRobotDialogViewModel.cs @@ -0,0 +1,457 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Entities; +using Perpetuum.AdminTool.NewItem; +using Perpetuum.AdminTool.NewRobot; +using Perpetuum.AdminTool.Packages; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.Translations; +using Perpetuum.ExportedTypes; +using Perpetuum.GenXY; + +namespace Perpetuum.AdminTool.ViewModels; + +public partial class NewRobotDialogViewModel : ObservableObject +{ + private readonly ConnectionSettings _connection; + private readonly ChangeApplier _changeApplier; + private readonly TranslationStore _translationStore; + private readonly NewItemRepository _repository; + private readonly NewRobotRepository _robotRepository; + private readonly LookupCache _lookupCache; + private readonly Dictionary _existingRowsById; + private readonly AppSession _session; + private readonly AppSettingsStore _store; + + [ObservableProperty] private PackageItemPickItem? _cloneSource; + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private string _saveResultSummary = ""; + [ObservableProperty] private IReadOnlyList _enabledItems = []; + + // Per-part clone source selections + [ObservableProperty] private PackageItemPickItem? _cloneHead; + [ObservableProperty] private PackageItemPickItem? _cloneChassis; + [ObservableProperty] private PackageItemPickItem? _cloneLeg; + [ObservableProperty] private PackageItemPickItem? _cloneInventory; + + // Per-part filtered entity lists (populated in InitializeAsync) + [ObservableProperty] private IReadOnlyList _headItems = []; + [ObservableProperty] private IReadOnlyList _chassisItems = []; + [ObservableProperty] private IReadOnlyList _legItems = []; + [ObservableProperty] private IReadOnlyList _inventoryItems = []; + + // Shared panels (same names as NewItemDialogViewModel — XAML tabs 1–8) + public BasicPanelViewModel BasicPanel { get; } + public BasicPanelViewModel CalibrationPanel { get; } + public BasicPanelViewModel PrototypePanel { get; } + public StatsPanelViewModel StatsPanel { get; } + public PropertyModifiersPanelViewModel PropertyModifiersPanel { get; } + public ProductionPanelViewModel ProductionPanel { get; } + public ResearchPanelViewModel ResearchPanel { get; } + public OptionsVisualPanelViewModel OptionsVisualPanel { get; } + + // Robot-specific panels (XAML tabs 9–14) + public BasicPanelViewModel HeadPanel { get; } + public StatsPanelViewModel HeadStatsPanel { get; } + public BasicPanelViewModel ChassisPanel { get; } + public StatsPanelViewModel ChassisStatsPanel { get; } + public BasicPanelViewModel LegPanel { get; } + public StatsPanelViewModel LegStatsPanel { get; } + public BasicPanelViewModel InventoryPanel { get; } + public StatsPanelViewModel InventoryStatsPanel { get; } + public OptionsVisualPanelViewModel HeadOptionsPanel { get; } + public OptionsVisualPanelViewModel ChassisOptionsPanel { get; } + public OptionsVisualPanelViewModel LegOptionsPanel { get; } + public OptionsVisualPanelViewModel InventoryOptionsPanel { get; } + public RobotTemplatePanelViewModel TemplatePanelViewModel { get; } + public RobotTemplateRelationPanelViewModel TemplateRelationPanelViewModel { get; } + public BonusesPanelViewModel BonusesPanel { get; } + + // Tab-gating proxies + public bool IsCraftable => BasicPanel.IsCraftable; + public bool HasPrototype => BasicPanel.HasPrototype; + public bool IsRobot => BasicPanel.IsRobot; + + public event EventHandler? CloseRequested; + + public NewRobotDialogViewModel( + ConnectionSettings connection, + ChangeApplier changeApplier, + TranslationStore translationStore, + NewItemRepository repository, + NewRobotRepository robotRepository, + LookupCache lookupCache, + IReadOnlyList existingRows, + AppSession session, + AppSettingsStore store) + { + _connection = connection; + _changeApplier = changeApplier; + _translationStore = translationStore; + _repository = repository; + _robotRepository = robotRepository; + _lookupCache = lookupCache; + _existingRowsById = existingRows.ToDictionary(r => r.Definition); + _session = session; + _store = store; + + var existingNames = existingRows.Select(r => r.DefinitionName) + .ToHashSet(StringComparer.Ordinal); + + BasicPanel = new BasicPanelViewModel(BasicPanelMode.Main, existingNames); + BasicPanel.IsRobot = true; + CalibrationPanel = new BasicPanelViewModel(BasicPanelMode.CalibrationTemplate, existingNames); + PrototypePanel = new BasicPanelViewModel(BasicPanelMode.Prototype, existingNames); + StatsPanel = new StatsPanelViewModel(); + PropertyModifiersPanel = new PropertyModifiersPanelViewModel(); + ProductionPanel = new ProductionPanelViewModel(); + ResearchPanel = new ResearchPanelViewModel(); + OptionsVisualPanel = new OptionsVisualPanelViewModel(); + + HeadPanel = new BasicPanelViewModel(BasicPanelMode.RobotPart, existingNames); + HeadStatsPanel = new StatsPanelViewModel(); + ChassisPanel = new BasicPanelViewModel(BasicPanelMode.RobotPart, existingNames); + ChassisStatsPanel = new StatsPanelViewModel(); + LegPanel = new BasicPanelViewModel(BasicPanelMode.RobotPart, existingNames); + LegStatsPanel = new StatsPanelViewModel(); + InventoryPanel = new BasicPanelViewModel(BasicPanelMode.RobotPart, existingNames); + InventoryStatsPanel = new StatsPanelViewModel(); + + HeadOptionsPanel = new OptionsVisualPanelViewModel(); + ChassisOptionsPanel = new OptionsVisualPanelViewModel(); + LegOptionsPanel = new OptionsVisualPanelViewModel(); + InventoryOptionsPanel = new OptionsVisualPanelViewModel(); + + TemplatePanelViewModel = new RobotTemplatePanelViewModel(); + TemplateRelationPanelViewModel = new RobotTemplateRelationPanelViewModel(); + BonusesPanel = new BonusesPanelViewModel(); + + BasicPanel.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(BasicPanelViewModel.DefinitionName)) + { + CalibrationPanel.SuggestName(BasicPanel.DefinitionName, "_cprg"); + PrototypePanel.SuggestName(BasicPanel.DefinitionName, "_pr"); + HeadPanel.SuggestName(BasicPanel.DefinitionName, "_head"); + ChassisPanel.SuggestName(BasicPanel.DefinitionName, "_chassis"); + LegPanel.SuggestName(BasicPanel.DefinitionName, "_leg"); + InventoryPanel.SuggestName(BasicPanel.DefinitionName, "_inventory"); + } + if (e.PropertyName == nameof(BasicPanelViewModel.CategoryFlags)) + ProductionPanel.UpdateCategory(BasicPanel.CategoryFlags); + if (e.PropertyName is nameof(BasicPanelViewModel.IsCraftable) + or nameof(BasicPanelViewModel.HasPrototype)) + { + OnPropertyChanged(nameof(IsCraftable)); + OnPropertyChanged(nameof(HasPrototype)); + } + if (e.PropertyName == nameof(BasicPanelViewModel.IsRobot)) + OnPropertyChanged(nameof(IsRobot)); + }; + } + + public async Task InitializeAsync( + IReadOnlyList aggregateFields, + Dictionary? englishNames = null) + { + IsLoading = true; + try + { + var lookups = await _repository.LoadAsync( + aggregateFields, + _lookupCache.Entities.ToList(), + englishNames); + + EnabledItems = BuildRobotItems(englishNames); + StatsPanel.Initialize(lookups); + HeadStatsPanel.Initialize(lookups); + ChassisStatsPanel.Initialize(lookups); + LegStatsPanel.Initialize(lookups); + InventoryStatsPanel.Initialize(lookups); + PropertyModifiersPanel.Initialize(lookups); + ProductionPanel.Initialize(lookups); + ResearchPanel.Initialize(lookups); + OptionsVisualPanel.Initialize(lookups); + HeadOptionsPanel.Initialize(lookups); + ChassisOptionsPanel.Initialize(lookups); + LegOptionsPanel.Initialize(lookups); + InventoryOptionsPanel.Initialize(lookups); + BonusesPanel.Initialize(lookups, englishNames); + + HeadItems = BuildPartItems((long)CategoryFlags.cf_robot_head); + ChassisItems = BuildPartItems((long)CategoryFlags.cf_robot_chassis); + LegItems = BuildPartItems((long)CategoryFlags.cf_robot_leg); + InventoryItems = BuildPartItems((long)CategoryFlags.cf_robot_inventory); + } + finally + { + IsLoading = false; + } + } + + partial void OnCloneSourceChanged(PackageItemPickItem? value) + { + if (value == null || IsLoading) return; + _ = LoadCloneAsync(value.Definition); + } + + partial void OnCloneHeadChanged(PackageItemPickItem? value) + { + if (value == null || IsLoading) return; + if (!_existingRowsById.TryGetValue(value.Definition, out var row)) return; + HeadPanel.LoadFromClone(row); + HeadPanel.SuggestName(BasicPanel.DefinitionName, "_head"); + HeadStatsPanel.LoadFromClone(row.Stats); + HeadOptionsPanel.LoadFromClone(row.Options, new Dictionary()); + } + + partial void OnCloneChassisChanged(PackageItemPickItem? value) + { + if (value == null || IsLoading) return; + if (!_existingRowsById.TryGetValue(value.Definition, out var row)) return; + ChassisPanel.LoadFromClone(row); + ChassisPanel.SuggestName(BasicPanel.DefinitionName, "_chassis"); + ChassisStatsPanel.LoadFromClone(row.Stats); + ChassisOptionsPanel.LoadFromClone(row.Options, new Dictionary()); + } + + partial void OnCloneLegChanged(PackageItemPickItem? value) + { + if (value == null || IsLoading) return; + if (!_existingRowsById.TryGetValue(value.Definition, out var row)) return; + LegPanel.LoadFromClone(row); + LegPanel.SuggestName(BasicPanel.DefinitionName, "_leg"); + LegStatsPanel.LoadFromClone(row.Stats); + LegOptionsPanel.LoadFromClone(row.Options, new Dictionary()); + } + + partial void OnCloneInventoryChanged(PackageItemPickItem? value) + { + if (value == null || IsLoading) return; + if (!_existingRowsById.TryGetValue(value.Definition, out var row)) return; + InventoryPanel.LoadFromClone(row); + InventoryPanel.SuggestName(BasicPanel.DefinitionName, "_inventory"); + InventoryStatsPanel.LoadFromClone(row.Stats); + InventoryOptionsPanel.LoadFromClone(row.Options, new Dictionary()); + } + + private IReadOnlyList BuildRobotItems(Dictionary? englishNames) + { + var node = new CategoryFlagsNode { Value = (long)CategoryFlags.cf_robots }; + var result = new List(); + foreach (var e in _lookupCache.Entities) + { + if (!e.Enabled || e.CategoryFlags == 0) continue; + if (!node.ContainsOrEquals(e.CategoryFlags)) continue; + var baseName = (englishNames != null && englishNames.TryGetValue(e.Name, out var eng) && !string.IsNullOrEmpty(eng)) + ? eng + : e.Name; + var tierLabel = PackageItemPickItem.GetTierLabel(e.CategoryFlags, e.TierType, e.TierLevel); + var displayName = tierLabel.Length > 0 ? $"{baseName} ({tierLabel})" : baseName; + result.Add(new PackageItemPickItem(e.Definition, displayName)); + } + return result.OrderBy(p => p.DisplayName, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private IReadOnlyList BuildPartItems(long rootFlag) + { + var node = new CategoryFlagsNode { Value = rootFlag }; + var result = new List(); + foreach (var e in _lookupCache.Entities) + { + if (!e.Enabled || e.CategoryFlags == 0) continue; + if (!node.ContainsOrEquals(e.CategoryFlags)) continue; + result.Add(new PackageItemPickItem(e.Definition, e.Name)); + } + return result.OrderBy(p => p.DisplayName, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private async Task LoadCloneAsync(int definition) + { + if (!_existingRowsById.TryGetValue(definition, out var row)) return; + + BasicPanel.LoadFromClone(row); + CalibrationPanel.LoadFromClone(row, "_cprg"); + PrototypePanel.LoadFromClone(row, "_pr"); + StatsPanel.LoadFromClone(row.Stats); + PropertyModifiersPanel.LoadFromClone(row.CategoryFlags); + + IsLoading = true; + try + { + var extended = await _repository.LoadCloneExtendedAsync(definition); + ProductionPanel.LoadFromClone(extended.Components); + ResearchPanel.LoadFromClone(extended); + OptionsVisualPanel.LoadFromClone(row.Options, extended.DefinitionConfig); + + var relation = await _robotRepository.LoadTemplateRelationAsync(definition); + if (relation != null) + TemplateRelationPanelViewModel.LoadFromClone(relation); + + var dict = GenxyConverter.Deserialize(row.Options ?? ""); + if (dict.TryGetValue("chassis", out var chassisVal) && chassisVal is int chassisDefinition) + { + var bonuses = await _robotRepository.LoadChassisBonusesAsync(chassisDefinition); + BonusesPanel.LoadFromClone(bonuses); + } + } + catch (Exception ex) + { + StatusMessage = $"Failed to load clone data: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + [RelayCommand(CanExecute = nameof(CanSave))] + private async Task SaveAsync() + { + var error = Validate(); + if (error != null) + { + StatusMessage = error; + return; + } + + IsLoading = true; + StatusMessage = ""; + try + { + var change = RobotSqlBuilder.Build(this); + + if (_session.CurrentMode == ApplyMode.SqlScript) + { + var dir = _store.Settings.SqlOutputDirectory; + if (string.IsNullOrWhiteSpace(dir)) + { + StatusMessage = "SQL output directory is not configured. Open Connection settings to set one."; + return; + } + + var script = SqlScriptBuilder.Build([change], _session.Email); + Directory.CreateDirectory(dir); + var fileName = SqlScriptBuilder.BuildFileName("robot", BasicPanel.DefinitionName); + var path = Path.Combine(dir, fileName); + await File.WriteAllTextAsync(path, script); + + var seededKeys = SeedTranslations(); + SaveResultSummary = BuildSummary(seededKeys, path); + CloseRequested?.Invoke(this, true); + } + else + { + await _changeApplier.ExecuteAsync([change]); + var seededKeys = SeedTranslations(); + await _lookupCache.RefreshAllAsync(_connection); + SaveResultSummary = BuildSummary(seededKeys, null); + CloseRequested?.Invoke(this, true); + } + } + catch (Exception ex) + { + StatusMessage = $"Save failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + private bool CanSave() => !IsLoading; + + [RelayCommand] + private void Cancel() => CloseRequested?.Invoke(this, false); + + private string? Validate() + { + if (BasicPanel.HasErrors) return "Basic tab has errors — check Definition Name and Category Flags."; + if (IsCraftable && CalibrationPanel.HasErrors) return "Calibration Template tab has errors."; + if (IsCraftable && HasPrototype && PrototypePanel.HasErrors) return "Prototype tab has errors."; + if (StatsPanel.HasDuplicateFields()) return "Stats tab: duplicate aggregate field."; + if (IsRobot) + { + if (HeadPanel.HasErrors) return "Head tab has errors."; + if (ChassisPanel.HasErrors) return "Chassis tab has errors."; + if (LegPanel.HasErrors) return "Leg tab has errors."; + if (InventoryPanel.HasErrors) return "Inventory tab has errors."; + if (HeadStatsPanel.HasDuplicateFields()) return "Head Stats: duplicate aggregate field."; + if (ChassisStatsPanel.HasDuplicateFields()) return "Chassis Stats: duplicate aggregate field."; + if (LegStatsPanel.HasDuplicateFields()) return "Leg Stats: duplicate aggregate field."; + if (InventoryStatsPanel.HasDuplicateFields()) return "Inventory Stats: duplicate aggregate field."; + if (BonusesPanel.HasDuplicates()) return "Robot Bonuses tab: duplicate (extension + target property) pair."; + if (TemplatePanelViewModel.HasErrors) return "Robot Template tab: name is required."; + } + if (IsCraftable && ProductionPanel.HasDuplicateIngredients()) return "Production tab: duplicate ingredient."; + if (IsCraftable && ResearchPanel.HasDuplicatePointTypes()) return "Research tab: duplicate point type."; + if (OptionsVisualPanel.HasDuplicateConfigColumns()) return "Options & Visual tab: duplicate config column."; + var tintError = OptionsVisualPanel.ValidateTintValues(); + if (tintError != null) return tintError; + return null; + } + + private List SeedTranslations() + { + var seeded = new List(); + if (!_translationStore.DirectoryExists) return seeded; + + void TryAdd(string key) + { + if (_translationStore.TryAddKey(key, out _)) seeded.Add(key); + } + + TryAdd(BasicPanel.DefinitionName); + TryAdd(BasicPanel.DescriptionToken); + if (IsCraftable) + { + TryAdd(CalibrationPanel.DefinitionName); + TryAdd(CalibrationPanel.DescriptionToken); + } + if (IsCraftable && HasPrototype) + { + TryAdd(PrototypePanel.DefinitionName); + TryAdd(PrototypePanel.DescriptionToken); + } + if (IsRobot) + { + TryAdd(HeadPanel.DefinitionName); + TryAdd(HeadPanel.DescriptionToken); + TryAdd(ChassisPanel.DefinitionName); + TryAdd(ChassisPanel.DescriptionToken); + TryAdd(LegPanel.DefinitionName); + TryAdd(LegPanel.DescriptionToken); + TryAdd(InventoryPanel.DefinitionName); + TryAdd(InventoryPanel.DescriptionToken); + } + + _translationStore.Save(); + return seeded; + } + + private string BuildSummary(List seededKeys, string? scriptPath) + { + var sb = new StringBuilder(); + if (scriptPath != null) + sb.AppendLine($"Robot '{BasicPanel.DefinitionName}' written to script: {scriptPath}"); + else + sb.AppendLine($"Robot '{BasicPanel.DefinitionName}' created."); + if (!_translationStore.DirectoryExists) + sb.AppendLine("Warning: GameRoot not configured — translation keys were NOT seeded."); + else if (seededKeys.Count > 0) + { + sb.AppendLine("Translation keys seeded:"); + foreach (var k in seededKeys) sb.AppendLine($" • {k}"); + } + return sb.ToString().TrimEnd(); + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/NpcLootViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/NpcLootViewModel.cs new file mode 100644 index 00000000..82818124 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/NpcLootViewModel.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Loot; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class NpcLootViewModel : ObservableObject + { + private readonly AppSettingsStore _settings; + private readonly ChangeQueue _queue; + private readonly LookupCache _lookups; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private string _filterText = ""; + [ObservableProperty] private NpcLootRow? _selectedRow; + + public ObservableCollection Rows { get; } = new(); + public ICollectionView View { get; } + + // Pickers and name lookup come from the shared cache. + public LookupCache Lookups => _lookups; + + // Snapshot of the last load, keyed by `id`. New (unsaved) rows have id <= 0 + // and are not present here. + private Dictionary _originalById = new(); + + public NpcLootViewModel(AppSettingsStore settings, ChangeQueue queue, LookupCache lookups) + { + _settings = settings; + _queue = queue; + _lookups = lookups; + View = CollectionViewSource.GetDefaultView(Rows); + View.Filter = MatchesFilter; + } + + partial void OnFilterTextChanged(string value) => View.Refresh(); + + partial void OnSelectedRowChanged(NpcLootRow? value) + { + if (value != null) + { + value.PropertyChanged -= OnRowPropertyChanged; + value.PropertyChanged += OnRowPropertyChanged; + } + } + + private void OnRowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not NpcLootRow row) return; + if (e.PropertyName == nameof(NpcLootRow.Definition)) + row.DefinitionName = ResolveName(row.Definition); + else if (e.PropertyName == nameof(NpcLootRow.LootDefinition)) + row.LootDefinitionName = ResolveName(row.LootDefinition); + } + + private string ResolveName(int definition) => + _lookups.EntityNamesByDefinition.TryGetValue(definition, out var n) ? n : ""; + + private bool MatchesFilter(object obj) + { + if (obj is not NpcLootRow row) return false; + if (string.IsNullOrWhiteSpace(FilterText)) return true; + var f = FilterText.Trim(); + if (int.TryParse(f, out var n)) + return row.Definition == n || row.LootDefinition == n || row.Id == n; + return row.DefinitionName.Contains(f, StringComparison.OrdinalIgnoreCase) + || row.LootDefinitionName.Contains(f, StringComparison.OrdinalIgnoreCase); + } + + public async Task ReloadAsync() + { + IsLoading = true; + StatusMessage = "Loading npcloot..."; + StatusIsError = false; + try + { + // Refresh the shared entitydefaults cache so dropdowns and name resolution + // see any rows added since the last reload. + try { await _lookups.RefreshEntitiesAsync(_settings.Settings.Connection); } + catch { /* non-fatal */ } + + var repo = new NpcLootRepository(_settings.Settings.Connection); + var loaded = await repo.LoadAllAsync(); + + Rows.Clear(); + _originalById = new Dictionary(loaded.Count); + foreach (var r in loaded) + { + r.DefinitionName = ResolveName(r.Definition); + r.LootDefinitionName = ResolveName(r.LootDefinition); + r.PropertyChanged -= OnRowPropertyChanged; + r.PropertyChanged += OnRowPropertyChanged; + Rows.Add(r); + _originalById[r.Id] = r.Original; + } + SelectedRow = null; + StatusMessage = $"Loaded {Rows.Count} loot drop rule(s)."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + public bool TryAddNew(int definition, int lootDefinition, int min, int max, double prob, bool dontDamage, bool repackaged, out string error) + { + error = ""; + if (definition <= 0 || !_lookups.EntityNamesByDefinition.ContainsKey(definition)) + { + error = "NPC definition must be selected from the entitydefaults list."; + return false; + } + if (lootDefinition <= 0 || !_lookups.EntityNamesByDefinition.ContainsKey(lootDefinition)) + { + error = "Item definition must be selected from the entitydefaults list."; + return false; + } + var row = NpcLootRow.CreateNew(definition, lootDefinition, min, max, prob, dontDamage, repackaged); + row.DefinitionName = ResolveName(definition); + row.LootDefinitionName = ResolveName(lootDefinition); + row.PropertyChanged -= OnRowPropertyChanged; + row.PropertyChanged += OnRowPropertyChanged; + Rows.Insert(0, row); + SelectedRow = row; + return true; + } + + public void RemoveSelected() + { + if (SelectedRow == null) return; + var row = SelectedRow; + SelectedRow = null; + Rows.Remove(row); + } + + public void SaveAll() + { + var changes = NpcLootChanges.ComputeBulkChanges(Rows, _originalById).ToList(); + if (changes.Count == 0) + { + StatusIsError = false; + StatusMessage = "No changes to save."; + return; + } + + foreach (var c in changes) _queue.Add(c); + + // New rows queued an INSERT but the IDENTITY hasn't returned a real id yet, so + // they cannot be diffed against future edits until the user reloads. We therefore + // do NOT roll forward the baseline for IsNew rows — only for existing edited rows. + var newOriginals = new Dictionary(Rows.Count); + foreach (var row in Rows) + { + if (row.IsNew) continue; + row.RefreshOriginalFromCurrent(); + newOriginals[row.Id] = row.Original; + } + _originalById = newOriginals; + + var destructive = changes.Count(c => c.IsDestructive); + StatusIsError = false; + StatusMessage = destructive > 0 + ? $"Queued {changes.Count} change(s), {destructive} destructive. Use the main Commit button to apply." + : $"Queued {changes.Count} change(s). Use the main Commit button to apply."; + } + + public int SuggestNextDefinition() => + SelectedRow?.Definition ?? (Rows.FirstOrDefault()?.Definition ?? 0); + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/PackagesViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/PackagesViewModel.cs new file mode 100644 index 00000000..7b5b5bae --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/PackagesViewModel.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Packages; +using Perpetuum.AdminTool.Seasons; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class PackagesViewModel : ObservableObject + { + private readonly PackageRepository _repo; + private readonly SeasonRepository _seasonRepo; + private readonly ChangeQueue _queue; + private readonly LookupCache _lookups; + private readonly ConnectionSettings _connection; + private readonly TranslationsViewModel? _translations; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + [ObservableProperty] private string _filterText = ""; + [ObservableProperty] private PackageRow? _selectedPackage; + + public ObservableCollection Packages { get; } = new(); + public ObservableCollection FilteredPackages { get; } = new(); + public ObservableCollection SelectedPackageItems { get; } = new(); + public ObservableCollection SelectedPackageUsage { get; } = new(); + + public ObservableCollection PickItems { get; } = new(); + private Dictionary _pickNamesByDefinition = new(); + + public bool HasSelection => SelectedPackage != null; + public bool IsActiveSeason => SelectedPackageUsage.Any(u => u.IsActive); + public bool CanDeleteSelected => SelectedPackage != null && SelectedPackage.SeasonCount == 0; + public bool CanSaveNewPackage => SelectedPackage is { IsNew: true, Id: 0 }; + + public string UsageDescription + { + get + { + if (SelectedPackage == null) return ""; + if (SelectedPackageUsage.Count == 0) return "Not used by any season."; + var lines = SelectedPackageUsage + .Select(u => $"• {u.SeasonName} — {u.Context}: {u.Detail}") + .ToList(); + return $"Used by {SelectedPackageUsage.Count} reference(s):\n" + string.Join("\n", lines); + } + } + + public PackagesViewModel( + PackageRepository repo, + SeasonRepository seasonRepo, + ChangeQueue queue, + LookupCache lookups, + ConnectionSettings connection, + TranslationsViewModel? translations = null) + { + _repo = repo; + _seasonRepo = seasonRepo; + _queue = queue; + _lookups = lookups; + _connection = connection; + _translations = translations; + } + + partial void OnFilterTextChanged(string value) => RefreshFilter(); + + partial void OnSelectedPackageChanged(PackageRow? value) + { + OnPropertyChanged(nameof(HasSelection)); + OnPropertyChanged(nameof(CanDeleteSelected)); + OnPropertyChanged(nameof(CanSaveNewPackage)); + _ = LoadSelectedDetailAsync(); + } + + public async Task LoadAsync() + { + IsLoading = true; + StatusMessage = "Loading packages..."; + StatusIsError = false; + try + { + var pkgs = await _repo.LoadAllPackagesAsync(); + Packages.Clear(); + foreach (var p in pkgs) Packages.Add(p); + + RebuildPickItems(); + RefreshFilter(); + + if (SelectedPackage != null) + { + var match = Packages.FirstOrDefault(p => p.Id == SelectedPackage.Id); + SelectedPackage = match; + } + else + { + SelectedPackageItems.Clear(); + SelectedPackageUsage.Clear(); + OnPropertyChanged(nameof(UsageDescription)); + OnPropertyChanged(nameof(IsActiveSeason)); + } + + StatusMessage = $"Loaded {Packages.Count} package(s)."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + public void RebuildPickItems() + { + var englishNames = BuildEnglishNameMap(); + var fresh = PackageItemPickItem.BuildFilteredList(_lookups.Entities, englishNames); + PickItems.Clear(); + foreach (var p in fresh) PickItems.Add(p); + _pickNamesByDefinition = fresh.ToDictionary(p => p.Definition, p => p.DisplayName); + } + + private Dictionary BuildEnglishNameMap() + { + var store = _translations?.Store; + if (store == null) return new Dictionary(); + var map = new Dictionary(store.Rows.Count, System.StringComparer.Ordinal); + foreach (var row in store.Rows) + { + var english = row[0]; + if (!string.IsNullOrEmpty(english)) + map[row.Key] = english; + } + return map; + } + + private void RefreshFilter() + { + FilteredPackages.Clear(); + var f = (FilterText ?? "").Trim(); + foreach (var p in Packages) + { + if (f.Length == 0 || p.Name.Contains(f, StringComparison.OrdinalIgnoreCase)) + FilteredPackages.Add(p); + } + } + + private async Task LoadSelectedDetailAsync() + { + SelectedPackageItems.Clear(); + SelectedPackageUsage.Clear(); + + if (SelectedPackage == null || SelectedPackage.Id <= 0) + { + OnPropertyChanged(nameof(UsageDescription)); + OnPropertyChanged(nameof(IsActiveSeason)); + return; + } + + try + { + var items = await _repo.LoadPackageItemsAsync(SelectedPackage.Id); + foreach (var it in items) + { + if (_pickNamesByDefinition.TryGetValue(it.Definition, out var name)) + it.DisplayName = name; + else if (_lookups.EntityNamesByDefinition.TryGetValue(it.Definition, out var fallback)) + it.DisplayName = fallback; + else + it.DisplayName = $"(def {it.Definition})"; + it.SelectedPickItem = PickItems.FirstOrDefault(p => p.Definition == it.Definition); + SelectedPackageItems.Add(it); + } + + var usage = await _repo.LoadSeasonUsageAsync(SelectedPackage.Id); + foreach (var u in usage) SelectedPackageUsage.Add(u); + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Detail load failed: {ex.Message}"; + } + + OnPropertyChanged(nameof(UsageDescription)); + OnPropertyChanged(nameof(IsActiveSeason)); + } + + [RelayCommand] + private void NewPackage() + { + var row = new PackageRow { Id = 0, Name = "New Package", IsNew = true, ItemCount = 0, SeasonCount = 0 }; + Packages.Add(row); + RefreshFilter(); + SelectedPackage = row; + StatusIsError = false; + StatusMessage = "New package created locally. Edit the name, add items, then click 'Save Package'."; + } + + [RelayCommand] + private void SaveNewPackage() + { + if (SelectedPackage == null || !SelectedPackage.IsNew) return; + if (string.IsNullOrWhiteSpace(SelectedPackage.Name)) + { + MessageBox.Show("Package name cannot be empty.", "Validation", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + var items = SelectedPackageItems.ToList(); + _queue.Add(PackageChanges.BuildInsertPackageWithItems(SelectedPackage.Name, items)); + SelectedPackageItems.Clear(); + StatusIsError = false; + StatusMessage = $"Queued INSERT for package '{SelectedPackage.Name}' with {items.Count} item(s). Commit the queue to save."; + } + + [RelayCommand] + private void DeletePackage() + { + if (SelectedPackage == null) return; + if (!CanDeleteSelected) + { + MessageBox.Show( + "Cannot delete a package that is referenced by any season. Remove its tier/leaderboard references first.", + "Package in use", + MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + var ok = MessageBox.Show( + $"Delete package '{SelectedPackage.Name}' (id {SelectedPackage.Id})? This is destructive.", + "Delete package", + MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (ok != MessageBoxResult.Yes) return; + + if (SelectedPackage.Id > 0) + _queue.Add(PackageChanges.BuildDeletePackage(SelectedPackage.Id)); + + var row = SelectedPackage; + SelectedPackage = null; + Packages.Remove(row); + RefreshFilter(); + StatusIsError = false; + StatusMessage = $"Queued DELETE for package '{row.Name}'."; + } + + [RelayCommand] + private void AddItem() + { + if (SelectedPackage == null) + { + MessageBox.Show("Select a package first.", "No package", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + if (PickItems.Count == 0) + { + MessageBox.Show("Picker list is empty. Reload entities and try again.", + "No picks available", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var pick = PickItems[0]; + + if (SelectedPackage.Id > 0) + { + _queue.Add(PackageChanges.BuildInsertPackageItem(SelectedPackage.Id, pick.Definition, 1)); + } + + var row = new PackageItemRow + { + Id = 0, + PackageId = SelectedPackage.Id, + Quantity = 1, + IsNew = true + }; + row.SelectedPickItem = pick; + SelectedPackageItems.Add(row); + SelectedPackage.ItemCount = SelectedPackage.ItemCount + 1; + StatusIsError = false; + var savedNote = SelectedPackage.Id > 0 ? "" : " (not yet queued — click 'Save Package' to queue)"; + StatusMessage = $"Added '{pick.DisplayName}' x1{savedNote}."; + } + + [RelayCommand] + private void RemoveItem(PackageItemRow? row) + { + if (row == null || SelectedPackage == null) return; + var ok = MessageBox.Show( + $"Remove item '{row.DisplayName}' x{row.Quantity}?", + "Remove item", + MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (ok != MessageBoxResult.Yes) return; + + if (row.Id > 0) + _queue.Add(PackageChanges.BuildDeletePackageItem(row.Id)); + + SelectedPackageItems.Remove(row); + SelectedPackage.ItemCount = System.Math.Max(0, SelectedPackage.ItemCount - 1); + StatusIsError = false; + StatusMessage = row.Id > 0 + ? $"Queued DELETE for package item id {row.Id}." + : "Removed unsaved item."; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/PresenceFlocksViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/PresenceFlocksViewModel.cs new file mode 100644 index 00000000..a48b84cd --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/PresenceFlocksViewModel.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Npc; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class PresenceFlocksViewModel : ObservableObject + { + private readonly AppSettingsStore _settings; + + public int PresenceId { get; } + public string PresenceName { get; } + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public ObservableCollection Flocks { get; } = new(); + + public PresenceFlocksViewModel(AppSettingsStore settings, int presenceId, string presenceName) + { + _settings = settings; + PresenceId = presenceId; + PresenceName = presenceName; + } + + public async Task ReloadAsync() + { + IsLoading = true; + StatusMessage = "Loading flocks..."; + StatusIsError = false; + try + { + var repo = new FlockRepository(_settings.Settings.Connection); + var rows = await repo.LoadByPresenceAsync(PresenceId); + Flocks.Clear(); + foreach (var r in rows) Flocks.Add(r); + StatusMessage = $"{Flocks.Count} flock(s) reference this presence."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/PresencesViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/PresencesViewModel.cs new file mode 100644 index 00000000..bd02c0b4 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/PresencesViewModel.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Npc; +using Perpetuum.AdminTool.Settings; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class PresencesViewModel : ObservableObject + { + private readonly AppSettingsStore _settings; + private readonly ChangeQueue _queue; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private string _filterText = ""; + [ObservableProperty] private PresenceRow? _selectedRow; + + public ObservableCollection Rows { get; } = new(); + public ICollectionView View { get; } + + public ObservableCollection ZoneSpawnPicks { get; } = new(); + private Dictionary _zoneSpawnPicksById = new(); + + private Dictionary _originalById = new(); + + public AppSettingsStore Settings => _settings; + + public PresencesViewModel(AppSettingsStore settings, ChangeQueue queue) + { + _settings = settings; + _queue = queue; + View = CollectionViewSource.GetDefaultView(Rows); + View.Filter = MatchesFilter; + } + + partial void OnFilterTextChanged(string value) => View.Refresh(); + + partial void OnSelectedRowChanged(PresenceRow? value) + { + if (value != null) + { + value.PropertyChanged -= OnRowPropertyChanged; + value.PropertyChanged += OnRowPropertyChanged; + } + } + + private void OnRowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not PresenceRow row) return; + if (e.PropertyName == nameof(PresenceRow.SpawnId)) + row.SpawnName = ResolveSpawnName(row.SpawnId); + } + + private string ResolveSpawnName(int? spawnId) + { + if (!spawnId.HasValue) return ""; + return _zoneSpawnPicksById.TryGetValue(spawnId.Value, out var p) ? p.Name : ""; + } + + private bool MatchesFilter(object obj) + { + if (obj is not PresenceRow row) return false; + if (string.IsNullOrWhiteSpace(FilterText)) return true; + var f = FilterText.Trim(); + if (int.TryParse(f, out var n)) + return row.Id == n || row.SpawnId == n; + return row.Name.Contains(f, StringComparison.OrdinalIgnoreCase) + || row.SpawnName.Contains(f, StringComparison.OrdinalIgnoreCase); + } + + public async Task ReloadAsync() + { + IsLoading = true; + StatusMessage = "Loading npcpresence..."; + StatusIsError = false; + try + { + var repo = new PresenceRepository(_settings.Settings.Connection); + var load = await repo.LoadAllAsync(); + + ZoneSpawnPicks.Clear(); + foreach (var p in load.ZoneSpawnPicks) ZoneSpawnPicks.Add(p); + _zoneSpawnPicksById = ZoneSpawnPicks + .GroupBy(p => p.SpawnId) + .ToDictionary(g => g.Key, g => g.First()); + + Rows.Clear(); + _originalById = new Dictionary(load.Rows.Count); + foreach (var r in load.Rows) + { + r.SpawnName = ResolveSpawnName(r.SpawnId); + r.PropertyChanged -= OnRowPropertyChanged; + r.PropertyChanged += OnRowPropertyChanged; + Rows.Add(r); + _originalById[r.Id] = r.Original; + } + SelectedRow = null; + StatusMessage = $"Loaded {Rows.Count} presence(s)."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + public bool TryAddNew(PresenceSnapshot seed, out string error) + { + error = ""; + if (string.IsNullOrWhiteSpace(seed.Name)) + { + error = "Name is required."; + return false; + } + if (Rows.Any(r => string.Equals(r.Name, seed.Name, StringComparison.OrdinalIgnoreCase))) + { + error = "A presence with this name already exists. The DB enforces a UNIQUE constraint on name."; + return false; + } + var row = PresenceRow.CreateNew(seed); + row.SpawnName = ResolveSpawnName(seed.SpawnId); + row.PropertyChanged -= OnRowPropertyChanged; + row.PropertyChanged += OnRowPropertyChanged; + Rows.Insert(0, row); + SelectedRow = row; + return true; + } + + public void RemoveSelected() + { + if (SelectedRow == null) return; + var row = SelectedRow; + SelectedRow = null; + Rows.Remove(row); + } + + public void SaveAll() + { + var changes = PresenceChanges.ComputeBulkChanges(Rows, _originalById).ToList(); + if (changes.Count == 0) + { + StatusIsError = false; + StatusMessage = "No changes to save."; + return; + } + + foreach (var c in changes) _queue.Add(c); + + var newOriginals = new Dictionary(Rows.Count); + foreach (var row in Rows) + { + if (row.IsNew) continue; + row.RefreshOriginalFromCurrent(); + newOriginals[row.Id] = row.Original; + } + _originalById = newOriginals; + + var destructive = changes.Count(c => c.IsDestructive); + StatusIsError = false; + StatusMessage = destructive > 0 + ? $"Queued {changes.Count} change(s), {destructive} destructive. Use the main Commit button to apply." + : $"Queued {changes.Count} change(s). Use the main Commit button to apply."; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/RobotTemplateEditorViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/RobotTemplateEditorViewModel.cs new file mode 100644 index 00000000..a20bd96c --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/RobotTemplateEditorViewModel.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Templates; +using Perpetuum.GenXY; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class RobotTemplateEditorViewModel : ObservableObject + { + // Top-level keys we own. Anything else found in the input dictionary is preserved + // verbatim so we don't accidentally drop server-side fields (e.g. cargo `items`). + private static readonly HashSet _ownedKeys = new(StringComparer.Ordinal) + { + "robot", "head", "chassis", "leg", "container", + "headModules", "chassisModules", "legModules" + }; + + private readonly IReadOnlyList _all; + private readonly Dictionary _byDef; + + // Keys we don't model — round-tripped on save. + private readonly Dictionary _passthrough = new(StringComparer.Ordinal); + + // Saved module data captured per part on initial load. When a part definition + // changes, slots are rebuilt; the user's prior module/ammo picks are mapped onto + // the new slot list by index where possible. + private List _initialHeadModules = new(); + private List _initialChassisModules = new(); + private List _initialLegModules = new(); + + // CategoryFlags roots used to filter each part dropdown. Hierarchical match — + // any descendant of the root is accepted. + private const long CfRobots = 0x0000000000000001L; + private const long CfRobotHead = 0x0000000000000150L; + private const long CfRobotChassis = 0x0000000000000250L; + private const long CfRobotLeg = 0x0000000000000350L; + private const long CfRobotInventory = 0x0000000000030915L; + + public ObservableCollection RobotPicks { get; } = new(); + public ObservableCollection HeadPicks { get; } = new(); + public ObservableCollection ChassisPicks { get; } = new(); + public ObservableCollection LegPicks { get; } = new(); + public ObservableCollection ContainerPicks { get; } = new(); + + public ObservableCollection HeadSlots { get; } = new(); + public ObservableCollection ChassisSlots { get; } = new(); + public ObservableCollection LegSlots { get; } = new(); + + [ObservableProperty] private int _robotDefinition; + [ObservableProperty] private int _headDefinition; + [ObservableProperty] private int _chassisDefinition; + [ObservableProperty] private int _legDefinition; + [ObservableProperty] private int _containerDefinition; + + [ObservableProperty] private string _errorMessage = ""; + [ObservableProperty] private string _initialGenxy = ""; + [ObservableProperty] private string _resultGenxy = ""; + + public RobotTemplateEditorViewModel( + IReadOnlyList allEntities, + string initialDescription) + { + _all = allEntities; + _byDef = allEntities.ToDictionary(e => e.Definition); + + // Each part dropdown is filtered by Enabled + a category-tree match against + // the relevant CategoryFlags root. Disabled rows still live in `_byDef` so + // existing references resolve their display name; they just can't be picked. + PopulatePicks(RobotPicks, CfRobots); + PopulatePicks(HeadPicks, CfRobotHead); + PopulatePicks(ChassisPicks, CfRobotChassis); + PopulatePicks(LegPicks, CfRobotLeg); + PopulatePicks(ContainerPicks, CfRobotInventory); + + InitialGenxy = initialDescription ?? ""; + + LoadFromGenxy(InitialGenxy); + } + + private void PopulatePicks(ObservableCollection sink, long categoryRoot) + { + var mask = CategoryFlagsMask(categoryRoot); + foreach (var e in _all) + { + if (!e.Enabled) continue; + if (e.CategoryFlags == 0) continue; + if ((e.CategoryFlags & mask) != categoryRoot) continue; + sink.Add(e); + } + } + + // Mirror of Perpetuum.CategoryFlagsExtensions.GetCategoryFlagsMask on long. + private static long CategoryFlagsMask(long target) + { + var mask = unchecked((long)0xFFFFFFFFFFFFFFFFUL); + while (((ulong)target & (ulong)mask) > 0) + { + mask <<= 8; + } + return ~mask; + } + + private void LoadFromGenxy(string genxy) + { + // Empty string → fresh template, all defs zero, all slot lists empty. + var dict = string.IsNullOrEmpty(genxy) + ? new Dictionary() + : GenxyConverter.Deserialize(genxy); + + RobotDefinition = ToInt(dict.GetValueOrDefault("robot")); + HeadDefinition = ToInt(dict.GetValueOrDefault("head")); + ChassisDefinition = ToInt(dict.GetValueOrDefault("chassis")); + LegDefinition = ToInt(dict.GetValueOrDefault("leg")); + ContainerDefinition = ToInt(dict.GetValueOrDefault("container")); + + _initialHeadModules = ReadModules(dict.GetValueOrDefault("headModules")); + _initialChassisModules = ReadModules(dict.GetValueOrDefault("chassisModules")); + _initialLegModules = ReadModules(dict.GetValueOrDefault("legModules")); + + // Round-trip everything else (e.g. `items`) back into the saved Genxy. + foreach (var kv in dict) + { + if (_ownedKeys.Contains(kv.Key)) continue; + _passthrough[kv.Key] = kv.Value; + } + + RebuildSlots(HeadSlots, HeadDefinition, _initialHeadModules); + RebuildSlots(ChassisSlots, ChassisDefinition, _initialChassisModules); + RebuildSlots(LegSlots, LegDefinition, _initialLegModules); + } + + partial void OnHeadDefinitionChanged(int value) => + RebuildSlots(HeadSlots, value, _initialHeadModules); + + partial void OnChassisDefinitionChanged(int value) => + RebuildSlots(ChassisSlots, value, _initialChassisModules); + + partial void OnLegDefinitionChanged(int value) => + RebuildSlots(LegSlots, value, _initialLegModules); + + private void RebuildSlots( + ObservableCollection sink, + int partDefinition, + List seedModules) + { + sink.Clear(); + if (partDefinition <= 0 || !_byDef.TryGetValue(partDefinition, out var part)) return; + + for (var i = 0; i < part.SlotFlags.Length; i++) + { + var slotIndex = i + 1; // 1-based + var slot = new RobotTemplateSlotViewModel(_byDef, _all) + { + SlotIndex = slotIndex, + SlotFlag = (uint)part.SlotFlags[i] // treat as unsigned bitmask + }; + slot.PopulateModulePicks(_all); + + // Pre-fill from seed modules: pick the seeded module whose slot index + // matches this slot. A user might have switched parts whose slot count + // differs — in that case slots beyond the seed are simply empty. + var seed = seedModules.FirstOrDefault(m => m.Slot == slotIndex); + if (seed != null) + { + slot.ModuleDefinition = seed.Definition; + slot.AmmoDefinition = seed.AmmoDefinition; + slot.AmmoQuantity = seed.AmmoQuantity; + } + + sink.Add(slot); + } + } + + public bool TrySerialize(out string error) + { + error = ""; + if (RobotDefinition <= 0) { error = "Robot definition is required."; return false; } + if (HeadDefinition <= 0) { error = "Head definition is required."; return false; } + if (ChassisDefinition <= 0) { error = "Chassis definition is required."; return false; } + if (LegDefinition <= 0) { error = "Leg definition is required."; return false; } + if (ContainerDefinition <= 0) { error = "Container definition is required."; return false; } + + var output = new Dictionary(StringComparer.Ordinal) + { + ["robot"] = RobotDefinition, + ["head"] = HeadDefinition, + ["chassis"] = ChassisDefinition, + ["leg"] = LegDefinition, + ["container"] = ContainerDefinition, + ["headModules"] = SerializeSlots(HeadSlots), + ["chassisModules"] = SerializeSlots(ChassisSlots), + ["legModules"] = SerializeSlots(LegSlots) + }; + + // Preserve unknown keys (cargo `items`, anything else). + foreach (var kv in _passthrough) + { + if (output.ContainsKey(kv.Key)) continue; + output[kv.Key] = kv.Value; + } + + ResultGenxy = GenxyConverter.Serialize(output); + return true; + } + + private static Dictionary SerializeSlots(IEnumerable slots) + { + var dict = new Dictionary(StringComparer.Ordinal); + var idx = 0; + foreach (var slot in slots) + { + if (slot.ModuleDefinition <= 0) continue; // skip empty slots + + var inner = new Dictionary(StringComparer.Ordinal) + { + ["definition"] = slot.ModuleDefinition, + ["slot"] = slot.SlotIndex + }; + + if (slot.AmmoDefinition > 0) + { + inner["ammoDefinition"] = slot.AmmoDefinition; + inner["ammoQuantity"] = slot.AmmoQuantity; + } + + dict[$"m{idx++}"] = inner; + } + return dict; + } + + private static List ReadModules(object? raw) + { + var result = new List(); + if (raw is not IDictionary outer) return result; + + foreach (var kv in outer) + { + if (kv.Value is not IDictionary m) continue; + result.Add(new ModuleData + { + Definition = ToInt(GetOrDefault(m, "definition")), + Slot = ToInt(GetOrDefault(m, "slot")), + AmmoDefinition = ToInt(GetOrDefault(m, "ammoDefinition")), + AmmoQuantity = ToInt(GetOrDefault(m, "ammoQuantity")) + }); + } + return result; + } + + private static object? GetOrDefault(IDictionary dict, string key) + => dict.TryGetValue(key, out var v) ? v : null; + + private static int ToInt(object? v) => v switch + { + null => 0, + int i => i, + long l => (int)l, + _ => 0 + }; + + private class ModuleData + { + public int Definition { get; init; } + public int Slot { get; init; } + public int AmmoDefinition { get; init; } + public int AmmoQuantity { get; init; } + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/RobotTemplateRelationsViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/RobotTemplateRelationsViewModel.cs new file mode 100644 index 00000000..b0b92e4e --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/RobotTemplateRelationsViewModel.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.Templates; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class RobotTemplateRelationsViewModel : ObservableObject + { + private readonly AppSettingsStore _settings; + private readonly ChangeQueue _queue; + private readonly LookupCache _lookups; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private string _filterText = ""; + [ObservableProperty] private RobotTemplateRelationRow? _selectedRow; + + public ObservableCollection Rows { get; } = new(); + public ICollectionView View { get; } + + // Pickers and name lookup come from the shared cache. + public LookupCache Lookups => _lookups; + + // Baseline for the diff, keyed by the row's *original* definition (the value at load + // time). Editing a row's Definition does not move it in this dict — that's how the + // resulting UPDATE knows which existing PK to target. + private Dictionary _originalByDefinition = new(); + + public RobotTemplateRelationsViewModel(AppSettingsStore settings, ChangeQueue queue, LookupCache lookups) + { + _settings = settings; + _queue = queue; + _lookups = lookups; + View = CollectionViewSource.GetDefaultView(Rows); + View.Filter = MatchesFilter; + } + + partial void OnFilterTextChanged(string value) => View.Refresh(); + + partial void OnSelectedRowChanged(RobotTemplateRelationRow? value) + { + if (value != null) + { + value.PropertyChanged -= OnRowPropertyChanged; + value.PropertyChanged += OnRowPropertyChanged; + } + } + + private void OnRowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not RobotTemplateRelationRow row) return; + if (e.PropertyName == nameof(RobotTemplateRelationRow.TemplateId)) + row.TemplateName = ResolveTemplateName(row.TemplateId); + else if (e.PropertyName == nameof(RobotTemplateRelationRow.Definition)) + row.DefinitionName = ResolveEntityName(row.Definition); + } + + private string ResolveEntityName(int definition) => + _lookups.EntityNamesByDefinition.TryGetValue(definition, out var n) ? n : ""; + + private string ResolveTemplateName(int templateId) => + _lookups.TemplateNamesById.TryGetValue(templateId, out var n) ? n : ""; + + private bool MatchesFilter(object obj) + { + if (obj is not RobotTemplateRelationRow row) return false; + if (string.IsNullOrWhiteSpace(FilterText)) return true; + var f = FilterText.Trim(); + if (int.TryParse(f, out var n)) + return row.Definition == n || row.TemplateId == n; + return row.DefinitionName.Contains(f, StringComparison.OrdinalIgnoreCase) + || row.TemplateName.Contains(f, StringComparison.OrdinalIgnoreCase); + } + + public async Task ReloadAsync() + { + IsLoading = true; + StatusMessage = "Loading robottemplaterelation..."; + StatusIsError = false; + try + { + // Refresh both lookup tables — relations point at both. + try { await _lookups.RefreshAllAsync(_settings.Settings.Connection); } + catch { /* non-fatal */ } + + var repo = new RobotTemplateRelationRepository(_settings.Settings.Connection); + var loaded = await repo.LoadAllAsync(); + + Rows.Clear(); + _originalByDefinition = new Dictionary(loaded.Count); + foreach (var r in loaded) + { + r.DefinitionName = ResolveEntityName(r.Definition); + r.TemplateName = ResolveTemplateName(r.TemplateId); + r.PropertyChanged -= OnRowPropertyChanged; + r.PropertyChanged += OnRowPropertyChanged; + Rows.Add(r); + _originalByDefinition[r.Original.Definition] = r.Original; + } + + SelectedRow = null; + StatusMessage = $"Loaded {Rows.Count} relation(s)."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + public bool TryAddNew( + int definition, int templateId, int itemScoreSum, int raceId, + int? missionLevel, int? missionLevelOverride, int? killEp, string? note, + out string error) + { + error = ""; + if (definition <= 0) + { + error = "Definition must be a positive integer (entitydefaults.definition)."; + return false; + } + if (templateId <= 0) + { + error = "Template id must be a positive integer (robottemplates.id)."; + return false; + } + if (Rows.Any(r => r.Definition == definition)) + { + error = $"A relation already exists for definition {definition}. " + + "PK is `definition` only — edit the existing row instead."; + return false; + } + if (!_lookups.EntityNamesByDefinition.ContainsKey(definition)) + { + error = $"Definition {definition} is not present in entitydefaults."; + return false; + } + if (!_lookups.TemplateNamesById.ContainsKey(templateId)) + { + error = $"Template id {templateId} is not present in robottemplates."; + return false; + } + + var row = RobotTemplateRelationRow.CreateNew( + definition, templateId, itemScoreSum, raceId, + missionLevel, missionLevelOverride, killEp, note); + row.DefinitionName = ResolveEntityName(definition); + row.TemplateName = ResolveTemplateName(templateId); + row.PropertyChanged -= OnRowPropertyChanged; + row.PropertyChanged += OnRowPropertyChanged; + Rows.Insert(0, row); + SelectedRow = row; + return true; + } + + public void RemoveSelected() + { + if (SelectedRow == null) return; + var row = SelectedRow; + SelectedRow = null; + Rows.Remove(row); + } + + public void SaveAll() + { + // Reject the save outright if two surviving rows would collide on the same PK. + // PK is `definition`, so two rows with the same current Definition must not be + // committed together — the underlying UPDATE/INSERT would violate the PK. + var collisions = Rows + .GroupBy(r => r.Definition) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + if (collisions.Count > 0) + { + StatusIsError = true; + StatusMessage = $"Two or more rows share definition(s): {string.Join(", ", collisions)}. " + + "Resolve before saving (PK is `definition`)."; + return; + } + + var changes = TemplateRelationChanges.ComputeBulkChanges(Rows, _originalByDefinition).ToList(); + if (changes.Count == 0) + { + StatusIsError = false; + StatusMessage = "No changes to save."; + return; + } + + foreach (var c in changes) _queue.Add(c); + + // Roll the baseline forward: edited / new rows now reflect the post-commit state. + // Re-key by current Definition because that's what the next save's WHERE clause should target. + var newOriginals = new Dictionary(Rows.Count); + foreach (var row in Rows) + { + row.IsNew = false; + row.RefreshOriginalFromCurrent(); + newOriginals[row.Original.Definition] = row.Original; + } + _originalByDefinition = newOriginals; + + var destructive = changes.Count(c => c.IsDestructive); + StatusIsError = false; + StatusMessage = destructive > 0 + ? $"Queued {changes.Count} change(s), {destructive} destructive. Use the main Commit button to apply." + : $"Queued {changes.Count} change(s). Use the main Commit button to apply."; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/RobotTemplateSlotViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/RobotTemplateSlotViewModel.cs new file mode 100644 index 00000000..0d9c25ac --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/RobotTemplateSlotViewModel.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Templates; + +namespace Perpetuum.AdminTool.ViewModels +{ + /// + /// One slot in a robot component. Holds the chosen module + (optional) ammo, + /// plus the pre-filtered candidate lists driven by `SlotFlag` and the chosen + /// module's `ammoCategoryFlags`. + /// + public partial class RobotTemplateSlotViewModel : ObservableObject + { + private readonly IReadOnlyDictionary _byDef; + + public int SlotIndex { get; init; } // 1-based + + /// + /// The slotFlags entry for this slot (a bitmask of `SlotFlags` enum values). + /// Used to filter modules: a module fits if `(module.ModuleFlag & SlotFlag) == module.ModuleFlag`. + /// + public long SlotFlag { get; init; } + + public string SlotFlagHex => "0x" + SlotFlag.ToString("X"); + + [ObservableProperty] private int _moduleDefinition; + [ObservableProperty] private int _ammoDefinition; + [ObservableProperty] private int _ammoQuantity; + [ObservableProperty] private bool _isAmmoable; + + public ObservableCollection ModulePicks { get; } = new(); + public ObservableCollection AmmoPicks { get; } = new(); + + // Specialized slots only accept specialized modules. + // SlotFlags.specialized = 11 → bit value 1 << 11 = 0x800. + private const long SpecializedFlag = 1L << 11; + + public RobotTemplateSlotViewModel( + IReadOnlyDictionary byDef, + IEnumerable allEntities) + { + _byDef = byDef; + + // Filter rule mirrors Perpetuum/Robots/RobotComponent.IsValidSlotTo: + // (moduleFlag & slotFlag) == moduleFlag + // AND (slot is non-specialized OR module is specialized) + // We don't apply it here yet because SlotFlag isn't set in the ctor — + // the ItemsSource binding seed runs after the property setter, so we + // populate ModulePicks from PopulateModulePicks() once after ctor. + } + + public void PopulateModulePicks(IEnumerable allEntities) + { + var slotIsSpecialized = (SlotFlag & SpecializedFlag) == SpecializedFlag; + + ModulePicks.Clear(); + foreach (var e in allEntities) + { + if (!e.Enabled) continue; + if (e.ModuleFlag == 0) continue; // not a module + if ((e.ModuleFlag & SlotFlag) != e.ModuleFlag) continue; + if (slotIsSpecialized && (e.ModuleFlag & SpecializedFlag) != SpecializedFlag) continue; + ModulePicks.Add(e); + } + } + + partial void OnModuleDefinitionChanged(int value) + { + RebuildAmmoPicks(); + } + + private void RebuildAmmoPicks() + { + AmmoPicks.Clear(); + IsAmmoable = false; + + if (ModuleDefinition <= 0 || + !_byDef.TryGetValue(ModuleDefinition, out var module) || + !module.IsAmmoable) + { + if (AmmoDefinition != 0) AmmoDefinition = 0; + if (AmmoQuantity != 0) AmmoQuantity = 0; + return; + } + + IsAmmoable = true; + + // Hierarchical filter: include candidates whose categoryflags is the + // module's options[ammoType] or any descendant in the categoryflags tree. + // Mirrors Perpetuum.CategoryFlagsExtensions.IsCategory: + // mask = bytes up through highest non-zero byte of `target` + // accept if (source & mask) == target + // (We re-implement the math here instead of calling into the server's + // extension — its static ctor binds to the Db facade we don't initialize.) + var target = module.AmmoType; + if (target == 0) return; + + var mask = CategoryFlagsMask(target); + foreach (var e in _byDef.Values.OrderBy(e => e.Name, System.StringComparer.OrdinalIgnoreCase)) + { + if (!e.Enabled) continue; + if (e.CategoryFlags == 0) continue; + if ((e.CategoryFlags & mask) != target) continue; + AmmoPicks.Add(e); + } + + // If the previously selected ammo no longer fits, clear it. + if (AmmoDefinition != 0 && !AmmoPicks.Any(a => a.Definition == AmmoDefinition)) + { + AmmoDefinition = 0; + AmmoQuantity = 0; + } + } + + // Mirror of Perpetuum.CategoryFlagsExtensions.GetCategoryFlagsMask, adapted + // to operate on long. Keeps shifting an all-ones mask up by 8 bits until the + // target's bits all sit below it, then returns the inverted mask — i.e. an + // all-ones value covering the bytes from the lowest up through the highest + // non-zero byte of `target`. + private static long CategoryFlagsMask(long target) + { + var mask = unchecked((long)0xFFFFFFFFFFFFFFFFUL); + while (((ulong)target & (ulong)mask) > 0) + { + mask <<= 8; + } + return ~mask; + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/RobotTemplatesViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/RobotTemplatesViewModel.cs new file mode 100644 index 00000000..a76f5d14 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/RobotTemplatesViewModel.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Export; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.Templates; +using Perpetuum.AdminTool.Views; +using Perpetuum.GenXY; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class RobotTemplatesViewModel : ObservableObject + { + private readonly AppSettingsStore _settings; + private readonly ChangeQueue _queue; + private readonly LookupCache _lookups; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private string _filterText = ""; + [ObservableProperty] private RobotTemplateRow? _selectedRow; + + public ObservableCollection AllRows { get; } = new(); + public ICollectionView View { get; } + + public RobotTemplatesViewModel(AppSettingsStore settings, ChangeQueue queue, LookupCache lookups) + { + _settings = settings; + _queue = queue; + _lookups = lookups; + View = CollectionViewSource.GetDefaultView(AllRows); + View.Filter = MatchesFilter; + } + + partial void OnFilterTextChanged(string value) => View.Refresh(); + + private bool MatchesFilter(object obj) + { + if (obj is not RobotTemplateRow row) return false; + if (string.IsNullOrWhiteSpace(FilterText)) return true; + var f = FilterText.Trim(); + if (int.TryParse(f, out var id) && row.Id == id) return true; + return row.Name.Contains(f, StringComparison.OrdinalIgnoreCase); + } + + public async Task ReloadAsync() + { + IsLoading = true; + StatusMessage = "Loading robot templates..."; + StatusIsError = false; + try + { + var repo = new RobotTemplateRepository(_settings.Settings.Connection); + var rows = await repo.LoadAllAsync(); + AllRows.Clear(); + foreach (var r in rows) AllRows.Add(r); + SelectedRow = null; + + // Push fresh template list into the shared cache so the Template-relations + // tab's dropdown reflects new/renamed templates. + try { await _lookups.RefreshTemplatesAsync(_settings.Settings.Connection); } + catch { /* non-fatal */ } + + StatusMessage = $"Loaded {AllRows.Count} template(s)."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + public Task> LoadEditorEntitiesAsync() + { + var repo = new RobotTemplateEditorRepository(_settings.Settings.Connection); + return repo.LoadAllAsync(); + } + + public bool TryAddNew(string name, out string error) + { + error = ""; + if (string.IsNullOrWhiteSpace(name)) + { + error = "Template name is required."; + return false; + } + var trimmed = name.Trim(); + var row = RobotTemplateRow.CreateNew(trimmed); + AllRows.Insert(0, row); + SelectedRow = row; + return true; + } + + public void RemoveRow(RobotTemplateRow row) + { + if (row == null) return; + if (ReferenceEquals(SelectedRow, row)) SelectedRow = null; + AllRows.Remove(row); + } + + public void Save() + { + if (SelectedRow == null) return; + var row = SelectedRow; + + if (row.IsNew && row.IsQueued) + { + StatusIsError = true; + StatusMessage = "Template queued for INSERT. Reload after Commit to continue editing."; + return; + } + + var changes = TemplateChanges.ComputeChanges(row).ToList(); + if (changes.Count == 0) + { + StatusIsError = false; + StatusMessage = "No changes to save."; + return; + } + foreach (var c in changes) _queue.Add(c); + + if (row.IsNew) + { + row.IsQueued = true; + StatusIsError = false; + StatusMessage = "Queued INSERT. Reload after Commit to refresh the assigned id."; + return; + } + + row.RefreshOriginalFromCurrent(); + StatusIsError = false; + StatusMessage = $"Queued {changes.Count} change(s). Use the main Commit button to apply."; + } + + public void Discard() + { + if (SelectedRow == null) return; + SelectedRow.ApplySnapshot(SelectedRow.Original); + StatusIsError = false; + StatusMessage = "Reverted to last-saved state."; + } + + public System.Collections.Generic.IReadOnlyList EnqueueDelete() + { + if (SelectedRow == null) return Array.Empty(); + if (SelectedRow.IsNew) return Array.Empty(); + var changes = TemplateChanges.ComputeDeleteChanges(SelectedRow).ToList(); + foreach (var c in changes) _queue.Add(c); + return changes; + } + + [RelayCommand(CanExecute = nameof(CanExport))] + private async Task ExportTemplateAsync() + { + if (SelectedRow == null) return; + IsLoading = true; + StatusMessage = "Generating export script..."; + StatusIsError = false; + try + { + var script = await RobotExporter.ExportAsync(SelectedRow.Id, _settings.Settings.Connection); + var vm = new ExportScriptViewModel($"Robot: {SelectedRow.Name}", script); + var win = new ExportScriptWindow(vm) { Owner = Application.Current?.MainWindow }; + win.ShowDialog(); + StatusMessage = "Export complete."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Export failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + private bool CanExport() => SelectedRow != null && !IsLoading; + + partial void OnSelectedRowChanged(RobotTemplateRow? value) => ExportTemplateCommand.NotifyCanExecuteChanged(); + + partial void OnIsLoadingChanged(bool _) => ExportTemplateCommand.NotifyCanExecuteChanged(); + + public void ValidateGenxy() + { + if (SelectedRow == null) return; + var raw = SelectedRow.Description ?? ""; + if (string.IsNullOrEmpty(raw)) + { + StatusIsError = false; + StatusMessage = "Description is empty (will store empty string)."; + return; + } + try + { + var dict = GenxyConverter.Deserialize(raw); + var roundTrip = GenxyConverter.Serialize(dict); + StatusIsError = false; + StatusMessage = $"Genxy parses OK. {dict.Count} top-level key(s). " + + (roundTrip == raw ? "Round-trip is byte-identical." : "Round-trip differs (will be re-serialized on save)."); + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Genxy parse error: {ex.Message}"; + } + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/SeasonDetailViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/SeasonDetailViewModel.cs new file mode 100644 index 00000000..b61c08d8 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/SeasonDetailViewModel.cs @@ -0,0 +1,588 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Export; +using Perpetuum.AdminTool.Views; +using Perpetuum.AdminTool.EquipmentSets; +using Perpetuum.AdminTool.Packages; +using Perpetuum.AdminTool.Seasons; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.Translations; +using Perpetuum.ExportedTypes; +using Perpetuum.Services.Seasons; +using SeasonRepository = Perpetuum.AdminTool.Seasons.SeasonRepository; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class SeasonDetailViewModel : ObservableObject + { + private readonly SeasonRepository _repo; + private readonly PackageRepository _pkgRepo; + private readonly ChangeQueue _queue; + private readonly LookupCache _cache; + private readonly ConnectionSettings _connection; + private readonly TranslationsViewModel? _translations; + private IReadOnlyList _oreAndLiquidMaterials = Array.Empty(); + private IReadOnlyList _organicMaterials = Array.Empty(); + + [ObservableProperty] private SeasonRow _season; + [ObservableProperty] private int _selectedTabIndex; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + public ObservableCollection ActivityRates { get; } = new(); + public ObservableCollection Objectives { get; } = new(); + public ObservableCollection Tiers { get; } = new(); + public ObservableCollection LeaderboardRewards { get; } = new(); + + public ObservableCollection Packages { get; } + public IReadOnlyList EquipmentSets { get; private set; } = + Array.Empty(); + public PackagesViewModel PackagesVm { get; } + public SeasonStatisticsViewModel StatisticsVm { get; } + + public IReadOnlyList ActivityTypeOptions { get; } = + new[] + { + new ActivityTypeOption(SeasonActivityType.NpcKill, "NPC Kill"), + new ActivityTypeOption(SeasonActivityType.PvpKill, "PvP Kill"), + new ActivityTypeOption(SeasonActivityType.MissionComplete, "Mission Complete"), + new ActivityTypeOption(SeasonActivityType.MineralMined, "Mineral Mined"), + new ActivityTypeOption(SeasonActivityType.EpSpent, "EP Spent"), + new ActivityTypeOption(SeasonActivityType.EpEarned, "EP Earned"), + new ActivityTypeOption(SeasonActivityType.NicEarned, "NIC Earned"), + new ActivityTypeOption(SeasonActivityType.NicSpent, "NIC Spent"), + new ActivityTypeOption(SeasonActivityType.IntrusionPoint, "Intrusion Point"), + new ActivityTypeOption(SeasonActivityType.PlantHarvested, "Plant Harvested"), + new ActivityTypeOption(SeasonActivityType.Prototyping, "Prototyping"), + new ActivityTypeOption(SeasonActivityType.ReverseEngineering, "Reverse Engineering"), + new ActivityTypeOption(SeasonActivityType.Production, "Production"), + new ActivityTypeOption(SeasonActivityType.ArtifactFound, "Artifact Found"), + new ActivityTypeOption(SeasonActivityType.DamageDone, "Damage Done"), + new ActivityTypeOption(SeasonActivityType.DamageReceived, "Damage Received"), + new ActivityTypeOption(SeasonActivityType.ArmorRestored, "Armor Restored"), + new ActivityTypeOption(SeasonActivityType.EnergyDrainDealt, "Energy Drain Dealt"), + new ActivityTypeOption(SeasonActivityType.EnergyDrainReceived, "Energy Drain Received"), + new ActivityTypeOption(SeasonActivityType.EnergyTransferDealt, "Energy Transfer Dealt"), + new ActivityTypeOption(SeasonActivityType.EnergyTransferReceived, "Energy Transfer Received"), + }; + + public IReadOnlyList ObjectiveFilterOptions { get; } = new[] + { + new ObjectiveFilterOption(ObjectiveFilterMode.All, "All"), + new ObjectiveFilterOption(ObjectiveFilterMode.OneTime, "One-time only"), + new ObjectiveFilterOption(ObjectiveFilterMode.Daily, "Daily only"), + }; + + public IReadOnlyList ScoringModeOptions { get; } = new[] + { + new ScoringModeOption(SeasonScoringMode.ActivityAndGlobal, "Activity + Global Score"), + new ScoringModeOption(SeasonScoringMode.ObjectivesOnly, "Objectives Only"), + }; + + [ObservableProperty] + private ObjectiveFilterMode _objectiveFilter = ObjectiveFilterMode.All; + + partial void OnObjectiveFilterChanged(ObjectiveFilterMode value) => + OnPropertyChanged(nameof(FilteredObjectives)); + + public IEnumerable FilteredObjectives => ObjectiveFilter switch + { + ObjectiveFilterMode.OneTime => Objectives.Where(o => !o.IsDaily), + ObjectiveFilterMode.Daily => Objectives.Where(o => o.IsDaily), + _ => Objectives, + }; + + public bool CanActivate => !Season.IsActive; + public bool CanDeactivate => Season.IsActive; + public string StatusBadge => Season.CardState switch + { + SeasonCardState.Active => "ACTIVE", + SeasonCardState.Draft => "DRAFT", + SeasonCardState.Ended => "ENDED", + _ => "" + }; + + public SeasonDetailViewModel( + SeasonRow season, + SeasonRepository repo, + PackageRepository pkgRepo, + ChangeQueue queue, + PackagesViewModel packagesVm, + SeasonStatisticsViewModel statsVm, + LookupCache cache, + ConnectionSettings connection, + ObservableCollection packages, + TranslationsViewModel? translations = null) + { + _season = season; + _repo = repo; + _pkgRepo = pkgRepo; + _queue = queue; + _cache = cache; + _connection = connection; + PackagesVm = packagesVm; + StatisticsVm = statsVm; + Packages = packages; + _translations = translations; + Objectives.CollectionChanged += (_, e) => + { + if (e.NewItems != null) + foreach (SeasonObjectiveRow r in e.NewItems) + r.PropertyChanged += OnObjectivePropertyChanged; + if (e.OldItems != null) + foreach (SeasonObjectiveRow r in e.OldItems) + r.PropertyChanged -= OnObjectivePropertyChanged; + OnPropertyChanged(nameof(FilteredObjectives)); + }; + } + + private void OnObjectivePropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SeasonObjectiveRow.IsDaily)) + OnPropertyChanged(nameof(FilteredObjectives)); + } + + private void BuildMaterialLists(TranslationsViewModel? translations) + { + const int EnglishLangId = 0; + Dictionary? englishNames = null; + if (translations?.Store?.Rows != null) + { + englishNames = translations.Store.Rows + .GroupBy(r => r.Key) + .ToDictionary(g => g.Key, g => g.First()[EnglishLangId]); + } + + var oreAndLiquid = new List(); + var organic = new List(); + + foreach (var e in _cache.Entities) + { + if (!e.Enabled || e.Hidden) continue; + + var displayName = (englishNames != null && + englishNames.TryGetValue(e.Name, out var eng) && + !string.IsNullOrEmpty(eng)) + ? eng : e.Name; + + if (IsCategoryMatch(e.CategoryFlags, (long)CategoryFlags.cf_ore) || + IsCategoryMatch(e.CategoryFlags, (long)CategoryFlags.cf_liquid)) + oreAndLiquid.Add(new MaterialPickItem(e.Definition, displayName)); + else if (IsCategoryMatch(e.CategoryFlags, (long)CategoryFlags.cf_organic)) + organic.Add(new MaterialPickItem(e.Definition, displayName)); + } + + _oreAndLiquidMaterials = oreAndLiquid + .OrderBy(m => m.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + _organicMaterials = organic + .OrderBy(m => m.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool IsCategoryMatch(long entityFlags, long category) + { + var mask = PackageItemPickItem.CategoryFlagsMask(category); + return (entityFlags & mask) == category; + } + + public async Task LoadAsync() + { + try + { + BuildMaterialLists(_translations); + EquipmentSets = await _repo.LoadEquipmentSetsAsync(); + OnPropertyChanged(nameof(EquipmentSets)); + StatusIsError = false; + StatusMessage = "Loading season detail..."; + + var dbRates = Season.Id > 0 + ? await _repo.LoadActivityRatesAsync(Season.Id) + : new List(); + var dbByType = dbRates.ToDictionary(r => r.ActivityType, r => r); + + ActivityRates.Clear(); + foreach (SeasonActivityType type in Enum.GetValues(typeof(SeasonActivityType))) + { + if (dbByType.TryGetValue(type, out var existing)) + { + ActivityRates.Add(existing); + } + else + { + ActivityRates.Add(new SeasonActivityRateRow + { + Id = 0, + SeasonId = Season.Id, + ActivityType = type, + PointsPerUnit = 0, + UnitScale = 1 + }); + } + } + + Objectives.Clear(); + if (Season.Id > 0) + foreach (var o in await _repo.LoadObjectivesAsync(Season.Id)) + { + if (o.PackageId.HasValue) + o.SelectedPackage = Packages.FirstOrDefault(p => p.Id == o.PackageId); + if (o.EquipmentSetId.HasValue) + o.SelectedEquipmentSet = EquipmentSets.FirstOrDefault(s => s.SetId == o.EquipmentSetId); + o.InitializeMaterialLists(_oreAndLiquidMaterials, _organicMaterials); + Objectives.Add(o); + } + + Tiers.Clear(); + if (Season.Id > 0) + foreach (var t in await _repo.LoadTiersAsync(Season.Id)) + { + if (t.PackageId.HasValue) + t.SelectedPackage = Packages.FirstOrDefault(p => p.Id == t.PackageId); + if (t.EquipmentSetId.HasValue) + t.SelectedEquipmentSet = EquipmentSets.FirstOrDefault(s => s.SetId == t.EquipmentSetId); + Tiers.Add(t); + } + + LeaderboardRewards.Clear(); + if (Season.Id > 0) + foreach (var l in await _repo.LoadLeaderboardRewardsAsync(Season.Id)) + { + if (l.PackageId.HasValue) + l.SelectedPackage = Packages.FirstOrDefault(p => p.Id == l.PackageId); + if (l.EquipmentSetId.HasValue) + l.SelectedEquipmentSet = EquipmentSets.FirstOrDefault(s => s.SetId == l.EquipmentSetId); + LeaderboardRewards.Add(l); + } + + OnPropertyChanged(nameof(CanActivate)); + OnPropertyChanged(nameof(CanDeactivate)); + OnPropertyChanged(nameof(StatusBadge)); + StatusMessage = $"Loaded season '{Season.Name}'."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + } + + partial void OnSelectedTabIndexChanged(int value) + { + if (value == 6 && Season.Id > 0) + _ = StatisticsVm.LoadAsync(Season.Id); + } + + [RelayCommand] + private void Activate() + { + if (Season.Id <= 0) + { + MessageBox.Show("Season is unsaved. Commit the queue first.", + "Cannot activate", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + var ok = MessageBox.Show( + $"Activate season '{Season.Name}'? This queues an UPDATE seasons SET is_active = 1.", + "Activate season", MessageBoxButton.YesNo, MessageBoxImage.Question); + if (ok != MessageBoxResult.Yes) return; + + _queue.Add(SeasonChanges.BuildActivate(Season.Id)); + Season.IsActive = true; + OnPropertyChanged(nameof(CanActivate)); + OnPropertyChanged(nameof(CanDeactivate)); + OnPropertyChanged(nameof(StatusBadge)); + StatusIsError = false; + StatusMessage = "Queued ACTIVATE. Use the main Commit button to apply."; + } + + [RelayCommand] + private void Deactivate() + { + if (Season.Id <= 0) return; + var ok = MessageBox.Show( + $"Deactivate season '{Season.Name}'?", + "Deactivate season", MessageBoxButton.YesNo, MessageBoxImage.Question); + if (ok != MessageBoxResult.Yes) return; + + _queue.Add(SeasonChanges.BuildDeactivate(Season.Id)); + Season.IsActive = false; + OnPropertyChanged(nameof(CanActivate)); + OnPropertyChanged(nameof(CanDeactivate)); + OnPropertyChanged(nameof(StatusBadge)); + StatusIsError = false; + StatusMessage = "Queued DEACTIVATE."; + } + + [RelayCommand] + private void SaveGeneral() + { + if (Season.Id <= 0) + { + _queue.Add(SeasonChanges.BuildInsert(Season)); + StatusMessage = "Queued INSERT for new season. After commit + reload, edit detail tabs."; + } + else + { + _queue.Add(SeasonChanges.BuildUpdate(Season)); + StatusMessage = "Queued UPDATE for season general fields."; + } + StatusIsError = false; + } + + [RelayCommand] + private void QueueActivityRateSave(SeasonActivityRateRow? row) + { + if (row == null) return; + if (Season.Id <= 0) + { + MessageBox.Show("Save the season (General tab) first.", "Season unsaved", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + row.SeasonId = Season.Id; + _queue.Add(SeasonChanges.BuildUpsertActivityRate(row)); + StatusIsError = false; + StatusMessage = $"Queued upsert for activity '{row.ActivityType}'."; + } + + [RelayCommand] + private void AddObjective() + { + if (Season.Id <= 0) + { + MessageBox.Show("Save the season (General tab) first.", "Season unsaved", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + var row = new SeasonObjectiveRow + { + SeasonId = Season.Id, + Name = "New Objective", + Description = "", + ActivityType = SeasonActivityType.NpcKill, + TargetValue = 1, + BonusPoints = 0, + DisplayOrder = Objectives.Count, + IsNew = true, + IsDaily = ObjectiveFilter == ObjectiveFilterMode.Daily, + }; + row.InitializeMaterialLists(_oreAndLiquidMaterials, _organicMaterials); + Objectives.Add(row); + StatusIsError = false; + StatusMessage = "Added objective row. Edit fields, then click 'Queue Save' on the row."; + } + + [RelayCommand] + private void RemoveObjective(SeasonObjectiveRow? row) + { + if (row == null) return; + var ok = MessageBox.Show($"Remove objective '{row.Name}'?", + "Remove objective", MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (ok != MessageBoxResult.Yes) return; + + if (row.Id > 0) _queue.Add(SeasonChanges.BuildDeleteObjective(row)); + Objectives.Remove(row); + StatusIsError = false; + StatusMessage = row.Id > 0 + ? $"Queued DELETE for objective id {row.Id}." + : "Removed unsaved objective."; + } + + [RelayCommand] + private void QueueSaveObjective(SeasonObjectiveRow? row) + { + if (row == null) return; + if (Season.Id <= 0) + { + MessageBox.Show("Save the season (General tab) first.", "Season unsaved", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + row.SeasonId = Season.Id; + if (row.Id == 0) + { + _queue.Add(SeasonChanges.BuildInsertObjective(row)); + StatusMessage = $"Queued INSERT for objective '{row.Name}'."; + } + else + { + _queue.Add(SeasonChanges.BuildUpdateObjective(row)); + StatusMessage = $"Queued UPDATE for objective '{row.Name}'."; + } + StatusIsError = false; + } + + [RelayCommand] + private void AddTier() + { + if (Season.Id <= 0) + { + MessageBox.Show("Save the season (General tab) first.", "Season unsaved", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + var row = new SeasonTierRow + { + SeasonId = Season.Id, + TierNumber = Tiers.Count + 1, + TierName = $"Tier {Tiers.Count + 1}", + PointsRequired = (Tiers.Count + 1) * 1000, + IsNew = true + }; + Tiers.Add(row); + StatusIsError = false; + StatusMessage = "Added tier row. Set a Package or Equipment Set reward, then click 'Queue Save'."; + } + + [RelayCommand] + private void RemoveTier(SeasonTierRow? row) + { + if (row == null) return; + var ok = MessageBox.Show($"Remove tier '{row.TierName}'?", + "Remove tier", MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (ok != MessageBoxResult.Yes) return; + + if (row.Id > 0) _queue.Add(SeasonChanges.BuildDeleteTier(row)); + Tiers.Remove(row); + StatusIsError = false; + StatusMessage = row.Id > 0 + ? $"Queued DELETE for tier id {row.Id}." + : "Removed unsaved tier."; + } + + [RelayCommand] + private void QueueSaveTier(SeasonTierRow? row) + { + if (row == null) return; + if (Season.Id <= 0) + { + MessageBox.Show("Save the season (General tab) first.", "Season unsaved", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + row.SeasonId = Season.Id; + if (row.Id == 0) + { + _queue.Add(SeasonChanges.BuildInsertTier(row)); + StatusMessage = $"Queued INSERT for tier '{row.TierName}'."; + } + else + { + _queue.Add(SeasonChanges.BuildUpdateTier(row)); + StatusMessage = $"Queued UPDATE for tier '{row.TierName}'."; + } + StatusIsError = false; + } + + [RelayCommand] + private void AddLeaderboardReward() + { + if (Season.Id <= 0) + { + MessageBox.Show("Save the season (General tab) first.", "Season unsaved", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + var row = new SeasonLeaderboardRewardRow + { + SeasonId = Season.Id, + RankMin = 1, + RankMax = 1, + IsNew = true + }; + LeaderboardRewards.Add(row); + StatusIsError = false; + StatusMessage = "Added leaderboard reward row. Set ranks and a reward, then click 'Queue Save'."; + } + + [RelayCommand] + private void QueueSaveLeaderboardReward(SeasonLeaderboardRewardRow? row) + { + if (row == null) return; + if (Season.Id <= 0) + { + MessageBox.Show("Save the season (General tab) first.", "Season unsaved", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + if (row.RankMin > row.RankMax) + { + StatusIsError = true; + StatusMessage = $"Invalid rank range: min ({row.RankMin}) must not be greater than max ({row.RankMax})."; + return; + } + row.SeasonId = Season.Id; + if (row.Id == 0) + { + _queue.Add(SeasonChanges.BuildInsertLeaderboardReward(row)); + StatusMessage = $"Queued INSERT for leaderboard reward (ranks {row.RankMin}-{row.RankMax})."; + } + else + { + _queue.Add(SeasonChanges.BuildUpdateLeaderboardReward(row)); + StatusMessage = $"Queued UPDATE for leaderboard reward id {row.Id}."; + } + StatusIsError = false; + } + + [RelayCommand] + private void RemoveLeaderboardReward(SeasonLeaderboardRewardRow? row) + { + if (row == null) return; + var ok = MessageBox.Show($"Remove leaderboard reward (ranks {row.RankMin}-{row.RankMax})?", + "Remove leaderboard reward", MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (ok != MessageBoxResult.Yes) return; + + if (row.Id > 0) _queue.Add(SeasonChanges.BuildDeleteLeaderboardReward(row)); + LeaderboardRewards.Remove(row); + StatusIsError = false; + StatusMessage = row.Id > 0 + ? $"Queued DELETE for leaderboard reward id {row.Id}." + : "Removed unsaved leaderboard reward."; + } + + [ObservableProperty] private bool _isExporting; + partial void OnIsExportingChanged(bool _) => ExportCommand.NotifyCanExecuteChanged(); + + [RelayCommand(CanExecute = nameof(CanExport))] + private async Task ExportAsync() + { + IsExporting = true; + StatusMessage = "Generating export script..."; + StatusIsError = false; + try + { + var script = await SeasonExporter.ExportAsync(Season.Id, _connection); + var vm = new ExportScriptViewModel($"Season: {Season.Name}", script); + var win = new ExportScriptWindow(vm) { Owner = System.Windows.Application.Current?.MainWindow }; + win.ShowDialog(); + StatusMessage = "Export complete."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Export failed: {ex.Message}"; + } + finally + { + IsExporting = false; + } + } + + private bool CanExport() => !IsExporting; + } + + public record ActivityTypeOption(SeasonActivityType Value, string Label); + public enum ObjectiveFilterMode { All, OneTime, Daily } + public record ObjectiveFilterOption(ObjectiveFilterMode Value, string Label); + public record ScoringModeOption(SeasonScoringMode Value, string Label); +} diff --git a/src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs new file mode 100644 index 00000000..6624bf58 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/SeasonStatisticsViewModel.cs @@ -0,0 +1,63 @@ +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Seasons; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class SeasonStatisticsViewModel : ObservableObject + { + private readonly SeasonRepository _repo; + private int _seasonId; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private int _totalParticipants; + [ObservableProperty] private int _activeLast7Days; + [ObservableProperty] private double _avgPointsPerDay; + [ObservableProperty] private string _retentionRate = "—"; + + public ObservableCollection TierDistribution { get; } = new(); + public ObservableCollection Top10 { get; } = new(); + public ObservableCollection ObjectiveCompletion { get; } = new(); + public ObservableCollection TodaysDailyObjectives { get; } = new(); + [ObservableProperty] private bool _hasTodaysDailyObjectives; + + public SeasonStatisticsViewModel(SeasonRepository repo) + { + _repo = repo; + } + + public async Task LoadAsync(int seasonId) + { + _seasonId = seasonId; + IsLoading = true; + try + { + TotalParticipants = await _repo.LoadParticipantCountAsync(seasonId); + ActiveLast7Days = await _repo.LoadActiveLast7DaysAsync(seasonId); + RetentionRate = TotalParticipants == 0 ? "—" + : $"{(ActiveLast7Days * 100.0 / TotalParticipants):F1}%"; + AvgPointsPerDay = await _repo.LoadAvgPointsPerDayAsync(seasonId); + + TierDistribution.Clear(); + foreach (var r in await _repo.LoadTierDistributionAsync(seasonId)) TierDistribution.Add(r); + + Top10.Clear(); + foreach (var r in await _repo.LoadTop10LeaderboardAsync(seasonId)) Top10.Add(r); + + ObjectiveCompletion.Clear(); + foreach (var r in await _repo.LoadObjectiveCompletionAsync(seasonId)) ObjectiveCompletion.Add(r); + + TodaysDailyObjectives.Clear(); + foreach (var r in await _repo.LoadTodaysDailyObjectivesAsync(seasonId)) + TodaysDailyObjectives.Add(r); + HasTodaysDailyObjectives = TodaysDailyObjectives.Count > 0; + } + finally { IsLoading = false; } + } + + [RelayCommand] + private async Task Refresh() => await LoadAsync(_seasonId); + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs new file mode 100644 index 00000000..471eae96 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/SeasonWizardViewModel.cs @@ -0,0 +1,392 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Packages; +using Perpetuum.AdminTool.Seasons; +using Perpetuum.Services.Seasons; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class SeasonWizardViewModel : ObservableObject + { + private readonly ChangeQueue _queue; + private readonly ObservableCollection _packages; + private readonly Action _onComplete; + + [ObservableProperty] private int _currentStep = 1; + + public bool IsStep1 => CurrentStep == 1; + public bool IsStep2 => CurrentStep == 2; + public bool IsStep3 => CurrentStep == 3; + public bool IsStep4 => CurrentStep == 4; + public bool IsStep5 => CurrentStep == 5; + public bool IsReviewStep => CurrentStep == 6; + + public bool CanGoBack => CurrentStep > 1; + public bool CanGoNext => CurrentStep < 6; + + public string StepTitle => CurrentStep switch + { + 1 => "Step 1 of 6 — Season Info", + 2 => "Step 2 of 6 — Activity Rates", + 3 => "Step 3 of 6 — Objectives (optional)", + 4 => "Step 4 of 6 — Tiers (optional)", + 5 => "Step 5 of 6 — Leaderboard Rewards (optional)", + 6 => "Step 6 of 6 — Review", + _ => "" + }; + + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _description = ""; + [ObservableProperty] private DateTime _startTime = DateTime.UtcNow.Date; + [ObservableProperty] private DateTime _endTime = DateTime.UtcNow.Date.AddDays(30); + [ObservableProperty] private string _startTimeText = "00:00"; + [ObservableProperty] private string _endTimeText = "00:00"; + [ObservableProperty] private bool _isRecurring; + [ObservableProperty] private int _recurrenceGapDays = 7; + [ObservableProperty] private SeasonScoringMode _scoringMode = SeasonScoringMode.ActivityAndGlobal; + + public IReadOnlyList ScoringModeOptions { get; } = new[] + { + new ScoringModeOption(SeasonScoringMode.ActivityAndGlobal, "Activity + Global Score"), + new ScoringModeOption(SeasonScoringMode.ObjectivesOnly, "Objectives Only"), + }; + + public ObservableCollection ActivityRates { get; } = new(); + public ObservableCollection Objectives { get; } = new(); + public ObservableCollection Tiers { get; } = new(); + public ObservableCollection LeaderboardRewards { get; } = new(); + + public ObservableCollection Packages => _packages; + public bool HasPackages => _packages.Count > 0; + + // Fix 2: only show activity types that have been enabled (PointsPerUnit > 0) in Step 2 + public IReadOnlyList ActiveObjectiveActivityTypeOptions => + ActivityRates + .Where(r => r.PointsPerUnit > 0) + .Select(r => new ActivityTypeOption(r.ActivityType, r.ActivityTypeLabel)) + .ToList(); + + // Improvements 8 & 9 + public int TotalObjectiveBonusPoints => Objectives.Sum(o => o.BonusPoints); + public int MaxTierPoints => Tiers.Count > 0 ? Tiers.Max(t => t.PointsRequired) : 0; + public bool HasObjectives => Objectives.Count > 0; + public bool HasTiers => Tiers.Count > 0; + public bool HasLeaderboardRewards => LeaderboardRewards.Count > 0; + + // Improvement 2: review section computed lines + public IReadOnlyList ReviewActiveRates => + ActivityRates.Where(r => r.PointsPerUnit > 0) + .Select(r => $" • {r.ActivityTypeLabel}: {r.EffectiveRate}") + .ToList(); + public bool HasActiveRates => ReviewActiveRates.Count > 0; + public string ReviewObjectivesHeader => Objectives.Count == 0 ? "Objectives: none" + : $"Objectives ({Objectives.Count}, {TotalObjectiveBonusPoints} bonus pts total):"; + public IReadOnlyList ReviewObjectiveLines => + Objectives.Select(o => + { + var typeName = ObjectiveActivityTypeOptions.FirstOrDefault(x => x.Value == o.ActivityType)?.Label ?? o.ActivityType.ToString(); + return $" • {o.Name} — {typeName}: {o.TargetValue:N0} → +{o.BonusPoints} pts"; + }).ToList(); + public string ReviewTiersHeader => Tiers.Count == 0 ? "Tiers: none" : $"Tiers ({Tiers.Count}):"; + public IReadOnlyList ReviewTierLines => + Tiers.Select(t => $" • {t.TierName}: {t.PointsRequired:N0} pts → {t.SelectedPackage?.Name ?? $"pkg {t.PackageId}"}").ToList(); + public string ReviewLeaderboardHeader => LeaderboardRewards.Count == 0 ? "Leaderboard Rewards: none" + : $"Leaderboard Rewards ({LeaderboardRewards.Count}):"; + public IReadOnlyList ReviewLeaderboardLines => + LeaderboardRewards.Select(l => $" • Rank {l.RankMin}–{l.RankMax}: {l.SelectedPackage?.Name ?? $"pkg {l.PackageId}"}").ToList(); + + public string ReviewScoringMode => ScoringMode switch + { + SeasonScoringMode.ObjectivesOnly => "Objectives Only", + _ => "Activity + Global Score", + }; + + public IReadOnlyList ObjectiveActivityTypeOptions { get; } = + new[] + { + new ActivityTypeOption(SeasonActivityType.NpcKill, "NPC Kill"), + new ActivityTypeOption(SeasonActivityType.PvpKill, "PvP Kill"), + new ActivityTypeOption(SeasonActivityType.MissionComplete, "Mission Complete"), + new ActivityTypeOption(SeasonActivityType.MineralMined, "Mineral Mined"), + new ActivityTypeOption(SeasonActivityType.EpSpent, "EP Spent"), + new ActivityTypeOption(SeasonActivityType.EpEarned, "EP Earned"), + new ActivityTypeOption(SeasonActivityType.NicEarned, "NIC Earned"), + new ActivityTypeOption(SeasonActivityType.NicSpent, "NIC Spent"), + new ActivityTypeOption(SeasonActivityType.IntrusionPoint, "Intrusion Point"), + new ActivityTypeOption(SeasonActivityType.PlantHarvested, "Plant Harvested"), + new ActivityTypeOption(SeasonActivityType.Prototyping, "Prototyping"), + new ActivityTypeOption(SeasonActivityType.ReverseEngineering, "Reverse Engineering"), + new ActivityTypeOption(SeasonActivityType.Production, "Production"), + new ActivityTypeOption(SeasonActivityType.ArtifactFound, "Artifact Found"), + new ActivityTypeOption(SeasonActivityType.DamageDone, "Damage Done"), + new ActivityTypeOption(SeasonActivityType.DamageReceived, "Damage Received"), + new ActivityTypeOption(SeasonActivityType.ArmorRestored, "Armor Restored"), + new ActivityTypeOption(SeasonActivityType.EnergyDrainDealt, "Energy Drain Dealt"), + new ActivityTypeOption(SeasonActivityType.EnergyDrainReceived, "Energy Drain Received"), + new ActivityTypeOption(SeasonActivityType.EnergyTransferDealt, "Energy Transfer Dealt"), + new ActivityTypeOption(SeasonActivityType.EnergyTransferReceived, "Energy Transfer Received"), + }; + + public string Step1Validation { get; private set; } = ""; + public string FinishHint => "Queues the season (Draft) with all configured rates, objectives, tiers, and rewards in one SQL batch. Activate via season detail after committing."; + + public SeasonWizardViewModel( + ChangeQueue queue, + ObservableCollection packages, + Action onComplete) + { + _queue = queue; + _packages = packages; + _onComplete = onComplete; + + foreach (SeasonActivityType type in Enum.GetValues(typeof(SeasonActivityType))) + { + ActivityRates.Add(new SeasonActivityRateRow + { + Id = 0, + SeasonId = 0, + ActivityType = type, + PointsPerUnit = 0, + UnitScale = 1 + }); + } + + Objectives.CollectionChanged += (_, _) => + { + OnPropertyChanged(nameof(TotalObjectiveBonusPoints)); + OnPropertyChanged(nameof(HasObjectives)); + OnPropertyChanged(nameof(ReviewObjectivesHeader)); + OnPropertyChanged(nameof(ReviewObjectiveLines)); + }; + Tiers.CollectionChanged += (_, _) => + { + OnPropertyChanged(nameof(MaxTierPoints)); + OnPropertyChanged(nameof(HasTiers)); + OnPropertyChanged(nameof(ReviewTiersHeader)); + OnPropertyChanged(nameof(ReviewTierLines)); + }; + LeaderboardRewards.CollectionChanged += (_, _) => + { + OnPropertyChanged(nameof(HasLeaderboardRewards)); + OnPropertyChanged(nameof(ReviewLeaderboardHeader)); + OnPropertyChanged(nameof(ReviewLeaderboardLines)); + }; + } + + partial void OnCurrentStepChanged(int value) + { + OnPropertyChanged(nameof(IsStep1)); OnPropertyChanged(nameof(IsStep2)); + OnPropertyChanged(nameof(IsStep3)); OnPropertyChanged(nameof(IsStep4)); + OnPropertyChanged(nameof(IsStep5)); OnPropertyChanged(nameof(IsReviewStep)); + OnPropertyChanged(nameof(CanGoBack)); OnPropertyChanged(nameof(CanGoNext)); + OnPropertyChanged(nameof(StepTitle)); OnPropertyChanged(nameof(FinishHint)); + if (value == 3) + OnPropertyChanged(nameof(ActiveObjectiveActivityTypeOptions)); + if (value == 6) + { + OnPropertyChanged(nameof(ReviewActiveRates)); + OnPropertyChanged(nameof(HasActiveRates)); + OnPropertyChanged(nameof(ReviewObjectivesHeader)); + OnPropertyChanged(nameof(ReviewObjectiveLines)); + OnPropertyChanged(nameof(ReviewTiersHeader)); + OnPropertyChanged(nameof(ReviewTierLines)); + OnPropertyChanged(nameof(ReviewLeaderboardHeader)); + OnPropertyChanged(nameof(ReviewLeaderboardLines)); + OnPropertyChanged(nameof(ReviewScoringMode)); + } + } + + partial void OnNameChanged(string value) => ValidateStep1(); + partial void OnStartTimeChanged(DateTime value) => ValidateStep1(); + partial void OnEndTimeChanged(DateTime value) => ValidateStep1(); + partial void OnStartTimeTextChanged(string value) => ApplyTimeText(value, isStart: true); + partial void OnEndTimeTextChanged(string value) => ApplyTimeText(value, isStart: false); + partial void OnIsRecurringChanged(bool value) => ValidateStep1(); + partial void OnRecurrenceGapDaysChanged(int value) => ValidateStep1(); + + private void ApplyTimeText(string text, bool isStart) + { + if (TryParseHHmm(text, out var ts)) + { + if (isStart) StartTime = DateTime.SpecifyKind(StartTime.Date + ts, DateTimeKind.Utc); + else EndTime = DateTime.SpecifyKind(EndTime.Date + ts, DateTimeKind.Utc); + } + ValidateStep1(); + } + + private static bool TryParseHHmm(string text, out TimeSpan result) + { + result = TimeSpan.Zero; + if (string.IsNullOrWhiteSpace(text)) return false; + var parts = text.Trim().Split(':'); + if (parts.Length != 2) return false; + if (!int.TryParse(parts[0], out var h) || !int.TryParse(parts[1], out var m)) return false; + if (h < 0 || h > 23 || m < 0 || m > 59) return false; + result = new TimeSpan(h, m, 0); + return true; + } + + private void ValidateStep1() + { + if (string.IsNullOrWhiteSpace(Name)) + Step1Validation = "Season name is required."; + else if (!TryParseHHmm(StartTimeText, out _)) + Step1Validation = "Start time must be in HH:mm format (UTC)."; + else if (!TryParseHHmm(EndTimeText, out _)) + Step1Validation = "End time must be in HH:mm format (UTC)."; + else if (EndTime <= StartTime) + Step1Validation = "End time must be after start time."; + else if (IsRecurring && RecurrenceGapDays < 1) + Step1Validation = "Gap between runs must be at least 1 day."; + else + Step1Validation = ""; + OnPropertyChanged(nameof(Step1Validation)); + } + + [RelayCommand] + private void Back() { if (CanGoBack) CurrentStep--; } + + [RelayCommand] + private void Next() + { + if (CurrentStep == 1) + { + ValidateStep1(); + if (!string.IsNullOrEmpty(Step1Validation)) return; + } + if (CanGoNext) CurrentStep++; + } + + [RelayCommand] + private void AddObjectiveRow() + { + Objectives.Add(new SeasonObjectiveRow + { + SeasonId = 0, + Name = "New Objective", + Description = "", + ActivityType = SeasonActivityType.NpcKill, + TargetValue = 1, + BonusPoints = 0, + DisplayOrder = Objectives.Count, + IsNew = true + }); + } + + [RelayCommand] + private void RemoveObjectiveRow(SeasonObjectiveRow? row) + { + if (row != null) Objectives.Remove(row); + } + + [RelayCommand] + private void AddTierRow() + { + var pkg = _packages.Count > 0 ? _packages[0] : null; + var row = new SeasonTierRow + { + SeasonId = 0, + TierNumber = Tiers.Count + 1, + TierName = $"Tier {Tiers.Count + 1}", + PointsRequired = (Tiers.Count + 1) * 1000, + PackageId = pkg?.Id ?? 0, + IsNew = true + }; + row.SelectedPackage = pkg; + Tiers.Add(row); + } + + [RelayCommand] + private void RemoveTierRow(SeasonTierRow? row) + { + if (row != null) Tiers.Remove(row); + } + + [RelayCommand] + private void AddLeaderboardRow() + { + var pkg = _packages.Count > 0 ? _packages[0] : null; + var row = new SeasonLeaderboardRewardRow + { + SeasonId = 0, + RankMin = 1, + RankMax = 1, + PackageId = pkg?.Id ?? 0, + IsNew = true + }; + row.SelectedPackage = pkg; + LeaderboardRewards.Add(row); + } + + [RelayCommand] + private void RemoveLeaderboardRow(SeasonLeaderboardRewardRow? row) + { + if (row != null) LeaderboardRewards.Remove(row); + } + + [RelayCommand] + private void Finish() + { + ValidateStep1(); + if (!string.IsNullOrEmpty(Step1Validation)) return; + _queue.Add(BuildSeasonScript()); + _onComplete(); + } + + private IPendingChange BuildSeasonScript() + { + var sb = new StringBuilder(); + sb.AppendLine("DECLARE @seasonId INT;"); + string displayName = IsRecurring ? $"{Name}, Run #1" : Name; + string gapSql = IsRecurring ? RecurrenceGapDays.ToString() : "NULL"; + string baseNameSql = IsRecurring ? SqlLiteral.Of(Name) : "NULL"; + sb.AppendLine("INSERT INTO seasons (name, description, start_time, end_time, is_active, " + + "is_recurring, recurrence_gap_days, recurrence_iteration, recurrence_base_name, scoring_mode)"); + sb.AppendLine($"VALUES ({SqlLiteral.Of(displayName)}, {SqlLiteral.Of(Description)},"); + sb.AppendLine($" '{DateTime.SpecifyKind(StartTime, DateTimeKind.Utc):yyyy-MM-ddTHH:mm:ss}', " + + $"'{DateTime.SpecifyKind(EndTime, DateTimeKind.Utc):yyyy-MM-ddTHH:mm:ss}', 0, " + + $"{(IsRecurring ? 1 : 0)}, {gapSql}, 1, {baseNameSql}, {(int)ScoringMode});"); + sb.AppendLine("SET @seasonId = SCOPE_IDENTITY();"); + + foreach (var rate in ActivityRates.Where(r => r.PointsPerUnit > 0)) + { + sb.AppendLine($"INSERT INTO season_activity_rates (season_id, activity_type, points_per_unit, unit_scale)"); + sb.AppendLine($"VALUES (@seasonId, {(int)rate.ActivityType}, {SqlLiteral.Of(rate.PointsPerUnit)}, {rate.UnitScale});"); + } + + int dispOrder = 0; + foreach (var obj in Objectives) + { + sb.AppendLine($"INSERT INTO season_objectives (season_id, name, description, activity_type, target_value, bonus_points, display_order)"); + sb.AppendLine($"VALUES (@seasonId, {SqlLiteral.Of(obj.Name)}, {SqlLiteral.Of(obj.Description)}, {(int)obj.ActivityType}, {obj.TargetValue}, {obj.BonusPoints}, {dispOrder++});"); + } + + foreach (var tier in Tiers) + { + sb.AppendLine($"INSERT INTO season_tiers (season_id, tier_number, tier_name, points_required, package_id)"); + sb.AppendLine($"VALUES (@seasonId, {tier.TierNumber}, {SqlLiteral.Of(tier.TierName)}, {tier.PointsRequired}, {tier.PackageId});"); + } + + foreach (var lb in LeaderboardRewards) + { + sb.AppendLine($"INSERT INTO season_leaderboard_rewards (season_id, rank_min, rank_max, package_id)"); + sb.AppendLine($"VALUES (@seasonId, {lb.RankMin}, {lb.RankMax}, {lb.PackageId});"); + } + + var parts = new List { "season" }; + var activeRates = ActivityRates.Count(r => r.PointsPerUnit > 0); + if (activeRates > 0) parts.Add($"{activeRates} rates"); + if (Objectives.Count > 0) parts.Add($"{Objectives.Count} objectives"); + if (Tiers.Count > 0) parts.Add($"{Tiers.Count} tiers"); + if (LeaderboardRewards.Count > 0) parts.Add($"{LeaderboardRewards.Count} leaderboard entries"); + + return new RawSqlChange($"seasons: insert '{Name}' with {string.Join(", ", parts)}", sb.ToString()); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/SeasonsViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/SeasonsViewModel.cs new file mode 100644 index 00000000..c9fff249 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/SeasonsViewModel.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Perpetuum.AdminTool.Common; +using Perpetuum.AdminTool.Editing; +using Perpetuum.AdminTool.Packages; +using Perpetuum.AdminTool.Seasons; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.Translations; +using Perpetuum.AdminTool.Views; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class SeasonsViewModel : ObservableObject + { + private readonly SeasonRepository _seasonRepo; + private readonly PackageRepository _pkgRepo; + private readonly ChangeQueue _queue; + private readonly LookupCache _lookups; + private readonly ConnectionSettings _connection; + private readonly TranslationsViewModel? _translations; + + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + + [ObservableProperty] private bool _showPackages; + [ObservableProperty] private bool _isInDetail; + [ObservableProperty] private SeasonDetailViewModel? _detailViewModel; + + public ObservableCollection Seasons { get; } = new(); + public PackagesViewModel PackagesVm { get; } + + public bool ShowSeasonsList => !ShowPackages && !IsInDetail; + public bool ShowPackagesView => ShowPackages && !IsInDetail; + + public SeasonsViewModel( + SeasonRepository seasonRepo, + PackageRepository pkgRepo, + ChangeQueue queue, + LookupCache lookups, + ConnectionSettings connection, + TranslationsViewModel? translations = null) + { + _seasonRepo = seasonRepo; + _pkgRepo = pkgRepo; + _queue = queue; + _lookups = lookups; + _connection = connection; + _translations = translations; + PackagesVm = new PackagesViewModel(_pkgRepo, _seasonRepo, _queue, _lookups, _connection, translations); + } + + partial void OnShowPackagesChanged(bool value) + { + OnPropertyChanged(nameof(ShowSeasonsList)); + OnPropertyChanged(nameof(ShowPackagesView)); + } + + partial void OnIsInDetailChanged(bool value) + { + OnPropertyChanged(nameof(ShowSeasonsList)); + OnPropertyChanged(nameof(ShowPackagesView)); + } + + public async Task LoadAsync() + { + IsLoading = true; + StatusMessage = "Loading seasons..."; + StatusIsError = false; + try + { + var rows = await _seasonRepo.LoadAllSeasonsAsync(); + Seasons.Clear(); + foreach (var r in rows) Seasons.Add(r); + + await PackagesVm.LoadAsync(); + + StatusMessage = $"Loaded {Seasons.Count} season(s)."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Load failed: {ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + [RelayCommand] + private void ShowSeasons() + { + IsInDetail = false; + ShowPackages = false; + } + + [RelayCommand] + private void ShowPackagesPanel() + { + IsInDetail = false; + ShowPackages = true; + } + + [RelayCommand] + private void BackToList() + { + IsInDetail = false; + DetailViewModel = null; + } + + [RelayCommand] + private void NavigateToSeason(SeasonRow? row) + { + if (row == null) return; + var statsVm = new SeasonStatisticsViewModel(_seasonRepo); + var detail = new SeasonDetailViewModel( + row, _seasonRepo, _pkgRepo, _queue, + PackagesVm, statsVm, + _lookups, _connection, PackagesVm.Packages, + _translations); + DetailViewModel = detail; + IsInDetail = true; + _ = detail.LoadAsync(); + } + + [RelayCommand] + private void NewSeason() + { + var wizardVm = new SeasonWizardViewModel(_queue, PackagesVm.Packages, () => + { + StatusIsError = false; + StatusMessage = "Wizard queued INSERT statements for new season."; + }); + var win = new SeasonWizardWindow(wizardVm) + { + Owner = Application.Current?.MainWindow + }; + win.ShowDialog(); + } + } +} diff --git a/src/Perpetuum.AdminTool/ViewModels/TranslationsViewModel.cs b/src/Perpetuum.AdminTool/ViewModels/TranslationsViewModel.cs new file mode 100644 index 00000000..0fe474c8 --- /dev/null +++ b/src/Perpetuum.AdminTool/ViewModels/TranslationsViewModel.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Perpetuum.AdminTool.Settings; +using Perpetuum.AdminTool.Translations; + +namespace Perpetuum.AdminTool.ViewModels +{ + public partial class TranslationsViewModel : ObservableObject + { + private readonly AppSettingsStore _settings; + + [ObservableProperty] private TranslationStore? _store; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _statusIsError; + [ObservableProperty] private TranslationRow? _selectedRow; + + public TranslationsViewModel(AppSettingsStore settings) + { + _settings = settings; + } + + public ObservableCollection Languages => + Store?.Languages ?? new ObservableCollection(); + + public ObservableCollection Rows => + Store?.Rows ?? new ObservableCollection(); + + public bool HasGameRoot => + !string.IsNullOrWhiteSpace(_settings.Settings.GameRootPath); + + public bool HasDictionaryDir => + Store != null && Store.DirectoryExists; + + public void Load() + { + try + { + Store = new TranslationStore(_settings.Settings.GameRootPath); + Store.Load(); + + if (!HasGameRoot) + { + StatusIsError = true; + StatusMessage = + "GameRoot path is not configured. Set it in Connection settings, then click Reload."; + return; + } + + if (!HasDictionaryDir) + { + StatusIsError = true; + StatusMessage = + $"customDictionary folder not found under '{_settings.Settings.GameRootPath}'. " + + "It will be created on first save."; + return; + } + + StatusIsError = false; + StatusMessage = + $"Loaded {Rows.Count} key(s) across {Languages.Count} language(s) " + + $"from {Store.DictionaryDirectory}."; + + OnPropertyChanged(nameof(Rows)); + OnPropertyChanged(nameof(Languages)); + OnPropertyChanged(nameof(HasDictionaryDir)); + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Failed to load translations: {ex.Message}"; + } + } + + public bool TryAddKey(string key, out string error) + { + if (Store == null) + { + error = "Translations not loaded."; + return false; + } + return Store.TryAddKey(key, out error); + } + + public bool TryAddLanguage(int langId, out string error) + { + if (Store == null) + { + error = "Translations not loaded."; + return false; + } + return Store.TryAddLanguage(langId, out error); + } + + public void RemoveSelected() + { + if (Store == null || SelectedRow == null) return; + Store.RemoveRow(SelectedRow); + SelectedRow = null; + } + + public void Save() + { + if (Store == null) return; + try + { + Store.Save(); + StatusIsError = false; + StatusMessage = + $"Saved {Languages.Count} language file(s) to {Store.DictionaryDirectory}."; + } + catch (Exception ex) + { + StatusIsError = true; + StatusMessage = $"Save failed: {ex.Message}"; + } + } + } +} diff --git a/src/Perpetuum.AdminTool/Views/AddAutoMarketItemWindow.xaml b/src/Perpetuum.AdminTool/Views/AddAutoMarketItemWindow.xaml new file mode 100644 index 00000000..cb07c397 --- /dev/null +++ b/src/Perpetuum.AdminTool/Views/AddAutoMarketItemWindow.xaml @@ -0,0 +1,33 @@ + + + + +