diff --git a/.env.local b/.env.local new file mode 100644 index 00000000..5a441ede --- /dev/null +++ b/.env.local @@ -0,0 +1,52 @@ +# Environment for local development. +# Compatible with podman/docker with linux containers. + +# Database settings +DB_PASSWORD="l3/dev/null || true + +log-asset: ## Follow asset logs + ./script/compose.sh logs asset -f + +log-db: ## Follow db logs + ./script/compose.sh logs db -f + +log-server: ## Follow server logs + ./script/compose.sh logs server -f + +test-unit: ## Run the unit test tier (2) in the test container, no database required + ./script/compose.sh --profile test run --no-deps --build --rm test dotnet test src/Perpetuum.Tests/Perpetuum.Tests.csproj -c Release -p:Platform=x64 --no-build + +test-integration: ## Run the integration test tier (3) in the test container, against the live database, bringing up db + migration first (migration is idempotent and exits when already done) + ./script/compose.sh up -d db --wait + ./script/compose.sh up migration + ./script/compose.sh --profile test run --build --rm test dotnet test src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj -c Release -p:Platform=x64 --no-build + +test-smoke: ## Run the end-to-end smoke test (tier 1) against the docker server stack + ./script/smoke-test.sh + +check-assets: ## Verify presence and integrity of assets listed in manifest + python3 ./script/check_assets.py --check-sizes + +cli: ## Run the CLI client in a container (connect, login, character select) + ./script/cli.sh $(ARGS) + +create-account: ## Create a new account with optional admin privileges (interactive or EMAIL=... PASSWORD=... ADMIN=true/false) + ./script/create-account.sh $(ARGS) + +.PHONY: help up start stop down delete restart reset clean-cache log-asset log-db log-server test-smoke test-unit test-integration check-assets extract-models cli create-account + + diff --git a/PerpetuumServer2.sln b/PerpetuumServer2.sln index 9a70b043..046928e6 100644 --- a/PerpetuumServer2.sln +++ b/PerpetuumServer2.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.11.35431.28 @@ -27,6 +27,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.Tests", "src\Perp EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.Tests.Integration", "src\Perpetuum.Tests.Integration\Perpetuum.Tests.Integration.csproj", "{ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.CliClient", "src\Perpetuum.CliClient\Perpetuum.CliClient.csproj", "{B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -77,6 +79,10 @@ Global {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 + {B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A}.Debug|x64.ActiveCfg = Debug|x64 + {B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A}.Debug|x64.Build.0 = Debug|x64 + {B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A}.Release|x64.ActiveCfg = Release|x64 + {B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -93,6 +99,7 @@ Global {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} + {B9D1A2E3-4F5C-6D7E-8F9A-0B1C2D3E4F5A} = {0BC991A1-133C-49ED-A141-80E2A906898B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {91874B60-30B0-4B48-9155-370E40BC70E6} diff --git a/README.md b/README.md index 551f4039..8de180be 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ # The Open Perpetuum Server 2 +## Native Windows host + +`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. ## Running a local server Windows and x64 only. The bootstrapper is annotated `[SupportedOSPlatform("windows")]` and the Admin @@ -79,3 +82,134 @@ 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]`. +## Docker compose + +Local development runs in **Linux containers** (Docker or Podman). + +`compose.yml` defines the asset server, SQL Server, migration job, and game server. Configuration lives in `.env.local`. + +Two named volumes persist between restarts: + +- `openperpetuum-data` — original `PerpetuumServer/data`, custom layers, and a generated `perpetuum.ini` +- `openperpetuum-db` — SQL Server files + +`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 and submodules + +```sh +git clone https://github.com/OpenPerpetuum/PerpetuumServer2.git +# or: git clone git@github.com:OpenPerpetuum/PerpetuumServer2.git +cd PerpetuumServer2 +git submodule init && git submodule update +``` + +Submodules: + +- `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) + +### 2. Custom resources + +Do this whenever the gamma layers or asset pack are updated. + +- 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`) + +Paths for `perpetuum-data` and `custom-layers` can be changed in `.env.local`. + +### 3. Configuration + +Edit `.env.local` for ports, the database password, paths, and the SQL connection string. + +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`). + +Linux does not support distributed transactions. `.env.local` sets `DISTRIBUTED_TRANSACTIONS=false` for that reason. + +`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. + +### 4. Run the server + +```sh +make up +``` + +This builds and starts the containers and runs migrations. The command returns before the game host is fully up; wait a few minutes. + +```sh +make log-server +``` + +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` + +The first connect can take several minutes while the asset server transfers files. + +### 6. Stop and Cache Management + +```sh +make down # stop and remove containers; keep data and db volumes +make delete # also delete the docker volumes +make reset # force re-run full migration from scratch and refresh cache +make clean-cache # delete migration snapshot backup and hash +``` + +### Database Migration & Snapshot Cache + +The migration container seeds configuration files and applies all database patches from the `db` (OPDB) submodule to the SQL Server database. + +To optimize local development startup time from ~90s down to ~2s, the migration job employs an **automatic snapshot caching mechanism** with SHA-256 change detection: + +```mermaid +flowchart TD + Start(["Start migration container"]) --> CheckDone{"/data/done exists && !FORCE_MIGRATION?"} + CheckDone -- "Yes" --> Skip(["Skip migration (0s)"]) + + CheckDone -- "No" --> SyncFiles["Sync layer assets & perpetuum.ini to /data"] + SyncFiles --> ComputeHash["Compute SHA-256 hash of all SQL patches, base .bak & scripts"] + + ComputeHash --> CheckCache{"perpetuumsa_migrated.bak exists && Hash matches?"} + + subgraph FastPath["Fast Path (Cache Hit: ~2s)"] + CheckCache -- "Yes (Cache Hit)" --> RestoreSnapshot["RESTORE DATABASE from snapshot (perpetuumsa_migrated.bak)"] + end + + subgraph SlowPath["Full Migration (First Run / Patch Changed: ~90s)"] + CheckCache -- "No (Miss / Changed / Force)" --> CreateDB["Ensure perpetuumsa DB exists"] + CreateDB --> RestoreBase["RESTORE DATABASE from Steam base perpetuumsa.bak"] + RestoreBase --> DiscoverPatches["Auto-discover patches in numerical order (Pre_Alpha_* -> Live_*)"] + DiscoverPatches --> ApplyPatches["Apply SQL scripts (live_patch_*.sql, Raw_SQL, or *.sql)"] + ApplyPatches --> AddTestAccount["Add test account (TOOL_test_account.sql)"] + AddTestAccount --> BackupSnapshot["BACKUP DATABASE to perpetuumsa_migrated.bak WITH COMPRESSION"] + BackupSnapshot --> SaveHash["Save SHA-256 hash to perpetuumsa_migrated.hash"] + end + + RestoreSnapshot --> MarkDone["touch /data/done"] + SaveHash --> MarkDone + MarkDone --> End(["Migration complete -> Game server starts"]) +``` + +#### Key Features + +- **Automated Patch Discovery**: Automatically iterates through `Pre_Alpha_*` and `Live_*` patch folders in version order. It runs consolidated patch files (`live_patch_*.sql` / `prealpha_patch_*.sql`), `Raw_SQL/*.sql`, or loose `*.sql` files, and copies any `Server/data` assets automatically. +- **Instant Restore on Volume Wipe**: When recreating containers with `make delete && make up`, the database snapshot (`perpetuum-data/database/perpetuumsa_migrated.bak`) is preserved on host disk and restored in ~2 seconds. +- **Zero-touch Invalidation**: If you modify, add, or delete any SQL patch in the `db/` submodule, the SHA-256 hash mismatch is detected automatically, triggering a full re-migration and snapshot update. +- **Clean / Force Options**: Use `make clean-cache` to delete the snapshot, or `make reset` (`FORCE_MIGRATION=true`) to force a fresh re-migration from the raw Steam base backup. + diff --git a/asset b/asset new file mode 160000 index 00000000..e00fe93e --- /dev/null +++ b/asset @@ -0,0 +1 @@ +Subproject commit e00fe93efeb3015789aeae94b66c281984177e2a diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..aed90f50 --- /dev/null +++ b/compose.yml @@ -0,0 +1,137 @@ +services: + asset: + build: + context: ./asset + dockerfile: ../docker/Dockerfile.asset.dev + restart: unless-stopped + networks: + - dev_env + volumes: + - .:/asset + ports: + - ${ASSET_PORT}:1337 + + # One-shot init service to ensure the database volume is owned by the non-root mssql user (UID 10001) + db-init: + image: busybox + restart: "no" + command: chown -R 10001:0 /var/opt/mssql + volumes: + - openperpetuum-db:/var/opt/mssql + + db: + image: mcr.microsoft.com/mssql/server:2025-latest + restart: unless-stopped + # UID 10001 is the default non-root 'mssql' service account in the Microsoft SQL Server image. + # GID 0 is the root group required for internal file permissions in the container. + user: "10001:0" + depends_on: + db-init: + condition: service_completed_successfully + entrypoint: + - /bin/bash + - -c + - | + # Background sleep watchdog: detects host clock jump on resume and restarts sqlservr to prevent scheduler spin + # This fixes an issue when running this container on linux pinning 1 core to 100% when waking up from sleep + ( + while true; do + t1=$$(date +%s) + sleep 2 + t2=$$(date +%s) + delta=$$((t2 - t1)) + if [ "$$delta" -gt 8 ]; then + echo "==> Host resume from sleep detected (clock jumped by $${delta}s). Restarting sqlservr..." + pkill -TERM -f sqlservr || kill 1 + break + fi + done + ) & + exec /opt/mssql/bin/launch_sqlservr.sh /opt/mssql/bin/sqlservr + ports: + - ${DB_PORT}:1433 + environment: + - ACCEPT_EULA=Y + - MSSQL_SA_PASSWORD=${DB_PASSWORD} + - MSSQL_MEMORY_LIMIT_MB=${DB_MEMORY_LIMIT_MB:-4096} + networks: + - dev_env + healthcheck: + test: /opt/mssql-tools18/bin/sqlcmd -S localhost -C -U sa -P "${DB_PASSWORD}" -Q "SELECT 1" -b -o /dev/null + interval: 10s + timeout: 3s + retries: 10 + hostname: db + volumes: + - openperpetuum-db:/var/opt/mssql + # Mount perpetuum database path that contains the perpetuumsa.bak + - "${PERPETUUM_DATA}/database:/data" + + migration: + build: + context: . + dockerfile: ./docker/Dockerfile.migration.dev + args: + ASSET_URL: ${ASSET_URL} + CONNECTION_STRING: ${CONNECTION_STRING} + SERVER_PORT: ${SERVER_PORT} + depends_on: + db: + condition: service_healthy + environment: + DB_PASSWORD: ${DB_PASSWORD} + FORCE_MIGRATION: ${FORCE_MIGRATION} + networks: + - dev_env + volumes: + - ./db:/migration + - "${PERPETUUM_DATA}:/base-data" + - "${CUSTOM_LAYERS}:/custom-layers" + - "./src/Perpetuum.ServerService2/data:/perpetuum-service-data" + - openperpetuum-data:/data + + server: + build: + context: . + dockerfile: ./docker/Dockerfile.server.dev + args: + RUNTIME_IDENTIFIER: ${RUNTIME_IDENTIFIER} + restart: unless-stopped + networks: + - dev_env + ports: + - ${SERVER_PORTS}:17700-17900 + environment: + GameRoot: ${GAME_ROOT} + DistributedTransactions: ${DISTRIBUTED_TRANSACTIONS} + # .NET 8 DATAS: dynamically scales GC heaps to reduce idle memory usage + DOTNET_GCDynamicAdaptationMode: 1 + depends_on: + migration: + condition: service_completed_successfully + volumes: + - openperpetuum-data:/data + + + # Test runner (unit + integration tiers). Excluded from the default stack via the "test" profile: + # make test-unit / make test-integration + # or manually: docker compose --profile test run --rm test + test: + profiles: [test] + build: + context: . + dockerfile: ./docker/Dockerfile.test + args: + SERVER_PORT: ${SERVER_PORT} + ASSET_URL: ${ASSET_URL} + CONNECTION_STRING: ${CONNECTION_STRING} + networks: + - dev_env + +networks: + dev_env: + driver: bridge + +volumes: + openperpetuum-data: + openperpetuum-db: diff --git a/db b/db new file mode 160000 index 00000000..489b8afa --- /dev/null +++ b/db @@ -0,0 +1 @@ +Subproject commit 489b8afa234450743a68a61bd9ee4af0b01fcbcd diff --git a/docker/Dockerfile.asset.dev b/docker/Dockerfile.asset.dev new file mode 100644 index 00000000..e7323376 --- /dev/null +++ b/docker/Dockerfile.asset.dev @@ -0,0 +1,11 @@ +# Development dockerfile for the asset + +FROM node:alpine3.23 + +WORKDIR /var/www/OPResource + +COPY . . + +RUN npm install . + +CMD ["node", "index.js"] \ No newline at end of file diff --git a/docker/Dockerfile.migration.dev b/docker/Dockerfile.migration.dev new file mode 100644 index 00000000..a488603d --- /dev/null +++ b/docker/Dockerfile.migration.dev @@ -0,0 +1,45 @@ +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 . +COPY template/perpetuum.ini.template . +COPY template/restore_DB_to_original_state.sql . +COPY template/restore_migrated_DB.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 && \ + sed -i "s/{CONNECTION_STRING}/${CONNECTION_STRING}/" perpetuum.ini && \ + set -x +# Using # delimiter to avoid conflict with / from the url +RUN sed -i "s#{ASSET_URL}#${ASSET_URL}#" perpetuum.ini + + + +ENTRYPOINT ["/work/migration.sh"] \ No newline at end of file diff --git a/docker/Dockerfile.server.dev b/docker/Dockerfile.server.dev new file mode 100644 index 00000000..d2104611 --- /dev/null +++ b/docker/Dockerfile.server.dev @@ -0,0 +1,33 @@ +# Development dockerfile for PerpetuumServer2 + +# Stage 1: Build the project +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build + +ARG RUNTIME_IDENTIFIER + +WORKDIR /build + +COPY src ./src/ + +# 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 + +# Stage 2: Runtime image +# Using runtime-deps, it includes the dependencies for dotnet without the runtime. +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 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 + +COPY --from=build /build/out . + +ENTRYPOINT ["/runtime/Perpetuum.ServerService2"] \ No newline at end of file diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test new file mode 100644 index 00000000..38b0462a --- /dev/null +++ b/docker/Dockerfile.test @@ -0,0 +1,42 @@ +# Test dockerfile for PerpetuumServer2 +# Builds both test tiers once, then reuses the image to run unit or integration tests: +# docker compose --profile test run --rm test +# +# Uses the SDK 8 image so the net8.0 test host runs without roll-forward. + +FROM mcr.microsoft.com/dotnet/sdk:8.0 + +# Install the missing GSSAPI/Kerberos dependency used by the SQL client and fontconfig for SkiaSharp +RUN apt-get update && apt-get install -y libgssapi-krb5-2 libfontconfig1 && rm -rf /var/lib/apt/lists/* + +WORKDIR /repo + +COPY Directory.Build.targets ./ +COPY PerpetuumServer2.sln ./ +COPY src ./src +# The schema conformance tests locate the repository root via the .sln and read the +# documented object names from docs/db_structure +COPY docs/db_structure ./docs/db_structure + +ARG SERVER_PORT=17700 +ARG ASSET_URL=http://localhost:16999 +ARG CONNECTION_STRING + +# Pre-build both tiers so `dotnet test --no-build` is fast and build failures surface at image build time +RUN dotnet build src/Perpetuum.Tests/Perpetuum.Tests.csproj -c Release -p:Platform=x64 && \ + dotnet build src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj -c Release -p:Platform=x64 + +# Game root for the integration tier: perpetuum.ini with the database connection string, +# same pattern as Dockerfile.migration.dev (the password ends up in the image layers, +# same trade-off as the migration image). +COPY template/perpetuum.ini.template /data/perpetuum.ini + +RUN sed -i "s/{SERVER_PORT}/${SERVER_PORT}/" /data/perpetuum.ini && \ + sed -i "s#{ASSET_URL}#${ASSET_URL}#" /data/perpetuum.ini && \ + sed -i "s/{CONNECTION_STRING}/${CONNECTION_STRING}/" /data/perpetuum.ini + +ENV PERPETUUM_GAMEROOT=/data +ENV GameRoot=/data + +# Default command: unit tier +CMD ["dotnet", "test", "src/Perpetuum.Tests/Perpetuum.Tests.csproj", "-c", "Release", "-p:Platform=x64", "--no-build"] diff --git a/docs/backlog/improvements.md b/docs/backlog/improvements.md index ea5cd43e..7286139e 100644 --- a/docs/backlog/improvements.md +++ b/docs/backlog/improvements.md @@ -64,6 +64,10 @@ Remaining stages, in order: ### Notes +- **Containerized tiers 2 and 3.** Both automated tiers run in one test container (compose service + `test`, profile `test`, `docker/Dockerfile.test`) via `make test-unit` / `make test-integration`. + No local dotnet SDK or `GameRoot` setup is needed: the image pre-builds the tests and generates + `perpetuum.ini` from the same template the migration service uses. - **No production code changes.** The four existing static service locators (`Logger.Current`, `Db.DbQueryFactory`, `EntityDefault.Reader`, `Entity.Services`) turned out to be sufficient seams for everything in stages 0-4. If a later stage genuinely cannot be tested without a new seam, that is diff --git a/docs/codebase/COVERAGE.md b/docs/codebase/COVERAGE.md new file mode 100644 index 00000000..3edc355e --- /dev/null +++ b/docs/codebase/COVERAGE.md @@ -0,0 +1,112 @@ +# Code Coverage + +**Analysis Date:** 2026-09-10 + +## Scope + +This document records the automated code coverage metrics for the backend and server codebase (`src/`), excluding the standalone client directory (`client/`). + +Measurements were performed inside the SDK test container using `dotnet-coverage` (Microsoft code coverage tools) and `ReportGenerator` against the net8.0 test suite. + +--- + +## Executive Summary + +| Scope | Line Coverage % | Covered / Coverable Lines | Branch Coverage % | Method Coverage % | Total Code Lines | +|---|---|---|---|---|---| +| **Production Code (CI Unit Tests)** | **9.2%** | 6,882 / 74,118 | **3.7%** (870 / 23,075) | **3.6%** (436 / 11,891) | 166,665 | +| **Production Code (Unit + Integration Tests)** | **9.5%** | 7,049 / 74,118 | **3.9%** (906 / 23,075) | **4.0%** (481 / 11,891) | 166,665 | +| **All Instrumented Assemblies (incl. Unit Test Suite)** | **11.3%** | 8,653 / 75,974 | **4.6%** (1,094 / 23,311) | **5.6%** (693 / 12,229) | 170,489 | +| **Full C# Solution (all 9 non-test projects in `src/`)** | **~8.3%** | 7,049 / ~85,000 | **~3.4%** | **~3.5%** | ~204,000 | + +--- + +## Per-Assembly Breakdown + +### Production Assemblies + +| Project / Assembly | Line Coverage | Branch Coverage | Source Files | Lines of Code | Description / Coverage Focus | +|---|---|---|---|---|---| +| `Perpetuum` | **11.0%** | **4.4%** | 1,337 files | ~142,800 | Core domain logic. Covered areas: geometry (`Area`, `PointExtensions`), SIMD math (`SimdMath`), data access fakes (`Db`, `DbQuery`), logging, bitmaps/layers. | +| `Perpetuum.CliClient` | **19.4%** | **15.0%** | 17 files | ~2,880 | CLI client helper commands, connection activity, authentication protocols. | +| `Perpetuum.RequestHandlers` | **0.4%** | **0.0%** | 585 files | ~26,500 | 200+ command handlers. Largely untested in isolation at the unit level; orchestrate zone/service logic. | +| `Perpetuum.ExportedTypes` | **0.0%** | **0.0%** | 14 files | ~7,670 | Enums, category flags, definition IDs, aggregate field constants. Declarative data without complex logic. | +| `Perpetuum.Bootstrapper` | **0.0%** | **0.0%** | 24 files | ~3,430 | Autofac dependency injection modules and container bootstrap wiring. | +| `Perpetuum.AdminTool` | **0.0%** | **0.0%** | 234 files | ~16,880 | WinForms / WPF server administration tool. | +| `Perpetuum.Server` | **0.0%** | **0.0%** | 1 file | ~110 | Console server entry point (`Program.cs`). | +| `Perpetuum.ServerService2` | **0.0%** | **0.0%** | 2 files | ~120 | Windows Service host entry point. | +| `Open.Nat` | **0.0%** | **0.0%** | 35 files | ~3,850 | Third-party UPnP / NAT-PMP traversal library. | + +### Test Assemblies + +| Test Project | Line Coverage | Tests Count | Lines of Code | Role | +|---|---|---|---|---| +| `Perpetuum.Tests` | **95.3%** | 209 unit tests | ~3,840 | Tier 2 automated unit test suite, test seams, in-memory recording fakes. | +| `Perpetuum.Tests.Integration` | **N/A** | 15 integration tests | ~820 | Tier 3 schema conformance & live DB queries. | + +--- + +## Subsystem Coverage Map + +### Well-Covered Subsystems (>80% coverage) +- **Math & Spatial Utilities:** `Area`, `SimdMath`, `PointExtensions`, `SizeExtensions`, `CompactPassabilityMask`, `SpatialCollectionsSkia`. +- **Data Query Abstraction:** `DbQuery`, `DbConnectionManager`, `ConnectionStringSupport`, recording fake ADO.NET providers. +- **Guard & Validation Extensions:** `Guard.cs`, `ValueTypeExtensions.cs`. +- **Image & Layer Utilities:** `BitmapExtensions`, `BinaryStreamSkia`, `LayerExtensions`, `HeightfieldMetadata`. +- **Known Regression Guards:** `Issue033EmptyFlockTests`, `Issue039InsuranceTransactionTests`. + +### Untested Subsystems (0% - 5% coverage) +- **Entity System (`src/Perpetuum/EntityFramework/`):** `Entity`, `EntityDefault`, `EntityDynamicProperties`, entity factories. +- **Module State Machines (`src/Perpetuum/Modules/`):** `ActiveModule.States`, combat equipment state transitions. +- **Request Handlers (`src/Perpetuum.RequestHandlers/`):** Over 200 client command handlers. +- **Zone Runtime & Threading (`src/Perpetuum/Zones/`, `src/Perpetuum/Threading/`):** `ProcessManager`, `Zone`, `ZoneSession`, movement, combat loops, NPC AI. +- **Game Services (`src/Perpetuum/Services/`):** `MissionEngine`, `SeasonService`, `MarketEngine`, `ProductionEngine`, `Looting`, `Social`, `Mail`. + +--- + +## Reproducing Coverage Reports + +Code coverage can be generated in the test Docker container using the commands below: + +### Generate Production Coverage (Excluding Tests) + +```bash +./script/compose.sh --profile test run --no-deps --rm test sh -c ' +export PATH="$PATH:/root/.dotnet/tools" +dotnet tool install -g dotnet-coverage >/dev/null 2>&1 || true +dotnet tool install -g dotnet-reportgenerator-globaltool >/dev/null 2>&1 || true + +dotnet-coverage collect "dotnet test src/Perpetuum.Tests/Perpetuum.Tests.csproj -c Release -p:Platform=x64 --no-build" \ + -f cobertura -o /tmp/coverage.cobertura.xml + +reportgenerator -reports:/tmp/coverage.cobertura.xml \ + -targetdir:/tmp/CoverageReport \ + -reporttypes:"TextSummary;Html" \ + -assemblyfilters:"-Perpetuum.Tests;-Perpetuum.Tests.*" + +cat /tmp/CoverageReport/Summary.txt +' +``` + +### Generate Merged Unit + Integration Coverage + +```bash +./script/compose.sh --profile test run --no-deps --rm test sh -c ' +export PATH="$PATH:/root/.dotnet/tools" +dotnet tool install -g dotnet-coverage >/dev/null 2>&1 || true +dotnet tool install -g dotnet-reportgenerator-globaltool >/dev/null 2>&1 || true + +dotnet-coverage collect "dotnet test src/Perpetuum.Tests/Perpetuum.Tests.csproj -c Release -p:Platform=x64 --no-build" \ + -f cobertura -o /tmp/cov_unit.cobertura.xml + +dotnet-coverage collect "dotnet test src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj -c Release -p:Platform=x64 --no-build" \ + -f cobertura -o /tmp/cov_int.cobertura.xml + +reportgenerator -reports:/tmp/cov_*.cobertura.xml \ + -targetdir:/tmp/CoverageReportMerged \ + -reporttypes:"TextSummary" \ + -assemblyfilters:"-Perpetuum.Tests;-Perpetuum.Tests.*" + +cat /tmp/CoverageReportMerged/Summary.txt +' +``` diff --git a/docs/codebase/TESTING.md b/docs/codebase/TESTING.md index 5822d84c..3d0d25a0 100644 --- a/docs/codebase/TESTING.md +++ b/docs/codebase/TESTING.md @@ -5,13 +5,13 @@ ## Current State The repository has an automated test suite in three tiers. It does not cover the whole codebase — the -coverage map below states what is covered and what is not. +coverage map below states what is covered and what is not. Detailed line and branch coverage metrics are documented in [`docs/codebase/COVERAGE.md`](COVERAGE.md). | Tier | Project | Count | Needs | |------|---------|-------|-------| | 1 — smoke | `tools/smoke-test.ps1` | 1 end-to-end run | A configured `GameRoot` and a live database | -| 2 — unit | `src/Perpetuum.Tests` | 99 tests | Nothing. Runs anywhere the solution builds | -| 3 — integration | `src/Perpetuum.Tests.Integration` | 10 tests | A configured `GameRoot` and a live database | +| 2 — unit | `src/Perpetuum.Tests` | 209 tests | Nothing. Runs anywhere the solution builds | +| 3 — integration | `src/Perpetuum.Tests.Integration` | 15 tests | A configured `GameRoot` and a live database | Tier 2 is the tier that runs in CI. Tiers 1 and 3 run on a developer machine that already has the standard server environment, and skip rather than fail when it is absent. @@ -31,12 +31,36 @@ set PERPETUUM_GAMEROOT=C:\PerpetuumServer\data dotnet test src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj -c Release -p:Platform=x64 ``` -Tier 1, a full server run: +### Docker (docker compose) + +Tiers 2 and 3 also run in a single test container (compose service `test`, profile `test`), with the +tests pre-built at image build time and the connection string generated from `template/perpetuum.ini.template` +in `docker/Dockerfile.test`: + +```bash +make test-unit # tier 2, no database required +make test-integration # tier 3, brings up db + migration, then runs against the live DB +make test-smoke # tier 1, brings up full server stack, asserts on startup/shutdown logs +``` + +The test profile is excluded from the default `up`/`down` stack. Note that tier 3 failures about +documented objects being absent from the database (e.g. `usp_RecalculateInsurancePrices`) are real schema +drift findings, not environment errors — the test suite reports what +`docs/db_structure/` claims against what the live `perpetuumsa` contains. + +Tier 1 on Windows (bare-metal server run): ```bash pwsh tools/smoke-test.ps1 -GameRoot C:\PerpetuumServer\data ``` +Tier 1 on Linux / Docker: + +```bash +make test-smoke +# or manually: ./script/smoke-test.sh +``` + ### Environment variables | Variable | Read by | Effect | diff --git a/script/check_assets.py b/script/check_assets.py new file mode 100755 index 00000000..e0f566f5 --- /dev/null +++ b/script/check_assets.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +""" +OpenPerpetuum Asset Integrity & Presence Checker +------------------------------------------------ +Verifies that all client and server assets listed in the resource manifest +(resource_0.dat / resource_*.dat) are actually present on disk and match +expected specifications (sizes, line endings, layer binary sources). + +Usage: + python3 script/check_assets.py [OPTIONS] + +Options: + --manifest PATH Path to manifest file (default: asset/resource_0.dat) + --asset-dir PATH Path to asset root directory (default: asset) + --layers-dir PATH Additional directory to search for raw layer .bin files + (e.g., custom-layers, perpetuum-data/layers) + --check-sizes Validate file sizes against sizes recorded in manifest + --check-definitions Cross-reference definitionProperties against asset list + --missing-only Only display missing or mismatched assets + --json Output results as JSON + --no-color Disable ANSI color codes in output + --help, -h Show this help message +""" + +import argparse +import json +import os +import re +import sys + + +class Colors: + def __init__(self, enabled=True): + self.enabled = enabled + + def _wrap(self, code, text): + if not self.enabled or not sys.stdout.isatty(): + return text + return f"\033[{code}m{text}\033[0m" + + def green(self, text): + return self._wrap("32", text) + + def red(self, text): + return self._wrap("31", text) + + def yellow(self, text): + return self._wrap("33", text) + + def cyan(self, text): + return self._wrap("36", text) + + def bold(self, text): + return self._wrap("1", text) + + def gray(self, text): + return self._wrap("90", text) + + +def parse_genxy_dict(text): + """ + Parses Genxy formatted dictionary / table into Python dict. + Supports nested brackets and key=value pairs. + """ + pos = 0 + length = len(text) + + def skip_ws(): + nonlocal pos + while pos < length and text[pos] in " \t\r\n": + pos += 1 + + def parse_dict(): + nonlocal pos + res = {} + while pos < length: + skip_ws() + if pos >= length: + break + if text[pos] == "]": + pos += 1 + break + if text[pos] in "#|": + pos += 1 + skip_ws() + key_start = pos + while pos < length and text[pos] not in "= \t\r\n]": + pos += 1 + key = text[key_start:pos].strip() + skip_ws() + if pos < length and text[pos] == "=": + pos += 1 + skip_ws() + if pos < length and text[pos] == "[": + pos += 1 + val = parse_dict() + else: + val_start = pos + while pos < length and text[pos] not in "|\r\n]": + pos += 1 + val = text[val_start:pos].strip() + if key: + res[key] = val + else: + pos += 1 + return res + + return parse_dict() + + +def parse_manifest(manifest_path): + """Loads and parses resource_0.dat manifest.""" + if not os.path.isfile(manifest_path): + raise FileNotFoundError(f"Manifest file not found: {manifest_path}") + + with open(manifest_path, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + + parsed = parse_genxy_dict(content) + resources = parsed.get("resources", parsed) + return resources + + +def parse_manifest_size(size_str): + """Parses manifest size string ('i48d8' -> hex, 'n100' -> decimal, '100' -> decimal).""" + if not size_str: + return None + size_str = size_str.strip() + try: + if size_str.startswith("i"): + return int(size_str[1:], 16) + elif size_str.startswith("n"): + return int(size_str[1:], 10) + else: + return int(size_str, 10) + except ValueError: + return None + + +def find_layer_bin(layer_rel_path, search_dirs): + """ + Given a layer resource path (e.g. lang0000/layers/0070/altitude0070/00000000.dat), + checks if a corresponding raw bin file (e.g. altitude.0070.bin) exists. + """ + m = re.match(r".*layers/(\d{4})/([a-z]+)\1/.*\.dat", layer_rel_path) + if not m: + return None, None + zone, layer = m.group(1), m.group(2) + bin_name = f"{layer}.{zone}.bin" + + for directory in search_dirs: + if not directory or not os.path.isdir(directory): + continue + candidate = os.path.join(directory, bin_name) + if os.path.isfile(candidate): + return candidate, bin_name + + return None, bin_name + + +def check_definitions_cross_reference(def_path, known_assets, asset_dir): + """ + Inspects definitionProperties/000000XX.txt to ensure all referenced + icons, models (gfx), and audio (sfx) exist. + """ + if not os.path.isfile(def_path): + return None + + with open(def_path, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + + referenced_icons = set() + referenced_gfx = set() + referenced_sfx = set() + + for m in re.finditer(r"\|icon=\$?([a-zA-Z0-9_\-]+)", content): + referenced_icons.add(m.group(1)) + + for m in re.finditer(r"\|miniicon=\$?([a-zA-Z0-9_\-]+)", content): + referenced_icons.add(m.group(1)) + + for m in re.finditer(r"\|(?:gfxresource|resource)=\$?([a-zA-Z0-9_\-]+)", content): + referenced_gfx.add(m.group(1)) + + for m in re.finditer(r"\|(?:basesound|terrainsound|sound)=\$?([a-zA-Z0-9_\-]+)", content): + referenced_sfx.add(m.group(1)) + + missing_defs = { + "icons": [icon for icon in sorted(referenced_icons) if icon not in known_assets], + "gfx": [gfx for gfx in sorted(referenced_gfx) if gfx not in known_assets], + "sfx": [sfx for sfx in sorted(referenced_sfx) if sfx not in known_assets], + } + return missing_defs + + +def run_checks(manifest_path, asset_dir, layer_search_dirs, check_sizes=False, check_defs=False): + resources = parse_manifest(manifest_path) + + results = { + "manifest_path": os.path.abspath(manifest_path), + "total_entries": len(resources), + "present": [], + "missing": [], + "layer_bins": [], + "size_mismatches": [], + "categories": {}, + "definition_cross_ref": None, + } + + known_asset_names = set(resources.keys()) + + for name, item in resources.items(): + if not isinstance(item, dict): + continue + + raw_path = item.get("path", "") + rel_path = raw_path[1:] if raw_path.startswith("$") else raw_path + version = item.get("version", "").lstrip("n") + manifest_size = parse_manifest_size(item.get("size")) + hash_val = item.get("hash", "").lstrip("i") + + # Determine category from path + parts = rel_path.split("/") + cat = parts[1] if len(parts) > 1 else (parts[0] if parts else "other") + results["categories"].setdefault(cat, {"total": 0, "present": 0, "missing": 0}) + results["categories"][cat]["total"] += 1 + + direct_path = os.path.join(asset_dir, rel_path) + + if os.path.isfile(direct_path): + actual_size = os.path.getsize(direct_path) + status = "present" + size_ok = True + crlf_match = False + + if check_sizes and manifest_size is not None: + if actual_size != manifest_size: + # Check if CRLF line ending difference + if direct_path.endswith((".txt", ".json", ".dat", ".xml")): + try: + with open(direct_path, "rb") as tf: + raw_bytes = tf.read() + crlf_size = len( + raw_bytes.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n") + ) + if crlf_size == manifest_size: + crlf_match = True + else: + size_ok = False + except Exception: + size_ok = False + else: + size_ok = False + + item_info = { + "name": name, + "path": rel_path, + "disk_path": direct_path, + "category": cat, + "version": version, + "actual_size": actual_size, + "manifest_size": manifest_size, + "crlf_match": crlf_match, + "hash": hash_val, + } + + results["present"].append(item_info) + results["categories"][cat]["present"] += 1 + + if not size_ok: + results["size_mismatches"].append(item_info) + + elif "layers/" in rel_path: + # Check if raw .bin source exists for on-the-fly streaming + bin_path, bin_name = find_layer_bin(rel_path, layer_search_dirs) + if bin_path: + item_info = { + "name": name, + "path": rel_path, + "bin_source": bin_path, + "bin_name": bin_name, + "category": cat, + "version": version, + } + results["layer_bins"].append(item_info) + results["categories"][cat]["present"] += 1 + else: + item_info = { + "name": name, + "path": rel_path, + "category": cat, + "version": version, + "expected_bin": bin_name, + "reason": f"Neither static .dat nor raw layer bin ({bin_name}) found", + } + results["missing"].append(item_info) + results["categories"][cat]["missing"] += 1 + else: + item_info = { + "name": name, + "path": rel_path, + "disk_path": direct_path, + "category": cat, + "version": version, + "manifest_size": manifest_size, + "reason": "File not found on disk", + } + results["missing"].append(item_info) + results["categories"][cat]["missing"] += 1 + + if check_defs: + # Look for the definitionProperties file referenced in manifest or latest in folder + def_file = os.path.join(asset_dir, "lang0000/definitionProperties/00000031.txt") + if not os.path.isfile(def_file): + # Find highest numbered file + dp_dir = os.path.join(asset_dir, "lang0000/definitionProperties") + if os.path.isdir(dp_dir): + files = sorted(os.listdir(dp_dir)) + if files: + def_file = os.path.join(dp_dir, files[-1]) + + results["definition_cross_ref"] = check_definitions_cross_reference( + def_file, known_asset_names, asset_dir + ) + + return results + + +def print_report(results, colors, missing_only=False, check_sizes=False, check_defs=False): + c = colors + print(f"\n{c.bold('=== OpenPerpetuum Asset Integrity & Presence Report ===')}\n") + print(f"Manifest: {c.cyan(results['manifest_path'])}") + print(f"Total entries in manifest: {c.bold(str(results['total_entries']))}\n") + + # Category Summary Table + print(f"{'Category':<24} | {'Total':<8} | {'Present / Bin':<15} | {'Missing':<8}") + print("-" * 62) + for cat, stats in sorted(results["categories"].items()): + total = stats["total"] + pres = stats["present"] + miss = stats["missing"] + miss_str = c.red(str(miss)) if miss > 0 else c.green("0") + pres_str = c.green(str(pres)) + print(f"{cat:<24} | {total:<8} | {pres_str:<24} | {miss_str:<8}") + print("-" * 62) + + total_pres = len(results["present"]) + len(results["layer_bins"]) + total_miss = len(results["missing"]) + print( + f"{c.bold('SUMMARY')}: {c.green(f'{total_pres} available')} " + f"({len(results['present'])} direct files, {len(results['layer_bins'])} layer bin sources), " + f"{c.red(f'{total_miss} missing') if total_miss > 0 else c.green('0 missing')}" + ) + + if check_sizes: + mismatches = len(results["size_mismatches"]) + crlf_matches = sum(1 for m in results["present"] if m.get("crlf_match")) + if mismatches > 0: + print(f"Size validation: {c.yellow(f'{mismatches} mismatches detected')}") + else: + print(f"Size validation: {c.green('All checked file sizes match manifest perfectly')}") + if crlf_matches > 0: + print( + f" {c.gray(f'({crlf_matches} text files matched after CRLF/LF line-ending normalization)')}" + ) + + # Missing items breakdown + if results["missing"]: + print(f"\n{c.bold(c.red('Missing Assets:'))}") + by_cat = {} + for item in results["missing"]: + by_cat.setdefault(item["category"], []).append(item) + + for cat, items in sorted(by_cat.items()): + print(f"\n {c.bold(c.yellow(f'[{cat.upper()}] ({len(items)} missing)'))}") + for item in items[:15]: + name = item["name"] + path = item["path"] + reason = item.get("reason", "") + print(f" - {c.bold(name)} ({c.gray(path)}) -> {reason}") + if len(items) > 15: + print(f" {c.gray(f'... and {len(items) - 15} more {cat} entries')}") + + # Size mismatches breakdown + if check_sizes and results["size_mismatches"]: + print(f"\n{c.bold(c.yellow('Size Mismatches:'))}") + for item in results["size_mismatches"][:15]: + print( + f" - {item['name']} ({item['path']}): " + f"actual {item['actual_size']} bytes vs manifest {item['manifest_size']} bytes" + ) + + # Definition cross reference report + if check_defs and results.get("definition_cross_ref"): + dx = results["definition_cross_ref"] + missing_dx_count = len(dx["icons"]) + len(dx["gfx"]) + len(dx["sfx"]) + print(f"\n{c.bold('Definition Properties Cross-Check:')}") + if missing_dx_count == 0: + print(f" {c.green('All referenced icons, gfx models, and sfx are declared in manifest.')}") + else: + if dx["icons"]: + count = len(dx['icons']) + sample = ', '.join(dx['icons'][:10]) + print( + f" - {c.yellow(f'Referenced icons missing in manifest ({count}):')} {sample}..." + ) + if dx["gfx"]: + count = len(dx['gfx']) + sample = ', '.join(dx['gfx'][:10]) + print( + f" - {c.yellow(f'Referenced gfx missing in manifest ({count}):')} {sample}..." + ) + if dx["sfx"]: + count = len(dx['sfx']) + sample = ', '.join(dx['sfx'][:10]) + print( + f" - {c.yellow(f'Referenced sfx missing in manifest ({count}):')} {sample}..." + ) + + # Remediation recommendations + if total_miss > 0: + print(f"\n{c.bold(c.cyan('Remediation Advice:'))}") + has_missing_layers = any(item["category"] == "layers" for item in results["missing"]) + has_missing_icons = any(item["category"] == "icons" for item in results["missing"]) + has_missing_gfx_sfx = any(item["category"] in ["gfx", "sfx", "textures"] for item in results["missing"]) + + if has_missing_layers: + print( + f" * {c.bold('Gamma Layers Missing')}: Download the latest gamma island layers zip and unpack `.bin` files into:" + ) + print(" - `asset/lang0000/layers/GAMMA_LAYERS_NEW/`") + print(" - `custom-layers/`") + if has_missing_icons or has_missing_gfx_sfx: + print( + f" * {c.bold('Asset Resource Pack Missing')}: Download the asset resource pack and extract `gfx`, `sfx`, `textures`, and `icons` into:" + ) + print(" - `asset/lang0000/`") + else: + print(f"\n{c.bold(c.green('All assets listed in manifest are valid and present on disk!'))}") + + print() + + +def main(): + parser = argparse.ArgumentParser( + description="Verify asset presence and integrity against Perpetuum resource_0.dat manifest." + ) + parser.add_argument( + "--manifest", + default="asset/resource_0.dat", + help="Path to manifest file (default: asset/resource_0.dat)", + ) + parser.add_argument( + "--asset-dir", + default="asset", + help="Path to asset root directory (default: asset)", + ) + parser.add_argument( + "--layers-dir", + action="append", + default=[], + help="Additional search directories for raw layer .bin files (can be specified multiple times)", + ) + parser.add_argument( + "--check-sizes", + action="store_true", + help="Validate actual file sizes against sizes recorded in manifest", + ) + parser.add_argument( + "--check-definitions", + action="store_true", + help="Cross-reference definitionProperties against asset list", + ) + parser.add_argument( + "--missing-only", + action="store_true", + help="Only display missing or mismatched assets", + ) + parser.add_argument( + "--json", + action="store_true", + help="Output results as JSON", + ) + parser.add_argument( + "--no-color", + action="store_true", + help="Disable ANSI color output", + ) + + args = parser.parse_args() + + colors = Colors(enabled=not args.no_color) + + # Build layer search directories + search_dirs = [ + os.path.join(args.asset_dir, "lang0000/layers/GAMMA_LAYERS_NEW"), + "custom-layers", + "perpetuum-data/layers", + "src/Perpetuum.ServerService2/data/layers", + ] + args.layers_dir + + try: + results = run_checks( + manifest_path=args.manifest, + asset_dir=args.asset_dir, + layer_search_dirs=search_dirs, + check_sizes=args.check_sizes, + check_defs=args.check_definitions, + ) + except Exception as e: + print(colors.red(f"Error: {e}"), file=sys.stderr) + sys.exit(1) + + if args.json: + print(json.dumps(results, indent=2)) + else: + print_report( + results, + colors=colors, + missing_only=args.missing_only, + check_sizes=args.check_sizes, + check_defs=args.check_definitions, + ) + + if results["missing"]: + sys.exit(2) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/script/cli.sh b/script/cli.sh new file mode 100755 index 00000000..159ac673 --- /dev/null +++ b/script/cli.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Runs the Perpetuum Server CLI client inside the .NET SDK container +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +DOCKER_FLAGS="-i" +if [ -t 0 ] && [ -t 1 ]; then + DOCKER_FLAGS="-it" +fi + +CLI_DLL="/repo/src/Perpetuum.CliClient/bin/x64/Release/net8.0/Perpetuum.CliClient.dll" + +# Build if DLL does not exist +if [ ! -f "${REPO_ROOT}/src/Perpetuum.CliClient/bin/x64/Release/net8.0/Perpetuum.CliClient.dll" ]; then + echo "Building Perpetuum CLI client..." + docker run --rm -v "${REPO_ROOT}:/repo" -w /repo mcr.microsoft.com/dotnet/sdk:8.0 \ + dotnet build src/Perpetuum.CliClient/Perpetuum.CliClient.csproj -c Release -p:Platform=x64 +fi + +docker run ${DOCKER_FLAGS} --rm \ + --network host \ + -v "${REPO_ROOT}:/repo" \ + -w /repo \ + mcr.microsoft.com/dotnet/sdk:8.0 \ + dotnet "${CLI_DLL}" "$@" diff --git a/script/compose.sh b/script/compose.sh new file mode 100755 index 00000000..978c370f --- /dev/null +++ b/script/compose.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eux + +# Default values +FILE=compose.yml +ENV=.env.local + +docker compose -f $FILE --env-file $ENV "$@" diff --git a/script/create-account.sh b/script/create-account.sh new file mode 100755 index 00000000..fe98712b --- /dev/null +++ b/script/create-account.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +# Creates a new account in Perpetuum Server database with optional admin access level. +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +COMPOSE_FILE="${REPO_ROOT}/compose.yml" +ENV_FILE="${REPO_ROOT}/.env.local" + +# Colors for terminal output +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +print_help() { + echo -e "${BOLD}Usage:${NC} $0 [options]" + echo "" + echo -e "${BOLD}Options:${NC}" + echo " -e, --email Account email or username (if omitted, will be prompted)" + echo " -p, --password Account password (if omitted, will be prompted securely)" + echo " -a, --admin Grant admin privileges (AccessLevel 14 / toolAdmin)" + echo " --no-admin Create standard player account (AccessLevel 2 / normal)" + echo " -h, --help Show this help message" + echo "" + echo -e "${BOLD}Environment variables:${NC}" + echo " EMAIL=" + echo " PASSWORD=" + echo " ADMIN=true|false|1|0" + echo "" + echo -e "${BOLD}Examples:${NC}" + echo " $0 # Interactive wizard (prompts for email, hidden password, admin)" + echo " $0 -e player1 # Prompts securely for password (hidden input)" + echo " $0 -e player1 -p secret123 # Standard player account" + echo " $0 -e gm_admin -p secret123 --admin # Admin account" + echo " make create-account EMAIL=gm ADMIN=true # Prompts securely for password" +} + +EMAIL_VAL="${EMAIL:-}" +PASSWORD_VAL="${PASSWORD:-${PASS:-}}" +ADMIN_VAL="" + +if [ -n "${ADMIN:-}" ]; then + case "${ADMIN,,}" in + true|1|yes|y) ADMIN_VAL="1" ;; + false|0|no|n) ADMIN_VAL="0" ;; + esac +fi + +EXPLICIT_EMAIL=0 +if [ -n "${EMAIL_VAL}" ]; then + EXPLICIT_EMAIL=1 +fi + +EXPLICIT_ADMIN=0 +if [ -n "${ADMIN_VAL}" ]; then + EXPLICIT_ADMIN=1 +fi + +# Parse CLI arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -e|--email|--user|--username) + EMAIL_VAL="$2" + EXPLICIT_EMAIL=1 + shift 2 + ;; + -p|--password|--pass) + PASSWORD_VAL="$2" + shift 2 + ;; + -a|--admin) + ADMIN_VAL="1" + EXPLICIT_ADMIN=1 + shift + ;; + --no-admin) + ADMIN_VAL="0" + EXPLICIT_ADMIN=1 + shift + ;; + -h|--help) + print_help + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown option '$1'${NC}" >&2 + echo "Use -h or --help for usage information." >&2 + exit 1 + ;; + esac +done + +# Helper function to prompt interactively even when invoked via make/subshell +read_prompt() { + local prompt_msg="$1" + local is_secret="${2:-0}" + local input="" + + if [ -t 0 ]; then + if [ "${is_secret}" -eq 1 ]; then + read -r -s -p "${prompt_msg}" input + echo "" >&2 + else + read -r -p "${prompt_msg}" input + fi + elif [ -r /dev/tty ]; then + if [ "${is_secret}" -eq 1 ]; then + read -r -s -p "${prompt_msg}" input &2 + else + read -r -p "${prompt_msg}" input &2 + done + else + echo -e "${RED}Error: Account email/username is required in non-interactive mode (-e or EMAIL=...)${NC}" >&2 + exit 1 + fi +fi + +# Trim whitespace from email +EMAIL_VAL="$(echo -n "${EMAIL_VAL}" | xargs)" + +if [ -z "${EMAIL_VAL}" ]; then + echo -e "${RED}Error: Account email/username cannot be empty.${NC}" >&2 + exit 1 +fi + +# Prompt securely for missing password (hiding characters) +if [ -z "${PASSWORD_VAL}" ]; then + if [ "${IS_INTERACTIVE}" -eq 1 ]; then + while true; do + PW_1=$(read_prompt "Enter password (hidden): " 1) + if [ -z "${PW_1}" ]; then + echo -e "${RED}Password cannot be empty. Please try again.${NC}" >&2 + continue + fi + + PW_2=$(read_prompt "Confirm password (hidden): " 1) + if [ "${PW_1}" != "${PW_2}" ]; then + echo -e "${RED}Passwords do not match. Please try again.${NC}" >&2 + continue + fi + + PASSWORD_VAL="${PW_1}" + break + done + else + echo -e "${RED}Error: Password is required in non-interactive mode (-p or PASSWORD=...)${NC}" >&2 + exit 1 + fi +fi + +if [ -z "${PASSWORD_VAL}" ]; then + echo -e "${RED}Error: Password cannot be empty.${NC}" >&2 + exit 1 +fi + +# Compute SHA-1 hash of password (ASCII encoding) to match Perpetuum authentication +if [[ ${#PASSWORD_VAL} -eq 40 && "${PASSWORD_VAL}" =~ ^[0-9a-fA-F]{40}$ ]]; then + PASSWORD_HASH="${PASSWORD_VAL^^}" +else + PASSWORD_HASH=$(echo -n "${PASSWORD_VAL}" | sha1sum | awk '{print toupper($1)}') +fi + +# Determine admin status +if [ -z "${ADMIN_VAL}" ]; then + if [ "${IS_INTERACTIVE}" -eq 1 ] && [ "${EXPLICIT_EMAIL}" -eq 0 ] && [ "${EXPLICIT_ADMIN}" -eq 0 ]; then + ADMIN_INPUT=$(read_prompt "Make this account an admin? [y/N]: " 0) + case "${ADMIN_INPUT,,}" in + y|yes|1|true) ADMIN_VAL="1" ;; + *) ADMIN_VAL="0" ;; + esac + else + ADMIN_VAL="0" + fi +fi + +if [ ! -f "${ENV_FILE}" ]; then + echo -e "${RED}Error: Environment file ${ENV_FILE} not found.${NC}" >&2 + exit 1 +fi + +compose() { + docker compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" "$@" +} + +# Ensure database container is running and healthy +db_status=$(compose ps -a --format '{{.State}}' db 2>/dev/null || echo "not_found") +if [ "${db_status}" != "running" ]; then + echo -e "${CYAN}Database container is not running. Starting database service...${NC}" + compose up -d db --wait +fi + +# Escape single quotes for SQL email +EMAIL_SQL="${EMAIL_VAL//\'/\'\'}" + +SQL_SCRIPT=$(cat <&1) + +# Check for database execution errors +if [ $? -ne 0 ]; then + echo -e "${RED}Error executing database query:${NC}" >&2 + echo "${OUTPUT}" >&2 + exit 1 +fi + +# Parse pipe-delimited result +RESULT_LINE=$(echo "${OUTPUT}" | grep -E '^[0-9-]+ *\|' | tail -n 1 || true) + +if [ -z "${RESULT_LINE}" ]; then + echo -e "${RED}Unexpected response from database:${NC}" >&2 + echo "${OUTPUT}" >&2 + exit 1 +fi + +ACCOUNT_ID=$(echo "${RESULT_LINE}" | awk -F'|' '{print $1}' | tr -d '[:space:]') +ACC_LEVEL=$(echo "${RESULT_LINE}" | awk -F'|' '{print $2}' | tr -d '[:space:]') +STATUS=$(echo "${RESULT_LINE}" | awk -F'|' '{print $3}' | tr -d '[:space:]') + +if [ "${STATUS}" = "EXISTS" ] || [ "${ACCOUNT_ID}" = "-1" ]; then + echo -e "${RED}Error: Account with email/username '${EMAIL_VAL}' already exists.${NC}" >&2 + exit 1 +fi + +if [ "${STATUS}" = "SUCCESS" ]; then + echo "" + echo -e "${GREEN}${BOLD}✓ Account created successfully!${NC}" + echo "----------------------------------------" + printf " %-18s: %s\n" "Account ID" "${ACCOUNT_ID}" + printf " %-18s: %s\n" "Username / Email" "${EMAIL_VAL}" + if [ "${ACC_LEVEL}" = "14" ]; then + printf " %-18s: %s\n" "Role" "Admin (Level 14 / toolAdmin)" + else + printf " %-18s: %s\n" "Role" "Player (Level 2 / normal)" + fi + printf " %-18s: %s\n" "Status" "Active (Email Confirmed)" + echo "----------------------------------------" + echo "" + exit 0 +fi + +echo -e "${RED}Failed to create account. Result: ${OUTPUT}${NC}" >&2 +exit 1 diff --git a/script/migration.sh b/script/migration.sh new file mode 100755 index 00000000..c5bda0a4 --- /dev/null +++ b/script/migration.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env sh + +# Check if /data/done exists (skips on simple container restarts) +if [ -f /data/done ] && [ "$FORCE_MIGRATION" != "true" ]; then + echo "Skipping migration, /data/done already exists." + exit 0 +fi + +set -eux + +CACHE_BAK="/base-data/database/perpetuumsa_migrated.bak" +CACHE_HASH_FILE="/base-data/database/perpetuumsa_migrated.hash" + +# 1. Sync server data and layer files to shared /data +echo "==> Syncing base server data and layers..." +cp -r /perpetuum-service-data/* /data/ +cp -v /work/perpetuum.ini /data/ +cp -r /base-data/layers /data/ +if [ -d /custom-layers ] && [ -n "$(ls -A /custom-layers 2>/dev/null)" ]; then + cp -r /custom-layers/* /data/layers/ +fi + +# Copy all patch-specific data/layers +for d in /migration/Patches/*/Server/data; do + if [ -d "$d" ] && [ -n "$(ls -A "$d" 2>/dev/null)" ]; then + cp -r "$d"/* /data/ + fi +done + +runSqlCmd () { + set +x + sqlcmd -S db -d perpetuumsa -C -U sa -P "${DB_PASSWORD}" -I -i "$1" + set -x +} + +# 2. Compute SHA-256 hash of all migration sources +compute_migration_hash() { + ( + find /migration -type f \( -name "*.sql" -o -name "*.bin" \) -exec sha256sum {} + | sort + [ -f /base-data/database/perpetuumsa.bak ] && sha256sum /base-data/database/perpetuumsa.bak + [ -f /work/restore_DB_to_original_state.sql ] && sha256sum /work/restore_DB_to_original_state.sql + [ -f /work/migration.sh ] && sha256sum /work/migration.sh + ) | sha256sum | awk '{print $1}' +} + +CURRENT_HASH=$(compute_migration_hash) +CACHED_HASH="" +[ -f "$CACHE_HASH_FILE" ] && CACHED_HASH=$(cat "$CACHE_HASH_FILE") + +USE_CACHE=false +if [ "$FORCE_MIGRATION" != "true" ] && [ -f "$CACHE_BAK" ] && [ "$CURRENT_HASH" = "$CACHED_HASH" ]; then + USE_CACHE=true +fi + +# 3. Restore from cache OR run automated full migration +if [ "$USE_CACHE" = "true" ]; then + echo "==> Migration cache HIT (hash matches). Restoring snapshot..." + set +x + sqlcmd -S db -C -U sa -P "${DB_PASSWORD}" -b -I -i "/work/restore_migrated_DB.sql" + set -x + echo "==> Restored migrated DB snapshot in seconds." +else + echo "==> Migration cache MISS or FORCED. Running full migration from scratch..." + + # Create perpetuumsa database if it does not exist + echo "IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'perpetuumsa') CREATE DATABASE perpetuumsa" > /work/create-database.sql + sqlcmd -S db -C -U sa -P "${DB_PASSWORD}" -I -i "/work/create-database.sql" + rm /work/create-database.sql + + # Restore base vanilla state + set +x + sqlcmd -S db -C -U sa -P "${DB_PASSWORD}" -b -I -i "/work/restore_DB_to_original_state.sql" + set -x + + # Discover and apply all patches dynamically in chronological version order + PATCH_DIRS=$(ls -1d /migration/Patches/Pre_Alpha_* 2>/dev/null | sort -V; ls -1d /migration/Patches/Live_* 2>/dev/null | sort -V) + + for patch_dir in $PATCH_DIRS; do + [ -d "$patch_dir" ] || continue + patch_name=$(basename "$patch_dir") + echo "==> Applying patch: $patch_name" + + # Check for consolidated patch file + consolidated=$(find "$patch_dir" -maxdepth 1 -type f \( -name "live_patch_*.sql" -o -name "prealpha_patch_*.sql" \) | head -n 1) + + if [ -n "$consolidated" ]; then + runSqlCmd "$consolidated" + elif [ -d "$patch_dir/Raw_SQL" ]; then + # Run all SQL scripts in Raw_SQL in numerical order + find "$patch_dir/Raw_SQL" -maxdepth 1 -type f -name "*.sql" | sort -V | while read -r sql_file; do + runSqlCmd "$sql_file" + done + else + # Run any top-level SQL scripts in the patch folder in order + find "$patch_dir" -maxdepth 1 -type f -name "*.sql" | sort -V | while read -r sql_file; do + runSqlCmd "$sql_file" + done + fi + done + + # Add test account (user: test, pass: test) + runSqlCmd "/migration/Tools/TOOL_test_account.sql" + + echo "==> Creating compressed migration database snapshot..." + set +x + sqlcmd -S db -C -U sa -P "${DB_PASSWORD}" -b -I -Q "BACKUP DATABASE perpetuumsa TO DISK = '/data/perpetuumsa_migrated.bak' WITH FORMAT, INIT, COMPRESSION" + set -x + + # Save hash and set permissions + echo "$CURRENT_HASH" > "$CACHE_HASH_FILE" + chmod 666 "$CACHE_BAK" "$CACHE_HASH_FILE" 2>/dev/null || true + echo "==> Migration snapshot cache saved." +fi + +touch /data/done +echo "Patching complete." diff --git a/script/smoke-test.sh b/script/smoke-test.sh new file mode 100755 index 00000000..e1f8d3c1 --- /dev/null +++ b/script/smoke-test.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# End-to-end smoke test for Perpetuum Server in Docker +# Builds and starts the server stack, asserts on startup logs, +# shuts down gracefully, and asserts on shutdown logs and exit codes. +# +# Exit codes: +# 0 pass +# 2 build failed or container failed to start +# 4 timed out waiting for the server to come online +# 5 a forbidden pattern was found in the log +# 6 the server did not shut down gracefully or reported non-zero exit +# 7 unexpected error + +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +TIMEOUT="${TIMEOUT:-180}" +SETTLE_SECONDS="${SETTLE_SECONDS:-15}" +SHUTDOWN_TIMEOUT="${SHUTDOWN_TIMEOUT:-120}" + +COMPOSE_FILE="${REPO_ROOT}/compose.yml" +ENV_FILE="${REPO_ROOT}/.env.local" + +# Helper to run compose commands +compose() { + docker compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" "$@" +} + +REQUIRED_ONLINE="State : \[Online\]" +REQUIRED_OFFLINE="State : \[Off\]" + +FORBIDDEN_PATTERNS=( + "Unhandled exception" + "System\.InvalidOperationException" + "System\.NullReferenceException" + "The current TransactionScope is already complete" + "nesting level exceeded" +) + +write_section() { + echo "" + echo "== $1" +} + +cd "${REPO_ROOT}" + +# --- Phase 0: Environment ------------------------------------------------- +write_section "Environment" +if [ ! -f "${ENV_FILE}" ]; then + echo "Environment file ${ENV_FILE} not found." >&2 + exit 7 +fi +echo "Compose File : ${COMPOSE_FILE}" +echo "Env File : ${ENV_FILE}" +echo "Online Timeout : ${TIMEOUT}s" +echo "Settle Soak : ${SETTLE_SECONDS}s" +echo "Shutdown Timeout : ${SHUTDOWN_TIMEOUT}s" + +# --- Phase 1: Launch ------------------------------------------------------ +write_section "Launch" +echo "Starting stack (db, migration, server)..." +compose stop server 2>/dev/null || true +compose rm -f server 2>/dev/null || true +if ! compose up -d --build --force-recreate server; then + echo "Failed to start server stack." >&2 + exit 2 +fi + +# --- Phase 2: Wait for [Online] ------------------------------------------- +write_section "Waiting for [Online]" +start_time=$(date +%s) +online=0 + +while true; do + current_time=$(date +%s) + elapsed=$((current_time - start_time)) + + if [ "$elapsed" -ge "$TIMEOUT" ]; then + break + fi + + if compose logs server 2>&1 | grep -qE "$REQUIRED_ONLINE"; then + online=1 + break + fi + + current_container=$(compose ps -a -q server 2>/dev/null || true) + if [ -n "$current_container" ]; then + status=$(docker inspect -f '{{.State.Status}}' "$current_container" 2>/dev/null || echo "unknown") + if [ "$status" = "exited" ] || [ "$status" = "dead" ]; then + echo "Server container exited unexpectedly with status: $status" >&2 + break + fi + fi + + sleep 1 +done + +elapsed_online=$(( $(date +%s) - start_time )) + +if [ "$online" -ne 1 ]; then + echo "Server did not reach [Online] within ${TIMEOUT} seconds." >&2 + echo "=== Server Logs ===" + compose logs server || true + exit 4 +fi + +echo "Online after ${elapsed_online} seconds." + +# --- Phase 3: Wait for startup to settle ---------------------------------- +write_section "Waiting for startup to settle" +echo "Server is [Online]. Soaking for ${SETTLE_SECONDS}s to allow initial zone flock spawning to complete..." +sleep "$SETTLE_SECONDS" +echo "Startup settle completed after ${SETTLE_SECONDS}s. Proceeding to assertions and shutdown." + +# --- Phase 4: Assert on the startup log ----------------------------------- +write_section "Startup assertions" +startup_log=$(compose logs server 2>&1) +violations=() + +for pattern in "${FORBIDDEN_PATTERNS[@]}"; do + if echo "$startup_log" | grep -qE "$pattern"; then + violations+=("$pattern") + fi +done + +spawn_count=$(echo "$startup_log" | grep -c "member spawned to zone" || true) +flock_count=$(echo "$startup_log" | grep -c "NPCs created" || true) + +echo "Reported values (not asserted):" +printf " %-18s: %s\n" "members spawned" "$spawn_count" +printf " %-18s: %s\n" "flock batches" "$flock_count" +printf " %-18s: %ss\n" "time to online" "$elapsed_online" +printf " %-18s: %ss\n" "settle duration" "$SETTLE_SECONDS" + +# --- Phase 5: Graceful shutdown ------------------------------------------- +write_section "Shutdown" +shutdown_start_time=$(date +%s) + +echo "Stopping server container (sending SIGTERM, timeout ${SHUTDOWN_TIMEOUT}s)..." +if ! compose stop -t "$SHUTDOWN_TIMEOUT" server; then + echo "Failed to stop server container." >&2 + exit 6 +fi + +shutdown_elapsed=$(( $(date +%s) - shutdown_start_time )) +echo "Shutdown took ${shutdown_elapsed}s." + +final_log=$(compose logs server 2>&1) +if ! echo "$final_log" | grep -qE "$REQUIRED_OFFLINE"; then + echo "Server exited but never reported [Off]." >&2 + exit 6 +fi + +server_container=$(compose ps -a -q server 2>/dev/null || true) +if [ -n "$server_container" ]; then + exit_code=$(docker inspect -f '{{.State.ExitCode}}' "$server_container" 2>/dev/null || echo "1") + if [ "$exit_code" -ne 0 ]; then + echo "Server container exit code was $exit_code, expected 0." >&2 + exit 6 + fi +fi + +echo "Graceful shutdown confirmed." + +# --- Phase 6: Verdict ----------------------------------------------------- +write_section "Verdict" +if [ ${#violations[@]} -gt 0 ]; then + echo "Forbidden patterns found in the log:" >&2 + for v in "${violations[@]}"; do + echo " $v" >&2 + done + exit 5 +fi + +echo "SMOKE TEST PASSED" +exit 0 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.Bootstrapper/PerpetuumBootstrapper.cs b/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs index 2cf07914..afdaabbb 100644 --- a/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs +++ b/src/Perpetuum.Bootstrapper/PerpetuumBootstrapper.cs @@ -129,7 +129,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); @@ -137,6 +137,7 @@ public void Init(string gameRoot) Logger.Current = _container.Resolve>(); GlobalConfiguration config = _container.Resolve(); + config.DistributedTransactions = distributedTransactions; _container.Resolve().State = HostState.Init; // Before anything builds a SqlConnection from it, which happens further down at the @@ -162,7 +163,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.CliClient/ChannelHistoryManager.cs b/src/Perpetuum.CliClient/ChannelHistoryManager.cs new file mode 100644 index 00000000..3a88cbb4 --- /dev/null +++ b/src/Perpetuum.CliClient/ChannelHistoryManager.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Perpetuum.CliClient.Models; + +namespace Perpetuum.CliClient +{ + public class ChannelHistoryManager + { + private readonly ConcurrentDictionary> _channelHistories = + new(StringComparer.OrdinalIgnoreCase); + + private readonly int _maxHistoryPerChannel; + + public ChannelHistoryManager(int maxHistoryPerChannel = 100) + { + _maxHistoryPerChannel = maxHistoryPerChannel; + } + + public void AddMessage(ChatMessage message) + { + if (string.IsNullOrEmpty(message.ChannelName)) + { + return; + } + + var list = _channelHistories.GetOrAdd(message.ChannelName, _ => new List()); + lock (list) + { + list.Add(message); + if (list.Count > _maxHistoryPerChannel) + { + list.RemoveAt(0); + } + } + } + + public IReadOnlyList GetRecentMessages(string channelName, int count = 5) + { + if (!_channelHistories.TryGetValue(channelName, out var list)) + { + return Array.Empty(); + } + + lock (list) + { + int skip = Math.Max(0, list.Count - count); + return list.Skip(skip).ToList(); + } + } + + public IReadOnlyList GetAllMessages(string channelName) + { + if (!_channelHistories.TryGetValue(channelName, out var list)) + { + return Array.Empty(); + } + + lock (list) + { + return list.ToList(); + } + } + + public void Clear(string channelName) + { + if (_channelHistories.TryGetValue(channelName, out var list)) + { + lock (list) + { + list.Clear(); + } + } + } + } +} diff --git a/src/Perpetuum.CliClient/ClientDictionaryExtensions.cs b/src/Perpetuum.CliClient/ClientDictionaryExtensions.cs new file mode 100644 index 00000000..48d1a7a0 --- /dev/null +++ b/src/Perpetuum.CliClient/ClientDictionaryExtensions.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient +{ + public static class ClientDictionaryExtensions + { + public static bool GetBool(this IDictionary? dict, string key, bool defaultValue = false) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return defaultValue; + } + + if (val is bool b) return b; + if (val is int i) return i != 0; + if (val is long l) return l != 0; + if (val is byte by) return by != 0; + if (val is short sh) return sh != 0; + if (val is string s) + { + if (bool.TryParse(s, out var sb)) return sb; + if (int.TryParse(s, out var si)) return si != 0; + } + + try + { + return Convert.ToBoolean(val); + } + catch + { + return defaultValue; + } + } + + public static int GetInt(this IDictionary? dict, string key, int defaultValue = 0) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return defaultValue; + } + + if (val is int i) return i; + if (val is long l) return (int)l; + if (val is short sh) return sh; + if (val is byte b) return b; + if (val is double d) return (int)d; + if (val is float f) return (int)f; + if (val is string s && int.TryParse(s, out var si)) return si; + + try + { + return Convert.ToInt32(val); + } + catch + { + return defaultValue; + } + } + + public static long GetLong(this IDictionary? dict, string key, long defaultValue = 0) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return defaultValue; + } + + if (val is long l) return l; + if (val is int i) return i; + if (val is short sh) return sh; + if (val is byte b) return b; + if (val is double d) return (long)d; + if (val is float f) return (long)f; + if (val is string s && long.TryParse(s, out var sl)) return sl; + + try + { + return Convert.ToInt64(val); + } + catch + { + return defaultValue; + } + } + + public static double GetDouble(this IDictionary? dict, string key, double defaultValue = 0.0) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return defaultValue; + } + + if (val is double d) return d; + if (val is float f) return f; + if (val is int i) return i; + if (val is long l) return l; + if (val is short sh) return sh; + if (val is byte b) return b; + if (val is string s && double.TryParse(s, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var sd)) return sd; + + try + { + return Convert.ToDouble(val, System.Globalization.CultureInfo.InvariantCulture); + } + catch + { + return defaultValue; + } + } + + public static string GetString(this IDictionary? dict, string key, string defaultValue = "") + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return defaultValue; + } + + return val.ToString() ?? defaultValue; + } + + public static DateTime? GetDateTime(this IDictionary? dict, string key) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return null; + } + + if (val is DateTime dt) return dt; + if (val is string s && DateTime.TryParse(s, out var dts)) return dts; + + return null; + } + + public static IDictionary? GetDictionary(this IDictionary? dict, string key) + { + if (dict == null || !dict.TryGetValue(key, out var val) || val == null) + { + return null; + } + + return val as IDictionary; + } + } +} diff --git a/src/Perpetuum.CliClient/CommandLineOptions.cs b/src/Perpetuum.CliClient/CommandLineOptions.cs new file mode 100644 index 00000000..285ae87e --- /dev/null +++ b/src/Perpetuum.CliClient/CommandLineOptions.cs @@ -0,0 +1,233 @@ +using System; + +namespace Perpetuum.CliClient +{ + public class CommandLineOptions + { + public string Host { get; set; } = "127.0.0.1"; + public int Port { get; set; } = 17700; + public string? Email { get; set; } + public string? Password { get; set; } + public string? PasswordHash { get; set; } + public string? Character { get; set; } + public string? Channel { get; set; } + public string? ChatMessage { get; set; } + public bool ListChannels { get; set; } + public bool ListStorage { get; set; } + public bool CreateAccount { get; set; } + public string? CreateCharacterNick { get; set; } + public int RaceId { get; set; } = 1; + public int SchoolId { get; set; } = 1; + public int MajorId { get; set; } = 1; + public int SparkId { get; set; } = 1; + public bool NonInteractive { get; set; } + public bool ShowHelp { get; set; } + + public static CommandLineOptions Parse(string[] args) + { + var options = new CommandLineOptions(); + + // Check environment variables first as defaults + var envHost = Environment.GetEnvironmentVariable("PERPETUUM_HOST"); + if (!string.IsNullOrWhiteSpace(envHost)) + { + options.Host = envHost; + } + + var envPort = Environment.GetEnvironmentVariable("PERPETUUM_PORT"); + if (!string.IsNullOrWhiteSpace(envPort) && int.TryParse(envPort, out var p)) + { + options.Port = p; + } + + var envEmail = Environment.GetEnvironmentVariable("PERPETUUM_EMAIL"); + if (!string.IsNullOrWhiteSpace(envEmail)) + { + options.Email = envEmail; + } + + var envPass = Environment.GetEnvironmentVariable("PERPETUUM_PASSWORD"); + if (!string.IsNullOrWhiteSpace(envPass)) + { + options.Password = envPass; + } + + for (int i = 0; i < args.Length; i++) + { + var arg = args[i]; + + switch (arg.ToLowerInvariant()) + { + case "--help": + case "-help": + case "-?": + case "/?": + options.ShowHelp = true; + break; + + case "--host": + case "-h": + if (i + 1 < args.Length) options.Host = args[++i]; + break; + + case "--port": + case "-p": + if (i + 1 < args.Length && int.TryParse(args[++i], out var portVal)) options.Port = portVal; + break; + + case "--email": + case "-e": + case "--user": + case "-u": + if (i + 1 < args.Length) options.Email = args[++i]; + break; + + case "--password": + case "--pass": + if (i + 1 < args.Length) options.Password = args[++i]; + break; + + case "--password-hash": + case "--hash": + if (i + 1 < args.Length) options.PasswordHash = args[++i]; + break; + + case "--character": + case "-c": + case "--char": + if (i + 1 < args.Length) options.Character = args[++i]; + break; + + case "--channel": + case "--chan": + if (i + 1 < args.Length) options.Channel = args[++i]; + break; + + case "--chat-msg": + case "--msg": + if (i + 1 < args.Length) options.ChatMessage = args[++i]; + break; + + case "--list-channels": + case "--channels": + options.ListChannels = true; + break; + + case "--storage": + case "--list-storage": + case "--hangar": + case "--inventory": + case "-s": + options.ListStorage = true; + break; + + case "--create-account": + case "--register": + options.CreateAccount = true; + break; + + case "--create-char": + case "--create-character": + case "-cc": + if (i + 1 < args.Length) options.CreateCharacterNick = args[++i]; + break; + + case "--race": + if (i + 1 < args.Length && int.TryParse(args[++i], out var raceVal)) options.RaceId = raceVal; + break; + + case "--school": + if (i + 1 < args.Length && int.TryParse(args[++i], out var schoolVal)) options.SchoolId = schoolVal; + break; + + case "--major": + if (i + 1 < args.Length && int.TryParse(args[++i], out var majorVal)) options.MajorId = majorVal; + break; + + case "--spark": + if (i + 1 < args.Length && int.TryParse(args[++i], out var sparkVal)) options.SparkId = sparkVal; + break; + + case "--non-interactive": + case "-n": + case "--batch": + options.NonInteractive = true; + break; + + default: + if (arg.StartsWith("--host=", StringComparison.OrdinalIgnoreCase)) + options.Host = arg.Substring(7); + else if (arg.StartsWith("--port=", StringComparison.OrdinalIgnoreCase) && int.TryParse(arg.Substring(7), out var pv)) + options.Port = pv; + else if (arg.StartsWith("--email=", StringComparison.OrdinalIgnoreCase)) + options.Email = arg.Substring(8); + else if (arg.StartsWith("--password=", StringComparison.OrdinalIgnoreCase)) + options.Password = arg.Substring(11); + else if (arg.StartsWith("--character=", StringComparison.OrdinalIgnoreCase)) + options.Character = arg.Substring(12); + else if (arg.StartsWith("--create-char=", StringComparison.OrdinalIgnoreCase)) + options.CreateCharacterNick = arg.Substring(14); + else if (arg.StartsWith("--create-character=", StringComparison.OrdinalIgnoreCase)) + options.CreateCharacterNick = arg.Substring(19); + else if (arg.StartsWith("--race=", StringComparison.OrdinalIgnoreCase) && int.TryParse(arg.Substring(7), out var rv)) + options.RaceId = rv; + else if (arg.StartsWith("--school=", StringComparison.OrdinalIgnoreCase) && int.TryParse(arg.Substring(9), out var sv)) + options.SchoolId = sv; + else if (arg.StartsWith("--major=", StringComparison.OrdinalIgnoreCase) && int.TryParse(arg.Substring(8), out var mv)) + options.MajorId = mv; + else if (arg.StartsWith("--spark=", StringComparison.OrdinalIgnoreCase) && int.TryParse(arg.Substring(8), out var spv)) + options.SparkId = spv; + else if (arg.StartsWith("--channel=", StringComparison.OrdinalIgnoreCase)) + options.Channel = arg.Substring(10); + else if (arg.StartsWith("--chat-msg=", StringComparison.OrdinalIgnoreCase)) + options.ChatMessage = arg.Substring(11); + break; + } + } + + return options; + } + + public static void PrintHelp() + { + Console.WriteLine(@"Perpetuum Server CLI Client + +Usage: + dotnet run --project src/Perpetuum.CliClient [options] + +Options: + -h, --host Server hostname or IP (default: 127.0.0.1 / PERPETUUM_HOST) + -p, --port Server port (default: 17700 / PERPETUUM_PORT) + -e, --email Account email / username (default: PERPETUUM_EMAIL) + --password Account plaintext password (default: PERPETUUM_PASSWORD) + --password-hash Account SHA-1 password hash (40-hex) + -c, --character Character ID or nickname to auto-select + --channel Chat channel to join/select + --chat-msg Chat message to send to the channel + --list-channels List available chat channels + -s, --storage List private station storage after character selection + --create-account Create a new account on the server + --create-char Create a new character with nickname + --race <1-3> Character race (1=Truxal, 2=Thelodica, 3=Nia-Korp; default: 1) + --school <1-3> Character school (1=Military, 2=Industrial, 3=Syndicate; default: 1) + --major <1-5> Character major (default: 1) + --spark <1-5> Character spark (default: 1) + -n, --non-interactive Run non-interactively (exit after character selection/message) + --help Show this help text + +Examples: + # Interactive mode + dotnet run --project src/Perpetuum.CliClient + + # Connect, create a character, and select it + dotnet run --project src/Perpetuum.CliClient -- -e pilot@example.com --password secret --create-char NewPilot + + # Connect, select character, and join shell + dotnet run --project src/Perpetuum.CliClient -- -h 127.0.0.1 -p 17700 -e pilot@example.com --password secret -c PilotName + + # Non-interactive / scripting: send a chat message + dotnet run --project src/Perpetuum.CliClient -- -e pilot@example.com --password secret -c PilotName --channel General --chat-msg ""Hello world!"" -n +"); + } + } +} diff --git a/src/Perpetuum.CliClient/ConsoleHelper.cs b/src/Perpetuum.CliClient/ConsoleHelper.cs new file mode 100644 index 00000000..0f0ee5f0 --- /dev/null +++ b/src/Perpetuum.CliClient/ConsoleHelper.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using Perpetuum.CliClient.Models; + +namespace Perpetuum.CliClient +{ + public static class ConsoleHelper + { + public static void WriteBanner() + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(@" + ___ ____ _____ _ _ ____ _____ ____ _____ _ _ _ _ __ __ + / _ \| _ \| ____| \ | | _ \| ____| _ \_ _| | | | | | | \/ | +| | | | |_) | _| | \| | |_) | _| | |_) || | | | | | | | | |\/| | +| |_| | __/| |___| |\ | __/| |___| _ < | | | |_| | |_| | | | | + \___/|_| |_____|_| \_|_| |_____|_| \_\|_| \___/ \___/|_| |_| + SERVER CLI CLIENT (OpenPerpetuum) +"); + Console.ResetColor(); + } + + public static void Info(string message) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("[INFO] "); + Console.ResetColor(); + Console.WriteLine(message); + } + + public static void Success(string message) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("[SUCCESS] "); + Console.ResetColor(); + Console.WriteLine(message); + } + + public static void Warn(string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("[WARN] "); + Console.ResetColor(); + Console.WriteLine(message); + } + + public static void Error(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.Write("[ERROR] "); + Console.ResetColor(); + Console.WriteLine(message); + } + + public static string ReadLine(string prompt, string? defaultValue = null) + { + Console.ForegroundColor = ConsoleColor.White; + if (!string.IsNullOrEmpty(defaultValue)) + { + Console.Write($"{prompt} [{defaultValue}]: "); + } + else + { + Console.Write($"{prompt}: "); + } + Console.ResetColor(); + + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) && defaultValue != null) + { + return defaultValue; + } + return input?.Trim() ?? string.Empty; + } + + public static string ReadPassword(string prompt) + { + Console.ForegroundColor = ConsoleColor.White; + Console.Write($"{prompt}: "); + Console.ResetColor(); + + var pass = new System.Text.StringBuilder(); + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + break; + } + if (key.Key == ConsoleKey.Backspace) + { + if (pass.Length > 0) + { + pass.Remove(pass.Length - 1, 1); + Console.Write("\b \b"); + } + } + else if (!char.IsControl(key.KeyChar)) + { + pass.Append(key.KeyChar); + Console.Write("*"); + } + } + return pass.ToString(); + } + + public static void PrintWelcome(WelcomeInfo welcome, string endpoint) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("------------------- SERVER INFO -------------------"); + Console.ResetColor(); + Console.WriteLine($" Server: {welcome.WorldName} ({endpoint})"); + Console.WriteLine($" Version: {welcome.Version}"); + Console.WriteLine($" Server Time: {welcome.OSTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "N/A"}"); + if (!string.IsNullOrEmpty(welcome.ResourceServerUrl)) + Console.WriteLine($" Assets URL: {welcome.ResourceServerUrl}"); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("---------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + } + + public static void PrintAccount(AccountInfo account) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("------------------ ACCOUNT INFO ------------------"); + Console.ResetColor(); + Console.WriteLine($" Account ID: {account.AccountId}"); + Console.WriteLine($" Email: {account.Email}"); + Console.WriteLine($" Access Level: {account.AccessLevel}"); + Console.WriteLine($" Credits: {account.Credit:N0} NIC"); + Console.WriteLine($" Subscriber: {(account.IsSubscriber ? "Yes" : "No")}"); + if (account.ValidUntil.HasValue) + Console.WriteLine($" Valid Until: {account.ValidUntil.Value:yyyy-MM-dd HH:mm:ss}"); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("---------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + } + + public static void PrintCharactersTable(List characters, int extensionPoints) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine($"---------------- AVAILABLE CHARACTERS (EP: {extensionPoints:N0}) ----------------"); + Console.ResetColor(); + + if (characters.Count == 0) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(" No characters found on this account."); + Console.ResetColor(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("-------------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + return; + } + + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine(" # | ID | Nickname | Location / Base | Status | Credits (NIC)"); + Console.WriteLine("----+---------+-----------------+---------------------------+--------+--------------"); + Console.ResetColor(); + + for (int i = 0; i < characters.Count; i++) + { + var c = characters[i]; + var location = c.IsDocked + ? (!string.IsNullOrEmpty(c.BaseName) ? c.BaseName : $"Base #{c.BaseEid}") + : (c.ZoneId.HasValue ? $"Zone #{c.ZoneId}" : "In Space"); + + var status = c.IsDocked ? "Docked" : "In Zone"; + if (c.InUse) status += " (Online)"; + + Console.WriteLine($" {i + 1,2} | {c.CharacterId,7} | {c.Nick,-15} | {location,-25} | {status,-6} | {c.Credit,13:N0}"); + } + + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("-------------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + } + + public static void PrintSelectedCharacter(CharacterSelectResult result, string nick = "") + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("================ CHARACTER SELECTED ================"); + Console.ResetColor(); + Console.WriteLine($" Nickname: {(string.IsNullOrEmpty(nick) ? "N/A" : nick)}"); + Console.WriteLine($" Character ID: {result.CharacterId}"); + Console.WriteLine($" Root EID: {result.RootEid}"); + Console.WriteLine($" Corporation EID: {result.CorporationEid}"); + if (result.AllianceEid > 0) + Console.WriteLine($" Alliance EID: {result.AllianceEid}"); + Console.WriteLine($" Status: {(result.IsDocked ? "Docked at Base" : "Active in Zone")}"); + if (result.ZoneData != null) + { + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine(" Zone Details:"); + foreach (var kvp in result.ZoneData) + { + Console.WriteLine($" - {kvp.Key}"); + } + Console.ResetColor(); + } + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("===================================================="); + Console.ResetColor(); + Console.WriteLine(); + } + + public static void PrintChannelsTable(List channels, string title = "AVAILABLE CHANNELS") + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine($"---------------- {title} ({channels.Count}) ----------------"); + Console.ResetColor(); + + if (channels.Count == 0) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(" No channels found."); + Console.ResetColor(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("-------------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + return; + } + + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine(" # | Channel Name | Type | Users | Topic"); + Console.WriteLine("----+--------------------------+--------------+-------+-----------------------------"); + Console.ResetColor(); + + for (int i = 0; i < channels.Count; i++) + { + var c = channels[i]; + var pw = c.HasPassword ? " [PW]" : ""; + var topic = string.IsNullOrEmpty(c.Topic) ? "-" : (c.Topic.Length > 30 ? c.Topic[..27] + "..." : c.Topic); + Console.WriteLine($" {i + 1,2} | {c.Name + pw,-24} | {c.TypeName,-12} | {c.MemberCount,5} | {topic}"); + } + + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("-------------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + } + + public static void PrintChatMessage(ChatMessage chat) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write($"[{chat.Timestamp:HH:mm:ss}] "); + Console.ForegroundColor = ConsoleColor.Magenta; + Console.Write($"[#{chat.ChannelName}] "); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write($"<{chat.SenderId}>: "); + Console.ResetColor(); + Console.WriteLine(chat.Message); + } + + public static void PrintChannelHistoryBox(string channelName, string? topic, int memberCount, IReadOnlyList recentMessages, int visibleLines = 5) + { + var width = 78; + try + { + if (Console.WindowWidth > 20) + { + width = Math.Clamp(Console.WindowWidth - 2, 50, 95); + } + } + catch { } + + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + var headerTitle = $" # {channelName} "; + var topicInfo = !string.IsNullOrEmpty(topic) ? $" - Topic: {topic}" : ""; + var headerRight = memberCount > 0 ? $" [{memberCount} online] " : " "; + var headerText = $"───{headerTitle}{topicInfo}"; + if (headerText.Length + headerRight.Length >= width) + { + headerText = headerText.Substring(0, Math.Max(10, width - headerRight.Length - 4)) + "..."; + } + var remainingDashes = Math.Max(0, width - headerText.Length - headerRight.Length - 2); + + Console.WriteLine($"┌{headerText}{new string('─', remainingDashes)}{headerRight}┐"); + Console.ResetColor(); + + if (recentMessages.Count == 0) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + var emptyMsg = $"│ (no recent messages in #{channelName})"; + var padEmpty = Math.Max(0, width - emptyMsg.Length - 1); + Console.WriteLine($"{emptyMsg}{new string(' ', padEmpty)}│"); + Console.ResetColor(); + } + else + { + var displayList = recentMessages.TakeLast(visibleLines).ToList(); + foreach (var msg in displayList) + { + var timeStr = $"[{msg.Timestamp:HH:mm:ss}]"; + var senderStr = $"<{msg.SenderId}>:"; + var text = msg.Message ?? string.Empty; + + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write("│ "); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.Write($"{timeStr} "); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write($"{senderStr} "); + Console.ResetColor(); + + var prefixLen = 2 + timeStr.Length + 1 + senderStr.Length + 1; + var availableTextLen = width - prefixLen - 2; + if (availableTextLen > 0 && text.Length > availableTextLen) + { + text = text.Substring(0, availableTextLen - 3) + "..."; + } + + Console.Write(text); + var pad = Math.Max(0, width - prefixLen - text.Length - 1); + Console.WriteLine(new string(' ', pad) + "│"); + } + } + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"└{new string('─', width - 2)}┘"); + Console.ResetColor(); + } + + public static void PrintStorageTable(StorageResult storage, string characterNick = "") + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("------------------- STATION PRIVATE STORAGE -------------------"); + Console.ResetColor(); + + if (!string.IsNullOrWhiteSpace(characterNick)) + { + Console.WriteLine($" Character: {characterNick}"); + } + + var baseLabel = !string.IsNullOrWhiteSpace(storage.BaseName) + ? $"{storage.BaseName} (#{storage.BaseEid})" + : (storage.BaseEid > 0 ? $"Base #{storage.BaseEid}" : "N/A"); + Console.WriteLine($" Location: {baseLabel}"); + Console.WriteLine($" Container: #{storage.ContainerEid} (Items: {storage.TotalItemCount:N0}, Total Volume: {storage.TotalVolume:N2} m³)"); + + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("---------------------------------------------------------------"); + Console.ResetColor(); + + if (storage.Items.Count == 0) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(" No items found in private storage at this station."); + Console.ResetColor(); + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("---------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + return; + } + + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine(" # | Item Name / Type | Qty | Pack | Health | Volume (m³) | Item EID"); + Console.WriteLine("----+-------------------------------+---------+------+--------+-------------+----------------"); + Console.ResetColor(); + + for (int i = 0; i < storage.Items.Count; i++) + { + var item = storage.Items[i]; + PrintStorageItemRow(i + 1, item, indent: 0); + } + + Console.ForegroundColor = ConsoleColor.DarkCyan; + Console.WriteLine("---------------------------------------------------------------"); + Console.ResetColor(); + Console.WriteLine(); + } + + private static void PrintStorageItemRow(int index, StorageItemInfo item, int indent) + { + var prefix = indent > 0 ? new string(' ', indent * 2) + "└── " : ""; + var name = prefix + item.DisplayName; + if (name.Length > 29) + { + name = name.Substring(0, 26) + "..."; + } + + var idxStr = indent == 0 ? $"{index,2}" : " "; + var packStr = item.IsRepackaged ? "Yes" : "No"; + var healthStr = $"{item.Health:F0}%"; + var volStr = $"{item.Volume:F2}"; + + Console.WriteLine($" {idxStr} | {name,-29} | {item.Quantity,7:N0} | {packStr,-4} | {healthStr,6} | {volStr,11} | {item.Eid}"); + + if (item.Items.Count > 0) + { + for (int j = 0; j < item.Items.Count; j++) + { + PrintStorageItemRow(j + 1, item.Items[j], indent + 1); + } + } + } + } +} diff --git a/src/Perpetuum.CliClient/Models/AccountInfo.cs b/src/Perpetuum.CliClient/Models/AccountInfo.cs new file mode 100644 index 00000000..b2c07e1e --- /dev/null +++ b/src/Perpetuum.CliClient/Models/AccountInfo.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class AccountInfo + { + public int AccountId { get; init; } + public string Email { get; init; } = string.Empty; + public AccessLevel AccessLevel { get; init; } = AccessLevel.normal; + public int Credit { get; init; } + public bool IsSubscriber { get; init; } + public bool EmailConfirmed { get; init; } + public DateTime? ValidUntil { get; init; } + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static AccountInfo FromDictionary(IDictionary data) + { + var accountId = data.GetInt(k.accountID); + if (accountId == 0) + { + accountId = data.GetInt("id"); + } + + var accessLevelInt = data.GetInt(k.accessLevel); + if (accessLevelInt == 0) + { + accessLevelInt = data.GetInt(k.accLevel); + } + + return new AccountInfo + { + AccountId = accountId, + Email = data.GetString(k.email), + AccessLevel = (AccessLevel)accessLevelInt, + Credit = data.GetInt(k.credit), + IsSubscriber = data.GetBool(k.isSubscriber), + EmailConfirmed = data.GetBool(k.emailConfirmed), + ValidUntil = data.GetDateTime(k.validUntil), + RawData = (data as IReadOnlyDictionary) ?? new Dictionary(data) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/ChannelInfo.cs b/src/Perpetuum.CliClient/Models/ChannelInfo.cs new file mode 100644 index 00000000..84047870 --- /dev/null +++ b/src/Perpetuum.CliClient/Models/ChannelInfo.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class ChannelInfo + { + public string Name { get; init; } = string.Empty; + public int Type { get; init; } + public bool HasPassword { get; init; } + public int MemberCount { get; init; } + public string Topic { get; init; } = string.Empty; + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public string TypeName => Type switch + { + 0 => "Public", + 1 => "Highlighted", + 2 => "Corporation", + 3 => "Gang", + 4 => "Station", + 5 => "Admin", + _ => $"Type({Type})" + }; + + public static ChannelInfo FromDictionary(IDictionary data) + { + return new ChannelInfo + { + Name = data.GetString(k.name), + Type = data.GetInt(k.type), + HasPassword = data.GetBool(k.password), + MemberCount = data.GetInt(k.count), + Topic = data.GetString(k.topic), + RawData = (data as IReadOnlyDictionary) ?? new Dictionary(data) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/CharacterListResult.cs b/src/Perpetuum.CliClient/Models/CharacterListResult.cs new file mode 100644 index 00000000..ad00f8ca --- /dev/null +++ b/src/Perpetuum.CliClient/Models/CharacterListResult.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class CharacterListResult + { + public List Characters { get; init; } = new List(); + public int ExtensionPoints { get; init; } + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static CharacterListResult FromDictionary(IDictionary data) + { + var result = new CharacterListResult(); + IDictionary? content = data; + + // In CharacterList handler, data is wrapped in "result": { characters: { ... }, extensionPoints: ... } + if (data.TryGetValue(k.result, out var resObj) && resObj is IDictionary resDict) + { + content = resDict; + } + + var extensionPoints = content.GetInt("extensionPoints"); + + if (content.TryGetValue("characters", out var charObj) && charObj is IDictionary charDict) + { + foreach (var kvp in charDict) + { + if (kvp.Value is IDictionary cdict) + { + result.Characters.Add(CharacterSummary.FromDictionary(cdict)); + } + } + } + + return new CharacterListResult + { + Characters = result.Characters, + ExtensionPoints = extensionPoints, + RawData = (data as IReadOnlyDictionary) ?? new Dictionary(data) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/CharacterSelectResult.cs b/src/Perpetuum.CliClient/Models/CharacterSelectResult.cs new file mode 100644 index 00000000..67f43943 --- /dev/null +++ b/src/Perpetuum.CliClient/Models/CharacterSelectResult.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class CharacterSelectResult + { + public int CharacterId { get; init; } + public long RootEid { get; init; } + public long CorporationEid { get; init; } + public long AllianceEid { get; init; } + public bool IsDocked { get; init; } + public long BaseEid { get; set; } + public string BaseName { get; set; } = string.Empty; + public IDictionary? ZoneData { get; init; } + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static CharacterSelectResult FromDictionary(IDictionary data) + { + var charId = data.GetInt(k.characterID); + var rootEid = data.GetLong(k.rootEID); + var corpEid = data.GetLong(k.corporationEID); + var allianceEid = data.GetLong(k.allianceEID); + var baseEid = data.GetLong(k.baseEID); + var baseName = data.GetString(k.baseName); + + IDictionary? zoneDict = data.GetDictionary(k.zone); + + return new CharacterSelectResult + { + CharacterId = charId, + RootEid = rootEid, + CorporationEid = corpEid, + AllianceEid = allianceEid, + IsDocked = zoneDict == null, + BaseEid = baseEid, + BaseName = baseName, + ZoneData = zoneDict, + RawData = (data as IReadOnlyDictionary) ?? new Dictionary(data) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/CharacterSummary.cs b/src/Perpetuum.CliClient/Models/CharacterSummary.cs new file mode 100644 index 00000000..6e8a03ec --- /dev/null +++ b/src/Perpetuum.CliClient/Models/CharacterSummary.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class CharacterSummary + { + public int CharacterId { get; init; } + public long RootEid { get; init; } + public string Nick { get; init; } = string.Empty; + public bool IsDocked { get; init; } + public long BaseEid { get; init; } + public string BaseName { get; init; } = string.Empty; + public long HomeBaseEid { get; init; } + public string HomeBaseName { get; init; } = string.Empty; + public int? ZoneId { get; init; } + public long Credit { get; init; } + public DateTime? Creation { get; init; } + public DateTime? LastUsed { get; init; } + public string MoodMessage { get; init; } = string.Empty; + public bool InUse { get; init; } + public bool IsOffensiveNick { get; init; } + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static CharacterSummary FromDictionary(IDictionary dict) + { + int? zoneId = null; + if (dict.TryGetValue(k.zoneID, out var zObj) && zObj != null) + { + zoneId = dict.GetInt(k.zoneID); + } + + return new CharacterSummary + { + CharacterId = dict.GetInt(k.characterID), + RootEid = dict.GetLong(k.rootEID), + Nick = dict.GetString(k.nick), + IsDocked = dict.GetBool(k.docked), + BaseEid = dict.GetLong(k.baseEID), + BaseName = dict.GetString(k.baseName), + HomeBaseEid = dict.GetLong(k.homeBaseEID), + HomeBaseName = dict.GetString(k.homeBaseName), + ZoneId = zoneId, + Credit = dict.GetLong(k.credit), + Creation = dict.GetDateTime(k.creation), + LastUsed = dict.GetDateTime(k.lastUsed), + MoodMessage = dict.GetString(k.moodMessage), + InUse = dict.GetBool(k.inUse), + IsOffensiveNick = dict.GetBool(k.offensiveNick), + RawData = (dict as IReadOnlyDictionary) ?? new Dictionary(dict) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/ChatMessage.cs b/src/Perpetuum.CliClient/Models/ChatMessage.cs new file mode 100644 index 00000000..b8550a62 --- /dev/null +++ b/src/Perpetuum.CliClient/Models/ChatMessage.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class ChatMessage + { + public string ChannelName { get; init; } = string.Empty; + public int SenderId { get; init; } + public string Message { get; init; } = string.Empty; + public DateTime Timestamp { get; init; } = DateTime.UtcNow; + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static ChatMessage? FromNotification(IDictionary notificationData) + { + var channelName = notificationData.GetString(k.channel); + var command = notificationData.GetInt(k.command); + + // ChannelNotify.Message is 6 + if (command != 6) + { + return null; + } + + var innerData = notificationData.GetDictionary(k.data); + if (innerData == null) + { + return null; + } + + var senderId = innerData.GetInt(k.sender); + var message = innerData.GetString(k.message); + + return new ChatMessage + { + ChannelName = channelName, + SenderId = senderId, + Message = message, + Timestamp = DateTime.UtcNow, + RawData = (notificationData as IReadOnlyDictionary) ?? new Dictionary(notificationData) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/StorageItemInfo.cs b/src/Perpetuum.CliClient/Models/StorageItemInfo.cs new file mode 100644 index 00000000..59fbddc4 --- /dev/null +++ b/src/Perpetuum.CliClient/Models/StorageItemInfo.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class StorageItemInfo + { + public long Eid { get; init; } + public int Definition { get; init; } + public string DefinitionName { get; set; } = string.Empty; + public string Name { get; init; } = string.Empty; + public int Quantity { get; init; } + public bool IsRepackaged { get; init; } + public double Health { get; init; } + public double Volume { get; init; } + public long Parent { get; init; } + public long Owner { get; init; } + public List Items { get; init; } = new(); + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public string DisplayName => !string.IsNullOrWhiteSpace(Name) + ? Name + : (!string.IsNullOrWhiteSpace(DefinitionName) ? FormatDefinitionName(DefinitionName) : $"Item #{Definition}"); + + public static string FormatDefinitionName(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return string.Empty; + var text = raw; + if (text.StartsWith("def_", StringComparison.OrdinalIgnoreCase)) + { + text = text.Substring(4); + } + + var parts = text.Split('_', StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Length > 0) + { + parts[i] = char.ToUpperInvariant(parts[i][0]) + (parts[i].Length > 1 ? parts[i].Substring(1) : ""); + } + } + return string.Join(" ", parts); + } + + public static StorageItemInfo FromDictionary(IDictionary dict, Dictionary? definitionNames = null) + { + var defId = dict.GetInt(k.definition); + string defName = string.Empty; + if (definitionNames != null && definitionNames.TryGetValue(defId, out var dn)) + { + defName = dn; + } + else if (dict.TryGetValue(k.definitionName, out var dnObj) && dnObj != null) + { + defName = dnObj.ToString() ?? string.Empty; + } + + var subItems = new List(); + if (dict.TryGetValue(k.items, out var itemsObj) && itemsObj != null) + { + if (itemsObj is IDictionary itemsDict) + { + foreach (var kvp in itemsDict) + { + if (kvp.Value is IDictionary childDict) + { + subItems.Add(FromDictionary(childDict, definitionNames)); + } + } + } + else if (itemsObj is IEnumerable itemsEnum) + { + foreach (var item in itemsEnum) + { + if (item is IDictionary childDict) + { + subItems.Add(FromDictionary(childDict, definitionNames)); + } + } + } + } + + var quantity = dict.GetInt(k.quantity, 1); + if (quantity <= 0) quantity = 1; + + return new StorageItemInfo + { + Eid = dict.GetLong(k.eid), + Definition = defId, + DefinitionName = defName, + Name = dict.GetString(k.name), + Quantity = quantity, + IsRepackaged = dict.GetBool(k.repackaged), + Health = dict.GetDouble(k.health, 100.0), + Volume = dict.GetDouble(k.volume), + Parent = dict.GetLong(k.parent), + Owner = dict.GetLong(k.owner), + Items = subItems, + RawData = (dict as IReadOnlyDictionary) ?? new Dictionary(dict) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/StorageResult.cs b/src/Perpetuum.CliClient/Models/StorageResult.cs new file mode 100644 index 00000000..6db3acd9 --- /dev/null +++ b/src/Perpetuum.CliClient/Models/StorageResult.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Perpetuum.CliClient.Models +{ + public class StorageResult + { + public long ContainerEid { get; init; } + public int ContainerDefinition { get; init; } + public long BaseEid { get; init; } + public string BaseName { get; set; } = string.Empty; + public List Items { get; init; } = new(); + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public int TotalItemCount => Items.Sum(i => i.Quantity > 0 ? i.Quantity : 1); + public double TotalVolume => Items.Sum(i => i.Volume); + + public static StorageResult FromDictionary(IDictionary dict, Dictionary? definitionNames = null) + { + var itemsList = new List(); + if (dict.TryGetValue(k.items, out var itemsObj) && itemsObj != null) + { + if (itemsObj is IDictionary itemsDict) + { + foreach (var kvp in itemsDict) + { + if (kvp.Value is IDictionary itemDict) + { + itemsList.Add(StorageItemInfo.FromDictionary(itemDict, definitionNames)); + } + } + } + else if (itemsObj is IEnumerable itemsEnum) + { + foreach (var item in itemsEnum) + { + if (item is IDictionary itemDict) + { + itemsList.Add(StorageItemInfo.FromDictionary(itemDict, definitionNames)); + } + } + } + } + + return new StorageResult + { + ContainerEid = dict.GetLong(k.eid), + ContainerDefinition = dict.GetInt(k.definition), + BaseEid = dict.GetLong(k.baseEID), + Items = itemsList, + RawData = (dict as IReadOnlyDictionary) ?? new Dictionary(dict) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Models/WelcomeInfo.cs b/src/Perpetuum.CliClient/Models/WelcomeInfo.cs new file mode 100644 index 00000000..c5c0b27e --- /dev/null +++ b/src/Perpetuum.CliClient/Models/WelcomeInfo.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient.Models +{ + public class WelcomeInfo + { + public string WorldName { get; init; } = string.Empty; + public string Version { get; init; } = string.Empty; + public DateTime? OSTime { get; init; } + public bool SteamLoginEnabled { get; init; } + public string? ResourceServerUrl { get; init; } + public bool IsDev { get; init; } + public IReadOnlyDictionary RawData { get; init; } = new Dictionary(); + + public static WelcomeInfo FromDictionary(IDictionary data) + { + return new WelcomeInfo + { + WorldName = data.GetString(k.worldName, "Perpetuum"), + Version = data.GetString("version", string.Empty), + OSTime = data.GetDateTime(k.OSTime), + SteamLoginEnabled = data.GetBool("steamLoginEnabled"), + ResourceServerUrl = data.TryGetValue("resourceServerURL", out var rUrl) && rUrl != null ? rUrl.ToString() : null, + IsDev = data.GetBool("dev"), + RawData = (data as IReadOnlyDictionary) ?? new Dictionary(data) + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Perpetuum.CliClient.csproj b/src/Perpetuum.CliClient/Perpetuum.CliClient.csproj new file mode 100644 index 00000000..e3605ae0 --- /dev/null +++ b/src/Perpetuum.CliClient/Perpetuum.CliClient.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + enable + annotations + true + x64 + $(NoWarn);CA1416 + Perpetuum.CliClient + Perpetuum.CliClient + + + + + + + + diff --git a/src/Perpetuum.CliClient/PerpetuumClient.cs b/src/Perpetuum.CliClient/PerpetuumClient.cs new file mode 100644 index 00000000..a0cecd26 --- /dev/null +++ b/src/Perpetuum.CliClient/PerpetuumClient.cs @@ -0,0 +1,484 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Perpetuum.CliClient.Models; +using Perpetuum.Network; + +namespace Perpetuum.CliClient +{ + public class PerpetuumClient : IDisposable + { + private PerpetuumClientConnection? _connection; + private Dictionary? _definitionNamesCache; + private bool _isDisposed; + + public bool IsConnected => _connection != null; + public WelcomeInfo? Welcome { get; private set; } + public AccountInfo? Account { get; private set; } + public CharacterSelectResult? SelectedCharacter { get; private set; } + public IPEndPoint? RemoteEndPoint { get; private set; } + + public event Action? MessageReceived; + public event Action? ChatMessageReceived; + public event Action? RawMessageReceived; + public event Action? Disconnected; + + public static string HashPassword(string password) + { + if (string.IsNullOrEmpty(password)) + { + return string.Empty; + } + + // If already a 40-character hex string, assume it is already SHA-1 hashed + if (password.Length == 40 && password.All(c => Uri.IsHexDigit(c))) + { + return password.ToUpperInvariant(); + } + + byte[] bytes = Encoding.ASCII.GetBytes(password); + byte[] hash = SHA1.HashData(bytes); + return Convert.ToHexString(hash).ToUpperInvariant(); + } + + public async Task ConnectAsync(string host, int port, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(15); + var endPoint = await ResolveEndPointAsync(host, port, cancellationToken); + RemoteEndPoint = endPoint; + + _connection = PerpetuumClientConnection.Connect(endPoint); + _connection.MessageReceived += msg => + { + if (msg.Command?.Text == Commands.ChannelNotification.Text && msg.Data != null) + { + var chat = ChatMessage.FromNotification(msg.Data); + if (chat != null) + { + ChatMessageReceived?.Invoke(chat); + } + } + MessageReceived?.Invoke(msg); + }; + _connection.Received += text => RawMessageReceived?.Invoke(text); + _connection.Disconnected += conn => Disconnected?.Invoke(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(effectiveTimeout); + + try + { + var welcomeMessage = await _connection.SendHandshakeAsync().WaitAsync(cts.Token); + CheckResponseError(welcomeMessage); + Welcome = WelcomeInfo.FromDictionary(welcomeMessage.Data); + return Welcome; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Connection handshake timed out after {effectiveTimeout.TotalSeconds:F0}s connecting to {host}:{port}"); + } + } + + public async Task SignInAsync(string email, string password, bool alreadyHashed = false, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var passwordHash = alreadyHashed ? password.ToUpperInvariant() : HashPassword(password); + + var data = new Dictionary + { + { k.email, email }, + { k.password, passwordHash }, + { k.client, 0 }, + { k.hash, "CLI-" + Environment.MachineName }, + { k.language, 0 } + }; + + var request = new Message(Commands.SignIn, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + Account = AccountInfo.FromDictionary(response.Data); + return Account; + } + + public async Task GetCharacterListAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var request = new Message(Commands.CharacterList, new Dictionary()); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + return CharacterListResult.FromDictionary(response.Data); + } + + public async Task SelectCharacterAsync(int characterId, long baseEid = 0, string baseName = "", TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var data = new Dictionary + { + { k.characterID, characterId }, + { k.language, 0 } + }; + + var request = new Message(Commands.CharacterSelect, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + SelectedCharacter = CharacterSelectResult.FromDictionary(response.Data); + if (baseEid > 0 && SelectedCharacter.BaseEid == 0) + { + SelectedCharacter.BaseEid = baseEid; + } + if (!string.IsNullOrEmpty(baseName) && string.IsNullOrEmpty(SelectedCharacter.BaseName)) + { + SelectedCharacter.BaseName = baseName; + } + + return SelectedCharacter; + } + + public async Task DeselectCharacterAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + if (!IsConnected || SelectedCharacter == null) + { + return; + } + + var request = new Message(Commands.CharacterDeselect, new Dictionary()); + try + { + var response = await SendAsync(request, timeout ?? TimeSpan.FromSeconds(5), cancellationToken); + CheckResponseError(response); + } + finally + { + SelectedCharacter = null; + } + } + + public async Task SignOutAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + if (!IsConnected || Account == null) + { + return; + } + + if (SelectedCharacter != null) + { + await DeselectCharacterAsync(timeout, cancellationToken); + } + + var request = new Message(Commands.SignOut, new Dictionary()); + try + { + var response = await SendAsync(request, timeout ?? TimeSpan.FromSeconds(5), cancellationToken); + CheckResponseError(response); + } + finally + { + Account = null; + SelectedCharacter = null; + } + } + + public async Task CreateAccountAsync(string email, string password, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var passwordHash = HashPassword(password); + var cmd = Commands.GetCommandByText("accountOpenCreate") ?? new Command("accountOpenCreate") { AccessLevel = AccessLevel.notDefined }; + + var data = new Dictionary + { + { k.email, email }, + { k.password, passwordHash } + }; + + var request = new Message(cmd, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + return response; + } + + public async Task CreateCharacterAsync(string nick, int raceId = 1, int schoolId = 1, int majorId = 1, int sparkId = 1, Dictionary? avatar = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var data = new Dictionary + { + { k.nick, nick }, + { k.avatar, avatar ?? new Dictionary() }, + { k.raceID, raceId }, + { k.schoolID, schoolId }, + { k.majorID, majorId }, + { k.sparkID, sparkId } + }; + + var request = new Message(Commands.CharacterCreate, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + if (response.Data != null && response.Data.TryGetValue(k.characterID, out var cidObj)) + { + return Convert.ToInt32(cidObj); + } + + return 0; + } + + public async Task> GetEntityDefaultsAsync(bool forceRefresh = false, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + if (_definitionNamesCache != null && !forceRefresh) + { + return _definitionNamesCache; + } + + EnsureConnected(); + var request = new Message(Commands.GetEntityDefaults, new Dictionary()); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + var map = new Dictionary(); + if (response.Data != null) + { + foreach (var kvp in response.Data) + { + if (kvp.Value is IDictionary defDict) + { + var defId = defDict.GetInt(k.definition); + var defName = defDict.GetString(k.definitionName); + if (defId > 0 && !string.IsNullOrEmpty(defName)) + { + map[defId] = defName; + } + } + } + } + + _definitionNamesCache = map; + return _definitionNamesCache; + } + + public async Task GetStorageAsync(long? baseEid = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + long targetBaseEid = 0; + + if (baseEid.HasValue && baseEid.Value > 0) + { + targetBaseEid = baseEid.Value; + } + else if (SelectedCharacter != null && SelectedCharacter.BaseEid > 0) + { + targetBaseEid = SelectedCharacter.BaseEid; + } + + if (targetBaseEid == 0) + { + throw new InvalidOperationException("No station base specified and current character is not docked at a known base."); + } + + if (_definitionNamesCache == null) + { + try + { + await GetEntityDefaultsAsync(timeout: TimeSpan.FromSeconds(5), cancellationToken: cancellationToken); + } + catch + { + // Proceed even if entity defaults lookup fails + } + } + + var data = new Dictionary + { + { k.baseEID, targetBaseEid } + }; + + var request = new Message(Commands.BaseGetMyItems, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + var result = StorageResult.FromDictionary(response.Data, _definitionNamesCache); + if (string.IsNullOrEmpty(result.BaseName) && SelectedCharacter != null && !string.IsNullOrEmpty(SelectedCharacter.BaseName)) + { + result.BaseName = SelectedCharacter.BaseName; + } + + return result; + } + + public async Task> GetChannelsAsync(bool myChannelsOnly = false, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var cmd = myChannelsOnly ? Commands.ChannelMyList : Commands.ChannelList; + var request = new Message(cmd, new Dictionary()); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + + var list = new List(); + if (response.Data != null) + { + foreach (var kvp in response.Data) + { + if (kvp.Value is IDictionary channelDict) + { + list.Add(ChannelInfo.FromDictionary(channelDict)); + } + } + } + + return list; + } + + public async Task JoinChannelAsync(string channelName, string password = "", TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var data = new Dictionary + { + { k.channel, channelName }, + { k.password, password } + }; + + var request = new Message(Commands.ChannelJoin, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + } + + public async Task LeaveChannelAsync(string channelName, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var data = new Dictionary + { + { k.channel, channelName } + }; + + var request = new Message(Commands.ChannelLeave, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + } + + public async Task SendChatMessageAsync(string channelName, string messageText, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var data = new Dictionary + { + { k.channel, channelName }, + { k.message, messageText } + }; + + var request = new Message(Commands.ChannelTalk, data); + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + } + + public async Task SendCommandAsync(string commandText, Dictionary? parameters = null, string target = "", TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var cmd = Commands.GetCommandByText(commandText) ?? new Command(commandText); + var request = new Message(cmd, parameters ?? new Dictionary()) + { + Sender = target + }; + + var response = await SendAsync(request, timeout, cancellationToken); + CheckResponseError(response); + return response; + } + + public async Task SendAsync(IMessage clientMessage, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(15); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(effectiveTimeout); + + try + { + var sendTask = _connection!.SendAsync(clientMessage); + return await sendTask.WaitAsync(cts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Command '{clientMessage.Command?.Text}' timed out after {effectiveTimeout.TotalSeconds:F0}s waiting for server reply"); + } + } + + public static void CheckResponseError(IMessage response) + { + if (response.Data == null) + { + return; + } + + if (response.Data.TryGetValue(k.rErr, out var errObj) && errObj != null) + { + var errorCode = (ErrorCodes)Convert.ToInt32(errObj); + if (errorCode != ErrorCodes.NoError) + { + IDictionary? extra = null; + if (response.Data.TryGetValue(k.extra, out var extraObj) && extraObj is IDictionary ed) + { + extra = ed; + } + + throw new PerpetuumClientException(errorCode, extraData: extra != null ? new Dictionary(extra) : null); + } + } + } + + private void EnsureConnected() + { + if (_connection == null) + { + throw new InvalidOperationException("Not connected to a Perpetuum server. Call ConnectAsync first."); + } + } + + private static async Task ResolveEndPointAsync(string host, int port, CancellationToken cancellationToken) + { + if (IPAddress.TryParse(host, out var ip)) + { + return new IPEndPoint(ip, port); + } + + var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken); + var v4 = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) + ?? addresses.FirstOrDefault(); + + if (v4 == null) + { + throw new SocketException((int)SocketError.HostNotFound); + } + + return new IPEndPoint(v4, port); + } + + public void Disconnect() + { + if (_connection != null) + { + _connection.Disconnect(); + _connection.Dispose(); + _connection = null; + } + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + Disconnect(); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/Perpetuum.CliClient/PerpetuumClientConnection.cs b/src/Perpetuum.CliClient/PerpetuumClientConnection.cs new file mode 100644 index 00000000..3433ec97 --- /dev/null +++ b/src/Perpetuum.CliClient/PerpetuumClientConnection.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using Perpetuum.GenXY; +using Perpetuum.Network; + +namespace Perpetuum.CliClient +{ + public class PerpetuumClientConnection : EncryptedTcpConnection + { + private readonly Rc4 _rc4; + private readonly ConcurrentDictionary>> _commandQueue = + new(StringComparer.OrdinalIgnoreCase); + + public event Action? MessageReceived; + public new event Action? Received; + + public PerpetuumClientConnection(Socket socket) : base(socket) + { + _rc4 = new Rc4(FastRandom.NextBytes(40)); + ObjectHelper.Swap(ref inIncrement, ref outIncrement); + ObjectHelper.Swap(ref inDecodingByte, ref outEncodingByte); + } + + public static PerpetuumClientConnection Connect(IPEndPoint endPoint) + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) + { + NoDelay = true + }; + socket.Connect(endPoint); + var connection = new PerpetuumClientConnection(socket); + connection.Receive(); + return connection; + } + + public Task SendHandshakeAsync() + { + byte[]? encryptedKey = Rsa.Encrypt(_rc4.streamKey); + if (encryptedKey == null) + { + throw new InvalidOperationException("Failed to encrypt RSA handshake stream key."); + } + + byte[] data = new byte[encryptedKey.Length + 4]; + Array.Copy(encryptedKey, 0, data, 4, encryptedKey.Length); + + var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EnqueueCompletionSource(Commands.Welcome.Text, source); + + // Bypass RC4 for RSA handshake, rolling XOR cipher is still applied by base.Send + base.Send(data); + return source.Task; + } + + public Task SendAsync(IMessage clientMessage) + { + var commandText = clientMessage.Command?.Text ?? string.Empty; + var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EnqueueCompletionSource(commandText, source); + + Send(clientMessage.ToBytes()); + return source.Task; + } + + public override void Send(byte[] data) + { + byte[] t = new byte[data.Length + 4]; + Array.Copy(data, 0, t, 4, data.Length); + _rc4.Encrypt(t, 1, t.Length - 1); + base.Send(t); + } + + private void EnqueueCompletionSource(string commandText, TaskCompletionSource source) + { + var q = _commandQueue.GetOrAdd(commandText, _ => new ConcurrentQueue>()); + q.Enqueue(source); + } + + protected override void OnReceived(byte[] data) + { + if (data == null || data.Length == 0) + { + return; + } + + _rc4.Decrypt(data, 1, data.Length - 1); + + byte compressionLevel = data[0]; + string messageText; + + if (compressionLevel == 2) + { + byte[] decompressed = GZip.Decompress(data, 5); + int offset = 3; // Decompressed stream includes 3 leading zero bytes + int length = Math.Max(0, decompressed.Length - offset); + messageText = Encoding.UTF8.GetString(decompressed, offset, length); + } + else + { + int offset = 4; // Uncompressed includes 1 byte compression level + 3 zero bytes + int length = Math.Max(0, data.Length - offset); + messageText = Encoding.UTF8.GetString(data, offset, length); + } + + OnReceivedMessageText(messageText); + base.OnReceived(data); + } + + private void OnReceivedMessageText(string messageText) + { + if (string.IsNullOrEmpty(messageText)) + { + return; + } + + var parts = messageText.Split(new[] { ':' }, 3); + var commandText = parts[0]; + var command = Commands.GetCommandByText(commandText) ?? new Command(commandText); + var sender = parts.Length > 1 ? parts[1] : string.Empty; + var genxyData = parts.Length > 2 ? parts[2] : string.Empty; + + Dictionary deserializedData; + try + { + deserializedData = GenxyConverter.Deserialize(genxyData); + } + catch + { + deserializedData = new Dictionary(); + } + + var message = new Message(command, deserializedData) + { + Sender = sender + }; + + MessageReceived?.Invoke(message); + + if (_commandQueue.TryGetValue(commandText, out var q)) + { + if (q.TryDequeue(out var source)) + { + source.TrySetResult(message); + } + } + + Received?.Invoke(messageText); + } + } +} diff --git a/src/Perpetuum.CliClient/PerpetuumClientException.cs b/src/Perpetuum.CliClient/PerpetuumClientException.cs new file mode 100644 index 00000000..ea6ba969 --- /dev/null +++ b/src/Perpetuum.CliClient/PerpetuumClientException.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; + +namespace Perpetuum.CliClient +{ + public class PerpetuumClientException : Exception + { + public ErrorCodes ErrorCode { get; } + public IReadOnlyDictionary? ExtraData { get; } + + public PerpetuumClientException(ErrorCodes errorCode, string? message = null, IReadOnlyDictionary? extraData = null) + : base(message ?? FormatErrorMessage(errorCode, extraData)) + { + ErrorCode = errorCode; + ExtraData = extraData; + } + + public static string FormatErrorMessage(ErrorCodes errorCode, IReadOnlyDictionary? extra = null) + { + return errorCode switch + { + ErrorCodes.NoSuchUser => "Invalid username/email or password.", + ErrorCodes.AccountNotFound => "Account not found.", + ErrorCodes.AccountBanned => "Account is currently banned.", + ErrorCodes.AccountHasBeenDisconnected => "Account was already logged in on another session and has been disconnected. Please try again.", + ErrorCodes.NoSimultaneousLoginsAllowed => "Simultaneous logins are not allowed.", + ErrorCodes.RelayIsClosedForPublic => "Server is currently in maintenance or restricted to administrators.", + ErrorCodes.NotSignedIn => "You are not signed in.", + ErrorCodes.AccessDenied => "Access denied for this resource or character.", + ErrorCodes.OffensiveNick => "This character's nickname was flagged as offensive and must be renamed.", + ErrorCodes.CharacterNotFound => "Character not found.", + ErrorCodes.CharacterAlreadySelected => "A character is already selected in this session.", + ErrorCodes.AccountAlreadyExists => "An account with this email already exists.", + ErrorCodes.NoSuchCommand => "Unknown command.", + ErrorCodes.InsufficientPrivileges => "Insufficient privileges to execute this command.", + ErrorCodes.RequiredArgumentIsNotSpecified => "Required arguments were missing.", + ErrorCodes.InviteOnlyServer => "This server is invite-only.", + _ => $"Server error: {errorCode} (Code {(int)errorCode})" + }; + } + } +} diff --git a/src/Perpetuum.CliClient/Program.cs b/src/Perpetuum.CliClient/Program.cs new file mode 100644 index 00000000..fedbfec1 --- /dev/null +++ b/src/Perpetuum.CliClient/Program.cs @@ -0,0 +1,878 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Perpetuum.CliClient.Models; +using Perpetuum.GenXY; + +namespace Perpetuum.CliClient +{ + internal class Program + { + private static PerpetuumClient? _client; + private static bool _exitRequested; + private static string _selectedCharacterNick = string.Empty; + private static long _selectedCharacterBaseEid; + private static string _selectedCharacterBaseName = string.Empty; + private static bool _selectedCharacterIsDocked; + private static string _activeChannelName = string.Empty; + private static List _lastChannelsList = new(); + + private static async Task Main(string[] args) + { + var options = CommandLineOptions.Parse(args); + if (options.ShowHelp) + { + CommandLineOptions.PrintHelp(); + return 0; + } + + ConsoleHelper.WriteBanner(); + + using var client = new PerpetuumClient(); + _client = client; + + // Setup graceful shutdown on Ctrl+C + Console.CancelKeyPress += (sender, e) => + { + e.Cancel = true; + _exitRequested = true; + ConsoleHelper.Warn("Shutdown requested, closing session..."); + Task.Run(async () => + { + try + { + if (_client?.SelectedCharacter != null) + { + await _client.DeselectCharacterAsync(); + } + if (_client?.Account != null) + { + await _client.SignOutAsync(); + } + } + catch { } + finally + { + _client?.Disconnect(); + Environment.Exit(0); + } + }); + }; + + // Register background message logging + client.MessageReceived += msg => + { + if (msg.Command == Commands.SignIn || msg.Command == Commands.CharacterList || msg.Command == Commands.CharacterSelect || msg.Command == Commands.Welcome || msg.Command == Commands.ChannelNotification) + { + return; // already handled by command response tasks or chat event + } + + var sender = (msg as Message)?.Sender ?? string.Empty; + Console.ForegroundColor = ConsoleColor.DarkMagenta; + Console.WriteLine($"\n[SERVER MSG] Command: {msg.Command?.Text} Sender: {sender}"); + if (msg.Data != null && msg.Data.Count > 0) + { + foreach (var kvp in msg.Data) + { + Console.WriteLine($" {kvp.Key}: {kvp.Value}"); + } + } + Console.ResetColor(); + }; + + // Register chat message notifications + client.ChatMessageReceived += chat => + { + ConsoleHelper.PrintChatMessage(chat); + }; + + client.Disconnected += () => + { + if (!_exitRequested) + { + ConsoleHelper.Warn("Disconnected from server."); + } + }; + + try + { + // Step 1: Connect & Handshake + ConsoleHelper.Info($"Connecting to Perpetuum Server at {options.Host}:{options.Port}..."); + var welcome = await client.ConnectAsync(options.Host, options.Port); + ConsoleHelper.Success("Connected and RSA/RC4 encryption handshake established."); + ConsoleHelper.PrintWelcome(welcome, $"{options.Host}:{options.Port}"); + + // Step 2 (Optional): Create Account + if (options.CreateAccount) + { + var email = options.Email; + if (string.IsNullOrWhiteSpace(email)) + { + email = ConsoleHelper.ReadLine("Enter new account email"); + } + var password = options.Password; + if (string.IsNullOrWhiteSpace(password)) + { + password = ConsoleHelper.ReadPassword("Enter new account password"); + } + + ConsoleHelper.Info($"Creating account for {email}..."); + await client.CreateAccountAsync(email, password); + ConsoleHelper.Success("Account created successfully!"); + options.Email = email; + options.Password = password; + } + + // Step 3: Login + AccountInfo account; + while (true) + { + var email = options.Email; + if (string.IsNullOrWhiteSpace(email)) + { + email = ConsoleHelper.ReadLine("Email / Username"); + } + + var password = options.Password; + var passwordHash = options.PasswordHash; + + if (string.IsNullOrWhiteSpace(password) && string.IsNullOrWhiteSpace(passwordHash)) + { + password = ConsoleHelper.ReadPassword("Password"); + } + + ConsoleHelper.Info($"Signing in as '{email}'..."); + try + { + account = await client.SignInAsync(email, password ?? passwordHash!, alreadyHashed: !string.IsNullOrEmpty(passwordHash)); + ConsoleHelper.Success($"Signed in successfully as {account.Email} (Account ID: {account.AccountId})"); + ConsoleHelper.PrintAccount(account); + break; + } + catch (PerpetuumClientException pex) + { + ConsoleHelper.Error(pex.Message); + if (options.NonInteractive) + { + return 1; + } + + // Reset credentials so user can re-enter + options.Email = null; + options.Password = null; + options.PasswordHash = null; + } + } + + // Step 3.5 (Optional): Create Character via CLI option + if (!string.IsNullOrWhiteSpace(options.CreateCharacterNick)) + { + ConsoleHelper.Info($"Creating character '{options.CreateCharacterNick}'..."); + var newCharId = await client.CreateCharacterAsync(options.CreateCharacterNick, options.RaceId, options.SchoolId, options.MajorId, options.SparkId); + ConsoleHelper.Success($"Character '{options.CreateCharacterNick}' created successfully! (Character ID: {newCharId})"); + options.Character = options.CreateCharacterNick; + } + + // Step 4: Get Character List + ConsoleHelper.Info("Retrieving characters..."); + var charListResult = await client.GetCharacterListAsync(); + ConsoleHelper.PrintCharactersTable(charListResult.Characters, charListResult.ExtensionPoints); + + // Step 5: Select Character + CharacterSummary? targetChar = null; + if (!string.IsNullOrWhiteSpace(options.Character)) + { + if (int.TryParse(options.Character, out var cid)) + { + targetChar = charListResult.Characters.FirstOrDefault(c => c.CharacterId == cid); + } + if (targetChar == null) + { + targetChar = charListResult.Characters.FirstOrDefault(c => string.Equals(c.Nick, options.Character, StringComparison.OrdinalIgnoreCase)); + } + + if (targetChar == null) + { + ConsoleHelper.Warn($"Character '{options.Character}' not found on account."); + } + } + + if (targetChar == null && charListResult.Characters.Count == 0 && !options.NonInteractive) + { + var createChoice = ConsoleHelper.ReadLine("No characters found on this account. Create a new character now? (Y/n)", "Y"); + if (createChoice.Equals("y", StringComparison.OrdinalIgnoreCase) || createChoice.Equals("yes", StringComparison.OrdinalIgnoreCase)) + { + var newNick = ConsoleHelper.ReadLine("Enter character nickname"); + if (!string.IsNullOrWhiteSpace(newNick)) + { + ConsoleHelper.Info($"Creating character '{newNick}'..."); + var newId = await client.CreateCharacterAsync(newNick); + ConsoleHelper.Success($"Character '{newNick}' created! (ID: {newId})"); + charListResult = await client.GetCharacterListAsync(); + ConsoleHelper.PrintCharactersTable(charListResult.Characters, charListResult.ExtensionPoints); + targetChar = charListResult.Characters.FirstOrDefault(c => c.CharacterId == newId); + } + } + } + + if (targetChar == null && charListResult.Characters.Count > 0) + { + if (options.NonInteractive) + { + // In non-interactive mode with no character specified, pick the first character + targetChar = charListResult.Characters[0]; + } + else + { + while (targetChar == null) + { + var input = ConsoleHelper.ReadLine($"Select character (1-{charListResult.Characters.Count}, ID, Nick, 'new' to create, or 'q' to skip)", "1"); + if (string.Equals(input, "q", StringComparison.OrdinalIgnoreCase) || string.Equals(input, "quit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + if (string.Equals(input, "new", StringComparison.OrdinalIgnoreCase) || string.Equals(input, "create", StringComparison.OrdinalIgnoreCase)) + { + var newNick = ConsoleHelper.ReadLine("Enter character nickname"); + if (!string.IsNullOrWhiteSpace(newNick)) + { + ConsoleHelper.Info($"Creating character '{newNick}'..."); + var newId = await client.CreateCharacterAsync(newNick); + ConsoleHelper.Success($"Character '{newNick}' created! (ID: {newId})"); + charListResult = await client.GetCharacterListAsync(); + ConsoleHelper.PrintCharactersTable(charListResult.Characters, charListResult.ExtensionPoints); + targetChar = charListResult.Characters.FirstOrDefault(c => c.CharacterId == newId); + if (targetChar != null) break; + } + continue; + } + + if (int.TryParse(input, out var idx) && idx >= 1 && idx <= charListResult.Characters.Count) + { + targetChar = charListResult.Characters[idx - 1]; + } + else if (int.TryParse(input, out var charId)) + { + targetChar = charListResult.Characters.FirstOrDefault(c => c.CharacterId == charId); + } + else + { + targetChar = charListResult.Characters.FirstOrDefault(c => string.Equals(c.Nick, input, StringComparison.OrdinalIgnoreCase)); + } + + if (targetChar == null) + { + ConsoleHelper.Warn("Invalid selection, please try again."); + } + } + } + } + + if (targetChar != null) + { + ConsoleHelper.Info($"Selecting character '{targetChar.Nick}' (ID: {targetChar.CharacterId})..."); + var selectResult = await client.SelectCharacterAsync(targetChar.CharacterId, targetChar.BaseEid, targetChar.BaseName); + _selectedCharacterNick = targetChar.Nick; + _selectedCharacterBaseEid = targetChar.BaseEid; + _selectedCharacterBaseName = targetChar.BaseName; + _selectedCharacterIsDocked = targetChar.IsDocked; + ConsoleHelper.Success($"Character '{targetChar.Nick}' selected successfully!"); + ConsoleHelper.PrintSelectedCharacter(selectResult, targetChar.Nick); + + if (targetChar.IsDocked) + { + if (options.ListStorage) + { + ConsoleHelper.Info("Retrieving private station storage..."); + try + { + var storage = await client.GetStorageAsync(targetChar.BaseEid); + ConsoleHelper.PrintStorageTable(storage, targetChar.Nick); + } + catch (Exception ex) + { + ConsoleHelper.Warn($"Could not retrieve station storage: {ex.Message}"); + } + } + else + { + var baseLabel = !string.IsNullOrEmpty(targetChar.BaseName) ? targetChar.BaseName : $"Base #{targetChar.BaseEid}"; + ConsoleHelper.Info($"Docked at: {baseLabel}. Type 'storage' to view private storage items."); + } + } + } + else + { + ConsoleHelper.Info("No character selected."); + } + + // Handle CLI channel / chat operations + if (options.ListChannels) + { + ConsoleHelper.Info("Retrieving chat channels..."); + var channels = await client.GetChannelsAsync(); + _lastChannelsList = channels; + ConsoleHelper.PrintChannelsTable(channels); + } + + if (!string.IsNullOrWhiteSpace(options.Channel)) + { + _activeChannelName = options.Channel; + ConsoleHelper.Info($"Joining channel '{_activeChannelName}'..."); + try + { + await client.JoinChannelAsync(_activeChannelName); + ConsoleHelper.Success($"Joined channel '{_activeChannelName}'."); + } + catch (PerpetuumClientException pex) + { + ConsoleHelper.Warn($"Join channel notice: {pex.Message}"); + } + } + + if (!string.IsNullOrWhiteSpace(options.ChatMessage)) + { + var targetChan = !string.IsNullOrWhiteSpace(options.Channel) ? options.Channel : "General"; + ConsoleHelper.Info($"Sending chat message to channel '{targetChan}': {options.ChatMessage}"); + await client.SendChatMessageAsync(targetChan, options.ChatMessage); + ConsoleHelper.Success("Chat message sent successfully."); + } + + if (options.NonInteractive) + { + ConsoleHelper.Success("Operation completed in non-interactive mode."); + return 0; + } + + // Step 6: Interactive REPL loop + await RunInteractiveLoopAsync(client); + return 0; + } + catch (Exception ex) + { + ConsoleHelper.Error($"Fatal error: {ex.Message}"); + return 1; + } + finally + { + try + { + if (client.SelectedCharacter != null) + { + await client.DeselectCharacterAsync(); + } + if (client.Account != null) + { + await client.SignOutAsync(); + } + } + catch { } + client.Disconnect(); + } + } + + private static async Task RunInteractiveLoopAsync(PerpetuumClient client) + { + Console.WriteLine(); + ConsoleHelper.Info("Entered interactive shell. Type 'help' for commands, 'exit' to quit."); + Console.WriteLine(); + + while (!_exitRequested && client.IsConnected) + { + var promptLabel = BuildPromptLabel(client); + + Console.ForegroundColor = ConsoleColor.Green; + Console.Write(promptLabel); + Console.ResetColor(); + + var line = Console.ReadLine(); + if (line == null) + { + break; + } + + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + continue; + } + + var parts = trimmed.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + var cmd = parts[0].ToLowerInvariant(); + + try + { + switch (cmd) + { + case "help": + case "?": + PrintShellHelp(); + break; + + case "whoami": + case "me": + case "info": + if (client.Account != null) + { + ConsoleHelper.PrintAccount(client.Account); + } + if (client.SelectedCharacter != null) + { + ConsoleHelper.PrintSelectedCharacter(client.SelectedCharacter, _selectedCharacterNick); + } + if (!string.IsNullOrEmpty(_activeChannelName)) + { + ConsoleHelper.Info($"Active chat channel: #{_activeChannelName}"); + } + break; + + case "chars": + case "list": + ConsoleHelper.Info("Refreshing character list..."); + var charList = await client.GetCharacterListAsync(); + ConsoleHelper.PrintCharactersTable(charList.Characters, charList.ExtensionPoints); + break; + + case "create-char": + case "createchar": + case "newchar": + case "char-create": + string charNick = parts.Length > 1 ? parts[1] : string.Empty; + if (string.IsNullOrWhiteSpace(charNick)) + { + charNick = ConsoleHelper.ReadLine("Enter character nickname"); + } + if (string.IsNullOrWhiteSpace(charNick)) + { + ConsoleHelper.Warn("Character nickname cannot be empty."); + break; + } + + int race = 1; + int school = 1; + int major = 1; + int spark = 1; + + for (int i = 2; i < parts.Length; i++) + { + var eqIdx = parts[i].IndexOf('='); + if (eqIdx > 0) + { + var key = parts[i].Substring(0, eqIdx).ToLowerInvariant(); + var val = parts[i].Substring(eqIdx + 1); + if (key == "race" && int.TryParse(val, out var rv)) race = rv; + else if (key == "school" && int.TryParse(val, out var sv)) school = sv; + else if (key == "major" && int.TryParse(val, out var mv)) major = mv; + else if (key == "spark" && int.TryParse(val, out var spv)) spark = spv; + } + else if (int.TryParse(parts[i], out var numVal)) + { + if (i == 2) race = numVal; + else if (i == 3) school = numVal; + else if (i == 4) major = numVal; + else if (i == 5) spark = numVal; + } + } + + ConsoleHelper.Info($"Creating character '{charNick}' (Race: {race}, School: {school})..."); + var createdCharId = await client.CreateCharacterAsync(charNick, race, school, major, spark); + ConsoleHelper.Success($"Character '{charNick}' created successfully! (Character ID: {createdCharId})"); + + var refreshedChars = await client.GetCharacterListAsync(); + ConsoleHelper.PrintCharactersTable(refreshedChars.Characters, refreshedChars.ExtensionPoints); + + var selectNow = ConsoleHelper.ReadLine($"Select character '{charNick}' now? (Y/n)", "Y"); + if (selectNow.Equals("y", StringComparison.OrdinalIgnoreCase) || selectNow.Equals("yes", StringComparison.OrdinalIgnoreCase)) + { + if (client.SelectedCharacter != null) + { + await client.DeselectCharacterAsync(); + } + var createdChar = refreshedChars.Characters.FirstOrDefault(c => c.CharacterId == createdCharId); + var baseEid = createdChar?.BaseEid ?? 0; + var baseName = createdChar?.BaseName ?? string.Empty; + var isDocked = createdChar?.IsDocked ?? true; + + var selectRes = await client.SelectCharacterAsync(createdCharId, baseEid, baseName); + _selectedCharacterNick = charNick; + _selectedCharacterBaseEid = baseEid; + _selectedCharacterBaseName = baseName; + _selectedCharacterIsDocked = isDocked; + ConsoleHelper.Success($"Character '{charNick}' selected."); + ConsoleHelper.PrintSelectedCharacter(selectRes, charNick); + } + break; + + case "select": + if (parts.Length < 2) + { + ConsoleHelper.Warn("Usage: select "); + break; + } + var listForSelect = await client.GetCharacterListAsync(); + var targetArg = parts[1]; + CharacterSummary? target = null; + if (int.TryParse(targetArg, out var num) && num >= 1 && num <= listForSelect.Characters.Count) + { + target = listForSelect.Characters[num - 1]; + } + else if (int.TryParse(targetArg, out var cid)) + { + target = listForSelect.Characters.FirstOrDefault(c => c.CharacterId == cid); + } + else + { + target = listForSelect.Characters.FirstOrDefault(c => string.Equals(c.Nick, targetArg, StringComparison.OrdinalIgnoreCase)); + } + + if (target == null) + { + ConsoleHelper.Error($"Character '{targetArg}' not found."); + break; + } + + ConsoleHelper.Info($"Selecting character '{target.Nick}' (ID: {target.CharacterId})..."); + var selRes = await client.SelectCharacterAsync(target.CharacterId, target.BaseEid, target.BaseName); + _selectedCharacterNick = target.Nick; + _selectedCharacterBaseEid = target.BaseEid; + _selectedCharacterBaseName = target.BaseName; + _selectedCharacterIsDocked = target.IsDocked; + ConsoleHelper.Success($"Character '{target.Nick}' selected."); + ConsoleHelper.PrintSelectedCharacter(selRes, target.Nick); + if (target.IsDocked) + { + var baseLabel = !string.IsNullOrEmpty(target.BaseName) ? target.BaseName : $"Base #{target.BaseEid}"; + ConsoleHelper.Info($"Docked at: {baseLabel}. Type 'storage' to view private storage items."); + } + break; + + case "deselect": + if (client.SelectedCharacter == null) + { + ConsoleHelper.Warn("No character currently selected."); + break; + } + ConsoleHelper.Info("Deselecting character..."); + await client.DeselectCharacterAsync(); + _selectedCharacterNick = string.Empty; + _selectedCharacterBaseEid = 0; + _selectedCharacterBaseName = string.Empty; + _selectedCharacterIsDocked = false; + _activeChannelName = string.Empty; + ConsoleHelper.Success("Character deselected."); + break; + + case "channels": + case "chans": + bool myOnly = parts.Length > 1 && string.Equals(parts[1], "my", StringComparison.OrdinalIgnoreCase); + ConsoleHelper.Info(myOnly ? "Retrieving joined channels..." : "Retrieving public channels..."); + var channels = await client.GetChannelsAsync(myChannelsOnly: myOnly); + _lastChannelsList = channels; + ConsoleHelper.PrintChannelsTable(channels, myOnly ? "MY CHANNELS" : "AVAILABLE CHANNELS"); + break; + + case "channel": + case "chan": + case "select-channel": + if (parts.Length < 2) + { + if (!string.IsNullOrEmpty(_activeChannelName)) + { + ConsoleHelper.Info($"Active channel: #{_activeChannelName}"); + } + else + { + ConsoleHelper.Warn("Usage: channel "); + } + break; + } + + var chanArg = parts[1]; + string targetChanName = chanArg; + + if (int.TryParse(chanArg, out var chanIdx) && chanIdx >= 1 && chanIdx <= _lastChannelsList.Count) + { + targetChanName = _lastChannelsList[chanIdx - 1].Name; + } + + if (targetChanName.StartsWith("#")) + { + targetChanName = targetChanName.Substring(1); + } + + ConsoleHelper.Info($"Selecting channel '{targetChanName}'..."); + try + { + await client.JoinChannelAsync(targetChanName); + } + catch (PerpetuumClientException pex) when (pex.ErrorCode == ErrorCodes.CharacterAlreadyOnChannel) + { + // Already in channel, proceed + } + + _activeChannelName = targetChanName; + ConsoleHelper.Success($"Active channel set to #{_activeChannelName}."); + break; + + case "join": + if (parts.Length < 2) + { + ConsoleHelper.Warn("Usage: join [password]"); + break; + } + var joinName = parts[1].TrimStart('#'); + var password = parts.Length > 2 ? parts[2] : string.Empty; + + ConsoleHelper.Info($"Joining channel '{joinName}'..."); + await client.JoinChannelAsync(joinName, password); + _activeChannelName = joinName; + ConsoleHelper.Success($"Joined and selected channel #{_activeChannelName}."); + break; + + case "leave": + var leaveName = parts.Length > 1 ? parts[1].TrimStart('#') : _activeChannelName; + if (string.IsNullOrEmpty(leaveName)) + { + ConsoleHelper.Warn("Usage: leave "); + break; + } + ConsoleHelper.Info($"Leaving channel '{leaveName}'..."); + await client.LeaveChannelAsync(leaveName); + if (string.Equals(_activeChannelName, leaveName, StringComparison.OrdinalIgnoreCase)) + { + _activeChannelName = string.Empty; + } + ConsoleHelper.Success($"Left channel '{leaveName}'."); + break; + + case "msg": + case "chat": + case "say": + if (parts.Length < 2) + { + ConsoleHelper.Warn("Usage: msg OR msg # "); + break; + } + + string msgChannel = _activeChannelName; + string messageText; + + if (parts[1].StartsWith("#") && parts.Length > 2) + { + msgChannel = parts[1].Substring(1); + messageText = trimmed.Substring(parts[0].Length + parts[1].Length + 2).Trim(); + } + else + { + messageText = trimmed.Substring(parts[0].Length + 1).Trim(); + } + + if (string.IsNullOrEmpty(msgChannel)) + { + ConsoleHelper.Warn("No active channel selected. Use 'channel ' or 'msg # '."); + break; + } + + if (client.SelectedCharacter == null) + { + ConsoleHelper.Warn("Select a character first before chatting."); + break; + } + + await client.SendChatMessageAsync(msgChannel, messageText); + break; + + case "profile": + if (client.SelectedCharacter == null) + { + ConsoleHelper.Warn("Select a character first to view profile."); + break; + } + ConsoleHelper.Info("Requesting character profile..."); + var profileCmd = Commands.GetCommandByText("characterGetMyProfile") ?? new Command("characterGetMyProfile"); + var profRes = await client.SendAsync(new Message(profileCmd, new Dictionary())); + PerpetuumClient.CheckResponseError(profRes); + ConsoleHelper.Success("Profile data received:"); + PrintDictionary(profRes.Data, 1); + break; + + case "storage": + case "hangar": + case "inventory": + case "items": + case "list-storage": + if (client.SelectedCharacter == null) + { + ConsoleHelper.Warn("Select a character first to view station storage."); + break; + } + + long targetBaseEid = _selectedCharacterBaseEid; + if (parts.Length > 1 && long.TryParse(parts[1], out var customBaseEid)) + { + targetBaseEid = customBaseEid; + } + + if (targetBaseEid == 0 && !_selectedCharacterIsDocked) + { + ConsoleHelper.Warn("Character is not currently docked at a station. Usage: storage [baseEid]"); + break; + } + + var baseNameStr = !string.IsNullOrEmpty(_selectedCharacterBaseName) && targetBaseEid == _selectedCharacterBaseEid + ? $"{_selectedCharacterBaseName} (#{targetBaseEid})" + : $"Base #{targetBaseEid}"; + ConsoleHelper.Info($"Retrieving station storage ({baseNameStr})..."); + var storageRes = await client.GetStorageAsync(targetBaseEid > 0 ? targetBaseEid : (long?)null); + ConsoleHelper.PrintStorageTable(storageRes, _selectedCharacterNick); + break; + + case "send": + case "cmd": + if (parts.Length < 2) + { + ConsoleHelper.Warn("Usage: send [key1=val1 key2=val2 ...]"); + break; + } + var commandName = parts[1]; + var paramDict = new Dictionary(); + for (int i = 2; i < parts.Length; i++) + { + var eqIdx = parts[i].IndexOf('='); + if (eqIdx > 0) + { + var k = parts[i].Substring(0, eqIdx); + var v = parts[i].Substring(eqIdx + 1); + if (int.TryParse(v, out var iv)) + paramDict[k] = iv; + else if (long.TryParse(v, out var lv)) + paramDict[k] = lv; + else if (bool.TryParse(v, out var bv)) + paramDict[k] = bv; + else + paramDict[k] = v; + } + } + + ConsoleHelper.Info($"Sending command '{commandName}'..."); + var customRes = await client.SendCommandAsync(commandName, paramDict); + ConsoleHelper.Success($"Received response for '{customRes.Command?.Text}':"); + PrintDictionary(customRes.Data, 1); + break; + + case "status": + case "ping": + ConsoleHelper.Info($"Connected to: {client.RemoteEndPoint}"); + ConsoleHelper.Info($"Session authenticated: {client.Account != null} (Account ID: {client.Account?.AccountId})"); + ConsoleHelper.Info($"Selected character: {(!string.IsNullOrEmpty(_selectedCharacterNick) ? _selectedCharacterNick : "None")}"); + ConsoleHelper.Info($"Active channel: {(!string.IsNullOrEmpty(_activeChannelName) ? $"#{_activeChannelName}" : "None")}"); + break; + + case "cls": + case "clear": + Console.Clear(); + ConsoleHelper.WriteBanner(); + break; + + case "logout": + case "signout": + ConsoleHelper.Info("Signing out..."); + await client.SignOutAsync(); + _selectedCharacterNick = string.Empty; + _activeChannelName = string.Empty; + ConsoleHelper.Success("Signed out."); + return; + + case "exit": + case "quit": + case "q": + ConsoleHelper.Info("Exiting..."); + return; + + default: + ConsoleHelper.Warn($"Unknown command '{cmd}'. Type 'help' for available commands."); + break; + } + } + catch (PerpetuumClientException pex) + { + ConsoleHelper.Error(pex.Message); + } + catch (Exception ex) + { + ConsoleHelper.Error($"Command error: {ex.Message}"); + } + } + } + + private static string BuildPromptLabel(PerpetuumClient client) + { + if (!string.IsNullOrEmpty(_selectedCharacterNick)) + { + if (!string.IsNullOrEmpty(_activeChannelName)) + { + return $"[Perpetuum:{_selectedCharacterNick} (#{_activeChannelName})]> "; + } + return $"[Perpetuum:{_selectedCharacterNick}]> "; + } + + if (client.Account != null) + { + return $"[Perpetuum:{client.Account.Email}]> "; + } + + return "[Perpetuum]> "; + } + + private static void PrintShellHelp() + { + Console.WriteLine(@" +Interactive Commands: + help, ? Show this help message + whoami, me, info Show account, character, and channel details + chars, list List all characters on the account + create-char [race=1] Create a new character (aliases: newchar, createchar) + select <#|id|name> Select a character by table number, ID, or nickname + deselect Deselect current character + storage [baseEid] List private station storage (aliases: hangar, inventory, items) + +Chat & Channels: + channels [public|my] List available or joined chat channels + channel Select/set the active chat channel + join [password] Join a channel and set as active + leave [name] Leave a channel + msg Send a message to the active channel + msg # Send a message to a specific channel + +General & Raw Commands: + profile Request and display character profile + send [k=v ...] Send a raw command to the server with optional key=value arguments + status, ping Show connection and session status + cls, clear Clear console screen + logout, signout Sign out of account + exit, quit, q Disconnect and exit +"); + } + + private static void PrintDictionary(IDictionary? dict, int indent) + { + if (dict == null) return; + var prefix = new string(' ', indent * 2); + foreach (var kvp in dict) + { + if (kvp.Value is IDictionary nested) + { + Console.WriteLine($"{prefix}{kvp.Key}:"); + PrintDictionary(nested, indent + 1); + } + else + { + Console.WriteLine($"{prefix}{kvp.Key}: {kvp.Value}"); + } + } + } + } +} 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..bcb7d857 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.Size, font, paint)); + bitmap.WithCanvas(gx => gx.DrawText(littleText, 20, 60 + font.Size, 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..bbde225f 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.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; } - 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..9a86feea 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.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; @@ -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.Size, 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.Size, 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.Size, 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.Size, 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.Size, 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.Size, 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.Size, 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.Size, 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..4f8d6071 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).Alpha == 0) { return c; } diff --git a/src/Perpetuum.Server/Program.cs b/src/Perpetuum.Server/Program.cs index 84fb80a0..d828f3d2 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); + // When using PerpetuumServer, the DistributedTransactions is enabled + bootstrapper.Init(gameRoot.Value, true); if (bootstrapper.TryInitUpnp(out bool upnpSuccess)) { 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/src/Perpetuum.Tests.Integration/Content/DefinitionTintInvariantTests.cs b/src/Perpetuum.Tests.Integration/Content/DefinitionTintInvariantTests.cs new file mode 100644 index 00000000..7bb94519 --- /dev/null +++ b/src/Perpetuum.Tests.Integration/Content/DefinitionTintInvariantTests.cs @@ -0,0 +1,43 @@ +using Microsoft.Data.SqlClient; +using Perpetuum.Tests.Integration.Infrastructure; +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Integration.Content +{ + [Collection(DatabaseCollection.Name)] + public class DefinitionTintInvariantTests + { + [RequiresGameRootFact] + public void Every_definition_tint_in_the_database_is_parseable_by_SkiaSharp() + { + DatabaseFixture fixture = new(); + using SqlConnection connection = fixture.OpenConnection(); + + using SqlCommand command = connection.CreateCommand(); + command.CommandText = """ + SELECT definition, tint + FROM dbo.definitionconfig + WHERE tint IS NOT NULL AND LTRIM(RTRIM(tint)) <> '' + """; + + using SqlDataReader reader = command.ExecuteReader(); + List unparseable = []; + + while (reader.Read()) + { + int definition = reader.GetInt32(0); + string tint = reader.GetString(1); + + if (!SKColor.TryParse(tint, out _)) + { + unparseable.Add($"Definition {definition}: '{tint}'"); + } + } + + Assert.True( + unparseable.Count == 0, + $"The following definitionconfig rows carry invalid/unparseable tint values for SkiaSharp: {string.Join(", ", unparseable)}"); + } + } +} diff --git a/src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj b/src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj index b7496cdb..5b704386 100644 --- a/src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj +++ b/src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj @@ -12,10 +12,13 @@ + + + diff --git a/src/Perpetuum.Tests.Integration/Skia/BitmapImageIntegrationTests.cs b/src/Perpetuum.Tests.Integration/Skia/BitmapImageIntegrationTests.cs new file mode 100644 index 00000000..41809865 --- /dev/null +++ b/src/Perpetuum.Tests.Integration/Skia/BitmapImageIntegrationTests.cs @@ -0,0 +1,192 @@ +using NSubstitute; +using Perpetuum.Host.Requests; +using Perpetuum.IO; +using Perpetuum.RequestHandlers.Zone; +using Perpetuum.Zones; +using Perpetuum.Zones.Terrains; +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Integration.Skia +{ + public class BitmapImageIntegrationTests : IDisposable + { + private readonly string _tempDirectory; + + public BitmapImageIntegrationTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), "opp_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(_tempDirectory, "bitmaps")); + Message.MessageBuilderFactory = () => new MessageBuilder(null, Substitute.For(), null); + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + try + { + Directory.Delete(_tempDirectory, recursive: true); + } + catch + { + // Ignored on cleanup + } + } + } + + [Fact] + public void SKBitmap_and_SKCanvas_encode_to_PNG_and_decode_accurately() + { + const int width = 16; + const int height = 16; + using var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); + + bitmap.WithCanvas(canvas => + { + canvas.Clear(SKColors.Transparent); + + using var redPaint = new SKPaint { Color = SKColors.Red, Style = SKPaintStyle.Fill }; + canvas.DrawRect(0, 0, 8, 8, redPaint); + + using var bluePaint = new SKPaint { Color = SKColors.Blue, Style = SKPaintStyle.Fill }; + canvas.DrawRect(8, 8, 8, 8, bluePaint); + }); + + // Encode to PNG stream + using var ms = new MemoryStream(); + bool encoded = bitmap.Encode(ms, SKEncodedImageFormat.Png, 100); + Assert.True(encoded); + Assert.True(ms.Length > 0); + + // Decode back with SKImage + ms.Position = 0; + using var img = SKImage.FromEncodedData(ms); + Assert.NotNull(img); + Assert.Equal(width, img.Width); + Assert.Equal(height, img.Height); + + using var decodedBmp = SKBitmap.FromImage(img); + Assert.NotNull(decodedBmp); + Assert.Equal(SKColors.Red, decodedBmp.GetPixel(4, 4)); + Assert.Equal(SKColors.Blue, decodedBmp.GetPixel(12, 12)); + Assert.Equal(SKColors.Empty, decodedBmp.GetPixel(12, 4)); // Transparent + } + + [Fact] + public void SaveBitmapHelper_writes_valid_PNG_to_filesystem() + { + var fileSystem = new FileSystem(_tempDirectory); + var zone = Substitute.For(); + zone.Id.Returns(1); + + var expectedFileName = zone.CreateTerrainDataFilename("stat_map", "png"); + var expectedFilePath = Path.Combine(_tempDirectory, "bitmaps", expectedFileName); + + using var bmp = new SKBitmap(8, 8, SKColorType.Rgba8888, SKAlphaType.Premul); + bmp.WithCanvas(c => c.Clear(SKColors.Green)); + + var helper = new SaveBitmapHelper(fileSystem); + helper.SaveBitmap(zone, bmp, "stat_map"); + + Assert.True(File.Exists(expectedFilePath)); + + using var readImg = SKImage.FromEncodedData(expectedFilePath); + Assert.NotNull(readImg); + using var readBmp = SKBitmap.FromImage(readImg); + Assert.Equal(SKColors.Green, readBmp.GetPixel(0, 0)); + Assert.Equal(SKColors.Green, readBmp.GetPixel(7, 7)); + } + + [Fact] + public void ZoneExtensions_CreatePassableBitmap_generates_correct_pixel_map() + { + var zone = Substitute.For(); + zone.Size.Returns(new SKSizeI(4, 4)); + + var blocks = new Layer(LayerType.Blocks, 4, 4); + var altitude = new AltitudeLayer(new ushort[4 * 4], 4, 4); + var slope = new SlopeLayer(altitude); + + var terrain = Substitute.For(); + terrain.Passable.Returns((ILayer)null!); + terrain.Blocks.Returns(blocks); + terrain.Slope.Returns(slope); + + // (0,0) is island + blocks[0, 0] = new BlockingInfo { Island = true }; + // (1,1) is passable (Flags = 0) + blocks[1, 1] = new BlockingInfo(); + // (2,2) is impassable/blocked (Flags != 0) + blocks[2, 2] = new BlockingInfo { Obstacle = true }; + + zone.Terrain.Returns(terrain); + + var passableColor = SKColors.Lime; + var islandColor = SKColors.Yellow; + + using var bmp = zone.CreatePassableBitmap(passableColor, islandColor); + + Assert.NotNull(bmp); + Assert.Equal(4, bmp.Width); + Assert.Equal(4, bmp.Height); + + Assert.Equal(islandColor, bmp.GetPixel(0, 0)); + Assert.Equal(passableColor, bmp.GetPixel(1, 1)); + Assert.Equal(SKColors.Black, bmp.GetPixel(2, 2)); + } + + [Fact] + public void ZoneSetLayerWithBitMap_applies_mask_from_PNG_image() + { + var fileSystem = new FileSystem(_tempDirectory); + var zone = Substitute.For(); + zone.Id.Returns(1); + zone.IsLayerEditLocked.Returns(false); + + var maskFileName = zone.CreateTerrainDataFilename("mask", "png"); + var maskPath = Path.Combine(_tempDirectory, "bitmaps", maskFileName); + + using (var maskBmp = new SKBitmap(4, 4, SKColorType.Rgba8888, SKAlphaType.Premul)) + { + maskBmp.WithCanvas(c => + { + c.Clear(SKColors.Transparent); + using var p = new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Fill }; + c.DrawRect(0, 0, 2, 2, p); + }); + using var fs = File.Create(maskPath); + maskBmp.Encode(fs, SKEncodedImageFormat.Png, 100); + } + + var controls = new Layer(LayerType.Control, 4, 4); + var terrain = Substitute.For(); + terrain.Controls.Returns(controls); + + zone.Terrain.Returns(terrain); + + var session = Substitute.For(); + + var request = Substitute.For(); + request.Zone.Returns(zone); + request.Session.Returns(session); + var requestData = new Dictionary + { + { k.file, "mask" }, + { k.flags, (int)TerrainControlFlags.SyndicateArea } + }; + request.Data.Returns(requestData); + + var handler = new ZoneSetLayerWithBitMap(fileSystem); + handler.HandleRequest(request); + + // Opaque area (0,0), (0,1), (1,0), (1,1) should have SyndicateArea flag set + Assert.True(controls[0, 0].SyndicateArea); + Assert.True(controls[1, 1].SyndicateArea); + + // Transparent area (2,2), (3,3) should remain not SyndicateArea + Assert.False(controls[2, 2].SyndicateArea); + Assert.False(controls[3, 3].SyndicateArea); + } + } +} diff --git a/src/Perpetuum.Tests/Fakes/Data/FakeDb.cs b/src/Perpetuum.Tests/Fakes/Data/FakeDb.cs index 3faf9c95..41b406fa 100644 --- a/src/Perpetuum.Tests/Fakes/Data/FakeDb.cs +++ b/src/Perpetuum.Tests/Fakes/Data/FakeDb.cs @@ -32,7 +32,9 @@ public sealed class FakeDb public static FakeDb Install() { FakeDb fake = new(); - Db.DbQueryFactory = () => new DbQuery(() => new FakeDbConnection(fake)); + Db.DbQueryFactory = () => new DbQuery( + () => new FakeDbConnection(fake), + new GlobalConfiguration { DistributedTransactions = false }); return fake; } diff --git a/src/Perpetuum.Tests/Perpetuum.Tests.csproj b/src/Perpetuum.Tests/Perpetuum.Tests.csproj index 888e45fc..dfa5e55d 100644 --- a/src/Perpetuum.Tests/Perpetuum.Tests.csproj +++ b/src/Perpetuum.Tests/Perpetuum.Tests.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -13,11 +13,13 @@ + + diff --git a/src/Perpetuum.Tests/Unit/AreaTests.cs b/src/Perpetuum.Tests/Unit/AreaTests.cs new file mode 100644 index 00000000..cabec175 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/AreaTests.cs @@ -0,0 +1,182 @@ +using Perpetuum.Zones; +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class AreaTests + { + [Fact] + public void Constructor_normalizes_coordinates_when_inverted() + { + var a = new Area(10, 20, 5, 8); + Assert.Equal(5, a.X1); + Assert.Equal(8, a.Y1); + Assert.Equal(10, a.X2); + Assert.Equal(20, a.Y2); + Assert.Equal(6, a.Width); + Assert.Equal(13, a.Height); + } + + [Fact] + public void FromRectangle_sets_coordinates() + { + var a = Area.FromRectangle(10, 20, 30, 40); + Assert.Equal(10, a.X1); + Assert.Equal(20, a.Y1); + Assert.Equal(39, a.X2); + Assert.Equal(59, a.Y2); + Assert.Equal(30, a.Width); + Assert.Equal(40, a.Height); + Assert.Equal(1200, a.Ground); + Assert.Equal(50.0, a.Diagonal); + } + + [Fact] + public void FromRadius_with_SKPointI_and_Position() + { + var pt = new SKPointI(50, 50); + var a1 = Area.FromRadius(pt, 10); + Assert.Equal(40, a1.X1); + Assert.Equal(40, a1.Y1); + Assert.Equal(60, a1.X2); + Assert.Equal(60, a1.Y2); + + var pos = new Position(50, 50); + var a2 = Area.FromRadius(pos, 10); + Assert.Equal(a1, a2); + + var a3 = Area.FromRadius(50, 50, 10); + Assert.Equal(a1, a3); + } + + [Fact] + public void Center_and_CenterPrecise() + { + var a = new Area(0, 0, 10, 10); + Assert.Equal(new SKPointI(5, 5), a.Center); + Assert.Equal(new Position(5.0, 5.0), a.CenterPrecise); + } + + [Fact] + public void Contains_checks_point_and_area() + { + var a = new Area(10, 10, 20, 20); + + Assert.True(a.Contains(new SKPointI(10, 10))); + Assert.True(a.Contains(new SKPointI(15, 15))); + Assert.True(a.Contains(new SKPointI(20, 20))); + Assert.True(a.Contains(new Position(15, 15))); + + Assert.False(a.Contains(new SKPointI(9, 15))); + Assert.False(a.Contains(new SKPointI(15, 21))); + + var subArea = new Area(12, 12, 18, 18); + Assert.True(a.Contains(subArea)); + + var overlappingArea = new Area(15, 15, 25, 25); + Assert.False(a.Contains(overlappingArea)); + } + + [Fact] + public void ContainsInInnerCircle_evaluates_circle() + { + var a = Area.FromRectangle(0, 0, 20, 20); + Assert.True(a.ContainsInInnerCircle(10, 10)); + Assert.True(a.ContainsInInnerCircle(10, 15)); + Assert.False(a.ContainsInInnerCircle(0, 0)); + } + + [Fact] + public void Clamp_SKSizeI_and_dimensions() + { + var a = new Area(-10, -5, 150, 250); + var clamped = a.Clamp(new SKSizeI(100, 200)); + + Assert.Equal(0, clamped.X1); + Assert.Equal(0, clamped.Y1); + Assert.Equal(99, clamped.X2); + Assert.Equal(199, clamped.Y2); + } + + [Fact] + public void IntersectsWith_and_Intersect() + { + var a = new Area(0, 0, 10, 10); + var b = new Area(5, 5, 15, 15); + var c = new Area(20, 20, 30, 30); + + Assert.True(a.IntersectsWith(b)); + Assert.False(a.IntersectsWith(c)); + + var intersection = a.Intersect(b); + Assert.Equal(new Area(5, 5, 10, 10), intersection); + + var noIntersection = a.Intersect(c); + Assert.Equal(Area.Empty, noIntersection); + } + + [Fact] + public void Union_combines_areas() + { + var a = new Area(0, 0, 10, 10); + var b = new Area(5, 5, 20, 20); + + var u = Area.Union(a, b); + Assert.Equal(new Area(0, 0, 20, 20), u); + } + + [Fact] + public void Slice_partitions_area() + { + var a = new Area(0, 0, 10, 10); + var slices = a.Slice(5).ToList(); + Assert.NotEmpty(slices); + foreach (var s in slices) + { + Assert.True(a.Contains(s)); + } + } + + [Fact] + public void AddBorder_expands_area() + { + var a = new Area(10, 10, 20, 20); + var expanded = a.AddBorder(2); + Assert.Equal(8, expanded.X1); + Assert.Equal(8, expanded.Y1); + Assert.Equal(22, expanded.X2); + Assert.Equal(22, expanded.Y2); + } + + [Fact] + public void Distance_and_SqrDistance_between_areas_and_points() + { + var a = new Area(0, 0, 10, 10); + var pt = new SKPointI(13, 0); + Assert.Equal(3.0, a.Distance(pt)); + Assert.Equal(9.0, a.SqrDistance(pt)); + + var b = new Area(14, 0, 20, 10); + Assert.Equal(4.0, a.Distance(b)); + Assert.Equal(16.0, a.SqrDistance(b)); + + var overlap = new Area(5, 5, 15, 15); + Assert.Equal(0.0, a.Distance(overlap)); + } + + [Fact] + public void Equality_and_ToString() + { + var a1 = new Area(1, 2, 3, 4); + var a2 = new Area(1, 2, 3, 4); + var a3 = new Area(1, 2, 3, 5); + + Assert.True(a1 == a2); + Assert.False(a1 != a2); + Assert.True(a1 != a3); + Assert.Equal(a1.GetHashCode(), a2.GetHashCode()); + Assert.Contains("X1 = 1", a1.ToString()); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/BinaryStreamSkiaTests.cs b/src/Perpetuum.Tests/Unit/BinaryStreamSkiaTests.cs new file mode 100644 index 00000000..9b544167 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/BinaryStreamSkiaTests.cs @@ -0,0 +1,58 @@ +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class BinaryStreamSkiaTests + { + [Fact] + public void AppendObject_SKColor_writes_3_bytes() + { + using var stream = new BinaryStream(); + var color = new SKColor(12, 34, 56, 255); + stream.AppendObject(color); + + byte[] bytes = stream.ToArray(); + Assert.Equal(3, bytes.Length); + Assert.Equal(12, bytes[0]); + Assert.Equal(34, bytes[1]); + Assert.Equal(56, bytes[2]); + } + + [Fact] + public void AppendPoint_SKPointI_writes_2_integers() + { + using var stream = new BinaryStream(); + var pt = new SKPointI(12345, 67890); + stream.AppendPoint(pt); + + stream.Position = 0; + int x = stream.ReadInt(); + int y = stream.ReadInt(); + + Assert.Equal(12345, x); + Assert.Equal(67890, y); + Assert.True(stream.AtEnd()); + } + + [Fact] + public void AppendArea_writes_4_integers() + { + using var stream = new BinaryStream(); + var area = new Area(10, 20, 30, 40); + stream.AppendArea(area); + + stream.Position = 0; + int x1 = stream.ReadInt(); + int y1 = stream.ReadInt(); + int x2 = stream.ReadInt(); + int y2 = stream.ReadInt(); + + Assert.Equal(10, x1); + Assert.Equal(20, y1); + Assert.Equal(30, x2); + Assert.Equal(40, y2); + Assert.True(stream.AtEnd()); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/BitmapExtensionsTests.cs b/src/Perpetuum.Tests/Unit/BitmapExtensionsTests.cs new file mode 100644 index 00000000..c70903dc --- /dev/null +++ b/src/Perpetuum.Tests/Unit/BitmapExtensionsTests.cs @@ -0,0 +1,69 @@ +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class BitmapExtensionsTests + { + [Fact] + public void WithCanvas_null_bitmap_returns_null() + { + SKBitmap? bitmap = null; + var result = bitmap.WithCanvas(_ => { }); + Assert.Null(result); + } + + [Fact] + public void WithCanvas_executes_action_and_modifies_bitmap() + { + using var bitmap = new SKBitmap(10, 10, SKColorType.Rgba8888, SKAlphaType.Premul); + var result = bitmap.WithCanvas(canvas => + { + using var paint = new SKPaint { Color = SKColors.Red, Style = SKPaintStyle.Fill }; + canvas.DrawRect(0, 0, 10, 10, paint); + }); + + Assert.Same(bitmap, result); + Assert.Equal(SKColors.Red, bitmap.GetPixel(5, 5)); + } + + [Fact] + public void ForEach_null_bitmap_returns_null() + { + SKBitmap? bitmap = null; + var result = bitmap.ForEach((_, _, _) => { }); + Assert.Null(result); + } + + [Fact] + public void ForEach_visits_every_pixel_and_modifies_bitmap() + { + const int width = 4; + const int height = 3; + using var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); + + int visitedCount = 0; + var visitedCoordinates = new List<(int X, int Y)>(); + + var result = bitmap.ForEach((bmp, x, y) => + { + visitedCount++; + visitedCoordinates.Add((x, y)); + bmp.SetPixel(x, y, new SKColor((byte)(x * 10), (byte)(y * 10), 0)); + }); + + Assert.Same(bitmap, result); + Assert.Equal(width * height, visitedCount); + + int index = 0; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + Assert.Equal((x, y), visitedCoordinates[index++]); + Assert.Equal(new SKColor((byte)(x * 10), (byte)(y * 10), 0), bitmap.GetPixel(x, y)); + } + } + } + } +} diff --git a/src/Perpetuum.Tests/Unit/CliClientTests.cs b/src/Perpetuum.Tests/Unit/CliClientTests.cs new file mode 100644 index 00000000..f6910ce8 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/CliClientTests.cs @@ -0,0 +1,625 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Perpetuum.CliClient; +using Perpetuum.CliClient.Models; +using Perpetuum.GenXY; +using Perpetuum.Network; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class CliClientTests + { + [Fact] + public void Password_hashing_produces_uppercase_sha1() + { + var plain = "secretPassword123"; + var hash = PerpetuumClient.HashPassword(plain); + + Assert.Equal(40, hash.Length); + Assert.Equal(hash.ToUpperInvariant(), hash); + Assert.Equal(hash, PerpetuumClient.HashPassword(plain)); + } + + [Fact] + public void Password_hashing_preserves_existing_40_hex_hash() + { + var existingHash = "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8"; + var result = PerpetuumClient.HashPassword(existingHash); + + Assert.Equal(existingHash, result); + } + + [Fact] + public void CommandLineOptions_parses_arguments_correctly() + { + var args = new[] + { + "--host", "192.168.1.50", + "--port", "17800", + "--email", "pilot@example.com", + "--password", "myPass", + "--character", "ThunderPilot", + "--channel", "General", + "--chat-msg", "Hello pilots!", + "--list-channels", + "--non-interactive" + }; + + var options = CommandLineOptions.Parse(args); + + Assert.Equal("192.168.1.50", options.Host); + Assert.Equal(17800, options.Port); + Assert.Equal("pilot@example.com", options.Email); + Assert.Equal("myPass", options.Password); + Assert.Equal("ThunderPilot", options.Character); + Assert.Equal("General", options.Channel); + Assert.Equal("Hello pilots!", options.ChatMessage); + Assert.True(options.ListChannels); + Assert.True(options.NonInteractive); + Assert.False(options.ShowHelp); + } + + [Fact] + public void CommandLineOptions_parses_create_character_arguments_correctly() + { + var args = new[] + { + "--email", "pilot@example.com", + "--password", "myPass", + "--create-char", "NewPilotName", + "--race", "2", + "--school", "3", + "--major", "4", + "--spark", "5" + }; + + var options = CommandLineOptions.Parse(args); + + Assert.Equal("pilot@example.com", options.Email); + Assert.Equal("myPass", options.Password); + Assert.Equal("NewPilotName", options.CreateCharacterNick); + Assert.Equal(2, options.RaceId); + Assert.Equal(3, options.SchoolId); + Assert.Equal(4, options.MajorId); + Assert.Equal(5, options.SparkId); + } + + [Fact] + public void ClientDictionaryExtensions_GetBool_handles_types_and_defaults() + { + var dict = new Dictionary + { + { "boolTrue", true }, + { "boolFalse", false }, + { "intOne", 1 }, + { "intZero", 0 }, + { "longOne", 1L }, + { "longZero", 0L }, + { "byteOne", (byte)1 }, + { "strTrue", "True" }, + { "strFalse", "false" }, + { "strOne", "1" }, + { "strZero", "0" }, + { "nullVal", null! } + }; + + Assert.True(dict.GetBool("boolTrue")); + Assert.False(dict.GetBool("boolFalse")); + Assert.True(dict.GetBool("intOne")); + Assert.False(dict.GetBool("intZero")); + Assert.True(dict.GetBool("longOne")); + Assert.False(dict.GetBool("longZero")); + Assert.True(dict.GetBool("byteOne")); + Assert.True(dict.GetBool("strTrue")); + Assert.False(dict.GetBool("strFalse")); + Assert.True(dict.GetBool("strOne")); + Assert.False(dict.GetBool("strZero")); + Assert.False(dict.GetBool("nullVal")); + Assert.False(dict.GetBool("missingKey")); + Assert.True(dict.GetBool("missingKey", defaultValue: true)); + } + + [Fact] + public void ClientDictionaryExtensions_GetInt_and_GetLong_handle_conversions() + { + var dict = new Dictionary + { + { "intVal", 42 }, + { "longVal", 9876543210L }, + { "doubleVal", 123.45 }, + { "strInt", "500" }, + { "byteVal", (byte)7 } + }; + + Assert.Equal(42, dict.GetInt("intVal")); + Assert.Equal(500, dict.GetInt("strInt")); + Assert.Equal(7, dict.GetInt("byteVal")); + Assert.Equal(123, dict.GetInt("doubleVal")); + Assert.Equal(0, dict.GetInt("missingKey")); + Assert.Equal(99, dict.GetInt("missingKey", 99)); + + Assert.Equal(9876543210L, dict.GetLong("longVal")); + Assert.Equal(42L, dict.GetLong("intVal")); + Assert.Equal(500L, dict.GetLong("strInt")); + Assert.Equal(0L, dict.GetLong("missingKey")); + Assert.Equal(100L, dict.GetLong("missingKey", 100L)); + } + + [Fact] + public void WelcomeInfo_parses_welcome_payload_with_genxy_types() + { + // GenXY serializes booleans as ints + var data = new Dictionary + { + { k.worldName, "Perpetuum Test World" }, + { "version", "p36.5-custom" }, + { k.OSTime, DateTime.UtcNow }, + { "steamLoginEnabled", 1 }, // int from Genxy + { "resourceServerURL", "http://assets.local:1337" }, + { "dev", 1 } // int from Genxy + }; + + var welcome = WelcomeInfo.FromDictionary(data); + + Assert.Equal("Perpetuum Test World", welcome.WorldName); + Assert.Equal("p36.5-custom", welcome.Version); + Assert.NotNull(welcome.OSTime); + Assert.True(welcome.SteamLoginEnabled); + Assert.Equal("http://assets.local:1337", welcome.ResourceServerUrl); + Assert.True(welcome.IsDev); + } + + [Fact] + public void AccountInfo_parses_sign_in_payload_with_genxy_types() + { + var validUntil = DateTime.UtcNow.AddDays(30); + var data = new Dictionary + { + { k.accountID, 42 }, + { k.email, "commander@syndicate.org" }, + { k.accLevel, (int)AccessLevel.gameAdmin }, + { k.credit, 5000000L }, // could be long from Genxy + { k.isSubscriber, 1 }, // int from Genxy + { k.emailConfirmed, 1 }, // int from Genxy + { k.validUntil, validUntil } + }; + + var account = AccountInfo.FromDictionary(data); + + Assert.Equal(42, account.AccountId); + Assert.Equal("commander@syndicate.org", account.Email); + Assert.Equal(AccessLevel.gameAdmin, account.AccessLevel); + Assert.Equal(5000000, account.Credit); + Assert.True(account.IsSubscriber); + Assert.True(account.EmailConfirmed); + Assert.Equal(validUntil, account.ValidUntil); + } + + [Fact] + public void CharacterListResult_parses_nested_characters_dictionary() + { + var char0 = new Dictionary + { + { k.characterID, 101 }, + { k.rootEID, 1000101L }, + { k.nick, "AlphaPilot" }, + { k.docked, 1 }, // int 1 from Genxy + { k.baseEID, 5001L }, + { k.baseName, "The Syndicate Outpost" }, + { k.credit, 250000L } + }; + + var char1 = new Dictionary + { + { k.characterID, 102 }, + { k.rootEID, 1000102L }, + { k.nick, "BetaScout" }, + { k.docked, 0 }, // int 0 from Genxy + { k.zoneID, 3 }, + { k.credit, 120000L } + }; + + var charactersDict = new Dictionary + { + { "c0", char0 }, + { "c1", char1 } + }; + + var innerResult = new Dictionary + { + { "characters", charactersDict }, + { "extensionPoints", 75000 } + }; + + var rootData = new Dictionary + { + { k.result, innerResult } + }; + + var listResult = CharacterListResult.FromDictionary(rootData); + + Assert.Equal(75000, listResult.ExtensionPoints); + Assert.Equal(2, listResult.Characters.Count); + + var first = listResult.Characters[0]; + Assert.Equal(101, first.CharacterId); + Assert.Equal("AlphaPilot", first.Nick); + Assert.True(first.IsDocked); + Assert.Equal("The Syndicate Outpost", first.BaseName); + + var second = listResult.Characters[1]; + Assert.Equal(102, second.CharacterId); + Assert.Equal("BetaScout", second.Nick); + Assert.False(second.IsDocked); + Assert.Equal(3, second.ZoneId); + } + + [Fact] + public void CharacterSelectResult_parses_docked_and_zone_results() + { + var dockedData = new Dictionary + { + { k.characterID, 101 }, + { k.rootEID, 1000101L }, + { k.corporationEID, 20001L }, + { k.allianceEID, 30001L } + }; + + var dockedRes = CharacterSelectResult.FromDictionary(dockedData); + Assert.Equal(101, dockedRes.CharacterId); + Assert.Equal(1000101L, dockedRes.RootEid); + Assert.Equal(20001L, dockedRes.CorporationEid); + Assert.Equal(30001L, dockedRes.AllianceEid); + Assert.True(dockedRes.IsDocked); + Assert.Null(dockedRes.ZoneData); + + var zoneData = new Dictionary + { + { k.characterID, 102 }, + { k.rootEID, 1000102L }, + { k.corporationEID, 20002L }, + { k.zone, new Dictionary { { "plugin", "zone_1" } } } + }; + + var zoneRes = CharacterSelectResult.FromDictionary(zoneData); + Assert.Equal(102, zoneRes.CharacterId); + Assert.False(zoneRes.IsDocked); + Assert.NotNull(zoneRes.ZoneData); + } + + [Fact] + public void ChannelInfo_and_ChatMessage_parse_correctly() + { + var channelData = new Dictionary + { + { k.name, "General" }, + { k.type, 0 }, + { k.password, 0 }, + { k.count, 15 }, + { k.topic, "Welcome to OpenPerpetuum" } + }; + + var channel = ChannelInfo.FromDictionary(channelData); + Assert.Equal("General", channel.Name); + Assert.Equal(0, channel.Type); + Assert.Equal("Public", channel.TypeName); + Assert.False(channel.HasPassword); + Assert.Equal(15, channel.MemberCount); + Assert.Equal("Welcome to OpenPerpetuum", channel.Topic); + + var notificationData = new Dictionary + { + { k.channel, "General" }, + { k.command, 6 }, // ChannelNotify.Message + { k.data, new Dictionary + { + { k.sender, 501 }, + { k.message, "Greetings from zone 0!" } + } + } + }; + + var chat = ChatMessage.FromNotification(notificationData); + Assert.NotNull(chat); + Assert.Equal("General", chat.ChannelName); + Assert.Equal(501, chat.SenderId); + Assert.Equal("Greetings from zone 0!", chat.Message); + } + + [Fact] + public void GenXY_serialization_deserialization_models_roundtrip() + { + // Simulate how GenxyWriter encodes server messages + var originalDict = new Dictionary + { + { k.accountID, 123 }, + { k.email, "test@openperpetuum.com" }, + { k.accLevel, (int)AccessLevel.normal }, + { k.credit, 2000000 }, + { k.isSubscriber, 1 }, // GenXY writes boolean as int + { k.emailConfirmed, 1 } + }; + + var genxyString = GenxyConverter.Serialize(originalDict); + var deserializedDict = GenxyConverter.Deserialize(genxyString); + + var account = AccountInfo.FromDictionary(deserializedDict); + Assert.Equal(123, account.AccountId); + Assert.Equal("test@openperpetuum.com", account.Email); + Assert.Equal(AccessLevel.normal, account.AccessLevel); + Assert.Equal(2000000, account.Credit); + Assert.True(account.IsSubscriber); + Assert.True(account.EmailConfirmed); + } + + [Fact] + public void Error_checking_throws_PerpetuumClientException_on_server_error() + { + var errorData = new Dictionary + { + { k.rErr, (int)ErrorCodes.NoSuchUser } + }; + + var msg = new Message(Commands.SignIn, errorData); + + var ex = Assert.Throws(() => PerpetuumClient.CheckResponseError(msg)); + Assert.Equal(ErrorCodes.NoSuchUser, ex.ErrorCode); + Assert.Contains("Invalid username/email or password", ex.Message); + } + + [Fact] + public void Commands_lookup_works_for_registered_commands() + { + var cmd = Commands.GetCommandByText("signIn"); + Assert.NotNull(cmd); + Assert.Equal("signIn", cmd.Text); + + var welcomeCmd = Commands.GetCommandByText("welcome"); + Assert.NotNull(welcomeCmd); + Assert.Equal("welcome", welcomeCmd.Text); + + var talkCmd = Commands.GetCommandByText("channelTalk"); + Assert.NotNull(talkCmd); + Assert.Equal("channelTalk", talkCmd.Text); + } + + [Fact] + public void Full_handshake_and_uncompressed_message_codec_simulation() + { + // 1. Client generates 40-byte RC4 key and encrypts with RSA + byte[] clientStreamKey = FastRandom.NextBytes(40); + var clientRc4 = new Rc4(clientStreamKey); + + byte[] rsaEncrypted = Rsa.Encrypt(clientStreamKey)!; + Assert.NotNull(rsaEncrypted); + + // 2. Server decrypts RSA and creates server RC4 + byte[] decryptedKey = Rsa.Decrypt(rsaEncrypted)!; + Assert.NotNull(decryptedKey); + Assert.Equal(clientStreamKey, decryptedKey); + var serverRc4 = new Rc4(decryptedKey); + + // 3. Server sends Welcome message (uncompressed) + var welcomeData = new Dictionary + { + { k.worldName, "Perpetuum" }, + { "version", "p36.5" }, + { "steamLoginEnabled", 0 } + }; + var serverWelcomeMsg = new Message(Commands.Welcome, welcomeData) { Sender = "Relay" }; + byte[] serverRawBytes = serverWelcomeMsg.ToBytes(); + + // Server OnProcessOutputRawData logic: + byte compressionLevel = 0; + var serverOutPacket = new byte[serverRawBytes.Length + 3]; + Buffer.BlockCopy(serverRawBytes, 0, serverOutPacket, 3, serverRawBytes.Length); + serverRc4.Encrypt(serverOutPacket); + var serverWireData = new byte[serverOutPacket.Length + 1]; + Buffer.BlockCopy(serverOutPacket, 0, serverWireData, 1, serverOutPacket.Length); + serverWireData[0] = compressionLevel; + + // 4. Client receives wire data and decodes: + clientRc4.Decrypt(serverWireData, 1, serverWireData.Length - 1); + int dataOffset = 4; + if (serverWireData[0] == 2) + { + serverWireData = GZip.Decompress(serverWireData, 5); + dataOffset--; + } + string clientReceivedText = Encoding.UTF8.GetString(serverWireData, dataOffset, serverWireData.Length - dataOffset); + var clientParsedMessage = Message.Parse(clientReceivedText); + + Assert.Equal(Commands.Welcome.Text, clientParsedMessage.Command.Text); + var welcome = WelcomeInfo.FromDictionary(clientParsedMessage.Data); + Assert.Equal("Perpetuum", welcome.WorldName); + Assert.Equal("p36.5", welcome.Version); + Assert.False(welcome.SteamLoginEnabled); + } + + [Fact] + public void Full_compressed_message_codec_simulation() + { + // Setup keys + byte[] streamKey = FastRandom.NextBytes(40); + var clientRc4 = new Rc4(streamKey); + var serverRc4 = new Rc4(streamKey); + + // Server builds a large character list payload (> 1024 bytes) + var charactersDict = new Dictionary(); + for (int i = 0; i < 20; i++) + { + charactersDict.Add($"c{i}", new Dictionary + { + { k.characterID, 1000 + i }, + { k.rootEID, 5000000L + i }, + { k.nick, $"TestPilotNumber_{i}_LongNameForPadding" }, + { k.docked, 1 }, + { k.baseName, "Syndicate Primary Alpha Base Research Station" }, + { k.credit, 1000000L * (i + 1) } + }); + } + var rootData = new Dictionary + { + { k.result, new Dictionary { { "characters", charactersDict }, { "extensionPoints", 150000 } } } + }; + var serverCharListMsg = new Message(Commands.CharacterList, rootData) { Sender = "Relay" }; + byte[] serverRawBytes = serverCharListMsg.ToBytes(); + Assert.True(serverRawBytes.Length > 1024, "Test payload should exceed 1024 bytes to trigger compression"); + + // Server OnProcessOutputRawData logic: + byte compressionLevel = 0; + var serverOutPacket = new byte[serverRawBytes.Length + 3]; + Buffer.BlockCopy(serverRawBytes, 0, serverOutPacket, 3, serverRawBytes.Length); + if (serverOutPacket.Length > 1024) + { + compressionLevel = 2; + serverOutPacket = GZip.Compress(serverOutPacket); + } + serverRc4.Encrypt(serverOutPacket); + var serverWireData = new byte[serverOutPacket.Length + 1]; + Buffer.BlockCopy(serverOutPacket, 0, serverWireData, 1, serverOutPacket.Length); + serverWireData[0] = compressionLevel; + + Assert.Equal(2, serverWireData[0]); + + // Client receives wire data and decodes: + clientRc4.Decrypt(serverWireData, 1, serverWireData.Length - 1); + int dataOffset = 4; + if (serverWireData[0] == 2) + { + serverWireData = GZip.Decompress(serverWireData, 5); + dataOffset--; + } + string clientReceivedText = Encoding.UTF8.GetString(serverWireData, dataOffset, serverWireData.Length - dataOffset); + var clientParsedMessage = Message.Parse(clientReceivedText); + + Assert.Equal(Commands.CharacterList.Text, clientParsedMessage.Command.Text); + var result = CharacterListResult.FromDictionary(clientParsedMessage.Data); + Assert.Equal(150000, result.ExtensionPoints); + Assert.Equal(20, result.Characters.Count); + Assert.Equal("TestPilotNumber_0_LongNameForPadding", result.Characters[0].Nick); + Assert.Equal("TestPilotNumber_19_LongNameForPadding", result.Characters[19].Nick); + } + + [Fact] + public void StorageResult_and_StorageItemInfo_parse_correctly() + { + var defDict = new Dictionary + { + { 101, "def_robot_arkhe" }, + { 202, "def_ammo_longrange_missile_a" }, + { 303, "def_module_laser_small" } + }; + + var item1 = new Dictionary + { + { k.eid, 50001L }, + { k.definition, 101 }, + { k.quantity, 1 }, + { k.repackaged, 1 }, + { k.health, 100.0 }, + { k.volume, 50.0 }, + { k.parent, 10001L }, + { k.owner, 2001L } + }; + + var subItem = new Dictionary + { + { k.eid, 50003L }, + { k.definition, 303 }, + { k.name, "Custom Laser" }, + { k.quantity, 2 }, + { k.repackaged, 0 }, + { k.health, 98.5 }, + { k.volume, 4.0 }, + { k.parent, 50002L }, + { k.owner, 2001L } + }; + + var item2 = new Dictionary + { + { k.eid, 50002L }, + { k.definition, 202 }, + { k.quantity, 500 }, + { k.repackaged, 1 }, + { k.health, 100.0 }, + { k.volume, 10.5 }, + { k.parent, 10001L }, + { k.owner, 2001L }, + { k.items, new Dictionary { { "c0", subItem } } } + }; + + var storagePayload = new Dictionary + { + { k.eid, 10001L }, + { k.definition, 999 }, + { k.baseEID, 777L }, + { k.items, new Dictionary + { + { "c0", item1 }, + { "c1", item2 } + } + } + }; + + var storage = StorageResult.FromDictionary(storagePayload, defDict); + + Assert.Equal(10001L, storage.ContainerEid); + Assert.Equal(999, storage.ContainerDefinition); + Assert.Equal(777L, storage.BaseEid); + Assert.Equal(2, storage.Items.Count); + Assert.Equal(501, storage.TotalItemCount); + Assert.Equal(60.5, storage.TotalVolume); + + var first = storage.Items[0]; + Assert.Equal(50001L, first.Eid); + Assert.Equal(101, first.Definition); + Assert.Equal("def_robot_arkhe", first.DefinitionName); + Assert.Equal("Robot Arkhe", first.DisplayName); + Assert.Equal(1, first.Quantity); + Assert.True(first.IsRepackaged); + Assert.Equal(50.0, first.Volume); + + var second = storage.Items[1]; + Assert.Equal(50002L, second.Eid); + Assert.Equal("def_ammo_longrange_missile_a", second.DefinitionName); + Assert.Equal("Ammo Longrange Missile A", second.DisplayName); + Assert.Equal(500, second.Quantity); + Assert.Single(second.Items); + + var child = second.Items[0]; + Assert.Equal(50003L, child.Eid); + Assert.Equal("Custom Laser", child.DisplayName); + Assert.Equal(2, child.Quantity); + Assert.False(child.IsRepackaged); + Assert.Equal(98.5, child.Health); + } + + [Fact] + public void StorageItemInfo_FormatDefinitionName_handles_various_formats() + { + Assert.Equal("Robot Arkhe", StorageItemInfo.FormatDefinitionName("def_robot_arkhe")); + Assert.Equal("Ammo Longrange Missile A", StorageItemInfo.FormatDefinitionName("def_ammo_longrange_missile_a")); + Assert.Equal("Laser Small", StorageItemInfo.FormatDefinitionName("laser_small")); + Assert.Equal(string.Empty, StorageItemInfo.FormatDefinitionName("")); + } + + [Fact] + public void CommandLineOptions_parses_storage_flags() + { + var opt1 = CommandLineOptions.Parse(new[] { "--storage" }); + Assert.True(opt1.ListStorage); + + var opt2 = CommandLineOptions.Parse(new[] { "-s" }); + Assert.True(opt2.ListStorage); + + var opt3 = CommandLineOptions.Parse(new[] { "--hangar" }); + Assert.True(opt3.ListStorage); + + var opt4 = CommandLineOptions.Parse(new[] { "--inventory" }); + Assert.True(opt4.ListStorage); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/ColorExtensionsTests.cs b/src/Perpetuum.Tests/Unit/ColorExtensionsTests.cs new file mode 100644 index 00000000..c9ff8f92 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/ColorExtensionsTests.cs @@ -0,0 +1,37 @@ +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class ColorExtensionsTests + { + [Theory] + [InlineData(0, 0, 0, 0f)] + [InlineData(255, 255, 255, 1f)] + [InlineData(255, 0, 0, 0.299f)] + [InlineData(0, 255, 0, 0.587f)] + [InlineData(0, 0, 255, 0.114f)] + public void GetLuminance_primary_colors_and_extremes(byte r, byte g, byte b, float expected) + { + var color = new SKColor(r, g, b); + float luminance = color.GetLuminance(); + Assert.Equal(expected, luminance, precision: 4); + } + + [Fact] + public void GetLuminance_arbitrary_rgb() + { + var color = new SKColor(128, 64, 32); + float expected = (0.299f * 128 + 0.587f * 64 + 0.114f * 32) / 255f; + Assert.Equal(expected, color.GetLuminance(), precision: 5); + } + + [Fact] + public void GetLuminance_ignores_alpha() + { + var c1 = new SKColor(100, 150, 200, 255); + var c2 = new SKColor(100, 150, 200, 0); + Assert.Equal(c1.GetLuminance(), c2.GetLuminance()); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/CompactPassabilityMaskTests.cs b/src/Perpetuum.Tests/Unit/CompactPassabilityMaskTests.cs new file mode 100644 index 00000000..da74d810 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/CompactPassabilityMaskTests.cs @@ -0,0 +1,58 @@ +using Perpetuum.Zones.Terrains; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class CompactPassabilityMaskTests + { + [Fact] + public void Bitmask_get_set_and_bounds() + { + var mask = new CompactPassabilityMask(64, 64); + + // Default is false (0) + Assert.False(mask.IsWalkable(0, 0)); + Assert.False(mask.IsWalkable(10, 10)); + + // Set some bits + mask.SetWalkable(0, 0, true); + mask.SetWalkable(15, 20, true); + mask.SetWalkable(31, 31, true); + mask.SetWalkable(32, 31, true); // cross 32-bit boundary + mask.SetWalkable(63, 63, true); + + Assert.True(mask.IsWalkable(0, 0)); + Assert.True(mask.IsWalkable(15, 20)); + Assert.True(mask.IsWalkable(31, 31)); + Assert.True(mask.IsWalkable(32, 31)); + Assert.True(mask.IsWalkable(63, 63)); + + // Unset a bit + mask.SetWalkable(15, 20, false); + Assert.False(mask.IsWalkable(15, 20)); + Assert.True(mask.IsWalkable(0, 0)); + + // Out of bounds + Assert.False(mask.IsWalkable(-1, 0)); + Assert.False(mask.IsWalkable(0, -1)); + Assert.False(mask.IsWalkable(64, 0)); + Assert.False(mask.IsWalkable(0, 64)); + } + + [Fact] + public void SetAll_fills_entire_grid() + { + var mask = new CompactPassabilityMask(100, 100); + mask.SetAll(true); + + Assert.True(mask.IsWalkable(0, 0)); + Assert.True(mask.IsWalkable(50, 50)); + Assert.True(mask.IsWalkable(99, 99)); + + mask.SetAll(false); + Assert.False(mask.IsWalkable(0, 0)); + Assert.False(mask.IsWalkable(50, 50)); + Assert.False(mask.IsWalkable(99, 99)); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/DbConnectionManagerTests.cs b/src/Perpetuum.Tests/Unit/DbConnectionManagerTests.cs new file mode 100644 index 00000000..e326358d --- /dev/null +++ b/src/Perpetuum.Tests/Unit/DbConnectionManagerTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Data; +using System.Transactions; +using Perpetuum.Data; +using Perpetuum.Tests.Fakes.Data; +using Perpetuum.Tests.Infrastructure; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + [Collection(PerpetuumStaticsCollection.Name)] + public class DbConnectionManagerTests + { + public DbConnectionManagerTests(PerpetuumStaticsFixture fixture) + { + _ = fixture; + } + + [Fact] + public void Multiple_queries_inside_transaction_scope_reuse_same_connection() + { + int connectionCreatedCount = 0; + FakeDb fakeDb = new(); + + Db.DbQueryFactory = () => new DbQuery( + () => + { + connectionCreatedCount++; + return new FakeDbConnection(fakeDb); + }, + new GlobalConfiguration { DistributedTransactions = false }); + + fakeDb.When("select 1", FakeResultSet.FromRows(["x"], [1])); + fakeDb.When("select 2", FakeResultSet.FromRows(["x"], [2])); + fakeDb.When("select 3", FakeResultSet.FromRows(["x"], [3])); + + using (TransactionScope scope = Db.CreateTransaction()) + { + Db.Query("select 1").Execute(); + Db.Query("select 2").Execute(); + Db.Query("select 3").Execute(); + + Assert.Equal(1, connectionCreatedCount); + Assert.Equal(1, DbConnectionManager.ActiveConnectionCount); + + scope.Complete(); + } + + Assert.Equal(0, DbConnectionManager.ActiveConnectionCount); + } + + [Fact] + public void Queries_outside_transaction_scope_use_separate_connections() + { + int connectionCreatedCount = 0; + FakeDb fakeDb = new(); + + Db.DbQueryFactory = () => new DbQuery( + () => + { + connectionCreatedCount++; + return new FakeDbConnection(fakeDb); + }, + new GlobalConfiguration { DistributedTransactions = false }); + + fakeDb.When("select 1", FakeResultSet.FromRows(["x"], [1])); + + Db.Query("select 1").Execute(); + Db.Query("select 1").Execute(); + + Assert.Equal(2, connectionCreatedCount); + Assert.Equal(0, DbConnectionManager.ActiveConnectionCount); + } + + [Fact] + public void Connection_is_disposed_when_transaction_scope_aborts() + { + int connectionCreatedCount = 0; + FakeDb fakeDb = new(); + FakeDbConnection? usedConnection = null; + + Db.DbQueryFactory = () => new DbQuery( + () => + { + connectionCreatedCount++; + usedConnection = new FakeDbConnection(fakeDb); + return usedConnection; + }, + new GlobalConfiguration { DistributedTransactions = false }); + + fakeDb.When("select 1", FakeResultSet.FromRows(["x"], [1])); + + try + { + using (TransactionScope scope = Db.CreateTransaction()) + { + Db.Query("select 1").Execute(); + Assert.Equal(ConnectionState.Open, usedConnection?.State); + throw new InvalidOperationException("Simulated failure inside transaction"); + } + } + catch (InvalidOperationException) + { + // Expected + } + + Assert.Equal(0, DbConnectionManager.ActiveConnectionCount); + Assert.Equal(ConnectionState.Closed, usedConnection?.State); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/DefinitionConfigSkiaTests.cs b/src/Perpetuum.Tests/Unit/DefinitionConfigSkiaTests.cs new file mode 100644 index 00000000..67e53d46 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/DefinitionConfigSkiaTests.cs @@ -0,0 +1,66 @@ +using Perpetuum.EntityFramework; +using Perpetuum.Tests.Fakes.Data; +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class DefinitionConfigSkiaTests + { + private static readonly string[] ColumnNames = + [ + "definition", "targetdefinition", "npcpresenceid", "item_work_range", "explosion_radius", + "cycle_time", "damage_chemical", "damage_explosive", "damage_kinetic", "damage_thermal", + "damage_toxic", "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", "typeexclusiverange", + "network_node_range", "hitsize", "tint" + ]; + + private static DefinitionConfig CreateConfigWithTint(string? tintValue) + { + object?[] row = new object?[ColumnNames.Length]; + row[0] = 123; // definition + row[ColumnNames.Length - 1] = tintValue; // tint + + var resultSet = FakeResultSet.FromRows(ColumnNames, row); + var reader = new FakeDataReader(resultSet); + reader.Read(); + return new DefinitionConfig(reader); + } + + [Fact] + public void Tint_parses_valid_hex_color() + { + var config = CreateConfigWithTint("#FF8040"); + Assert.Equal(new SKColor(255, 128, 64, 255), config.Tint); + } + + [Fact] + public void Tint_parses_valid_hex_color_with_alpha() + { + var config = CreateConfigWithTint("#80112233"); + Assert.Equal(new SKColor(0x11, 0x22, 0x33, 0x80), config.Tint); + } + + [Fact] + public void Tint_invalid_string_results_in_default_color() + { + var config = CreateConfigWithTint("not-a-valid-color"); + Assert.Equal(default, config.Tint); + } + + [Fact] + public void Tint_null_or_empty_defaults_to_white() + { + var configNull = CreateConfigWithTint(null); + Assert.Equal(SKColors.White, configNull.Tint); + + var configEmpty = CreateConfigWithTint(string.Empty); + Assert.Equal(SKColors.White, configEmpty.Tint); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/GenxySkiaTests.cs b/src/Perpetuum.Tests/Unit/GenxySkiaTests.cs new file mode 100644 index 00000000..24e15e62 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/GenxySkiaTests.cs @@ -0,0 +1,66 @@ +using Perpetuum.GenXY; +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class GenxySkiaTests + { + [Fact] + public void SKColor_serialization_and_deserialization_roundtrip() + { + var original = new SKColor(255, 128, 64, 200); + string serialized = GenxyConverter.SerializeObject(original); + + Assert.StartsWith("c", serialized); + + var deserialized = GenxyConverter.DeserializeObject(serialized); + Assert.Equal(original.Red, deserialized.Red); + Assert.Equal(original.Green, deserialized.Green); + Assert.Equal(original.Blue, deserialized.Blue); + Assert.Equal(original.Alpha, deserialized.Alpha); + } + + [Fact] + public void SKPointI_serialization_and_deserialization_roundtrip() + { + var original = new SKPointI(1234, 5678); + string serialized = GenxyConverter.SerializeObject(original); + + Assert.StartsWith("p", serialized); + + var deserialized = GenxyConverter.DeserializeObject(serialized); + Assert.Equal(original, deserialized); + } + + [Fact] + public void Area_serialization_and_deserialization_roundtrip() + { + var original = new Area(10, 20, 100, 200); + string serialized = GenxyConverter.SerializeObject(original); + + Assert.StartsWith("r", serialized); + + var deserialized = GenxyConverter.DeserializeObject(serialized); + Assert.Equal(original, deserialized); + } + + [Fact] + public void Dictionary_containing_Skia_types_roundtrips() + { + var dict = new Dictionary + { + { "tint", new SKColor(10, 20, 30, 40) }, + { "location", new SKPointI(50, 60) }, + { "boundary", new Area(1, 2, 3, 4) } + }; + + string serialized = GenxyConverter.Serialize(dict); + var deserialized = GenxyConverter.Deserialize(serialized); + + Assert.Equal(new SKColor(10, 20, 30, 40), (SKColor)deserialized["tint"]); + Assert.Equal(new SKPointI(50, 60), (SKPointI)deserialized["location"]); + Assert.Equal(new Area(1, 2, 3, 4), (Area)deserialized["boundary"]); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/HeightfieldMetadataTests.cs b/src/Perpetuum.Tests/Unit/HeightfieldMetadataTests.cs new file mode 100644 index 00000000..9f1d45fb --- /dev/null +++ b/src/Perpetuum.Tests/Unit/HeightfieldMetadataTests.cs @@ -0,0 +1,80 @@ +using Perpetuum.Zones.Terrains; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class HeightfieldMetadataTests + { + [Fact] + public void Chunk_bounds_and_ray_above_chunk_queries() + { + var rawData = new ushort[64 * 64]; + var altLayer = new AltitudeLayer(rawData, 64, 64); + + // Fill a chunk (0,0 to 15,15) with height 10.0 (raw: 10 * 32 = 320) + for (int y = 0; y < 16; y++) + { + for (int x = 0; x < 16; x++) + { + altLayer[x, y] = (ushort)(10 * 32); + } + } + + // Fill another chunk (16,0 to 31,15) with height 50.0 (raw: 50 * 32 = 1600) + for (int y = 0; y < 16; y++) + { + for (int x = 16; x < 32; x++) + { + altLayer[x, y] = (ushort)(50 * 32); + } + } + + var metadata = HeightfieldMetadata.ExtractFrom(altLayer, null, chunkSize: 16); + + Assert.Equal(4, metadata.ChunksX); + Assert.Equal(4, metadata.ChunksY); + + // Chunk (0,0) has max height 10.0 + metadata.GetChunkBounds(0, 0, out float min0, out float max0); + Assert.Equal(10.0f, min0); + Assert.Equal(10.0f, max0); + + // Chunk (1,0) has max height 50.0 + metadata.GetChunkBounds(1, 0, out float min1, out float max1); + Assert.Equal(50.0f, min1); + Assert.Equal(50.0f, max1); + + // Ray at Z=20 is strictly above chunk (0,0), but NOT above chunk (1,0) + Assert.True(metadata.CanRayPassAboveChunk(0, 0, rayMinZ: 20.0f)); + Assert.False(metadata.CanRayPassAboveChunk(1, 0, rayMinZ: 20.0f)); + + // Ray at Z=60 is above both + Assert.True(metadata.CanRayPassAboveChunk(0, 0, rayMinZ: 60.0f)); + Assert.True(metadata.CanRayPassAboveChunk(1, 0, rayMinZ: 60.0f)); + } + + [Fact] + public void Coordinates_mapping() + { + var metadata = new HeightfieldMetadata(2048, 2048, chunkSize: 16); + Assert.Equal(128, metadata.ChunksX); + Assert.Equal(128, metadata.ChunksY); + + metadata.GetChunkCoordinates(0, 0, out int cx0, out int cy0); + Assert.Equal(0, cx0); + Assert.Equal(0, cy0); + + metadata.GetChunkCoordinates(15, 15, out int cx1, out int cy1); + Assert.Equal(0, cx1); + Assert.Equal(0, cy1); + + metadata.GetChunkCoordinates(16, 32, out int cx2, out int cy2); + Assert.Equal(1, cx2); + Assert.Equal(2, cy2); + + metadata.GetChunkCoordinates(2047, 2047, out int cx3, out int cy3); + Assert.Equal(127, cx3); + Assert.Equal(127, cy3); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/LayerExtensionsTests.cs b/src/Perpetuum.Tests/Unit/LayerExtensionsTests.cs new file mode 100644 index 00000000..361dabcd --- /dev/null +++ b/src/Perpetuum.Tests/Unit/LayerExtensionsTests.cs @@ -0,0 +1,70 @@ +using Perpetuum.Zones; +using Perpetuum.Zones.Terrains; +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class LayerExtensionsTests + { + [Fact] + public void IsValidPosition_checks_layer_dimensions() + { + var layer = new Layer(LayerType.Altitude, 50, 60); + + Assert.True(layer.IsValidPosition(0, 0)); + Assert.True(layer.IsValidPosition(49, 59)); + Assert.False(layer.IsValidPosition(-1, 0)); + Assert.False(layer.IsValidPosition(50, 10)); + Assert.False(layer.IsValidPosition(10, 60)); + } + + [Fact] + public void GetValue_with_SKPointI_and_Position() + { + var layer = new Layer(LayerType.Altitude, 20, 20); + layer[5, 8] = 42; + + Assert.Equal(42, layer.GetValue(new SKPointI(5, 8))); + Assert.Equal(42, layer.GetValue(new Position(5.2, 8.7))); + } + + [Fact] + public void UpdateAll_and_UpdateValue() + { + var layer = new Layer(LayerType.Altitude, 10, 10); + layer.UpdateAll((x, y, _) => x + y); + + Assert.Equal(0, layer[0, 0]); + Assert.Equal(7, layer[3, 4]); + Assert.Equal(18, layer[9, 9]); + + layer.UpdateValue(3, 4, v => v * 10); + Assert.Equal(70, layer[3, 4]); + } + + [Fact] + public void GetValue_clamps_out_of_bounds() + { + var layer = new Layer(LayerType.Altitude, 10, 20); + layer[0, 0] = 100; + layer[9, 19] = 200; + + Assert.Equal(100, layer[-5, -10]); + Assert.Equal(200, layer[50, 100]); + } + + [Fact] + public void Position_IsValid_rejects_negative_fractional_coords() + { + var size = new SKSizeI(100, 100); + + Assert.True(new Position(0.0, 0.0).IsValid(size)); + Assert.True(new Position(99.9, 99.9).IsValid(size)); + Assert.False(new Position(-0.1, 50.0).IsValid(size)); + Assert.False(new Position(50.0, -0.5).IsValid(size)); + Assert.False(new Position(50.0, 50.0, -0.1).IsValid(size)); + Assert.False(new Position(100.0, 50.0).IsValid(size)); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/PathFinderSkiaTests.cs b/src/Perpetuum.Tests/Unit/PathFinderSkiaTests.cs new file mode 100644 index 00000000..13ab96ad --- /dev/null +++ b/src/Perpetuum.Tests/Unit/PathFinderSkiaTests.cs @@ -0,0 +1,101 @@ +using Perpetuum.PathFinders; +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class PathFinderSkiaTests + { + [Fact] + public void AStarFinder_finds_straight_path() + { + var finder = new AStarFinder(Heuristic.Manhattan, (x, y) => x >= 0 && x < 10 && y >= 0 && y < 10); + var start = new SKPointI(0, 0); + var end = new SKPointI(5, 0); + + var path = finder.FindPath(start, end, TestContext.Current.CancellationToken); + + Assert.NotNull(path); + Assert.NotEmpty(path); + Assert.Equal(start, path.First()); + Assert.Equal(end, path.Last()); + } + + [Fact] + public void AStarFinder_navigates_around_obstacle() + { + bool Passable(int x, int y) + { + if (x < 0 || x >= 10 || y < 0 || y >= 10) return false; + if (x == 2 && y < 5) return false; + return true; + } + + var finder = new AStarFinder(Heuristic.Euclidean, Passable); + var start = new SKPointI(0, 0); + var end = new SKPointI(4, 0); + + var path = finder.FindPath(start, end, TestContext.Current.CancellationToken); + + Assert.NotNull(path); + Assert.Equal(start, path.First()); + Assert.Equal(end, path.Last()); + Assert.DoesNotContain(path, p => p.X == 2 && p.Y < 5); + } + + [Fact] + public void AStarFinder_start_equals_end_returns_empty_path() + { + var finder = new AStarFinder(Heuristic.Manhattan, (x, y) => true); + var pt = new SKPointI(3, 3); + var path = finder.FindPath(pt, pt, TestContext.Current.CancellationToken); + + Assert.NotNull(path); + Assert.Empty(path); + } + + [Fact] + public void AStarFinder_unreachable_destination_returns_null() + { + bool Passable(int x, int y) => !(x == 5 && y == 5); + + var finder = new AStarFinder(Heuristic.Manhattan, Passable); + var path = finder.FindPath(new SKPointI(0, 0), new SKPointI(5, 5), TestContext.Current.CancellationToken); + + Assert.Null(path); + } + + [Fact] + public void AStarFinder_cancellation_returns_null() + { + var finder = new AStarFinder(Heuristic.Manhattan, (x, y) => true); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var path = finder.FindPath(new SKPointI(0, 0), new SKPointI(100, 100), cts.Token); + Assert.Null(path); + } + + [Fact] + public void AStarLimited_HasPath_returns_true_for_nearby_target() + { + var limited = new AStarLimited(Heuristic.Manhattan, (x, y) => true, max: 10); + Assert.True(limited.HasPath(new SKPointI(0, 0), new SKPointI(3, 3))); + Assert.True(limited.HasPath(new SKPointI(2, 2), new SKPointI(2, 2))); + } + + [Fact] + public void AStarLimited_HasPath_returns_false_when_exceeding_max_depth() + { + var limited = new AStarLimited(Heuristic.Manhattan, (x, y) => true, max: 5); + Assert.False(limited.HasPath(new SKPointI(0, 0), new SKPointI(20, 20))); + } + + [Fact] + public void AStarLimited_HasPath_returns_false_when_destination_impassable() + { + var limited = new AStarLimited(Heuristic.Manhattan, (x, y) => !(x == 2 && y == 2), max: 10); + Assert.False(limited.HasPath(new SKPointI(0, 0), new SKPointI(2, 2))); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/PointExtensionsTests.cs b/src/Perpetuum.Tests/Unit/PointExtensionsTests.cs new file mode 100644 index 00000000..08882f68 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/PointExtensionsTests.cs @@ -0,0 +1,174 @@ +using System.Numerics; +using Perpetuum.Zones; +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class PointExtensionsTests + { + [Fact] + public void ToPosition_SKPointI_centers_at_half_tile() + { + var pt = new SKPointI(10, 25); + var pos = pt.ToPosition(); + Assert.Equal(10.5, pos.X); + Assert.Equal(25.5, pos.Y); + } + + [Fact] + public void ToPosition_SKPoint_preserves_coordinates() + { + var pt = new SKPoint(12.75f, 34.25f); + var pos = pt.ToPosition(); + Assert.Equal(12.75, pos.X, precision: 2); + Assert.Equal(34.25, pos.Y, precision: 2); + } + + [Fact] + public void ToVector2_SKPointI() + { + var pt = new SKPointI(7, 42); + var v = pt.ToVector2(); + Assert.Equal(new Vector2(7f, 42f), v); + } + + [Fact] + public void GetNonDiagonalNeighbours_returns_4_orthogonal_neighbours() + { + var pt = new SKPointI(5, 5); + var neighbours = pt.GetNonDiagonalNeighbours().ToList(); + Assert.Equal(4, neighbours.Count); + Assert.Contains(new SKPointI(5, 6), neighbours); + Assert.Contains(new SKPointI(6, 5), neighbours); + Assert.Contains(new SKPointI(5, 4), neighbours); + Assert.Contains(new SKPointI(4, 5), neighbours); + } + + [Fact] + public void GetNeighbours_returns_8_surrounding_neighbours() + { + var pt = new SKPointI(10, 10); + var neighbours = pt.GetNeighbours().ToList(); + Assert.Equal(8, neighbours.Count); + var expected = new[] + { + new SKPointI(9, 9), new SKPointI(10, 9), new SKPointI(11, 9), + new SKPointI(9, 10), new SKPointI(11, 10), + new SKPointI(9, 11), new SKPointI(10, 11), new SKPointI(11, 11) + }; + foreach (var exp in expected) + { + Assert.Contains(exp, neighbours); + } + } + + [Theory] + [InlineData(1, 9)] + [InlineData(2, 25)] + [InlineData(3, 49)] + public void GetNeighbours_with_size_returns_square_grid(int size, int expectedCount) + { + var pt = new SKPointI(10, 10); + var neighbours = pt.GetNeighbours(size).ToList(); + Assert.Equal(expectedCount, neighbours.Count); + Assert.All(neighbours, p => + { + Assert.InRange(p.X, 10 - size, 10 + size); + Assert.InRange(p.Y, 10 - size, 10 + size); + }); + } + + [Fact] + public void GetNearestPoint_finds_closest_point() + { + var origin = new SKPointI(0, 0); + var points = new[] + { + new SKPointI(10, 10), + new SKPointI(3, 4), + new SKPointI(8, 2), + new SKPointI(1, 1) + }; + + var nearest = origin.GetNearestPoint(points); + Assert.Equal(new SKPointI(1, 1), nearest); + } + + [Fact] + public void GetNearestPoint_empty_enumerable_returns_empty_point() + { + var origin = new SKPointI(5, 5); + var nearest = origin.GetNearestPoint(Array.Empty()); + Assert.Equal(SKPointI.Empty, nearest); + } + + [Theory] + [InlineData(0, 0, 3, 4, 5.0, true)] + [InlineData(0, 0, 3, 4, 4.9, false)] + [InlineData(0, 0, 0, 0, 0.0, true)] + public void IsInRange_evaluates_distance_threshold(int x1, int y1, int x2, int y2, double range, bool expected) + { + var p1 = new SKPointI(x1, y1); + var p2 = new SKPointI(x2, y2); + Assert.Equal(expected, p1.IsInRange(p2, range)); + } + + [Fact] + public void Distance_and_SqrDistance_calculations() + { + var p1 = new SKPointI(1, 2); + var p2 = new SKPointI(4, 6); + + Assert.Equal(25, p1.SqrDistance(p2)); + Assert.Equal(25, p1.SqrDistance(4, 6)); + Assert.Equal(5.0, p1.Distance(p2)); + } + + [Theory] + [InlineData(0, 0, 0, -10, 0.0)] // North + [InlineData(0, 0, 10, 0, 0.25)] // East + [InlineData(0, 0, 0, 10, 0.5)] // South + [InlineData(0, 0, -10, 0, 0.75)] // West + public void DirectionTo_cardinal_directions(int x1, int y1, int x2, int y2, double expectedDir) + { + var from = new SKPointI(x1, y1); + var to = new SKPointI(x2, y2); + double dir = from.DirectionTo(to); + Assert.Equal(expectedDir, dir, precision: 3); + } + + [Fact] + public void OffsetInDirection_roundtrip_cardinal() + { + var origin = new SKPointI(100, 100); + + var north = origin.OffsetInDirection(0.0, 10); + Assert.Equal(new SKPointI(100, 90), north); + + var south = origin.OffsetInDirection(0.5, 10); + Assert.Equal(new SKPointI(100, 110), south); + + var east = origin.OffsetInDirection(0.25, 10); + Assert.Equal(new SKPointI(110, 100), east); + + var west = origin.OffsetInDirection(0.75, 10); + Assert.Equal(new SKPointI(90, 100), west); + } + + [Fact] + public void FloodFill_bounded_by_validator() + { + var start = new SKPointI(5, 5); + var points = start.FloodFill(p => p.X >= 4 && p.X <= 6 && p.Y >= 4 && p.Y <= 6).ToList(); + + Assert.Equal(9, points.Count); + Assert.Contains(start, points); + Assert.All(points, p => + { + Assert.InRange(p.X, 4, 6); + Assert.InRange(p.Y, 4, 6); + }); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/SimdMathTests.cs b/src/Perpetuum.Tests/Unit/SimdMathTests.cs new file mode 100644 index 00000000..5ebad70d --- /dev/null +++ b/src/Perpetuum.Tests/Unit/SimdMathTests.cs @@ -0,0 +1,170 @@ +using Perpetuum.Simd; +using System; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class SimdMathTests + { + [Fact] + public void CalculateSquaredDistances2D_matches_scalar_math() + { + int count = 67; // Non-multiple of 16, 8, and 4 + float srcX = 150.5f; + float srcY = 200.25f; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + float[] simdDistSq = new float[count]; + float[] expectedDistSq = new float[count]; + + var rnd = new Random(42); + for (int i = 0; i < count; i++) + { + targetXs[i] = (float)(rnd.NextDouble() * 1000.0); + targetYs[i] = (float)(rnd.NextDouble() * 1000.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + expectedDistSq[i] = (dx * dx) + (dy * dy); + } + + SimdMath.CalculateSquaredDistances2D(srcX, srcY, targetXs, targetYs, simdDistSq); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedDistSq[i], simdDistSq[i], precision: 3); + } + } + + [Fact] + public void CalculateSquaredDistances3D_matches_scalar_with_z_scaling() + { + int count = 45; + float srcX = 50.0f; + float srcY = 75.0f; + float srcZ = 120.0f; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + float[] targetZs = new float[count]; + float[] simdDistSq = new float[count]; + float[] expectedDistSq = new float[count]; + + var rnd = new Random(123); + for (int i = 0; i < count; i++) + { + targetXs[i] = (float)(rnd.NextDouble() * 500.0); + targetYs[i] = (float)(rnd.NextDouble() * 500.0); + targetZs[i] = (float)(rnd.NextDouble() * 200.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) / 4.0f; + expectedDistSq[i] = (dx * dx) + (dy * dy) + (dz * dz); + } + + SimdMath.CalculateSquaredDistances3D(srcX, srcY, srcZ, targetXs, targetYs, targetZs, simdDistSq); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedDistSq[i], simdDistSq[i], precision: 3); + } + } + + [Fact] + public void FilterPointsInRange2D_filters_correctly() + { + int count = 50; + float srcX = 100.0f; + float srcY = 100.0f; + float range = 25.0f; + float rangeSq = range * range; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + bool[] simdResults = new bool[count]; + bool[] expectedResults = new bool[count]; + + var rnd = new Random(999); + for (int i = 0; i < count; i++) + { + // Place some points inside range (e.g. within 25) and some outside + targetXs[i] = srcX + (float)((rnd.NextDouble() - 0.5) * 60.0); + targetYs[i] = srcY + (float)((rnd.NextDouble() - 0.5) * 60.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + expectedResults[i] = ((dx * dx) + (dy * dy)) <= rangeSq; + } + + SimdMath.FilterPointsInRange2D(srcX, srcY, range, targetXs, targetYs, simdResults); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedResults[i], simdResults[i]); + } + } + + [Fact] + public void FilterPositionsInRange3D_filters_correctly() + { + int count = 64; + float srcX = 200.0f; + float srcY = 200.0f; + float srcZ = 50.0f; + float range = 30.0f; + float rangeSq = range * range; + + float[] targetXs = new float[count]; + float[] targetYs = new float[count]; + float[] targetZs = new float[count]; + bool[] simdResults = new bool[count]; + bool[] expectedResults = new bool[count]; + + var rnd = new Random(777); + for (int i = 0; i < count; i++) + { + targetXs[i] = srcX + (float)((rnd.NextDouble() - 0.5) * 70.0); + targetYs[i] = srcY + (float)((rnd.NextDouble() - 0.5) * 70.0); + targetZs[i] = srcZ + (float)((rnd.NextDouble() - 0.5) * 100.0); + + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) / 4.0f; + expectedResults[i] = ((dx * dx) + (dy * dy) + (dz * dz)) <= rangeSq; + } + + SimdMath.FilterPositionsInRange3D(srcX, srcY, srcZ, range, targetXs, targetYs, targetZs, simdResults); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expectedResults[i], simdResults[i]); + } + } + + [Fact] + public void EdgeCases_empty_and_small_arrays() + { + // Empty + SimdMath.CalculateSquaredDistances2D(0, 0, ReadOnlySpan.Empty, ReadOnlySpan.Empty, Span.Empty); + SimdMath.FilterPointsInRange2D(0, 0, 10, ReadOnlySpan.Empty, ReadOnlySpan.Empty, Span.Empty); + + // 1 element + float[] x1 = [10.0f]; + float[] y1 = [20.0f]; + float[] d1 = new float[1]; + SimdMath.CalculateSquaredDistances2D(0, 0, x1, y1, d1); + Assert.Equal(500.0f, d1[0]); + + // 3 elements (below Vector128 width of 4) + float[] x3 = [1.0f, 2.0f, 3.0f]; + float[] y3 = [0.0f, 0.0f, 0.0f]; + float[] d3 = new float[3]; + SimdMath.CalculateSquaredDistances2D(0, 0, x3, y3, d3); + Assert.Equal(1.0f, d3[0]); + Assert.Equal(4.0f, d3[1]); + Assert.Equal(9.0f, d3[2]); + } + } +} diff --git a/src/Perpetuum.Tests/Unit/SizeExtensionsTests.cs b/src/Perpetuum.Tests/Unit/SizeExtensionsTests.cs new file mode 100644 index 00000000..05788b0d --- /dev/null +++ b/src/Perpetuum.Tests/Unit/SizeExtensionsTests.cs @@ -0,0 +1,91 @@ +using Perpetuum.Zones; +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class SizeExtensionsTests + { + [Fact] + public void Contains_SKPointI_and_xy_coordinates() + { + var size = new SKSizeI(100, 200); + + Assert.True(size.Contains(new SKPointI(0, 0))); + Assert.True(size.Contains(new SKPointI(99, 199))); + Assert.True(size.Contains(50, 100)); + + Assert.False(size.Contains(new SKPointI(-1, 0))); + Assert.False(size.Contains(new SKPointI(0, -1))); + Assert.False(size.Contains(new SKPointI(100, 100))); + Assert.False(size.Contains(new SKPointI(50, 200))); + Assert.False(size.Contains(-5, -5)); + } + + [Fact] + public void GetCenter_returns_midpoint() + { + var size = new SKSizeI(100, 60); + Assert.Equal(new SKPointI(50, 30), size.GetCenter()); + } + + [Fact] + public void ToArea_creates_matching_area() + { + var size = new SKSizeI(100, 200); + var area = size.ToArea(); + + Assert.Equal(0, area.X1); + Assert.Equal(0, area.Y1); + Assert.Equal(99, area.X2); + Assert.Equal(199, area.Y2); + Assert.Equal(100, area.Width); + Assert.Equal(200, area.Height); + } + + [Fact] + public void Ground_returns_width_times_height() + { + var size = new SKSizeI(20, 30); + Assert.Equal(600, size.Ground()); + } + + [Fact] + public void Diagonal_calculates_hypotenuse() + { + var size = new SKSizeI(3, 4); + Assert.Equal(5.0, size.Diagonal()); + } + + [Fact] + public void CreateArray_allocates_correct_length() + { + var size = new SKSizeI(10, 5); + int[] arr = size.CreateArray(); + Assert.Equal(50, arr.Length); + } + + [Fact] + public void Create2DArray_allocates_correct_dimensions() + { + var size = new SKSizeI(10, 5); + int[,] arr = size.Create2DArray(); + Assert.Equal(10, arr.GetLength(0)); + Assert.Equal(5, arr.GetLength(1)); + } + + [Fact] + public void GetRandomPosition_respects_margins() + { + var size = new SKSizeI(100, 100); + const int margin = 10; + + for (int i = 0; i < 50; i++) + { + Position p = size.GetRandomPosition(margin); + Assert.InRange(p.X, 10, 90); + Assert.InRange(p.Y, 10, 90); + } + } + } +} diff --git a/src/Perpetuum.Tests/Unit/SpatialCollectionsSkiaTests.cs b/src/Perpetuum.Tests/Unit/SpatialCollectionsSkiaTests.cs new file mode 100644 index 00000000..2f6f4f75 --- /dev/null +++ b/src/Perpetuum.Tests/Unit/SpatialCollectionsSkiaTests.cs @@ -0,0 +1,59 @@ +using Perpetuum.Collections.Spatial; +using SkiaSharp; +using Xunit; + +namespace Perpetuum.Tests.Unit +{ + public class SpatialCollectionsSkiaTests + { + private class TestCell : Cell + { + public TestCell(Area area) : base(area) { } + } + + [Fact] + public void Grid_creates_cells_and_maps_coordinates() + { + var grid = new Grid(100, 100, 10, 10, area => new TestCell(area)); + + var cell1 = grid.GetCell(new SKPointI(15, 25)); + var cell2 = grid.GetCell(15, 25); + + Assert.NotNull(cell1); + Assert.Same(cell1, cell2); + Assert.Equal(10, cell1.BoundingBox.X1); + Assert.Equal(20, cell1.BoundingBox.Y1); + + Assert.Null(grid.GetCell(new SKPointI(-10, 0))); + Assert.Null(grid.GetCell(new SKPointI(105, 50))); + } + + [Fact] + public void Grid_CalculateGridSize_divides_by_TilesPerGrid() + { + var size = new SKSizeI(2048, 2048); + var gridSize = Grid.CalculateGridSize(size); + Assert.Equal(2048 / Grid.TilesPerGrid, gridSize.Width); + Assert.Equal(2048 / Grid.TilesPerGrid, gridSize.Height); + } + + [Fact] + public void QuadTree_Add_and_Query_with_SKPointI_and_Area() + { + var bounds = new Area(0, 0, 100, 100); + var quadTree = new QuadTree(bounds); + + quadTree.Add(new SKPointI(10, 10), "Item1"); + quadTree.Add(new SKPointI(20, 20), "Item2"); + quadTree.Add(new SKPointI(80, 80), "Item3"); + + var queryArea = new Area(0, 0, 30, 30); + var results = quadTree.Query(queryArea).ToList(); + + Assert.Equal(2, results.Count); + Assert.Contains(results, r => r.Value == "Item1" && r.X == 10 && r.Y == 10); + Assert.Contains(results, r => r.Value == "Item2" && r.X == 20 && r.Y == 20); + Assert.DoesNotContain(results, r => r.Value == "Item3"); + } + } +} 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..baf5c6a6 100644 --- a/src/Perpetuum/BitmapExtensions.cs +++ b/src/Perpetuum/BitmapExtensions.cs @@ -1,20 +1,20 @@ -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 canvas = new SKCanvas(bitmap)) { - action(g); + action(canvas); } return bitmap; @@ -24,7 +24,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/Data/DbConnectionManager.cs b/src/Perpetuum/Data/DbConnectionManager.cs new file mode 100644 index 00000000..9ec96f71 --- /dev/null +++ b/src/Perpetuum/Data/DbConnectionManager.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Concurrent; +using System.Data; +using System.Transactions; +using Perpetuum.Log; + +namespace Perpetuum.Data +{ + /// + /// Manages sharing of open database connections within ambient transaction scopes. + /// Reusing a single open connection across multiple queries inside the same TransactionScope + /// ensures local transaction isolation (LTM) on SQL Server without triggering distributed + /// transaction escalation (MSDTC), ensuring full compatibility with Linux and reducing + /// connection pool contention. + /// + public static class DbConnectionManager + { + private static readonly ConcurrentDictionary ActiveConnections = new(); + private static readonly object SyncRoot = new(); + + public static IDbConnection GetOrCreateConnection(Transaction transaction, DbConnectionFactory connectionFactory) + { + if (ActiveConnections.TryGetValue(transaction, out IDbConnection? existingConn)) + { + return existingConn; + } + + lock (SyncRoot) + { + if (ActiveConnections.TryGetValue(transaction, out existingConn)) + { + return existingConn; + } + + IDbConnection connection = connectionFactory(); + connection.Open(); + + ActiveConnections[transaction] = connection; + + transaction.TransactionCompleted += (sender, e) => + { + lock (SyncRoot) + { + ActiveConnections.TryRemove(transaction, out _); + } + + try + { + connection.Dispose(); + } + catch (Exception ex) + { + Logger.Exception(ex); + } + }; + + return connection; + } + } + + public static int ActiveConnectionCount => ActiveConnections.Count; + } +} diff --git a/src/Perpetuum/Data/DbQuery.cs b/src/Perpetuum/Data/DbQuery.cs index ef8edaa1..0614cfbb 100644 --- a/src/Perpetuum/Data/DbQuery.cs +++ b/src/Perpetuum/Data/DbQuery.cs @@ -1,4 +1,4 @@ -using System.Data; +using System.Data; using System.Data.Common; using System.Transactions; @@ -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; @@ -51,33 +51,55 @@ public DbQuery SetParameter(string name, object value) private T ExecuteHelper(Func execute) { - using IDbConnection connection = _connectionFactory(); - connection.Open(); + Transaction? currentTx = Transaction.Current; + IDbConnection connection; + bool shouldDisposeConnection = true; - if (Transaction.Current != null && connection is DbConnection dbConnection) + if (currentTx != null) { - dbConnection.EnlistTransaction(Transaction.Current); + connection = DbConnectionManager.GetOrCreateConnection(currentTx, _connectionFactory); + shouldDisposeConnection = false; + } + else + { + connection = _connectionFactory(); + connection.Open(); } - IDbCommand command = connection.CreateCommand(); - command.CommandText = _commandText; - command.CommandType = _commandText.Contains(' ') ? CommandType.Text : CommandType.StoredProcedure; - command.CommandTimeout = _commandTimeout; - - if (_parameters != null) + try { - foreach (KeyValuePair kvp in _parameters) + if (configuration.DistributedTransactions && currentTx != null && connection is DbConnection dbConnection) { - IDbDataParameter parameter = command.CreateParameter(); - parameter.ParameterName = kvp.Key; - parameter.Value = kvp.Value ?? DBNull.Value; - command.Parameters.Add(parameter); + dbConnection.EnlistTransaction(currentTx); } - } - using (command) + IDbCommand command = connection.CreateCommand(); + command.CommandText = _commandText; + command.CommandType = _commandText.Contains(' ') ? CommandType.Text : CommandType.StoredProcedure; + command.CommandTimeout = _commandTimeout; + + if (_parameters != null) + { + foreach (KeyValuePair kvp in _parameters) + { + IDbDataParameter parameter = command.CreateParameter(); + parameter.ParameterName = kvp.Key; + parameter.Value = kvp.Value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + } + + using (command) + { + return execute(command); + } + } + finally { - return execute(command); + if (shouldDisposeConnection) + { + connection.Dispose(); + } } } diff --git a/src/Perpetuum/EntityFramework/DefinitionConfig.cs b/src/Perpetuum/EntityFramework/DefinitionConfig.cs index bfce9500..b28b5ba6 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($"Could not parse tint {_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/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; } 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 4d9818c7..2f10f631 100644 --- a/src/Perpetuum/Modules/DrillerModule.cs +++ b/src/Perpetuum/Modules/DrillerModule.cs @@ -15,8 +15,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 { @@ -69,7 +69,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 12dcf29e..30e5f34e 100644 --- a/src/Perpetuum/Modules/LargeHarvesterModule.cs +++ b/src/Perpetuum/Modules/LargeHarvesterModule.cs @@ -61,7 +61,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/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 8eca9e4b..e549c29b 100644 --- a/src/Perpetuum/Network/TcpConnection.cs +++ b/src/Perpetuum/Network/TcpConnection.cs @@ -31,7 +31,9 @@ public TcpConnection(Socket socket) _socket.NoDelay = true; _socket.ReceiveBufferSize = RECEIVE_BUFFER_SIZE; _socket.SendBufferSize = SEND_BUFFER_SIZE; - _socket.SetKeepAlive(true, 1000 * 60 * 60 * 2, 5000); + var keepAliveTime = new TimeSpan(2, 0, 0); // 2 hours + var keepAliveInterval = new TimeSpan(0, 0, 5); // 5 seconds + _socket.SetKeepAlive(true, keepAliveTime, keepAliveInterval); _activity = new ConnectionActivity(DateTime.Now); @@ -372,4 +374,4 @@ private void OnHandleSocketException(SocketException soex) } } } -} \ No newline at end of file +} 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 62bb25aa..f1b2ce4d 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; @@ -215,7 +215,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; @@ -224,11 +224,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/Relay/ServerStateInfo.cs b/src/Perpetuum/Services/Relay/ServerStateInfo.cs index 49e5a181..373f0958 100644 --- a/src/Perpetuum/Services/Relay/ServerStateInfo.cs +++ b/src/Perpetuum/Services/Relay/ServerStateInfo.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Perpetuum.Data; using Perpetuum.Log; @@ -93,9 +94,21 @@ public void SaveServerInfoToDb(ServerInfo serverInfo) public void PostCurrentServerInfoToWebService() { var serverInfo = GetServerInfo(); - var data = serverInfo.Serialize(); - var reply = Http.Post("http://www.perpetuum-online.com/Server_list", data); - Logger.DebugInfo(reply); + if (serverInfo == null || !serverInfo.IsBroadcast) + { + return; + } + + try + { + var data = serverInfo.Serialize(); + var reply = Http.Post("http://www.perpetuum-online.com/Server_list", data); + Logger.DebugInfo(reply); + } + catch (Exception ex) + { + Logger.Warning($"Failed to post server info to web service: {ex.Message}"); + } } } } 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..46b6ccca 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++) { @@ -90,7 +87,7 @@ protected override Point FindRelicPosition(RelicInfo info) 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..e717796b 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,7 +45,7 @@ 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); return p.RandomElement(); diff --git a/src/Perpetuum/Simd/SimdMath.cs b/src/Perpetuum/Simd/SimdMath.cs new file mode 100644 index 00000000..756ec45d --- /dev/null +++ b/src/Perpetuum/Simd/SimdMath.cs @@ -0,0 +1,375 @@ +using System; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; + +namespace Perpetuum.Simd +{ + /// + /// SIMD-accelerated math operations with AVX-512, AVX2, SSE2 and scalar fallbacks for .NET 8. + /// + public static class SimdMath + { + public static bool IsAvx512Supported => Vector512.IsHardwareAccelerated && Avx512F.IsSupported; + public static bool IsAvx2Supported => Vector256.IsHardwareAccelerated && Avx2.IsSupported; + public static bool IsVector128Supported => Vector128.IsHardwareAccelerated; + + /// + /// Calculates squared 2D distances from a source point (srcX, srcY) to an array of target points. + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void CalculateSquaredDistances2D( + float srcX, float srcY, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, + Span destinationDistSq) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, destinationDistSq.Length)); + int i = 0; + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + distSq.CopyTo(destinationDistSq.Slice(i, 16)); + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + distSq.CopyTo(destinationDistSq.Slice(i, 8)); + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + distSq.CopyTo(destinationDistSq.Slice(i, 4)); + } + } + + // Scalar remainder loop + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + destinationDistSq[i] = (dx * dx) + (dy * dy); + } + } + + /// + /// Calculates squared 3D distances with Perpetuum Z-scaling (dz = (targetZ - srcZ) / 4.0). + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void CalculateSquaredDistances3D( + float srcX, float srcY, float srcZ, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, ReadOnlySpan targetZs, + Span destinationDistSq) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, Math.Min(targetZs.Length, destinationDistSq.Length))); + int i = 0; + + const float zScale = 0.25f; // 1.0 / 4.0 + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + var vSrcZ = Vector512.Create(srcZ); + var vZScale = Vector512.Create(zScale); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + var vZ = Vector512.Create(targetZs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + distSq.CopyTo(destinationDistSq.Slice(i, 16)); + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + var vSrcZ = Vector256.Create(srcZ); + var vZScale = Vector256.Create(zScale); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + var vZ = Vector256.Create(targetZs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + distSq.CopyTo(destinationDistSq.Slice(i, 8)); + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + var vSrcZ = Vector128.Create(srcZ); + var vZScale = Vector128.Create(zScale); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + var vZ = Vector128.Create(targetZs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + distSq.CopyTo(destinationDistSq.Slice(i, 4)); + } + } + + // Scalar remainder loop + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) * zScale; + destinationDistSq[i] = (dx * dx) + (dy * dy) + (dz * dz); + } + } + + /// + /// Filters target points that are within a specified 2D range from (srcX, srcY). + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void FilterPointsInRange2D( + float srcX, float srcY, float range, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, + Span inRangeResults) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, inRangeResults.Length)); + float rangeSq = range * range; + int i = 0; + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + var vRangeSq = Vector512.Create(rangeSq); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + var mask = Vector512.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 16; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + var vRangeSq = Vector256.Create(rangeSq); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + var mask = Vector256.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 8; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + var vRangeSq = Vector128.Create(rangeSq); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var distSq = (dx * dx) + (dy * dy); + + var mask = Vector128.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 4; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + inRangeResults[i] = ((dx * dx) + (dy * dy)) <= rangeSq; + } + } + + /// + /// Filters target points that are within a specified 3D range with Perpetuum Z-scaling. + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public static void FilterPositionsInRange3D( + float srcX, float srcY, float srcZ, float range, + ReadOnlySpan targetXs, ReadOnlySpan targetYs, ReadOnlySpan targetZs, + Span inRangeResults) + { + int count = Math.Min(targetXs.Length, Math.Min(targetYs.Length, Math.Min(targetZs.Length, inRangeResults.Length))); + float rangeSq = range * range; + const float zScale = 0.25f; + int i = 0; + + if (IsAvx512Supported && count >= 16) + { + var vSrcX = Vector512.Create(srcX); + var vSrcY = Vector512.Create(srcY); + var vSrcZ = Vector512.Create(srcZ); + var vZScale = Vector512.Create(zScale); + var vRangeSq = Vector512.Create(rangeSq); + + for (; i <= count - 16; i += 16) + { + var vX = Vector512.Create(targetXs.Slice(i, 16)); + var vY = Vector512.Create(targetYs.Slice(i, 16)); + var vZ = Vector512.Create(targetZs.Slice(i, 16)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + var mask = Vector512.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 16; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsAvx2Supported && count >= 8) + { + var vSrcX = Vector256.Create(srcX); + var vSrcY = Vector256.Create(srcY); + var vSrcZ = Vector256.Create(srcZ); + var vZScale = Vector256.Create(zScale); + var vRangeSq = Vector256.Create(rangeSq); + + for (; i <= count - 8; i += 8) + { + var vX = Vector256.Create(targetXs.Slice(i, 8)); + var vY = Vector256.Create(targetYs.Slice(i, 8)); + var vZ = Vector256.Create(targetZs.Slice(i, 8)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + var mask = Vector256.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 8; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + else if (IsVector128Supported && count >= 4) + { + var vSrcX = Vector128.Create(srcX); + var vSrcY = Vector128.Create(srcY); + var vSrcZ = Vector128.Create(srcZ); + var vZScale = Vector128.Create(zScale); + var vRangeSq = Vector128.Create(rangeSq); + + for (; i <= count - 4; i += 4) + { + var vX = Vector128.Create(targetXs.Slice(i, 4)); + var vY = Vector128.Create(targetYs.Slice(i, 4)); + var vZ = Vector128.Create(targetZs.Slice(i, 4)); + + var dx = vX - vSrcX; + var dy = vY - vSrcY; + var dz = (vZ - vSrcZ) * vZScale; + var distSq = (dx * dx) + (dy * dy) + (dz * dz); + + var mask = Vector128.LessThanOrEqual(distSq, vRangeSq); + + for (int j = 0; j < 4; j++) + { + inRangeResults[i + j] = mask.GetElement(j) != 0; + } + } + } + + for (; i < count; i++) + { + float dx = targetXs[i] - srcX; + float dy = targetYs[i] - srcY; + float dz = (targetZs[i] - srcZ) * zScale; + inRangeResults[i] = ((dx * dx) + (dy * dy) + (dz * dz)) <= rangeSq; + } + } + } +} 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..7e970151 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) { 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 b330e8b0..32240508 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/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 3e9c326c..68c9641a 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 511b3958..d13e9e54 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/CombatAI.cs @@ -11,7 +11,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 { @@ -233,7 +233,7 @@ protected virtual void ReturnToHomePosition() WriteLog("Enter evade mode."); } - protected Task> FindNewAttackPositionAsync(Unit hostile) + protected Task> FindNewAttackPositionAsync(Unit hostile) { source?.Cancel(); source = new CancellationTokenSource(); @@ -294,7 +294,7 @@ protected void UpdateHostile(TimeSpan time, bool moveThreatToPseudoThreat = true return; } - List path = t.Result; + List path = t.Result; if (path == null) { @@ -378,9 +378,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. @@ -392,7 +392,7 @@ private List FindNewAttackPosition(Unit hostile, CancellationToken cancel priorityQueue.Enqueue(startNode); - HashSet closed = + HashSet closed = [ startNode.position ]; @@ -410,7 +410,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)) { @@ -445,7 +445,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); @@ -459,9 +459,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/CoveringAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/CoveringAI.cs index 6a2ca9fe..9f3aaf31 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/CoveringAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/CoveringAI.cs @@ -7,7 +7,7 @@ using Perpetuum.Zones.NpcSystem.AI.Behaviors; using Perpetuum.Zones.NpcSystem.ThreatManaging; using Perpetuum.Zones.Terrains; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI { @@ -164,7 +164,7 @@ private void UpdateMovement(TimeSpan time) // Snapshot the hostile + screen-target picture on the main thread so // the worker doesn't iterate live ThreatManager/Group/Visibility sets. List hostiles = smartCreature.GetActiveHostiles().ToList(); - Point? screenTarget = ComputeScreenTarget(centroid.Value); + SKPointI? screenTarget = ComputeScreenTarget(centroid.Value); pathPending = true; @@ -177,7 +177,7 @@ private void UpdateMovement(TimeSpan time) return; } - List path = t.Result; + List path = t.Result; if (path == null) { if (!holdLogged) @@ -217,7 +217,7 @@ private void UpdateMovement(TimeSpan time) } } - private Task> FindNewCoverPositionAsync(List hostiles, Point? screenTarget) + private Task> FindNewCoverPositionAsync(List hostiles, SKPointI? screenTarget) { source?.Cancel(); source = new CancellationTokenSource(); @@ -226,10 +226,10 @@ private Task> FindNewCoverPositionAsync(List hostiles, Poin return Task.Run(() => { - List coverPath = FindCoverPosition(hostiles, ct); + List coverPath = FindCoverPosition(hostiles, ct); if (coverPath != null) { - Point goal = coverPath[coverPath.Count - 1]; + SKPointI goal = coverPath[coverPath.Count - 1]; WriteLog($"CoveringAI: cover found at {goal.X},{goal.Y}"); return coverPath; @@ -245,10 +245,10 @@ private Task> FindNewCoverPositionAsync(List hostiles, Poin } WriteLog($"CoveringAI: no cover, falling back to screen at {screenTarget.Value.X},{screenTarget.Value.Y}"); - List screenPath = FindScreenPath(screenTarget.Value, ct); + List screenPath = FindScreenPath(screenTarget.Value, ct); if (screenPath != null) { - Point goal = screenPath[screenPath.Count - 1]; + SKPointI goal = screenPath[screenPath.Count - 1]; WriteLog($"CoveringAI: screen path found at {goal.X},{goal.Y}"); } @@ -290,7 +290,7 @@ private SmartCreature SelectScreenFriendly() // so the friendly's hitbox sits between us and the bulk of incoming fire. // Returns null when there's no friendly to screen behind, or the friendly // is sitting on top of the centroid (degenerate direction). - private Point? ComputeScreenTarget(Position centroid) + private SKPointI? ComputeScreenTarget(Position centroid) { SmartCreature friendly = SelectScreenFriendly(); if (friendly == null) @@ -310,14 +310,14 @@ private SmartCreature SelectScreenFriendly() double tx = friendlyPos.X + (dx / length * ScreenOffsetTiles); double ty = friendlyPos.Y + (dy / length * ScreenOffsetTiles); - return new Point((int)tx, (int)ty); + return new SKPointI((int)tx, (int)ty); } // Worker-thread A* over walkable tiles around the NPC, looking for the nearest // tile from which every active hostile is LoS-blocked by terrain (not plants — // see D4). Modeled on SupportAI.FindSupportPosition; must not mutate AI fields // directly — results travel through Interlocked.Exchange(ref nextMovement, ...). - private List FindCoverPosition(List hostiles, CancellationToken cancellationToken) + private List FindCoverPosition(List hostiles, CancellationToken cancellationToken) { try { @@ -328,7 +328,7 @@ private List FindCoverPosition(List hostiles, CancellationToken // D5: cap search radius at HomeRange so cover never pulls us off-leash. int coverRadius = (int)Math.Max(1, Math.Min(CoverRadius, smartCreature.HomeRange)); - Point origin = smartCreature.CurrentPosition.ToPoint(); + SKPointI origin = smartCreature.CurrentPosition.ToPoint(); double maxNode = Math.Pow(coverRadius, 2) * Math.PI; PriorityQueue priorityQueue = new((int)Math.Max(1, maxNode)); @@ -336,7 +336,7 @@ private List FindCoverPosition(List hostiles, CancellationToken priorityQueue.Enqueue(startNode); - HashSet closed = + HashSet closed = [ startNode.position ]; @@ -353,7 +353,7 @@ private List FindCoverPosition(List hostiles, CancellationToken return BuildPath(current); } - foreach (Point n in current.position.GetNeighbours()) + foreach (SKPointI n in current.position.GetNeighbours()) { if (closed.Contains(n)) { @@ -401,7 +401,7 @@ private List FindCoverPosition(List hostiles, CancellationToken } } - private bool IsValidCoverPosition(Point position, List hostiles) + private bool IsValidCoverPosition(SKPointI position, List hostiles) { IZone zone = smartCreature.Zone; if (zone == null) @@ -444,12 +444,12 @@ private bool IsValidCoverPosition(Point position, List hostiles) // D6 — we trust that "behind a teammate, relative to the threat centroid" // is good enough screening on average. Heuristic biases the search toward // screenTarget so we stop expanding once we reach it. - private List FindScreenPath(Point screenTarget, CancellationToken cancellationToken) + private List FindScreenPath(SKPointI screenTarget, CancellationToken cancellationToken) { try { int coverRadius = (int)Math.Max(1, Math.Min(CoverRadius, smartCreature.HomeRange)); - Point origin = smartCreature.CurrentPosition.ToPoint(); + SKPointI origin = smartCreature.CurrentPosition.ToPoint(); double maxNode = Math.Pow(coverRadius, 2) * Math.PI; PriorityQueue priorityQueue = new((int)Math.Max(1, maxNode)); @@ -457,7 +457,7 @@ private List FindScreenPath(Point screenTarget, CancellationToken cancell priorityQueue.Enqueue(startNode); - HashSet closed = + HashSet closed = [ startNode.position ]; @@ -474,7 +474,7 @@ private List FindScreenPath(Point screenTarget, CancellationToken cancell return BuildPath(current); } - foreach (Point n in current.position.GetNeighbours()) + foreach (SKPointI n in current.position.GetNeighbours()) { if (closed.Contains(n)) { @@ -519,7 +519,7 @@ private List FindScreenPath(Point screenTarget, CancellationToken cancell } } - private static bool IsAtScreenTarget(Point candidate, Point screenTarget) + private static bool IsAtScreenTarget(SKPointI candidate, SKPointI screenTarget) { int dx = Math.Abs(candidate.X - screenTarget.X); int dy = Math.Abs(candidate.Y - screenTarget.Y); @@ -527,9 +527,9 @@ private static bool IsAtScreenTarget(Point candidate, Point screenTarget) return dx <= ScreenArriveTolerance && dy <= ScreenArriveTolerance; } - 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/FleeAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/FleeAI.cs index 79d3e71d..60f0d2f6 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/FleeAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/FleeAI.cs @@ -4,6 +4,7 @@ using Perpetuum.Robots; using Perpetuum.Zones.Movements; using Perpetuum.Zones.NpcSystem.ThreatManaging; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI { @@ -111,7 +112,7 @@ private void StartRetreatPath() .FindPathAsync(start, destination) .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/HunterDrones/HunterApproachAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterApproachAI.cs index 76b29c36..d916d369 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterApproachAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterApproachAI.cs @@ -3,6 +3,7 @@ using Perpetuum.Timers; using Perpetuum.Units; using Perpetuum.Zones.Movements; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI.HunterDrones { @@ -63,7 +64,7 @@ private void RepathToTarget() .FindPathAsync(smartCreature.CurrentPosition, target.CurrentPosition) .ContinueWith(t => { - System.Drawing.Point[] path = t.Result; + SKPointI[] path = t.Result; if (path == null) { return; diff --git a/src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterRetreatAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterRetreatAI.cs index 034f55bc..f6cbcedb 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterRetreatAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterRetreatAI.cs @@ -2,6 +2,7 @@ using Perpetuum.PathFinders; using Perpetuum.Zones.Movements; using Perpetuum.Zones.RemoteControl; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI.HunterDrones { @@ -30,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) { path = new AStarFinder(Heuristic.Manhattan, (x, y) => true) diff --git a/src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterSelfDestructAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterSelfDestructAI.cs index 5dbb3938..8a9a68bc 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterSelfDestructAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/HunterDrones/HunterSelfDestructAI.cs @@ -5,6 +5,7 @@ using Perpetuum.Units; using Perpetuum.Zones.Effects; using Perpetuum.Zones.Movements; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI.HunterDrones { @@ -93,7 +94,7 @@ private void RepathToTarget() .FindPathAsync(smartCreature.CurrentPosition, target.CurrentPosition) .ContinueWith(t => { - System.Drawing.Point[] path = t.Result; + SKPointI[] path = t.Result; if (path == null) { return; diff --git a/src/Perpetuum/Zones/NpcSystem/AI/IdleAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/IdleAI.cs index cd3a4246..e8aab942 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/IdleAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/IdleAI.cs @@ -1,4 +1,4 @@ -using Perpetuum.Modules; +using Perpetuum.Modules; using Perpetuum.Zones.Movements; using System; 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/AI/SupportAI.cs b/src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs index bcf63240..dc2544fb 100644 --- a/src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs +++ b/src/Perpetuum/Zones/NpcSystem/AI/SupportAI.cs @@ -6,7 +6,7 @@ using Perpetuum.Units; using Perpetuum.Zones.Locking.Locks; using Perpetuum.Zones.Movements; -using System.Drawing; +using SkiaSharp; namespace Perpetuum.Zones.NpcSystem.AI { @@ -300,7 +300,7 @@ private void UpdateMovement(SmartCreature target, TimeSpan time) return; } - List path = t.Result; + List path = t.Result; if (path == null) { return; @@ -353,7 +353,7 @@ private bool HasLineOfSight(Unit target) return r != null && !r.hit; } - private Task> FindNewSupportPositionAsync(Unit target) + private Task> FindNewSupportPositionAsync(Unit target) { // T7: snapshot the screen-positioning inputs on the main thread so the // worker doesn't iterate live ThreatManager / Group / Visibility state. @@ -388,12 +388,12 @@ private List SnapshotFriendlyPositions() // valid tiles, then score `distance(target) + screenBonus` and return the // best — so support bots prefer to sit behind a friendly relative to the // hostile centroid when otherwise equivalent. - private List FindSupportPosition(Unit target, Position? threatCentroid, List friendlyPositions, CancellationToken cancellationToken) + private List FindSupportPosition(Unit target, Position? threatCentroid, List friendlyPositions, CancellationToken cancellationToken) { try { int approachRange = (int)Math.Max(1, supportRange * 0.7); - Point end = target.CurrentPosition.GetRandomPositionInRange2D(0, approachRange).ToPoint(); + SKPointI end = target.CurrentPosition.GetRandomPositionInRange2D(0, approachRange).ToPoint(); double maxNode = Math.Pow(smartCreature.HomeRange, 2) * Math.PI; PriorityQueue priorityQueue = new((int)maxNode); @@ -401,7 +401,7 @@ private List FindSupportPosition(Unit target, Position? threatCentroid, L priorityQueue.Enqueue(startNode); - HashSet closed = + HashSet closed = [ startNode.position ]; @@ -424,7 +424,7 @@ private List FindSupportPosition(Unit target, Position? threatCentroid, L } } - foreach (Point n in current.position.GetNeighbours()) + foreach (SKPointI n in current.position.GetNeighbours()) { if (closed.Contains(n)) { @@ -483,7 +483,7 @@ private List FindSupportPosition(Unit target, Position? threatCentroid, L } } - private static double ScoreCandidate(Point candidate, Position targetPosition, Position? threatCentroid, List friendlyPositions) + private static double ScoreCandidate(SKPointI candidate, Position targetPosition, Position? threatCentroid, List friendlyPositions) { double distance = targetPosition.TotalDistance2D(candidate); double bonus = HasScreeningFriendly(candidate, threatCentroid, friendlyPositions) ? ScreenBonus : 0.0; @@ -491,7 +491,7 @@ private static double ScoreCandidate(Point candidate, Position targetPosition, P return distance + bonus; } - private static bool HasScreeningFriendly(Point candidate, Position? threatCentroid, List friendlyPositions) + private static bool HasScreeningFriendly(SKPointI candidate, Position? threatCentroid, List friendlyPositions) { if (!threatCentroid.HasValue || friendlyPositions == null || friendlyPositions.Count == 0) { @@ -539,7 +539,7 @@ private static bool HasScreeningFriendly(Point candidate, Position? threatCentro return false; } - private bool IsValidSupportPosition(Unit target, Point position) + private bool IsValidSupportPosition(Unit target, SKPointI position) { IZone zone = smartCreature.Zone; if (zone == null) @@ -559,9 +559,9 @@ private bool IsValidSupportPosition(Unit target, Point position) return r != null && !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/Flocks/NormalFlock.cs b/src/Perpetuum/Zones/NpcSystem/Flocks/NormalFlock.cs index 8c7be994..dce8c825 100644 --- a/src/Perpetuum/Zones/NpcSystem/Flocks/NormalFlock.cs +++ b/src/Perpetuum/Zones/NpcSystem/Flocks/NormalFlock.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using Perpetuum.Timers; using Perpetuum.Units; using Perpetuum.Zones.NpcSystem.Presences; diff --git a/src/Perpetuum/Zones/NpcSystem/Presences/DynamicPresence.cs b/src/Perpetuum/Zones/NpcSystem/Presences/DynamicPresence.cs index 0842cf6a..1b9809f0 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 { 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 6d90b6cf..47324473 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 { @@ -56,7 +53,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; @@ -65,7 +62,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); @@ -75,7 +72,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) { @@ -123,10 +120,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/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..9091a9d8 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 Perpetuum.Collections.Spatial; using System.Numerics; +using SkiaSharp; namespace Perpetuum.Zones { @@ -32,9 +32,9 @@ 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; + return _x >= 0 && _y >= 0 && _z >= 0 && size.Contains(intX, intY) && intZ < short.MaxValue; } public void Normalize() @@ -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/CompactPassabilityMask.cs b/src/Perpetuum/Zones/Terrains/CompactPassabilityMask.cs new file mode 100644 index 00000000..e9bdc537 --- /dev/null +++ b/src/Perpetuum/Zones/Terrains/CompactPassabilityMask.cs @@ -0,0 +1,79 @@ +using System; +using System.Runtime.CompilerServices; + +namespace Perpetuum.Zones.Terrains +{ + /// + /// A high-performance 1-bit per tile passability bitmask. + /// Provides cache-efficient (512 KB per 2048x2048 zone) walkability queries. + /// + public class CompactPassabilityMask + { + public int Width { get; } + public int Height { get; } + + private readonly uint[] _bits; + + public CompactPassabilityMask(int width, int height) + { + Width = width; + Height = height; + int totalBits = width * height; + _bits = new uint[(totalBits + 31) / 32]; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsWalkable(int x, int y) + { + if ((uint)x >= (uint)Width || (uint)y >= (uint)Height) + return false; + + int bitIndex = (y * Width) + x; + int arrayIndex = bitIndex >> 5; + int bitOffset = bitIndex & 31; + + return (_bits[arrayIndex] & (1u << bitOffset)) != 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetWalkable(int x, int y, bool walkable) + { + if ((uint)x >= (uint)Width || (uint)y >= (uint)Height) + return; + + int bitIndex = (y * Width) + x; + int arrayIndex = bitIndex >> 5; + int bitOffset = bitIndex & 31; + + if (walkable) + { + _bits[arrayIndex] |= (1u << bitOffset); + } + else + { + _bits[arrayIndex] &= ~(1u << bitOffset); + } + } + + public void SetAll(bool walkable) + { + uint value = walkable ? uint.MaxValue : 0u; + Array.Fill(_bits, value); + } + + public static CompactPassabilityMask ExtractFrom(ILayer blockingLayer, SlopeLayer slopeLayer, double slopeThreshold = 4.0) + { + var mask = new CompactPassabilityMask(blockingLayer.Width, blockingLayer.Height); + for (int y = 0; y < blockingLayer.Height; y++) + { + for (int x = 0; x < blockingLayer.Width; x++) + { + bool blocked = blockingLayer.GetValue(x, y).Height > 0; + bool slopeOk = slopeLayer.CheckSlope(x, y, slopeThreshold); + mask.SetWalkable(x, y, !blocked && slopeOk); + } + } + return mask; + } + } +} diff --git a/src/Perpetuum/Zones/Terrains/HeightfieldMetadata.cs b/src/Perpetuum/Zones/Terrains/HeightfieldMetadata.cs new file mode 100644 index 00000000..e21f7b80 --- /dev/null +++ b/src/Perpetuum/Zones/Terrains/HeightfieldMetadata.cs @@ -0,0 +1,129 @@ +using System; +using System.Runtime.CompilerServices; + +namespace Perpetuum.Zones.Terrains +{ + /// + /// Pre-extracted hierarchical chunk bounding metadata from terrain layers (altitude and blocking) + /// to accelerate spatial queries, Line-of-Sight (LOS) raycasting, and obstacle checks. + /// + public class HeightfieldMetadata + { + public const int DefaultChunkSize = 16; + + public int Width { get; } + public int Height { get; } + public int ChunkSize { get; } + public int ChunksX { get; } + public int ChunksY { get; } + + private readonly float[] _minHeights; + private readonly float[] _maxHeights; + + public HeightfieldMetadata(int width, int height, int chunkSize = DefaultChunkSize) + { + Width = width; + Height = height; + ChunkSize = Math.Max(1, chunkSize); + ChunksX = (width + ChunkSize - 1) / ChunkSize; + ChunksY = (height + ChunkSize - 1) / ChunkSize; + + _minHeights = new float[ChunksX * ChunksY]; + _maxHeights = new float[ChunksX * ChunksY]; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetChunkIndex(int chunkX, int chunkY) => (chunkY * ChunksX) + chunkX; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void GetChunkCoordinates(int tileX, int tileY, out int chunkX, out int chunkY) + { + chunkX = Math.Clamp(tileX / ChunkSize, 0, ChunksX - 1); + chunkY = Math.Clamp(tileY / ChunkSize, 0, ChunksY - 1); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void GetChunkBounds(int chunkX, int chunkY, out float minH, out float maxH) + { + if (chunkX < 0 || chunkX >= ChunksX || chunkY < 0 || chunkY >= ChunksY) + { + minH = float.MinValue; + maxH = float.MaxValue; + return; + } + + int idx = GetChunkIndex(chunkX, chunkY); + minH = _minHeights[idx]; + maxH = _maxHeights[idx]; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool CanRayPassAboveChunk(int chunkX, int chunkY, float rayMinZ) + { + if (chunkX < 0 || chunkX >= ChunksX || chunkY < 0 || chunkY >= ChunksY) + return false; + + int idx = GetChunkIndex(chunkX, chunkY); + return rayMinZ > _maxHeights[idx]; + } + + /// + /// Extracts and bakes chunk min/max metadata from an AltitudeLayer and optional Blocking Layer. + /// + public static HeightfieldMetadata ExtractFrom(AltitudeLayer altitudeLayer, ILayer blockingLayer = null, int chunkSize = DefaultChunkSize) + { + var metadata = new HeightfieldMetadata(altitudeLayer.Width, altitudeLayer.Height, chunkSize); + metadata.RecomputeAll(altitudeLayer, blockingLayer); + return metadata; + } + + public void RecomputeAll(AltitudeLayer altitudeLayer, ILayer blockingLayer = null) + { + for (int cy = 0; cy < ChunksY; cy++) + { + for (int cx = 0; cx < ChunksX; cx++) + { + RecomputeChunk(cx, cy, altitudeLayer, blockingLayer); + } + } + } + + public void RecomputeChunk(int chunkX, int chunkY, AltitudeLayer altitudeLayer, ILayer blockingLayer = null) + { + int startX = chunkX * ChunkSize; + int startY = chunkY * ChunkSize; + int endX = Math.Min(startX + ChunkSize, Width); + int endY = Math.Min(startY + ChunkSize, Height); + + float min = float.MaxValue; + float max = float.MinValue; + + for (int y = startY; y < endY; y++) + { + for (int x = startX; x < endX; x++) + { + float alt = (float)altitudeLayer.GetAltitudeAsDouble(x, y); + float blockHeight = 0; + if (blockingLayer != null) + { + blockHeight = blockingLayer.GetValue(x, y).Height; + } + + float totalHeight = alt + blockHeight; + if (totalHeight < min) min = totalHeight; + if (totalHeight > max) max = totalHeight; + } + } + + if (min > max) + { + min = 0; + max = 0; + } + + int idx = GetChunkIndex(chunkX, chunkY); + _minHeights[idx] = min; + _maxHeights[idx] = max; + } + } +} 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/Layer.cs b/src/Perpetuum/Zones/Terrains/Layer.cs index 3c2f6725..4d5b9332 100644 --- a/src/Perpetuum/Zones/Terrains/Layer.cs +++ b/src/Perpetuum/Zones/Terrains/Layer.cs @@ -143,15 +143,36 @@ public void SetArea(Area area, T[] data) public T GetValue(int x, int y) { - Debug.Assert(x >= 0 && x < Width && y >= 0 && y < Height, "invalid position!"); - return RawData[y * Width + x]; + if (RawData == null || RawData.Length == 0 || Width <= 0 || Height <= 0) + return default; + + x = Math.Clamp(x, 0, Width - 1); + y = Math.Clamp(y, 0, Height - 1); + int idx = y * Width + x; + if (idx < 0 || idx >= RawData.Length) + { + return default; + } + + return RawData[idx]; } public void SetValue(int x, int y, T value) { - Debug.Assert(x >= 0 && x < Width && y >= 0 && y < Height, "invalid position!"); + if (RawData == null || RawData.Length == 0 || Width <= 0 || Height <= 0) + return; + + if (x < 0 || x >= Width || y < 0 || y >= Height) + return; + + int idx = y * Width + x; + if (idx < 0 || idx >= RawData.Length) + { + return; + } + OnUpdating(x, y, ref value); - RawData[y * Width + x] = value; + RawData[idx] = value; OnUpdated(x, y); } diff --git a/src/Perpetuum/Zones/Terrains/LayerExtensions.cs b/src/Perpetuum/Zones/Terrains/LayerExtensions.cs index f5b01f01..1563ccfe 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); } @@ -35,7 +34,7 @@ public static void UpdateAll(this ILayer layer, Func update { for (var y = 0; y < layer.Height; y++) { - for (var x = 0; x < layer.Height; x++) + for (var x = 0; x < layer.Width; x++) { var current = layer[x, y]; var updated = updater(x, y, current); 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..77f9554b 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 { 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/TerrainControlInfo.cs b/src/Perpetuum/Zones/Terrains/TerrainControlInfo.cs index f621b360..1f338708 100644 --- a/src/Perpetuum/Zones/Terrains/TerrainControlInfo.cs +++ b/src/Perpetuum/Zones/Terrains/TerrainControlInfo.cs @@ -7,6 +7,11 @@ public struct TerrainControlInfo : IEquatable { private TerrainControlFlags _flags; + public TerrainControlInfo(TerrainControlFlags flags) + { + _flags = flags; + } + public TerrainControlFlags Flags { get { return _flags; } 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..7c557dad 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; } @@ -326,6 +326,7 @@ public Player GetPlayer(long eid) } private readonly ShiftedConsumerTimer _updateUnitsTimer = new ShiftedConsumerTimer(500); + private readonly IntervalTimer _idleUpdateTimer = new IntervalTimer(1000); private Action _updateProfiler; @@ -344,6 +345,22 @@ public override void Update(TimeSpan time) { UpdateSessions(time); + // Throttle unit physics, AI, and visibility processing when no players are in the zone + if (_players.IsEmpty) + { + _idleUpdateTimer.Update(time); + if (!_idleUpdateTimer.Passed) + { + RiftManager?.Update(time); + RelicManager?.Update(time); + MiningLogHandler.Update(time); + HarvestLogHandler.Update(time); + return; + } + + _idleUpdateTimer.Reset(); + } + _updateUnitsTimer.Update(time).IsPassed(ProcessUpdatedUnits); UpdateUnits(time); 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..a69fa76d 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.Opaque); } } 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..1621e75e 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 @@ -38,7 +38,42 @@ public LayerFileIO(IFileSystem fileSystem) public T[] LoadLayerData(IZone zone, string name) where T : struct { string path = zone.CreateTerrainDataFilename(name); - T[] data = _fileSystem.ReadLayer(path); + T[] data; + + if (typeof(T) == typeof(TerrainControlInfo)) + { + byte[] rawBytes = _fileSystem.ReadLayerAsByteArray(path); + int totalCells = zone.Size.Width * zone.Size.Height; + if (rawBytes.Length == totalCells) + { + var converted = new TerrainControlInfo[totalCells]; + for (int i = 0; i < totalCells; i++) + { + converted[i] = new TerrainControlInfo((TerrainControlFlags)rawBytes[i]); + } + data = (T[])(object)converted; + } + else + { + data = rawBytes.ToArray(); + } + } + else + { + data = _fileSystem.ReadLayer(path); + } + + int expectedCells = zone.Size.Width * zone.Size.Height; + if (expectedCells > 0 && (data == null || data.Length != expectedCells)) + { + var safeData = new T[expectedCells]; + if (data != null && data.Length > 0) + { + Array.Copy(data, safeData, Math.Min(data.Length, expectedCells)); + } + data = safeData; + } + Logger.Info("Layer data loaded. (" + name + ") zone:" + zone.Id); return data; } @@ -64,23 +99,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 +126,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 +143,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 +173,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.template b/template/perpetuum.ini.template new file mode 100644 index 00000000..c4b180e9 --- /dev/null +++ b/template/perpetuum.ini.template @@ -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 diff --git a/template/restore_migrated_DB.sql b/template/restore_migrated_DB.sql new file mode 100644 index 00000000..b35d0754 --- /dev/null +++ b/template/restore_migrated_DB.sql @@ -0,0 +1,16 @@ +USE [master] +GO + +IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'perpetuumsa') +BEGIN + ALTER DATABASE perpetuumsa SET SINGLE_USER WITH ROLLBACK IMMEDIATE +END +GO + +RESTORE DATABASE perpetuumsa FROM DISK = '/data/perpetuumsa_migrated.bak' WITH +MOVE 'perpetuumsa' TO '/data/psa.mdf', +MOVE 'perpetuumsa_log' TO '/data/psa_log.ldf', REPLACE +GO + +ALTER DATABASE perpetuumsa SET MULTI_USER +GO