mirror of
https://github.com/Empostor/Empostor.git
synced 2026-10-20 11:39:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
846ccb19e3 | ||
|
|
8ea0dc98e9 | ||
|
|
46495ed501 | ||
|
|
453ebbdf70 | ||
|
|
3888bfa7ee | ||
|
|
4e4f87a0a7 | ||
|
|
40388f319a | ||
|
|
0c89f5e07f | ||
|
|
9dcd7c529c | ||
|
|
6fd771e0cf | ||
|
|
8eaf33595b | ||
|
|
ec0aa78a75 | ||
|
|
d25dd117b8 | ||
|
|
cf9c137f7e | ||
|
|
977f60789e | ||
|
|
d4f6311a15 | ||
|
|
33910c45f2 | ||
|
|
4988b11fd2 | ||
|
|
4609653438 | ||
|
|
5514b66823 | ||
|
|
9f005bc4e6 | ||
|
|
8899510583 | ||
|
|
0f7ca87e6b | ||
|
|
71d384c8e6 | ||
|
|
1c8de1c5f8 | ||
|
|
2fa1a2e060 | ||
|
|
91ac52626b |
No files matched your search
@@ -1,39 +0,0 @@
|
||||
# Build output
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/publish/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vs/
|
||||
.vscode/
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Docs
|
||||
docs/
|
||||
*.md
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# Logs
|
||||
Log/
|
||||
*.log
|
||||
|
||||
# Data
|
||||
Data/
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
@@ -97,47 +97,3 @@ jobs:
|
||||
--prerelease \
|
||||
--notes "Automated nightly build from ${GITHUB_SHA}" \
|
||||
--target "${GITHUB_SHA}"
|
||||
|
||||
docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
arch:
|
||||
- { docker: linux/amd64, rid: amd64 }
|
||||
- { docker: linux/arm64, rid: arm64 }
|
||||
- { docker: linux/arm/v7, rid: arm }
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Build Docker image (${{ matrix.arch.rid }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.arch.docker }}
|
||||
load: true
|
||||
tags: empostor-server:${{ matrix.arch.rid }}
|
||||
build-args: |
|
||||
VERSIONSUFFIX=docker
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
- name: Export Docker image to tar
|
||||
run: docker save empostor-server:${{ matrix.arch.rid }} -o empostor-server-docker-${{ matrix.arch.rid }}.tar
|
||||
|
||||
- name: Upload Docker image
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: empostor-docker-${{ matrix.arch.rid }}
|
||||
path: empostor-server-docker-${{ matrix.arch.rid }}.tar
|
||||
@@ -1,6 +1,7 @@
|
||||
/.vscode
|
||||
/.claude
|
||||
/build
|
||||
/release
|
||||
/.vs
|
||||
/tools
|
||||
|
||||
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
# ============================================
|
||||
# Empostor Server Docker Image
|
||||
# ============================================
|
||||
# Multi-stage build: compiles from source, then produces a minimal runtime image.
|
||||
#
|
||||
# Build (local):
|
||||
# docker build -t empostor-server .
|
||||
#
|
||||
# Build (multi-arch):
|
||||
# docker buildx build --platform linux/amd64,linux/arm64 -t empostor-server .
|
||||
#
|
||||
# Run:
|
||||
# docker compose up -d
|
||||
# ============================================
|
||||
|
||||
# ── Build stage ────────────────────────────
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG VERSIONSUFFIX=docker
|
||||
|
||||
WORKDIR /source
|
||||
|
||||
# Restore dependencies (layer caching)
|
||||
COPY src/Empostor.Server/Empostor.Server.csproj ./src/Empostor.Server/
|
||||
COPY src/Empostor.Api/Empostor.Api.csproj ./src/Empostor.Api/
|
||||
COPY src/Empostor.Api.Innersloth.Generator/Empostor.Api.Innersloth.Generator.csproj ./src/Empostor.Api.Innersloth.Generator/
|
||||
COPY src/Empostor.Plugins.PlayerLog/Empostor.Plugins.PlayerLog.csproj ./src/Empostor.Plugins.PlayerLog/
|
||||
COPY src/Directory.Build.props ./src/
|
||||
|
||||
RUN case "$TARGETARCH" in \
|
||||
amd64) RID=linux-x64 ;; \
|
||||
arm64) RID=linux-arm64 ;; \
|
||||
arm) RID=linux-arm ;; \
|
||||
*) echo "Unsupported arch: $TARGETARCH"; exit 1 ;; \
|
||||
esac && \
|
||||
dotnet restore -r "$RID" ./src/Empostor.Server/Empostor.Server.csproj
|
||||
|
||||
# Copy sources and publish
|
||||
COPY src/. ./src/
|
||||
|
||||
RUN case "$TARGETARCH" in \
|
||||
amd64) RID=linux-x64 ;; \
|
||||
arm64) RID=linux-arm64 ;; \
|
||||
arm) RID=linux-arm ;; \
|
||||
*) echo "Unsupported arch: $TARGETARCH"; exit 1 ;; \
|
||||
esac && \
|
||||
[ "$VERSIONSUFFIX" = "none" ] && VERSIONSUFFIX="" ; \
|
||||
dotnet publish ./src/Empostor.Server/Empostor.Server.csproj \
|
||||
-c Release \
|
||||
-r "$RID" \
|
||||
--no-restore \
|
||||
--self-contained false \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:PublishTrimmed=false \
|
||||
-p:VersionSuffix="$VERSIONSUFFIX" \
|
||||
-o /app
|
||||
|
||||
# Copy default config (must be overridden in production)
|
||||
RUN cp ./src/Empostor.Server/config.json /app/config.json 2>/dev/null || true
|
||||
|
||||
# ── Runtime stage ──────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy published output from build stage
|
||||
COPY --from=build /app ./
|
||||
|
||||
# Create writable runtime directories
|
||||
RUN mkdir -p /app/plugins /app/libraries /app/Languages /app/Log /app/Data /app/marketplace \
|
||||
&& chmod 777 /app/plugins /app/libraries /app/Languages /app/Log /app/Data /app/marketplace
|
||||
|
||||
# Environment
|
||||
ENV ASPNETCORE_URLS=http://0.0.0.0:80
|
||||
ENV EMPOSTOR_Server__PublicIp=0.0.0.0
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
|
||||
ENV DOTNET_RUNNING_IN_CONTAINER=1
|
||||
|
||||
EXPOSE 80/tcp
|
||||
EXPOSE 22023/tcp
|
||||
# Game / matchmaker UDP (Server.ListenPort) — always bound, even in delta mode.
|
||||
EXPOSE 22023/udp
|
||||
# Delta UDP port pool (Server.DeltaPortStart/End). Keep in sync with config.json.
|
||||
EXPOSE 22024-42024/udp
|
||||
|
||||
VOLUME ["/app/config", "/app/plugins", "/app/libraries", "/app/Languages", "/app/Log", "/app/Data", "/app/marketplace"]
|
||||
|
||||
ENTRYPOINT ["./Empostor.Server"]
|
||||
@@ -46,6 +46,49 @@ It is also recommended to [set up a Reverse Proxy](https://empostor.github.io/ge
|
||||
4. Press "Open in Among Us". Among Us should open.
|
||||
5. Your server should appear in the regions list!
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings live in `config.json` next to the server binary (a full sample is [`src/Empostor.Server/config.json`](src/Empostor.Server/config.json)). Unknown keys are ignored, so an old config keeps working after an update.
|
||||
|
||||
| Section | Purpose |
|
||||
| --- | --- |
|
||||
| `Server` | Public IP/port, listen address and the dynamic (delta) UDP port range `DeltaPortStart`..`DeltaPortEnd` |
|
||||
| `HttpServer` | Web API, admin panel and matchmaking token endpoint (`Enabled`, `ListenIp`, `ListenPort`) |
|
||||
| `AntiCheat` | Server-side anticheat, including the [IP request rate limit](#ip-request-rate-limit) |
|
||||
| `Timeout` | Spawn and connection timeouts |
|
||||
| `Compatibility` | Game version compatibility rules |
|
||||
| `Debug` | Game recorder |
|
||||
| `AuthApi` | Friend code resolution (`Mode`: `GameServices`, `Ume`, `Both` or `Off`) |
|
||||
| `Admin` | Admin panel password and marketplace URL |
|
||||
| `HPLP` | HPLP public region listing |
|
||||
|
||||
### AntiCheat
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Enabled` | `true` | Master switch of the anticheat |
|
||||
| `BanIpFromGame` | `true` | Also block banned IPs on the game (UDP) side |
|
||||
| `AllowCheatingHosts` | `Never` | What happens to hosts that cheat |
|
||||
| `EnableGameFlowChecks`, `EnableMustBeHostChecks`, `EnableOwnershipChecks`, `EnableRoleChecks`, `EnableTargetChecks` | `true` | Game flow / state validation |
|
||||
| `EnableColorLimitChecks`, `EnableNameLimitChecks`, `EnableItemLimitChecks` | `true` | Lobby limits (colors, names, items) |
|
||||
| `ForbidProtocolExtensions` | `true` | Reject unknown/extension protocol messages |
|
||||
| `EnablePacketSizeChecks`, `PacketSizeLimit` | `true`, `1203` | Maximum inbound packet size |
|
||||
| `EnableIpRateLimit` | `true` | IP request rate limit (below) |
|
||||
| `IpRateLimitRequests` | `30` | Max TCP (HTTP) requests per IP per window, `0` disables the limit |
|
||||
| `IpRateLimitWindowMinutes` | `5` | Length of the rate limit window in minutes |
|
||||
|
||||
#### Ip request rate limit
|
||||
|
||||
Within one window (5 minutes by default) the same IP may only make `IpRateLimitRequests` (default `30`) TCP (HTTP) requests — this protects the HTTP API against connection/request flooding. When the limit is exceeded:
|
||||
|
||||
- Every normal endpoint answers with `429 Too Many Requests` and a localized hint, chosen from the request's `Accept-Language` header, e.g. `请求过于频繁请 5 分钟后重试`. The `Retry-After` header says how many seconds are left.
|
||||
- `POST /api/user` **and** `PUT`/`GET /api/games` (the whole join flow of the game client: matchmaking token + FindHost) are **never rejected over HTTP** — failing them there would only leave the client on a black screen, which just logs `CoSendRequest failed` and hangs. Both still count against the quota, and while an IP is over quota the server also skips the outbound friend code API calls (a cached/fallback code is used instead) so the endpoint cannot be abused for amplification. To keep a flooding IP from draining the dynamic port range, such joins are **not given a pool port**: they all receive the same shared *reject port*, a single pre-bound UDP socket whose only job is to answer the handshake with the localized hint in the client's own language (`请求过于频繁请 5 分钟后重试`) and close it again — the player is told why they cannot join instead of seeing a black screen, and only one socket is used no matter how many requests arrive. `GamesController` echoes the token's port back, so the client is naturally sent to that reject port. In fixed-port mode (`DeltaPortStart=0`) the main port is used instead.
|
||||
- `/admin` and `/api/admin` are neither counted nor limited, so the dashboard can never lock the operator out.
|
||||
|
||||
The message lives under the key `ratelimit.too_frequent` in `Languages/*.json` (and in the built-in defaults in `LanguageService.cs`), so you can reword or translate it there. Set `IpRateLimitRequests` to `0` to turn the whole feature off.
|
||||
|
||||
Empostor usually runs behind a CDN or reverse proxy, so the real client IP is taken from the `X-Real-IP` header, then from the first entry of `X-Forwarded-For`, and only afterwards from the socket address. Make sure your CDN/proxy forwards one of these headers — otherwise every player would share the proxy IP's quota. Note that the game client polls some endpoints (e.g. `/api/games`) while you play: if `30` per 5 minutes is too tight for your players, raise `IpRateLimitRequests` (for example to `120`).
|
||||
|
||||
## Contributing
|
||||
Please read the [contributing guidelines](https://github.com/Empostor/Empostor/blob/main/CONTRIBUTING.md).
|
||||
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
services:
|
||||
empostor:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: empostor-server
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
# HTTP API / Admin panel (TCP)
|
||||
- "22023:22023/tcp"
|
||||
# Game / Matchmaker UDP — REQUIRED. The matchmaker always binds this UDP
|
||||
# port (Server.ListenPort) so players can connect; without it the server
|
||||
# is unreachable even in delta mode.
|
||||
- "22023:22023/udp"
|
||||
# Delta UDP port pool. With DeltaPortStart/End enabled, each player is
|
||||
# assigned a unique UDP port from this range (Server.DeltaPortStart/End).
|
||||
# Must stay in sync with config.json.
|
||||
- "22024-42024:22024-42024/udp"
|
||||
# Uncomment to expose admin HTTP panel:
|
||||
# - "80:80/tcp"
|
||||
|
||||
volumes:
|
||||
# Server config. NOTE: if ./config.json does NOT exist on the host, Docker
|
||||
# creates an EMPTY DIRECTORY at that path and mounts it over the baked-in
|
||||
# /app/config.json, which breaks startup. Create the file first (or remove
|
||||
# this line to use the image's default config) before running.
|
||||
- ./config.json:/app/config.json:ro
|
||||
# Writable runtime data
|
||||
- empostor_plugins:/app/plugins
|
||||
- empostor_libraries:/app/libraries
|
||||
- empostor_languages:/app/Languages
|
||||
- ./Log:/app/Log
|
||||
- ./Data:/app/Data
|
||||
- ./marketplace:/app/marketplace
|
||||
# Per-component configs (writable from admin panel)
|
||||
- ./discord_webhook.json:/app/discord_webhook.json
|
||||
|
||||
environment:
|
||||
- DOTNET_RUNNING_IN_CONTAINER=1
|
||||
- ASPNETCORE_URLS=http://0.0.0.0:80
|
||||
# PublicIp MUST be your real public IP / domain, or remote players can't
|
||||
# connect. 0.0.0.0 / 127.0.0.1 will NOT work for anyone but localhost.
|
||||
- EMPOSTOR_Server__PublicIp=0.0.0.0
|
||||
- EMPOSTOR_Server__ListenIp=0.0.0.0
|
||||
|
||||
volumes:
|
||||
empostor_plugins:
|
||||
empostor_libraries:
|
||||
empostor_languages:
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"empostor_version": "2.0.0",
|
||||
"empostor_version": "2.0.1",
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugin.Chat.dll"
|
||||
}
|
||||
]
|
||||
@@ -20,7 +20,7 @@
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"empostor_version": "2.0.0",
|
||||
"empostor_version": "2.0.1",
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugin.Code.dll"
|
||||
}
|
||||
]
|
||||
@@ -33,13 +33,13 @@
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"empostor_version": "2.0.0",
|
||||
"empostor_version": "2.0.1",
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugins.Welcome.dll"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "duck.hayashiume.friendcodevalidator",
|
||||
"id": "duck.Empostor.friendcodevalidator",
|
||||
"name": "Friend Code Validator",
|
||||
"description": "Kicks clients whose friend code does not match the official AB+#XXXX format.",
|
||||
"author": "MatchDuck & HayashiUme",
|
||||
@@ -52,20 +52,20 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cn.hayashiume.fixedcode",
|
||||
"id": "cn.Empostor.fixedcode",
|
||||
"name": "Fixed Room Code",
|
||||
"description": "Assigns a fixed room code to a host based on their friend code.",
|
||||
"author": "HayashiUme",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"empostor_version": "2.0.0",
|
||||
"empostor_version": "2.0.1",
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugins.FixedCode.dll"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cn.hayashiume.titles",
|
||||
"id": "cn.Empostor.titles",
|
||||
"name": "Custom Title",
|
||||
"description": "Assigns a title based on their friend code and show in front of their name.",
|
||||
"author": "HayashiUme",
|
||||
@@ -150,7 +150,7 @@
|
||||
"versions": [
|
||||
{
|
||||
"version": "2.2.0",
|
||||
"empostor_version": "2.0.0",
|
||||
"empostor_version": "2.0.1",
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/Reactor.Empostor.dll"
|
||||
}
|
||||
]
|
||||
@@ -176,7 +176,7 @@
|
||||
"versions": [
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"empostor_version": "2.0.0",
|
||||
"empostor_version": "2.0.1",
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/Empostor.Plugins.DiscordWebhook.dll"
|
||||
}
|
||||
]
|
||||
@@ -232,5 +232,18 @@
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/Empostor.Plugins.Privacy.dll"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cn.hayashiume.maintenance",
|
||||
"name": "Maintenance Server",
|
||||
"description": "Maintian server to refuse plater connect to the server",
|
||||
"author": "Empostor",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"empostor_version": "2.0.1",
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/Empostor.Plugins.Maintenance.dll"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)/Localization')">
|
||||
<None Remove="Localization/**/*.json" />
|
||||
<Content Remove="Localization/**/*.json" />
|
||||
<EmbeddedResource Include="Localization/*.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -150,7 +150,6 @@
|
||||
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
|
||||
}
|
||||
}
|
||||
},
|
||||
".NETStandard,Version=v2.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,9 @@ public enum CheatCategory
|
||||
/// <summary>A packet was sent with more items than possible in the game.</summary>
|
||||
ItemLimits,
|
||||
|
||||
/// <summary>A client sent more messages in a window than the room can legitimately produce.</summary>
|
||||
RateLimit,
|
||||
|
||||
/// <summary>Legacy category for unsorted anticheat checks.</summary>
|
||||
Other,
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using Empostor.Api.Games;
|
||||
using Empostor.Api.Languages;
|
||||
using Empostor.Api.Localization;
|
||||
using Empostor.Api.Net;
|
||||
using Empostor.Api.Net.Inner.Objects;
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Empostor.Api.Commands
|
||||
|
||||
public required IGame Game { get; init; }
|
||||
|
||||
public required LanguageService Lang { get; init; }
|
||||
public required ILocalizationService Localization { get; init; }
|
||||
|
||||
public Innersloth.Language SenderLanguage => Sender.Client.Language;
|
||||
|
||||
@@ -27,7 +27,10 @@ namespace Empostor.Api.Commands
|
||||
=> SenderLanguage == Innersloth.Language.SChinese ||
|
||||
SenderLanguage == Innersloth.Language.TChinese;
|
||||
|
||||
public LanguageString GetString(string key)
|
||||
=> Lang.Get(key, SenderLanguage);
|
||||
public string GetString(string key, params object?[] arguments)
|
||||
=> Localization.Get(new LocalizedMessageKey("empostor", key), SenderLanguage, arguments);
|
||||
|
||||
public string GetPluginString(string owner, string key, params object?[] arguments)
|
||||
=> Localization.Get(new LocalizedMessageKey(owner, key), SenderLanguage, arguments);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ namespace Empostor.Api.Commands
|
||||
|
||||
string Usage { get; }
|
||||
|
||||
string LocalizationOwner => "empostor";
|
||||
|
||||
ValueTask<bool> ExecuteAsync(CommandContext ctx);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,14 @@ namespace Empostor.Api.Config
|
||||
|
||||
public bool EnableTargetChecks { get; set; } = true;
|
||||
|
||||
public bool EnableRateLimits { get; set; } = true;
|
||||
|
||||
public int RpcRateLimitPerSecond { get; set; } = 20;
|
||||
|
||||
public int TaskRateLimitCount { get; set; } = 3;
|
||||
|
||||
public double TaskRateLimitWindowSeconds { get; set; } = 2;
|
||||
|
||||
public bool ForbidProtocolExtensions { get; set; } = true;
|
||||
|
||||
public bool EnablePacketSizeChecks { get; set; } = true;
|
||||
|
||||
@@ -9,5 +9,7 @@ namespace Empostor.Api.Config
|
||||
public bool AllowHostAuthority { get; set; } = false;
|
||||
|
||||
public bool AllowVersionMixing { get; set; } = false;
|
||||
|
||||
public bool AllowDuplicateLogin { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -54,5 +54,10 @@
|
||||
/// A custom message can be set in <see cref="GameJoinResult.Message" />.
|
||||
/// </remarks>
|
||||
Custom,
|
||||
|
||||
/// <summary>
|
||||
/// The same account is already playing in this game.
|
||||
/// </summary>
|
||||
DuplicateConnection,
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,7 @@
|
||||
"Reactor": "ReactorSystemType",
|
||||
"LifeSupp": "LifeSuppSystemType",
|
||||
"Ventilation": "VentilationSystem",
|
||||
"Decontamination": "DeconSystem",
|
||||
"Decontamination2": "DeconSystem",
|
||||
"Sabotage": "SabotageSystemType"
|
||||
}
|
||||
@@ -6,5 +6,7 @@
|
||||
"Security": "SecurityCameraSystemType",
|
||||
"Ventilation": "VentilationSystem",
|
||||
"Laboratory": "ReactorSystemType",
|
||||
"Decontamination": "DeconSystem",
|
||||
"Decontamination2": "DeconSystem",
|
||||
"Sabotage": "SabotageSystemType"
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace Empostor.Api.Innersloth
|
||||
Platform = (Platforms)reader.Tag;
|
||||
PlatformName = reader.ReadString();
|
||||
|
||||
XboxPlatformId = Platform == Platforms.Xbox ? reader.ReadUInt64() : null;
|
||||
XboxPlatformId = Platform is Platforms.StandaloneWin10 or Platforms.Xbox ? reader.ReadUInt64() : null;
|
||||
PsnPlatformId = Platform == Platforms.Playstation ? reader.ReadUInt64() : null;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,9 @@ namespace Empostor.Api.Innersloth
|
||||
writer.Write(PlatformName);
|
||||
switch (Platform)
|
||||
{
|
||||
case Platforms.StandaloneWin10:
|
||||
case Platforms.Xbox:
|
||||
writer.Write(XboxPlatformId ?? throw new NullReferenceException($"{nameof(XboxPlatformId)} shouldn't be null when {nameof(Platform)} is Xbox"));
|
||||
writer.Write(XboxPlatformId ?? throw new NullReferenceException($"{nameof(XboxPlatformId)} shouldn't be null when {nameof(Platform)} is {Platform}"));
|
||||
break;
|
||||
case Platforms.Playstation:
|
||||
writer.Write(PsnPlatformId ?? throw new NullReferenceException($"{nameof(PsnPlatformId)} shouldn't be null when {nameof(Platform)} is Playstation"));
|
||||
|
||||
@@ -1,876 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Empostor.Api.Innersloth;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Api.Languages;
|
||||
|
||||
public sealed class LanguageService
|
||||
{
|
||||
private static readonly string LangDir = Path.Combine(Directory.GetCurrentDirectory(), "Languages");
|
||||
public readonly ILogger<LanguageService> _logger;
|
||||
private readonly ConcurrentDictionary<string, Dictionary<string, string>> _tables = new();
|
||||
private static readonly string FallbackLang = "en";
|
||||
|
||||
private static readonly Dictionary<Language, string> LangCodeMap = new()
|
||||
{
|
||||
[Language.English] = "en",
|
||||
[Language.Latam] = "es",
|
||||
[Language.Brazilian] = "pt_BR",
|
||||
[Language.Portuguese] = "pt",
|
||||
[Language.Korean] = "ko",
|
||||
[Language.Russian] = "ru",
|
||||
[Language.Dutch] = "nl",
|
||||
[Language.Filipino] = "fil",
|
||||
[Language.French] = "fr",
|
||||
[Language.German] = "de",
|
||||
[Language.Italian] = "it",
|
||||
[Language.Japanese] = "ja",
|
||||
[Language.Spanish] = "es",
|
||||
[Language.SChinese] = "zh_CN",
|
||||
[Language.TChinese] = "zh_TW",
|
||||
[Language.Irish] = "ga",
|
||||
};
|
||||
|
||||
public LanguageService(ILogger<LanguageService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
EnsureDefaults();
|
||||
LoadAll();
|
||||
}
|
||||
|
||||
public LanguageString Get(string key, Language language = Language.English)
|
||||
{
|
||||
var code = LangCodeMap.TryGetValue(language, out var c) ? c : FallbackLang;
|
||||
var text = Lookup(key, code) ?? Lookup(key, FallbackLang) ?? key;
|
||||
return new LanguageString(text);
|
||||
}
|
||||
|
||||
public LanguageString Get(string key, string langCode)
|
||||
{
|
||||
var text = Lookup(key, langCode) ?? Lookup(key, FallbackLang) ?? key;
|
||||
return new LanguageString(text);
|
||||
}
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
_tables.Clear();
|
||||
LoadAll();
|
||||
_logger.LogInformation("LanguageReloaded all language files.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register translations provided by a plugin.
|
||||
/// Merges into existing language tables without overwriting existing keys.
|
||||
/// </summary>
|
||||
public void AddPluginTranslations(IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> translations)
|
||||
{
|
||||
foreach (var (langCode, table) in translations)
|
||||
{
|
||||
if (!_tables.TryGetValue(langCode, out var existing))
|
||||
{
|
||||
existing = new Dictionary<string, string>();
|
||||
_tables[langCode] = existing;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in table)
|
||||
{
|
||||
if (!existing.ContainsKey(key))
|
||||
{
|
||||
existing[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("LanguagePlugin added {Count} keys for {Lang}", table.Count, langCode);
|
||||
}
|
||||
}
|
||||
|
||||
private string? Lookup(string key, string langCode)
|
||||
{
|
||||
return _tables.TryGetValue(langCode, out var table) && table.TryGetValue(key, out var val)
|
||||
? val
|
||||
: null;
|
||||
}
|
||||
|
||||
private void LoadAll()
|
||||
{
|
||||
if (!Directory.Exists(LangDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(LangDir, "*.json"))
|
||||
{
|
||||
var code = Path.GetFileNameWithoutExtension(file);
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
var table = JsonSerializer.Deserialize<Dictionary<string, string>>(json)
|
||||
?? new Dictionary<string, string>();
|
||||
_tables[code] = table;
|
||||
_logger.LogDebug("LanguageLoaded {Code} ({Count} keys)", code, table.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "LanguageFailed to load {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("LanguageLoaded {Count} language file(s).", _tables.Count);
|
||||
}
|
||||
|
||||
private void EnsureDefaults()
|
||||
{
|
||||
Directory.CreateDirectory(LangDir);
|
||||
WriteIfMissing("en.json", DefaultEn);
|
||||
WriteIfMissing("zh_CN.json", DefaultZhCn);
|
||||
WriteIfMissing("zh_TW.json", DefaultZhTw);
|
||||
WriteIfMissing("ko.json", DefaultKo);
|
||||
WriteIfMissing("ru.json", DefaultRu);
|
||||
WriteIfMissing("de.json", DefaultDe);
|
||||
WriteIfMissing("fr.json", DefaultFr);
|
||||
WriteIfMissing("ja.json", DefaultJa);
|
||||
WriteIfMissing("pt.json", DefaultPt);
|
||||
WriteIfMissing("pt_BR.json", DefaultPtBr);
|
||||
WriteIfMissing("es.json", DefaultEs);
|
||||
WriteIfMissing("it.json", DefaultIt);
|
||||
WriteIfMissing("nl.json", DefaultNl);
|
||||
WriteIfMissing("fil.json", DefaultFil);
|
||||
WriteIfMissing("ga.json", DefaultGa);
|
||||
}
|
||||
|
||||
private void WriteIfMissing(string fileName, string content)
|
||||
{
|
||||
var path = Path.Combine(LangDir, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
File.WriteAllText(path, content);
|
||||
}
|
||||
}
|
||||
|
||||
// Language Tranlations used AI.
|
||||
// Any Pr fix grammer wrongs is OK!
|
||||
private const string DefaultEn = """
|
||||
{
|
||||
"command.error": "An error occurred while executing #{0}.",
|
||||
"command.usage": "Usage: #{0}",
|
||||
"command.help.list": "=== Commands ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "Unknown command: #{0}",
|
||||
"command.help.aliases": "Aliases: {0}",
|
||||
"command.note.host_only": "Only the host can use #note.",
|
||||
"command.note.cleared": "Note cleared.",
|
||||
"command.note.set": "Note set: {0}",
|
||||
"command.color.invalid": "Invalid color ID. Valid range: 0–17.",
|
||||
"command.color.set": "Color changed to {0} ({1}).",
|
||||
"command.title.too_long": "Title too long. Max 12 characters.",
|
||||
"command.title.cleared": "Title removed.",
|
||||
"command.title.set": "Title set: {0}",
|
||||
"command.stat.disabled": "Player statistics are not enabled on this server.",
|
||||
"command.stat.no_stats": "No statistics recorded yet. Play a game first!",
|
||||
"command.stat.header": "=== Your Statistics ===",
|
||||
"command.stat.games": "Games Played: {0}",
|
||||
"command.stat.wins": "Wins: {0}",
|
||||
"command.stat.losses": "Losses: {0}",
|
||||
"command.stat.impostor": "Impostor Wins: {0}",
|
||||
"command.stat.kills": "Kills: {0}",
|
||||
"command.stat.deaths": "Deaths: {0}",
|
||||
"command.stat.tasks": "Tasks Completed: {0}",
|
||||
"command.stat.exiled": "Times Exiled: {0}",
|
||||
"welcome.join": "Welcome, {0}! Friend code: {1} | Room: {2}",
|
||||
"welcome.last_connect": "Last seen: {0}",
|
||||
"welcome.first_time": "First time here, welcome!",
|
||||
"command.max.host_only": "Only the host can change the max players.",
|
||||
"command.max.invalid": "Invalid number. Valid range: 1-127.",
|
||||
"command.max.set": "Max players set to {0}.",
|
||||
"command.max.warning": "If you do not have CrowdedMod or another mod that supports 15+ players, please set it back to 15 before creating your next room.",
|
||||
"command.players.not_in_lobby": "This command can only be used in the lobby.",
|
||||
"command.players.header": "=== Players ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "Your ping: {0}ms",
|
||||
"command.help.description": "Show available commands.",
|
||||
"command.help.usage": "help [command]",
|
||||
"command.stat.description": "Show your player statistics.",
|
||||
"command.stat.usage": "stat",
|
||||
"command.setcolor.description": "Change your player color.",
|
||||
"command.setcolor.usage": "setcolor <0-17>",
|
||||
"command.players.description": "List players in the current game.",
|
||||
"command.players.usage": "players",
|
||||
"command.ping.description": "Show your network ping.",
|
||||
"command.ping.usage": "ping",
|
||||
"command.note.description": "Set or clear a lobby note (host only).",
|
||||
"command.note.usage": "note <text> | note clear",
|
||||
"command.max.description": "Set max players for the lobby (host only).",
|
||||
"command.max.usage": "max <1-15>",
|
||||
"ban.notice.header": "You are banned from this server.",
|
||||
"ban.notice.reason": "Reason: {0}",
|
||||
"ban.notice.unban": "Unban time: {0}",
|
||||
"ban.notice.unban_permanent": "Unban time: Permanent",
|
||||
"ban.notice.contact": "If you have any questions, please contact the administrator."
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultZhCn = """
|
||||
{
|
||||
"command.error": "执行 #{0}时发生错误。",
|
||||
"command.usage": "用法:#{0}",
|
||||
"command.help.list": "=== 指令列表 ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "未知指令:#{0}",
|
||||
"command.help.aliases": "别名:{0}",
|
||||
"command.note.host_only": "只有房主可以使用 #note。",
|
||||
"command.note.cleared": "备注已清除。",
|
||||
"command.note.set": "备注已设置:{0}",
|
||||
"command.color.invalid": "无效颜色 ID,范围:0–17。",
|
||||
"command.color.set": "颜色已更改为 {0}({1})。",
|
||||
"command.title.too_long": "头衔过长,最多 12 个字符。",
|
||||
"command.title.cleared": "头衔已移除。",
|
||||
"command.title.set": "头衔已设置:{0}",
|
||||
"command.stat.disabled": "此服务器未启用玩家统计。",
|
||||
"command.stat.no_stats": "暂无统计数据,先玩一局游戏吧!",
|
||||
"command.stat.header": "=== 你的统计 ===",
|
||||
"command.stat.games": "游戏场数:{0}",
|
||||
"command.stat.wins": "胜利:{0}",
|
||||
"command.stat.losses": "失败:{0}",
|
||||
"command.stat.impostor": "内鬼胜利:{0}",
|
||||
"command.stat.kills": "击杀:{0}",
|
||||
"command.stat.deaths": "死亡:{0}",
|
||||
"command.stat.tasks": "完成任务:{0}",
|
||||
"command.stat.exiled": "被票出:{0}",
|
||||
"welcome.join": "欢迎,{0}!好友代码:{1} | 房间:{2}",
|
||||
"welcome.last_connect": "上次在线:{0}",
|
||||
"welcome.first_time": "首次光临,欢迎!",
|
||||
"command.max.host_only": "只有房主可以修改最高人数。",
|
||||
"command.max.invalid": "无效数字。有效范围:1–127。",
|
||||
"command.max.set": "最高人数已设置为 {0}。",
|
||||
"command.max.warning": "如果您未安装CrowdedMod或其他支持15人以上的Mod,请在下次创建房间前调回15人。",
|
||||
"command.players.not_in_lobby": "此指令只能在大厅中使用。",
|
||||
"command.players.header": "=== 在线玩家 ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "你的延迟:{0}ms",
|
||||
"command.help.description": "显示可用命令。",
|
||||
"command.help.usage": "help [命令]",
|
||||
"command.stat.description": "显示你的游戏统计。",
|
||||
"command.stat.usage": "stat",
|
||||
"command.setcolor.description": "修改你的颜色。",
|
||||
"command.setcolor.usage": "setcolor <0-17>",
|
||||
"command.players.description": "列出当前房间的玩家。",
|
||||
"command.players.usage": "players",
|
||||
"command.ping.description": "显示你的网络延迟。",
|
||||
"command.ping.usage": "ping",
|
||||
"command.note.description": "设置或清除房间备注(仅房主)。",
|
||||
"command.note.usage": "note <文字> | note clear",
|
||||
"command.max.description": "设置房间最大人数(仅房主)。",
|
||||
"command.max.usage": "max <1-15>",
|
||||
"ban.notice.header": "您已被服务器封禁",
|
||||
"ban.notice.reason": "理由:{0}",
|
||||
"ban.notice.unban": "解封时间:{0}",
|
||||
"ban.notice.unban_permanent": "解封时间:永久",
|
||||
"ban.notice.contact": "如有问题请联系管理员"
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultZhTw = """
|
||||
{
|
||||
"command.error": "執行 #{0}時發生錯誤。",
|
||||
"command.usage": "用法:#{0}",
|
||||
"command.help.list": "=== 指令列表 ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "未知指令:#{0}",
|
||||
"command.help.aliases": "別名:{0}",
|
||||
"command.note.host_only": "只有房主可以使用 #note。",
|
||||
"command.note.cleared": "備註已清除。",
|
||||
"command.note.set": "備註已設置:{0}",
|
||||
"command.color.invalid": "無效顏色 ID,範圍:0–17。",
|
||||
"command.color.set": "顏色已更改為 {0}({1})。",
|
||||
"command.title.too_long": "頭銜過長,最多 12 個字符。",
|
||||
"command.title.cleared": "頭銜已移除。",
|
||||
"command.title.set": "頭銜已設置:{0}",
|
||||
"command.stat.disabled": "此伺服器未啟用玩家統計。",
|
||||
"command.stat.no_stats": "暫無統計數據,先玩一局遊戲吧!",
|
||||
"command.stat.header": "=== 你的統計 ===",
|
||||
"command.stat.games": "遊戲場數:{0}",
|
||||
"command.stat.wins": "勝利:{0}",
|
||||
"command.stat.losses": "失敗:{0}",
|
||||
"command.stat.impostor": "內鬼勝利:{0}",
|
||||
"command.stat.kills": "擊殺:{0}",
|
||||
"command.stat.deaths": "死亡:{0}",
|
||||
"command.stat.tasks": "完成任務:{0}",
|
||||
"command.stat.exiled": "被票出:{0}",
|
||||
"welcome.join": "歡迎,{0}!好代碼:{1} | 房間:{2}",
|
||||
"welcome.last_connect": "上次上線:{0}",
|
||||
"welcome.first_time": "首次光臨,歡迎!",
|
||||
"command.max.host_only": "只有房主可以修改最高人數。",
|
||||
"command.max.invalid": "無效數字。有效範圍:1–127。",
|
||||
"command.max.set": "最高人數已設置為 {0}。",
|
||||
"command.max.warning": "如果您未安裝CrowdedMod或其他支援15人以上的Mod,請在下次創建房間前調回15人。",
|
||||
"command.players.not_in_lobby": "此指令只能在大廳中使用。",
|
||||
"command.players.header": "=== 線上玩家 ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "你的延遲:{0}ms",
|
||||
"command.help.description": "顯示可用命令。",
|
||||
"command.help.usage": "help [命令]",
|
||||
"command.stat.description": "顯示你的遊戲統計。",
|
||||
"command.stat.usage": "stat",
|
||||
"command.setcolor.description": "修改你的顏色。",
|
||||
"command.setcolor.usage": "setcolor <0-17>",
|
||||
"command.players.description": "列出當前房間的玩家。",
|
||||
"command.players.usage": "players",
|
||||
"command.ping.description": "顯示你的網路延遲。",
|
||||
"command.ping.usage": "ping",
|
||||
"command.note.description": "設置或清除房間備註(僅房主)。",
|
||||
"command.note.usage": "note <文字> | note clear",
|
||||
"command.max.description": "設置房間最大人數(僅房主)。",
|
||||
"command.max.usage": "max <1-15>",
|
||||
"ban.notice.header": "您已被伺服器封禁",
|
||||
"ban.notice.reason": "理由:{0}",
|
||||
"ban.notice.unban": "解封時間:{0}",
|
||||
"ban.notice.unban_permanent": "解封時間:永久",
|
||||
"ban.notice.contact": "如有問題請聯絡管理員"
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultKo = """
|
||||
{
|
||||
"command.error": "#{0}실행 중 오류가 발생했습니다.",
|
||||
"command.usage": "사용법: #{0}",
|
||||
"command.help.list": "=== 명령어 목록 ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "알 수 없는 명령어: #{0}",
|
||||
"command.help.aliases": "별칭: {0}",
|
||||
"command.note.host_only": "#note는 방장만 사용할 수 있습니다.",
|
||||
"command.note.cleared": "메모가 삭제되었습니다.",
|
||||
"command.note.set": "메모 설정됨: {0}",
|
||||
"command.color.invalid": "잘못된 색상 ID. 유효 범위: 0–17.",
|
||||
"command.color.set": "색상이 {0}({1})으로 변경되었습니다.",
|
||||
"command.title.too_long": "칭호가 너무 깁니다. 최대 12자.",
|
||||
"command.title.cleared": "칭호가 제거되었습니다.",
|
||||
"command.title.set": "칭호 설정됨: {0}",
|
||||
"command.stat.disabled": "이 서버에서는 플레이어 통계가 활성화되지 않았습니다.",
|
||||
"command.stat.no_stats": "아직 통계가 기록되지 않았습니다. 먼저 게임을 플레이하세요!",
|
||||
"command.stat.header": "=== 나의 통계 ===",
|
||||
"command.stat.games": "플레이한 게임: {0}",
|
||||
"command.stat.wins": "승리: {0}",
|
||||
"command.stat.losses": "패배: {0}",
|
||||
"command.stat.impostor": "임포스터 승리: {0}",
|
||||
"command.stat.kills": "킬: {0}",
|
||||
"command.stat.deaths": "사망: {0}",
|
||||
"command.stat.tasks": "완료한 작업: {0}",
|
||||
"command.stat.exiled": "추방됨: {0}",
|
||||
"welcome.join": "환영합니다, {0}! 친구 코드: {1} | 방: {2}",
|
||||
"welcome.last_connect": "마지막 접속: {0}",
|
||||
"welcome.first_time": "처음 방문이세요, 환영합니다!",
|
||||
"command.max.host_only": "방장만 최대 인원을 변경할 수 있습니다.",
|
||||
"command.max.invalid": "잘못된 숫자입니다. 유효 범위: 1–127.",
|
||||
"command.max.set": "최대 인원이 {0}명으로 설정되었습니다.",
|
||||
"command.max.warning": "CrowdedMod 또는 15명 이상을 지원하는 모드가 설치되어 있지 않다면, 다음 방을 만들기 전에 15명으로 되돌리십시오.",
|
||||
"command.players.not_in_lobby": "이 명령어는 로비에서만 사용할 수 있습니다.",
|
||||
"command.players.header": "=== 플레이어 ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "내 핑: {0}ms",
|
||||
"ban.notice.header": "이 서버에서 차단되었습니다.",
|
||||
"ban.notice.reason": "사유: {0}",
|
||||
"ban.notice.unban": "차단 해제 시간: {0}",
|
||||
"ban.notice.unban_permanent": "차단 해제 시간: 영구",
|
||||
"ban.notice.contact": "문의 사항이 있으면 관리자에게 연락해 주세요."
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultRu = """
|
||||
{
|
||||
"command.error": "Ошибка при выполнении #{0}.",
|
||||
"command.usage": "Использование: #{0}",
|
||||
"command.help.list": "=== Команды ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "Неизвестная команда: #{0}",
|
||||
"command.help.aliases": "Псевдонимы: {0}",
|
||||
"command.note.host_only": "Только хост может использовать #note.",
|
||||
"command.note.cleared": "Заметка удалена.",
|
||||
"command.note.set": "Заметка установлена: {0}",
|
||||
"command.color.invalid": "Неверный ID цвета. Допустимый диапазон: 0–17.",
|
||||
"command.color.set": "Цвет изменён на {0} ({1}).",
|
||||
"command.title.too_long": "Титул слишком длинный. Максимум 12 символов.",
|
||||
"command.title.cleared": "Титул удалён.",
|
||||
"command.title.set": "Титул установлен: {0}",
|
||||
"command.stat.disabled": "Статистика игроков не включена на этом сервере.",
|
||||
"command.stat.no_stats": "Статистика пока не записана. Сыграйте сначала!",
|
||||
"command.stat.header": "=== Ваша статистика ===",
|
||||
"command.stat.games": "Игр сыграно: {0}",
|
||||
"command.stat.wins": "Побед: {0}",
|
||||
"command.stat.losses": "Поражений: {0}",
|
||||
"command.stat.impostor": "Побед за предателя: {0}",
|
||||
"command.stat.kills": "Убийств: {0}",
|
||||
"command.stat.deaths": "Смертей: {0}",
|
||||
"command.stat.tasks": "Заданий выполнено: {0}",
|
||||
"command.stat.exiled": "Изгнаний: {0}",
|
||||
"welcome.join": "Добро пожаловать, {0}! Код друга: {1} | Комната: {2}",
|
||||
"welcome.last_connect": "Последний вход: {0}",
|
||||
"welcome.first_time": "Первый раз здесь, добро пожаловать!",
|
||||
"command.max.host_only": "Только хост может изменить максимальное количество игроков.",
|
||||
"command.max.invalid": "Неверное число. Допустимый диапазон: 1–127.",
|
||||
"command.max.set": "Максимальное количество игроков установлено на {0}.",
|
||||
"command.max.warning": "Если у вас не установлен CrowdedMod или другой мод, поддерживающий 15+ игроков, верните значение на 15 перед созданием следующей комнаты.",
|
||||
"command.players.not_in_lobby": "Эта команда может использоваться только в лобби.",
|
||||
"command.players.header": "=== Игроки ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}мс",
|
||||
"command.ping.result": "Ваш пинг: {0}мс",
|
||||
"ban.notice.header": "Вы забанены на этом сервере.",
|
||||
"ban.notice.reason": "Причина: {0}",
|
||||
"ban.notice.unban": "Время разбана: {0}",
|
||||
"ban.notice.unban_permanent": "Время разбана: навсегда",
|
||||
"ban.notice.contact": "Если у вас есть вопросы, обратитесь к администратору."
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultDe = """
|
||||
{
|
||||
"command.error": "Fehler beim Ausführen von #{0}.",
|
||||
"command.usage": "Verwendung: #{0}",
|
||||
"command.help.list": "=== Befehle ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "Unbekannter Befehl: #{0}",
|
||||
"command.help.aliases": "Aliase: {0}",
|
||||
"command.note.host_only": "Nur der Host kann #note verwenden.",
|
||||
"command.note.cleared": "Notiz gelöscht.",
|
||||
"command.note.set": "Notiz gesetzt: {0}",
|
||||
"command.color.invalid": "Ungültige Farb-ID. Gültiger Bereich: 0–17.",
|
||||
"command.color.set": "Farbe geändert zu {0} ({1}).",
|
||||
"command.title.too_long": "Titel zu lang. Maximal 12 Zeichen.",
|
||||
"command.title.cleared": "Titel entfernt.",
|
||||
"command.title.set": "Titel gesetzt: {0}",
|
||||
"command.stat.disabled": "Spielerstatistiken sind auf diesem Server nicht aktiviert.",
|
||||
"command.stat.no_stats": "Noch keine Statistiken. Spielen Sie zuerst ein Spiel!",
|
||||
"command.stat.header": "=== Deine Statistiken ===",
|
||||
"command.stat.games": "Gespielte Spiele: {0}",
|
||||
"command.stat.wins": "Siege: {0}",
|
||||
"command.stat.losses": "Niederlagen: {0}",
|
||||
"command.stat.impostor": "Impostor-Siege: {0}",
|
||||
"command.stat.kills": "Kills: {0}",
|
||||
"command.stat.deaths": "Tode: {0}",
|
||||
"command.stat.tasks": "Aufgaben erledigt: {0}",
|
||||
"command.stat.exiled": "Verbannt: {0}",
|
||||
"welcome.join": "Willkommen, {0}! Freundescode: {1} | Raum: {2}",
|
||||
"welcome.last_connect": "Zuletzt online: {0}",
|
||||
"welcome.first_time": "Zum ersten Mal hier, willkommen!",
|
||||
"command.max.host_only": "Nur der Host kann die maximale Spieleranzahl ändern.",
|
||||
"command.max.invalid": "Ungültige Zahl. Gültiger Bereich: 1–127.",
|
||||
"command.max.set": "Maximale Spieleranzahl auf {0} gesetzt.",
|
||||
"command.max.warning": "Falls du CrowdedMod oder eine andere Mod für 15+ Spieler nicht installiert hast, stelle den Wert vor dem nächsten Raum wieder auf 15.",
|
||||
"command.players.not_in_lobby": "Dieser Befehl kann nur in der Lobby verwendet werden.",
|
||||
"command.players.header": "=== Spieler ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "Dein Ping: {0}ms",
|
||||
"ban.notice.header": "Du wurdest von diesem Server gebannt.",
|
||||
"ban.notice.reason": "Grund: {0}",
|
||||
"ban.notice.unban": "Entbannungszeit: {0}",
|
||||
"ban.notice.unban_permanent": "Entbannungszeit: Dauerhaft",
|
||||
"ban.notice.contact": "Bei Fragen wende dich an den Administrator."
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultFr = """
|
||||
{
|
||||
"command.error": "Erreur lors de l'exécution de #{0}.",
|
||||
"command.usage": "Utilisation : #{0}",
|
||||
"command.help.list": "=== Commandes ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "Commande inconnue : #{0}",
|
||||
"command.help.aliases": "Alias : {0}",
|
||||
"command.note.host_only": "Seul l'hôte peut utiliser #note.",
|
||||
"command.note.cleared": "Note effacée.",
|
||||
"command.note.set": "Note définie : {0}",
|
||||
"command.color.invalid": "ID de couleur invalide. Plage valide : 0–17.",
|
||||
"command.color.set": "Couleur changée en {0} ({1}).",
|
||||
"command.title.too_long": "Titre trop long. Maximum 12 caractères.",
|
||||
"command.title.cleared": "Titre supprimé.",
|
||||
"command.title.set": "Titre défini : {0}",
|
||||
"command.stat.disabled": "Les statistiques des joueurs ne sont pas activées sur ce serveur.",
|
||||
"command.stat.no_stats": "Aucune statistique enregistrée. Jouez d'abord une partie !",
|
||||
"command.stat.header": "=== Vos statistiques ===",
|
||||
"command.stat.games": "Parties jouées : {0}",
|
||||
"command.stat.wins": "Victoires : {0}",
|
||||
"command.stat.losses": "Défaites : {0}",
|
||||
"command.stat.impostor": "Victoires Imposteur : {0}",
|
||||
"command.stat.kills": "Meurtres : {0}",
|
||||
"command.stat.deaths": "Morts : {0}",
|
||||
"command.stat.tasks": "Tâches accomplies : {0}",
|
||||
"command.stat.exiled": "Exilés : {0}",
|
||||
"welcome.join": "Bienvenue, {0} ! Code ami : {1} | Salle : {2}",
|
||||
"welcome.last_connect": "Dernière visite : {0}",
|
||||
"welcome.first_time": "Première visite, bienvenue !",
|
||||
"command.max.host_only": "Seul l'hôte peut modifier le nombre maximum de joueurs.",
|
||||
"command.max.invalid": "Nombre invalide. Plage valide : 1–127.",
|
||||
"command.max.set": "Nombre maximum de joueurs défini à {0}.",
|
||||
"command.max.warning": "Si vous n'avez pas installé CrowdedMod ou un autre mod supportant 15+ joueurs, veuillez remettre à 15 avant de créer votre prochaine salle.",
|
||||
"command.players.not_in_lobby": "Cette commande ne peut être utilisée que dans le lobby.",
|
||||
"command.players.header": "=== Joueurs ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "Votre ping : {0}ms",
|
||||
"ban.notice.header": "Vous êtes banni de ce serveur.",
|
||||
"ban.notice.reason": "Raison : {0}",
|
||||
"ban.notice.unban": "Débannissement : {0}",
|
||||
"ban.notice.unban_permanent": "Débannissement : Permanent",
|
||||
"ban.notice.contact": "En cas de question, contactez un administrateur."
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultJa = """
|
||||
{
|
||||
"command.error": "#{0}の実行中にエラーが発生しました。",
|
||||
"command.usage": "使い方:#{0}",
|
||||
"command.help.list": "=== コマンド一覧 ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "不明なコマンド:#{0}",
|
||||
"command.help.aliases": "エイリアス:{0}",
|
||||
"command.note.host_only": "#note はホストのみ使用できます。",
|
||||
"command.note.cleared": "ノートを削除しました。",
|
||||
"command.note.set": "ノートを設定しました:{0}",
|
||||
"command.color.invalid": "無効なカラーID。有効範囲:0–17。",
|
||||
"command.color.set": "カラーを {0}({1})に変更しました。",
|
||||
"command.title.too_long": "称号が長すぎます。最大12文字。",
|
||||
"command.title.cleared": "称号を削除しました。",
|
||||
"command.title.set": "称号を設定しました:{0}",
|
||||
"command.stat.disabled": "このサーバーではプレイヤー統計が有効になっていません。",
|
||||
"command.stat.no_stats": "まだ統計が記録されていません。まずゲームをプレイしてください!",
|
||||
"command.stat.header": "=== あなたの統計 ===",
|
||||
"command.stat.games": "プレイしたゲーム:{0}",
|
||||
"command.stat.wins": "勝利:{0}",
|
||||
"command.stat.losses": "敗北:{0}",
|
||||
"command.stat.impostor": "インポスター勝利:{0}",
|
||||
"command.stat.kills": "キル:{0}",
|
||||
"command.stat.deaths": "デス:{0}",
|
||||
"command.stat.tasks": "タスク完了:{0}",
|
||||
"command.stat.exiled": "追放された回数:{0}",
|
||||
"welcome.join": "ようこそ、{0}!フレンドコード:{1} | ルーム:{2}",
|
||||
"welcome.last_connect": "最終接続: {0}",
|
||||
"welcome.first_time": "初めてのご来訪、ようこそ!",
|
||||
"command.max.host_only": "ホストのみが最大プレイヤー数を変更できます。",
|
||||
"command.max.invalid": "無効な数字です。有効範囲: 1–127。",
|
||||
"command.max.set": "最大プレイヤー数を {0} に設定しました。",
|
||||
"command.max.warning": "CrowdedModまたは15人以上をサポートするModがインストールされていない場合は、次のルームを作成する前に15に戻してください。",
|
||||
"command.players.not_in_lobby": "このコマンドはロビーでのみ使用できます。",
|
||||
"command.players.header": "=== プレイヤー ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "あなたのPing: {0}ms",
|
||||
"ban.notice.header": "このサーバーからBANされています。",
|
||||
"ban.notice.reason": "理由:{0}",
|
||||
"ban.notice.unban": "BAN解除日時:{0}",
|
||||
"ban.notice.unban_permanent": "BAN解除日時:永久",
|
||||
"ban.notice.contact": "ご不明な点があれば管理者にお問い合わせください。"
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultPt = """
|
||||
{
|
||||
"command.error": "Ocorreu um erro ao executar #{0}.",
|
||||
"command.usage": "Uso: #{0}",
|
||||
"command.help.list": "=== Comandos ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "Comando desconhecido: #{0}",
|
||||
"command.help.aliases": "Aliases: {0}",
|
||||
"command.note.host_only": "Apenas o anfitrião pode usar #note.",
|
||||
"command.note.cleared": "Nota removida.",
|
||||
"command.note.set": "Nota definida: {0}",
|
||||
"command.color.invalid": "ID de cor inválido. Intervalo válido: 0–17.",
|
||||
"command.color.set": "Cor alterada para {0} ({1}).",
|
||||
"command.title.too_long": "Título muito longo. Máximo 12 caracteres.",
|
||||
"command.title.cleared": "Título removido.",
|
||||
"command.title.set": "Título definido: {0}",
|
||||
"command.stat.disabled": "As estatísticas de jogadores não estão ativadas neste servidor.",
|
||||
"command.stat.no_stats": "Nenhuma estatística registada. Jogue primeiro!",
|
||||
"command.stat.header": "=== As suas estatísticas ===",
|
||||
"command.stat.games": "Jogos: {0}",
|
||||
"command.stat.wins": "Vitórias: {0}",
|
||||
"command.stat.losses": "Derrotas: {0}",
|
||||
"command.stat.impostor": "Vitórias Impostor: {0}",
|
||||
"command.stat.kills": "Assassínios: {0}",
|
||||
"command.stat.deaths": "Mortes: {0}",
|
||||
"command.stat.tasks": "Tarefas: {0}",
|
||||
"command.stat.exiled": "Exilado: {0}",
|
||||
"welcome.join": "Bem-vindo, {0}! Código de amigo: {1} | Sala: {2}",
|
||||
"welcome.last_connect": "Última visita: {0}",
|
||||
"welcome.first_time": "Primeira vez aqui, bem-vindo!",
|
||||
"command.max.host_only": "Apenas o anfitrião pode alterar o máximo de jogadores.",
|
||||
"command.max.invalid": "Número inválido. Intervalo válido: 1–127.",
|
||||
"command.max.set": "Máximo de jogadores definido para {0}.",
|
||||
"command.max.warning": "Se não tiver o CrowdedMod ou outro mod que suporte 15+ jogadores, volte a definir para 15 antes de criar a próxima sala.",
|
||||
"command.players.not_in_lobby": "Este comando só pode ser usado no lobby.",
|
||||
"command.players.header": "=== Jogadores ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "Seu ping: {0}ms",
|
||||
"ban.notice.header": "Você foi banido deste servidor.",
|
||||
"ban.notice.reason": "Motivo: {0}",
|
||||
"ban.notice.unban": "Desbanimento: {0}",
|
||||
"ban.notice.unban_permanent": "Desbanimento: Permanente",
|
||||
"ban.notice.contact": "Em caso de dúvidas, contacte o administrador."
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultPtBr = """
|
||||
{
|
||||
"command.error": "Ocorreu um erro ao executar #{0}.",
|
||||
"command.usage": "Uso: #{0}",
|
||||
"command.help.list": "=== Comandos ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "Comando desconhecido: #{0}",
|
||||
"command.help.aliases": "Apelidos: {0}",
|
||||
"command.note.host_only": "Somente o anfitrião pode usar #note.",
|
||||
"command.note.cleared": "Nota removida.",
|
||||
"command.note.set": "Nota definida: {0}",
|
||||
"command.color.invalid": "ID de cor inválido. Intervalo válido: 0–17.",
|
||||
"command.color.set": "Cor alterada para {0} ({1}).",
|
||||
"command.title.too_long": "Título muito longo. Máximo 12 caracteres.",
|
||||
"command.title.cleared": "Título removido.",
|
||||
"command.title.set": "Título definido: {0}",
|
||||
"command.stat.disabled": "As estatísticas de jogadores não estão ativadas neste servidor.",
|
||||
"command.stat.no_stats": "Nenhuma estatística registrada. Jogue primeiro!",
|
||||
"command.stat.header": "=== Suas estatísticas ===",
|
||||
"command.stat.games": "Jogos: {0}",
|
||||
"command.stat.wins": "Vitórias: {0}",
|
||||
"command.stat.losses": "Derrotas: {0}",
|
||||
"command.stat.impostor": "Vitórias Impostor: {0}",
|
||||
"command.stat.kills": "Assassinatos: {0}",
|
||||
"command.stat.deaths": "Mortes: {0}",
|
||||
"command.stat.tasks": "Tarefas: {0}",
|
||||
"command.stat.exiled": "Exilado: {0}",
|
||||
"welcome.join": "Bem-vindo, {0}! Código de amigo: {1} | Sala: {2}",
|
||||
"command.max.host_only": "Somente o anfitrião pode alterar o máximo de jogadores.",
|
||||
"command.max.invalid": "Número inválido. Intervalo válido: 1–127.",
|
||||
"command.max.set": "Máximo de jogadores definido para {0}.",
|
||||
"command.max.warning": "Se você não tiver o CrowdedMod ou outro mod que suporte 15+ jogadores, volte a definir para 15 antes de criar a próxima sala.",
|
||||
"command.players.not_in_lobby": "Este comando só pode ser usado no lobby.",
|
||||
"command.players.header": "=== Jogadores ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "Seu ping: {0}ms",
|
||||
"ban.notice.header": "Você foi banido deste servidor.",
|
||||
"ban.notice.reason": "Motivo: {0}",
|
||||
"ban.notice.unban": "Desbanimento: {0}",
|
||||
"ban.notice.unban_permanent": "Desbanimento: Permanente",
|
||||
"ban.notice.contact": "Em caso de dúvidas, contate o administrador."
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultEs = """
|
||||
{
|
||||
"command.error": "Error al ejecutar #{0}.",
|
||||
"command.usage": "Uso: #{0}",
|
||||
"command.help.list": "=== Comandos ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "Comando desconocido: #{0}",
|
||||
"command.help.aliases": "Alias: {0}",
|
||||
"command.note.host_only": "Solo el anfitrión puede usar #note.",
|
||||
"command.note.cleared": "Nota eliminada.",
|
||||
"command.note.set": "Nota establecida: {0}",
|
||||
"command.color.invalid": "ID de color inválido. Rango válido: 0–17.",
|
||||
"command.color.set": "Color cambiado a {0} ({1}).",
|
||||
"command.title.too_long": "Título demasiado largo. Máximo 12 caracteres.",
|
||||
"command.title.cleared": "Título eliminado.",
|
||||
"command.title.set": "Título establecido: {0}",
|
||||
"command.stat.disabled": "Las estadísticas de jugadores no están activadas en este servidor.",
|
||||
"command.stat.no_stats": "Aún no hay estadísticas. ¡Juega una partida primero!",
|
||||
"command.stat.header": "=== Tus estadísticas ===",
|
||||
"command.stat.games": "Partidas jugadas: {0}",
|
||||
"command.stat.wins": "Victorias: {0}",
|
||||
"command.stat.losses": "Derrotas: {0}",
|
||||
"command.stat.impostor": "Victorias impostor: {0}",
|
||||
"command.stat.kills": "Asesinatos: {0}",
|
||||
"command.stat.deaths": "Muertes: {0}",
|
||||
"command.stat.tasks": "Tareas completadas: {0}",
|
||||
"command.stat.exiled": "Exiliado: {0}",
|
||||
"welcome.join": "Bienvenido, {0}! Código de amigo: {1} | Sala: {2}",
|
||||
"command.max.host_only": "Solo el anfitrión puede cambiar el máximo de jugadores.",
|
||||
"command.max.invalid": "Número inválido. Rango válido: 1–127.",
|
||||
"command.max.set": "Máximo de jugadores establecido en {0}.",
|
||||
"command.max.warning": "Si no tienes CrowdedMod u otro mod que soporte 15+ jugadores, vuelve a ponerlo en 15 antes de crear tu próxima sala.",
|
||||
"command.players.not_in_lobby": "Este comando solo puede usarse en el lobby.",
|
||||
"command.players.header": "=== Jugadores ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "Tu ping: {0}ms",
|
||||
"ban.notice.header": "Has sido baneado de este servidor.",
|
||||
"ban.notice.reason": "Motivo: {0}",
|
||||
"ban.notice.unban": "Fin del baneo: {0}",
|
||||
"ban.notice.unban_permanent": "Fin del baneo: Permanente",
|
||||
"ban.notice.contact": "Si tienes dudas, contacta con un administrador."
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultIt = """
|
||||
{
|
||||
"command.error": "Errore durante l'esecuzione di #{0}.",
|
||||
"command.usage": "Utilizzo: #{0}",
|
||||
"command.help.list": "=== Comandi ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "Comando sconosciuto: #{0}",
|
||||
"command.help.aliases": "Alias: {0}",
|
||||
"command.note.host_only": "Solo l'host può usare #note.",
|
||||
"command.note.cleared": "Nota rimossa.",
|
||||
"command.note.set": "Nota impostata: {0}",
|
||||
"command.color.invalid": "ID colore non valido. Intervallo: 0–17.",
|
||||
"command.color.set": "Colore cambiato in {0} ({1}).",
|
||||
"command.title.too_long": "Titolo troppo lungo. Max 12 caratteri.",
|
||||
"command.title.cleared": "Titolo rimosso.",
|
||||
"command.title.set": "Titolo impostato: {0}",
|
||||
"command.stat.disabled": "Le statistiche dei giocatori non sono attive su questo server.",
|
||||
"command.stat.no_stats": "Nessuna statistica registrata. Gioca prima una partita!",
|
||||
"command.stat.header": "=== Le tue statistiche ===",
|
||||
"command.stat.games": "Partite giocate: {0}",
|
||||
"command.stat.wins": "Vittorie: {0}",
|
||||
"command.stat.losses": "Sconfitte: {0}",
|
||||
"command.stat.impostor": "Vittorie impostore: {0}",
|
||||
"command.stat.kills": "Uccisioni: {0}",
|
||||
"command.stat.deaths": "Morti: {0}",
|
||||
"command.stat.tasks": "Compiti completati: {0}",
|
||||
"command.stat.exiled": "Esiliato: {0}",
|
||||
"welcome.join": "Benvenuto, {0}! Codice amico: {1} | Stanza: {2}",
|
||||
"command.max.host_only": "Solo l'host può cambiare il numero massimo di giocatori.",
|
||||
"command.max.invalid": "Numero non valido. Intervallo valido: 1–127.",
|
||||
"command.max.set": "Numero massimo di giocatori impostato a {0}.",
|
||||
"command.max.warning": "Se non hai installato CrowdedMod o un'altra mod che supporta 15+ giocatori, riportalo a 15 prima di creare la prossima stanza.",
|
||||
"command.players.not_in_lobby": "Questo comando può essere usato solo nella lobby.",
|
||||
"command.players.header": "=== Giocatori ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "Il tuo ping: {0}ms",
|
||||
"ban.notice.header": "Sei stato bannato da questo server.",
|
||||
"ban.notice.reason": "Motivo: {0}",
|
||||
"ban.notice.unban": "Sbanno: {0}",
|
||||
"ban.notice.unban_permanent": "Sbanno: Permanente",
|
||||
"ban.notice.contact": "Per qualsiasi domanda, contatta l'amministratore."
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultNl = """
|
||||
{
|
||||
"command.error": "Fout bij uitvoeren van #{0}.",
|
||||
"command.usage": "Gebruik: #{0}",
|
||||
"command.help.list": "=== Commando's ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "Onbekend commando: #{0}",
|
||||
"command.help.aliases": "Aliassen: {0}",
|
||||
"command.note.host_only": "Alleen de host kan #note gebruiken.",
|
||||
"command.note.cleared": "Notitie verwijderd.",
|
||||
"command.note.set": "Notitie ingesteld: {0}",
|
||||
"command.color.invalid": "Ongeldig kleur-ID. Geldig bereik: 0–17.",
|
||||
"command.color.set": "Kleur gewijzigd naar {0} ({1}).",
|
||||
"command.title.too_long": "Titel te lang. Maximaal 12 tekens.",
|
||||
"command.title.cleared": "Titel verwijderd.",
|
||||
"command.title.set": "Titel ingesteld: {0}",
|
||||
"command.stat.disabled": "Spelerstatistieken zijn niet ingeschakeld op deze server.",
|
||||
"command.stat.no_stats": "Nog geen statistieken. Speel eerst een spel!",
|
||||
"command.stat.header": "=== Jouw statistieken ===",
|
||||
"command.stat.games": "Gespeelde spellen: {0}",
|
||||
"command.stat.wins": "Overwinningen: {0}",
|
||||
"command.stat.losses": "Verliezen: {0}",
|
||||
"command.stat.impostor": "Impostor overwinningen: {0}",
|
||||
"command.stat.kills": "Moorden: {0}",
|
||||
"command.stat.deaths": "Sterfgevallen: {0}",
|
||||
"command.stat.tasks": "Taken voltooid: {0}",
|
||||
"command.stat.exiled": "Verbannen: {0}",
|
||||
"welcome.join": "Welkom, {0}! Vriendcode: {1} | Kamer: {2}",
|
||||
"command.max.host_only": "Alleen de host kan het maximale aantal spelers wijzigen.",
|
||||
"command.max.invalid": "Ongeldig getal. Geldig bereik: 1–127.",
|
||||
"command.max.set": "Maximaal aantal spelers ingesteld op {0}.",
|
||||
"command.max.warning": "Als je CrowdedMod of een andere mod voor 15+ spelers niet hebt geïnstalleerd, zet het dan terug naar 15 voordat je een nieuwe kamer maakt.",
|
||||
"command.players.not_in_lobby": "Dit commando kan alleen in de lobby worden gebruikt.",
|
||||
"command.players.header": "=== Spelers ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "Jouw ping: {0}ms",
|
||||
"ban.notice.header": "Je bent geband van deze server.",
|
||||
"ban.notice.reason": "Reden: {0}",
|
||||
"ban.notice.unban": "Ontbantijd: {0}",
|
||||
"ban.notice.unban_permanent": "Ontbantijd: Permanent",
|
||||
"ban.notice.contact": "Neem bij vragen contact op met de beheerder."
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultFil = """
|
||||
{
|
||||
"command.error": "May error sa pagpapatakbo ng #{0}.",
|
||||
"command.usage": "Paggamit: #{0}",
|
||||
"command.help.list": "=== Mga Command ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "Hindi kilalang command: #{0}",
|
||||
"command.help.aliases": "Mga Alias: {0}",
|
||||
"command.note.host_only": "Ang host lang ang maaaring gumamit ng #note.",
|
||||
"command.note.cleared": "Natanggal ang tala.",
|
||||
"command.note.set": "Naitakda ang tala: {0}",
|
||||
"command.color.invalid": "Di-wastong color ID. Valid na hanay: 0–17.",
|
||||
"command.color.set": "Binago ang kulay sa {0} ({1}).",
|
||||
"command.title.too_long": "Masyadong mahaba ang pamagat. Max 12 karakter.",
|
||||
"command.title.cleared": "Natanggal ang pamagat.",
|
||||
"command.title.set": "Naitakda ang pamagat: {0}",
|
||||
"command.stat.disabled": "Hindi pinagana ang estadistika ng manlalaro sa server na ito.",
|
||||
"command.stat.no_stats": "Wala pang naitalang estadistika. Maglaro muna!",
|
||||
"command.stat.header": "=== Iyong Estadistika ===",
|
||||
"command.stat.games": "Mga Larong Nalaro: {0}",
|
||||
"command.stat.wins": "Mga Panalo: {0}",
|
||||
"command.stat.losses": "Mga Talo: {0}",
|
||||
"command.stat.impostor": "Mga Panalong Impostor: {0}",
|
||||
"command.stat.kills": "Mga Napatay: {0}",
|
||||
"command.stat.deaths": "Mga Kamatayan: {0}",
|
||||
"command.stat.tasks": "Mga Natapos na Gawain: {0}",
|
||||
"command.stat.exiled": "Itinapon: {0}",
|
||||
"welcome.join": "Maligayang pagdating, {0}! Friend code: {1} | Silid: {2}",
|
||||
"command.max.host_only": "Ang host lang ang maaaring magbago ng maximum na bilang ng manlalaro.",
|
||||
"command.max.invalid": "Di-wastong numero. Valid na saklaw: 1–127.",
|
||||
"command.max.set": "Itinakda ang maximum na manlalaro sa {0}.",
|
||||
"command.max.warning": "Kung wala kang CrowdedMod o ibang mod na sumusuporta sa 15+ manlalaro, ibalik ito sa 15 bago gumawa ng susunod na silid.",
|
||||
"command.players.not_in_lobby": "Ang command na ito ay maaari lamang gamitin sa lobby.",
|
||||
"command.players.header": "=== Mga Manlalaro ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "Ang iyong ping: {0}ms",
|
||||
"ban.notice.header": "Banned ka sa server na ito.",
|
||||
"ban.notice.reason": "Dahilan: {0}",
|
||||
"ban.notice.unban": "Oras ng pag-unban: {0}",
|
||||
"ban.notice.unban_permanent": "Oras ng pag-unban: Permanente",
|
||||
"ban.notice.contact": "Kung may tanong, makipag-ugnayan sa administrator."
|
||||
}
|
||||
""";
|
||||
|
||||
private const string DefaultGa = """
|
||||
{
|
||||
"command.error": "Earráid agus #{0}á rith.",
|
||||
"command.usage": "Úsáid: #{0}",
|
||||
"command.help.list": "=== Orduithe ===",
|
||||
"command.help.entry": "#{0} — {1}",
|
||||
"command.help.unknown": "Ordú anaithnid: #{0}",
|
||||
"command.help.aliases": "Ailíasanna: {0}",
|
||||
"command.note.host_only": "Ní féidir ach leis an óstach #note a úsáid.",
|
||||
"command.note.cleared": "Nóta glanadh.",
|
||||
"command.note.set": "Nóta socraithe: {0}",
|
||||
"command.color.invalid": "ID datha neamhbhailí. Raon bailí: 0–17.",
|
||||
"command.color.set": "Dath athraithe go {0} ({1}).",
|
||||
"command.title.too_long": "Teideal ró-fhada. Uasmhéid 12 carachtar.",
|
||||
"command.title.cleared": "Teideal bainte.",
|
||||
"command.title.set": "Teideal socraithe: {0}",
|
||||
"command.stat.disabled": "Níl staitisticí imreoirí ar siúl ar an bhfreastalaí seo.",
|
||||
"command.stat.no_stats": "Níl aon staitisticí taifeadta fós. Imir cluiche ar dtús!",
|
||||
"command.stat.header": "=== Do Staitisticí ===",
|
||||
"command.stat.games": "Cluichí Imearthe: {0}",
|
||||
"command.stat.wins": "Buaite: {0}",
|
||||
"command.stat.losses": "Caillte: {0}",
|
||||
"command.stat.impostor": "Buaite mar Impostor: {0}",
|
||||
"command.stat.kills": "Maruithe: {0}",
|
||||
"command.stat.deaths": "Básanna: {0}",
|
||||
"command.stat.tasks": "Tascanna Críochnaithe: {0}",
|
||||
"command.stat.exiled": "Díbirthe: {0}",
|
||||
"welcome.join": "Fáilte, {0}! Cód cara: {1} | Seomra: {2}",
|
||||
"command.max.host_only": "Ní féidir ach leis an óstach an t-uaslíon imreoirí a athrú.",
|
||||
"command.max.invalid": "Uimhir neamhbhailí. Raon bailí: 1–127.",
|
||||
"command.max.set": "Uaslín imreoirí socraithe go {0}.",
|
||||
"command.max.warning": "Mura bhfuil CrowdedMod nó mod eile agat a thacaíonn le 15+ imreoir, cuir ar ais go 15 é sula gcruthóidh tú an chéad seomra eile.",
|
||||
"command.players.not_in_lobby": "Ní féidir an t-ordú seo a úsáid ach sa stocaireacht.",
|
||||
"command.players.header": "=== Imreoirí ({0}) ===",
|
||||
"command.players.entry": "{0} | {1} | {2}ms",
|
||||
"command.ping.result": "Do phing: {0}ms",
|
||||
"ban.notice.header": "Tá cosc ort ar an bhfreastalaí seo.",
|
||||
"ban.notice.reason": "Cúis: {0}",
|
||||
"ban.notice.unban": "Am dí-chosc: {0}",
|
||||
"ban.notice.unban_permanent": "Am dí-chosc: Buan",
|
||||
"ban.notice.contact": "Má tá ceist agat, déan teagmháil le riarthóir."
|
||||
}
|
||||
""";
|
||||
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Empostor.Api.Languages;
|
||||
|
||||
public sealed class LanguageString
|
||||
{
|
||||
private string _value;
|
||||
|
||||
internal LanguageString(string value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public LanguageString Format(params object[] args)
|
||||
{
|
||||
_value = string.Format(_value, args);
|
||||
return this;
|
||||
}
|
||||
|
||||
public LanguageString Replace(string oldValue, string newValue)
|
||||
{
|
||||
_value = _value.Replace(oldValue, newValue, StringComparison.Ordinal);
|
||||
return this;
|
||||
}
|
||||
|
||||
public LanguageString Replace(string oldValue, object newValue)
|
||||
{
|
||||
_value = _value.Replace(oldValue, newValue?.ToString() ?? string.Empty, StringComparison.Ordinal);
|
||||
return this;
|
||||
}
|
||||
|
||||
public LanguageString ReplaceAll(params (string placeholder, object value)[] replacements)
|
||||
{
|
||||
foreach (var (placeholder, value) in replacements)
|
||||
_value = _value.Replace(placeholder, value?.ToString() ?? string.Empty, StringComparison.Ordinal);
|
||||
return this;
|
||||
}
|
||||
|
||||
public string Get() => _value;
|
||||
|
||||
public override string ToString() => _value;
|
||||
|
||||
public static implicit operator string(LanguageString ls) => ls._value;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace Empostor.Api.Localization;
|
||||
|
||||
public interface ILocalizationRegistry
|
||||
{
|
||||
/// <summary>Registers embedded JSON during startup. Files are not reloaded during this session.</summary>
|
||||
bool TryRegister(LocalizationCatalog catalog, out IDisposable? registration);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Empostor.Api.Innersloth;
|
||||
|
||||
namespace Empostor.Api.Localization;
|
||||
|
||||
public interface ILocalizationService
|
||||
{
|
||||
string Get(LocalizedMessageKey message, Language language, params object?[] arguments);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Reflection;
|
||||
using Empostor.Api.Innersloth;
|
||||
|
||||
namespace Empostor.Api.Localization;
|
||||
|
||||
/// <summary>Describes embedded JSON resources named prefix.language-code.json.</summary>
|
||||
public sealed record LocalizationCatalog(string Owner, Assembly Assembly, string ResourcePrefix, Language DefaultLanguage = Language.English);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Empostor.Api.Localization;
|
||||
|
||||
public readonly record struct LocalizedMessageKey(string Owner, string Key)
|
||||
{
|
||||
public override string ToString() => $"{Owner}:{Key}";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Net.Inner.Objects;
|
||||
using Empostor.Api.Net.Inner.Objects.ShipStatus;
|
||||
|
||||
namespace Empostor.Api.Net.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles custom <see cref="IInnerShipStatus" /> system state and update payloads.
|
||||
/// </summary>
|
||||
public interface ICustomSystemType : ICustomMessage
|
||||
{
|
||||
ValueTask DeserializeAsync(IInnerShipStatus shipStatus, IClientPlayer sender, IClientPlayer? target, IMessageReader reader, bool initialState);
|
||||
|
||||
ValueTask<bool> HandleUpdateSystemAsync(IInnerShipStatus shipStatus, IInnerPlayerControl? playerControl, IClientPlayer sender, IClientPlayer? target, IMessageReader reader);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ public static class MessageWriterExtensions
|
||||
}
|
||||
}
|
||||
|
||||
public static void Write(this IMessageWriter writer, IMessageWriter message, bool includeHeader)
|
||||
{
|
||||
writer.Write(message.ToByteArray(includeHeader));
|
||||
}
|
||||
|
||||
public static void Write(this IMessageWriter writer, Vector2 vector)
|
||||
{
|
||||
writer.Write((ushort)(Mathf.ReverseLerp(vector.X) * (double)ushort.MaxValue));
|
||||
|
||||
@@ -6,22 +6,27 @@ namespace Empostor.Api.Net.Messages.Rpcs
|
||||
{
|
||||
public static class Rpc35UpdateSystem
|
||||
{
|
||||
public static void Serialize(IMessageWriter writer, SystemTypes systemType, IInnerPlayerControl playerControl, ushort sequenceId, byte state, byte ventId)
|
||||
public static void Serialize(IMessageWriter writer, SystemTypes systemType, IInnerPlayerControl playerControl, IMessageWriter payload)
|
||||
{
|
||||
writer.Write((byte)systemType);
|
||||
writer.Write(playerControl);
|
||||
writer.Write(sequenceId);
|
||||
writer.Write(state);
|
||||
writer.Write(ventId);
|
||||
writer.Write(payload, false);
|
||||
}
|
||||
|
||||
public static void Deserialize(IMessageReader reader, IGame game, out SystemTypes systemType, out IInnerPlayerControl? playerControl, out ushort sequenceId, out byte state, out byte ventId)
|
||||
public static void Serialize(IMessageWriter writer, SystemTypes systemType, IInnerPlayerControl playerControl, byte amount)
|
||||
{
|
||||
writer.Write((byte)systemType);
|
||||
writer.Write(playerControl);
|
||||
writer.Write(amount);
|
||||
}
|
||||
|
||||
public static void Deserialize(IMessageReader reader, IGame game, out SystemTypes systemType, out IInnerPlayerControl? playerControl, out IMessageReader payload)
|
||||
{
|
||||
systemType = (SystemTypes)reader.ReadByte();
|
||||
playerControl = reader.ReadNetObject<IInnerPlayerControl>(game);
|
||||
sequenceId = reader.ReadUInt16();
|
||||
state = reader.ReadByte();
|
||||
ventId = reader.ReadByte();
|
||||
|
||||
// The system payload is appended directly with Write(payload, false); it is not a nested message.
|
||||
payload = reader;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Empostor.Api.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Implement this on your plugin class to provide translations that are
|
||||
/// automatically registered with the language service on enable.
|
||||
/// </summary>
|
||||
public interface IPluginLanguageProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns translations grouped by language code.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> GetTranslations();
|
||||
}
|
||||
@@ -144,7 +144,6 @@
|
||||
"resolved": "4.5.0",
|
||||
"contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g=="
|
||||
}
|
||||
},
|
||||
"net8.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,7 @@
|
||||
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"command.narrator.description":"Ask the narrator for AI-powered advice during a meeting.",
|
||||
"command.narrator.usage":"narrator <your question>",
|
||||
"narrator.meeting_only":"You can only use #narrator during a meeting.",
|
||||
"narrator.disabled":"The narrator is currently disabled by the host.",
|
||||
"narrator.status_enabled":"enabled",
|
||||
"narrator.status_disabled":"disabled",
|
||||
"narrator.host_status":"Status: {0} | Max uses per player per game: {1}",
|
||||
"narrator.host_commands":"Host commands: #narrator enable | disable | limit <number>",
|
||||
"narrator.host_enabled":"Narrator enabled for this game.",
|
||||
"narrator.host_disabled":"Narrator disabled for this game.",
|
||||
"narrator.host_limit_set":"Narrator usage limit set to {0} per player per game.",
|
||||
"narrator.host_limit_zero":"Narrator usage limit set to 0 (effectively disabled).",
|
||||
"narrator.host_limit_usage":"Usage: #narrator limit <number>\nExample: #narrator limit 3",
|
||||
"narrator.usage":"Usage: #narrator <your question or statement>\nExample: #narrator I'm being accused but I have a visual task, how do I prove myself?"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"command.narrator.description":"在会议中向旁白寻求AI建议。",
|
||||
"command.narrator.usage":"narrator <你要提的问题>",
|
||||
"narrator.meeting_only":"只能在会议中使用 #narrator。",
|
||||
"narrator.disabled":"房主已禁用旁白功能。",
|
||||
"narrator.status_enabled":"已启用",
|
||||
"narrator.status_disabled":"已禁用",
|
||||
"narrator.host_status":"状态:{0} | 每局每人最大次数:{1}",
|
||||
"narrator.host_commands":"房主指令:#narrator enable | disable | limit <次数>",
|
||||
"narrator.host_enabled":"旁白已在此局中启用。",
|
||||
"narrator.host_disabled":"旁白已在此局中禁用。",
|
||||
"narrator.host_limit_set":"旁白使用次数限制已设为每人每局 {0} 次。",
|
||||
"narrator.host_limit_zero":"旁白使用次数限制已设为 0(等效禁用)。",
|
||||
"narrator.host_limit_usage":"用法:#narrator limit <次数>\n示例:#narrator limit 3",
|
||||
"narrator.usage":"用法:#narrator <你要说的话>\n示例:#narrator 我被指控了但我有可视任务,怎么证明自己?"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public sealed class NarratorCommand : ICommand
|
||||
}
|
||||
|
||||
public string Name => "narrator";
|
||||
public string LocalizationOwner => NarratorPlugin.Owner;
|
||||
|
||||
public string[] Aliases => new[] { "nar", "n" };
|
||||
|
||||
@@ -55,9 +56,7 @@ public sealed class NarratorCommand : ICommand
|
||||
var max = _service.GetMaxUses(ctx.Game);
|
||||
var statusText = T(ctx, enabled ? "narrator.status_enabled" : "narrator.status_disabled",
|
||||
enabled ? "enabled" : "disabled");
|
||||
var msg = T(ctx, "narrator.host_status", "Status: {0} | Max uses per player per game: {1}")
|
||||
.Replace("{0}", statusText)
|
||||
.Replace("{1}", max.ToString());
|
||||
var msg = T(ctx, "narrator.host_status", "Status: {0} | Max uses per player per game: {1}", statusText, max);
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(msg, ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
@@ -77,8 +76,7 @@ public sealed class NarratorCommand : ICommand
|
||||
T(ctx, "narrator.host_limit_zero", "Limit set to 0 (effectively disabled)."), ctx.PlayerControl);
|
||||
else
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "narrator.host_limit_set", "Limit set to {0} per player per game.")
|
||||
.Replace("{0}", limit.ToString()),
|
||||
T(ctx, "narrator.host_limit_set", "Limit set to {0} per player per game.", limit),
|
||||
ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
@@ -147,9 +145,8 @@ public sealed class NarratorCommand : ICommand
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(sb.ToString(), ctx.PlayerControl);
|
||||
}
|
||||
|
||||
private static string T(CommandContext ctx, string key, string defaultText)
|
||||
private static string T(CommandContext ctx, string key, string defaultText, params object?[] arguments)
|
||||
{
|
||||
string result = ctx.Lang.Get(key, ctx.SenderLanguage);
|
||||
return result == key ? defaultText : result;
|
||||
return ctx.GetPluginString(NarratorPlugin.Owner, key, arguments);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Localization;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugin.Narrator;
|
||||
|
||||
[EmpostorPlugin("cn.hayashiume.narrator")]
|
||||
public sealed class NarratorPlugin : PluginBase, IPluginLanguageProvider
|
||||
[EmpostorPlugin(Owner)]
|
||||
public sealed class NarratorPlugin : PluginBase
|
||||
{
|
||||
public const string Owner = "cn.hayashiume.narrator";
|
||||
private readonly ILocalizationRegistry _registry;
|
||||
private readonly ILogger<NarratorPlugin> _logger;
|
||||
|
||||
public NarratorPlugin(ILogger<NarratorPlugin> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private IDisposable? _registration;
|
||||
public NarratorPlugin(ILogger<NarratorPlugin> logger, ILocalizationRegistry registry) { _logger = logger; _registry = registry; }
|
||||
public override ValueTask EnableAsync()
|
||||
{
|
||||
_logger.LogInformation("[Narrator] Plugin enabled. #narrator command registered.");
|
||||
if (!_registry.TryRegister(new LocalizationCatalog(Owner, typeof(NarratorPlugin).Assembly, "Empostor.Plugin.Narrator.Localization"), out _registration))
|
||||
_logger.LogWarning("Could not register localization resources for {Owner}", Owner);
|
||||
return default;
|
||||
}
|
||||
|
||||
public override ValueTask DisableAsync() => default;
|
||||
|
||||
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> GetTranslations()
|
||||
{
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
["en"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.narrator.description"] = "Ask the narrator for AI-powered advice during a meeting.",
|
||||
["command.narrator.usage"] = "narrator <your question>",
|
||||
["narrator.meeting_only"] = "You can only use #narrator during a meeting.",
|
||||
["narrator.disabled"] = "The narrator is currently disabled by the host.",
|
||||
["narrator.status_enabled"] = "enabled",
|
||||
["narrator.status_disabled"] = "disabled",
|
||||
["narrator.host_status"] = "Status: {0} | Max uses per player per game: {1}",
|
||||
["narrator.host_commands"] = "Host commands: #narrator enable | disable | limit <number>",
|
||||
["narrator.host_enabled"] = "Narrator enabled for this game.",
|
||||
["narrator.host_disabled"] = "Narrator disabled for this game.",
|
||||
["narrator.host_limit_set"] = "Narrator usage limit set to {0} per player per game.",
|
||||
["narrator.host_limit_zero"] = "Narrator usage limit set to 0 (effectively disabled).",
|
||||
["narrator.host_limit_usage"] = "Usage: #narrator limit <number>\nExample: #narrator limit 3",
|
||||
["narrator.usage"] = "Usage: #narrator <your question or statement>\nExample: #narrator I'm being accused but I have a visual task, how do I prove myself?",
|
||||
},
|
||||
["zh_CN"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.narrator.description"] = "在会议中向旁白寻求AI建议。",
|
||||
["command.narrator.usage"] = "narrator <你要提的问题>",
|
||||
["narrator.meeting_only"] = "只能在会议中使用 #narrator。",
|
||||
["narrator.disabled"] = "房主已禁用旁白功能。",
|
||||
["narrator.status_enabled"] = "已启用",
|
||||
["narrator.status_disabled"] = "已禁用",
|
||||
["narrator.host_status"] = "状态:{0} | 每局每人最大次数:{1}",
|
||||
["narrator.host_commands"] = "房主指令:#narrator enable | disable | limit <次数>",
|
||||
["narrator.host_enabled"] = "旁白已在此局中启用。",
|
||||
["narrator.host_disabled"] = "旁白已在此局中禁用。",
|
||||
["narrator.host_limit_set"] = "旁白使用次数限制已设为每人每局 {0} 次。",
|
||||
["narrator.host_limit_zero"] = "旁白使用次数限制已设为 0(等效禁用)。",
|
||||
["narrator.host_limit_usage"] = "用法:#narrator limit <次数>\n示例:#narrator limit 3",
|
||||
["narrator.usage"] = "用法:#narrator <你要说的话>\n示例:#narrator 我被指控了但我有可视任务,怎么证明自己?",
|
||||
},
|
||||
["zh_TW"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.narrator.description"] = "在會議中向旁白尋求AI建議。",
|
||||
["command.narrator.usage"] = "narrator <你要提的問題>",
|
||||
["narrator.meeting_only"] = "只能在會議中使用 #narrator。",
|
||||
["narrator.disabled"] = "房主已停用旁白功能。",
|
||||
["narrator.status_enabled"] = "已啟用",
|
||||
["narrator.status_disabled"] = "已停用",
|
||||
["narrator.host_status"] = "狀態:{0} | 每局每人最大次數:{1}",
|
||||
["narrator.host_commands"] = "房主指令:#narrator enable | disable | limit <次數>",
|
||||
["narrator.host_enabled"] = "旁白已在此局中啟用。",
|
||||
["narrator.host_disabled"] = "旁白已在此局中停用。",
|
||||
["narrator.host_limit_set"] = "旁白使用次數限制已設為每人每局 {0} 次。",
|
||||
["narrator.host_limit_zero"] = "旁白使用次數限制已設為 0(等效停用)。",
|
||||
["narrator.host_limit_usage"] = "用法:#narrator limit <次數>\n範例:#narrator limit 3",
|
||||
["narrator.usage"] = "用法:#narrator <你要說的話>\n範例:#narrator 我被指控了但我有可視任務,怎麼證明自己?",
|
||||
},
|
||||
};
|
||||
}
|
||||
public override ValueTask DisableAsync() { _registration?.Dispose(); return default; }
|
||||
}
|
||||
@@ -216,7 +216,7 @@ public sealed class NarratorService
|
||||
if (game.Options is NormalGameOptions normalOpts)
|
||||
{
|
||||
sb.AppendLine($"Visual tasks enabled: {(normalOpts.VisualTasks ? "Yes" : "No")}");
|
||||
sb.AppendLine($"Confirm ejects: {(normalOpts.ConfirmEmpostor ? "On" : "Off")}");
|
||||
sb.AppendLine($"Confirm ejects: {(normalOpts.ConfirmImpostor ? "On" : "Off")}");
|
||||
}
|
||||
|
||||
sb.AppendLine($"Total impostors: {game.Options.NumImpostors}");
|
||||
|
||||
@@ -7,4 +7,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Commands;
|
||||
|
||||
namespace Empostor.Plugins.DiscordWebhook;
|
||||
|
||||
public sealed class DiscordWebhookCommand : ICommand
|
||||
{
|
||||
private readonly LiveRoomToggle _toggle;
|
||||
private readonly DiscordWebhookListener _listener;
|
||||
|
||||
public DiscordWebhookCommand(LiveRoomToggle toggle, DiscordWebhookListener listener)
|
||||
{
|
||||
_toggle = toggle;
|
||||
_listener = listener;
|
||||
}
|
||||
|
||||
public string Name => "dcwb";
|
||||
public string LocalizationOwner => DiscordWebhookPlugin.Owner;
|
||||
|
||||
public string[] Aliases => new[] { "discordwebhook" };
|
||||
|
||||
public string Description => "Turn this room's Discord posting on or off. Host only.";
|
||||
|
||||
public string Usage => "dcwb <on|off>";
|
||||
|
||||
public async ValueTask<bool> ExecuteAsync(CommandContext ctx)
|
||||
{
|
||||
if (!ctx.Sender.IsHost)
|
||||
{
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "discordwebhook.host_only", "Only the host can toggle Discord posting."),
|
||||
ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ctx.Args.Length == 0)
|
||||
{
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "discordwebhook.usage", "Usage: #dcwb <on|off>"), ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
var arg = ctx.Args[0].Trim().ToLowerInvariant();
|
||||
bool enabled;
|
||||
if (arg == "on" || arg == "true" || arg == "1")
|
||||
{
|
||||
enabled = true;
|
||||
}
|
||||
else if (arg == "off" || arg == "false" || arg == "0")
|
||||
{
|
||||
enabled = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "discordwebhook.usage", "Usage: #dcwb <on|off>"), ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (enabled == !_toggle.IsMuted(ctx.Game.Code))
|
||||
{
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "discordwebhook.already", "Discord posting is already {0} for this room.", State(ctx, enabled)),
|
||||
ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
_listener.SetRoomForwarding(ctx.Game, enabled);
|
||||
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "discordwebhook.changed", "Discord posting {0} for this room.", State(ctx, enabled)),
|
||||
ctx.PlayerControl);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string State(CommandContext ctx, bool enabled)
|
||||
=> enabled
|
||||
? T(ctx, "discordwebhook.state_on", "on")
|
||||
: T(ctx, "discordwebhook.state_off", "off");
|
||||
|
||||
private static string T(CommandContext ctx, string key, string defaultText, params object?[] arguments)
|
||||
{
|
||||
return ctx.GetPluginString(DiscordWebhookPlugin.Owner, key, arguments);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public sealed class DiscordWebhookListener : IEventListener
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly DiscordWebhookStore _config;
|
||||
private readonly LiveGameTracker _tracker;
|
||||
private readonly LiveRoomToggle _toggle;
|
||||
|
||||
// Per-lobby edit throttle, plus a marker so skipped updates are flushed once instead of lost.
|
||||
private readonly ConcurrentDictionary<int, DateTime> _lastEdit = new();
|
||||
@@ -35,19 +36,52 @@ public sealed class DiscordWebhookListener : IEventListener
|
||||
ILogger<DiscordWebhookListener> logger,
|
||||
IHttpClientFactory http,
|
||||
DiscordWebhookStore config,
|
||||
LiveGameTracker tracker)
|
||||
LiveGameTracker tracker,
|
||||
LiveRoomToggle toggle)
|
||||
{
|
||||
_logger = logger;
|
||||
_http = http;
|
||||
_config = config;
|
||||
_tracker = tracker;
|
||||
_toggle = toggle;
|
||||
}
|
||||
|
||||
// ---- Live mode: one persistent message per lobby, edited as it changes ----
|
||||
|
||||
/// <summary>Applies the host's <c>#dcwb</c> choice to one lobby, creating the message on enable.</summary>
|
||||
public void SetRoomForwarding(IGame game, bool enabled)
|
||||
{
|
||||
_toggle.Set(game.Code, enabled);
|
||||
_logger.LogInformation(
|
||||
"DiscordWebhook forwarding for {Code} turned {State} by {Player}",
|
||||
GameCodeParser.IntToGameName(game.Code),
|
||||
enabled ? "on" : "off",
|
||||
game.Host?.Client.Name ?? "—");
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Enabling mid-lobby: mirror it right away instead of waiting for the next player change.
|
||||
if (_tracker.TryGet(game.Code, out var lobby) && lobby.Game != null)
|
||||
{
|
||||
_ = UpdateLobbyAsync(lobby.Game, force: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = CreateLiveMessageAsync(game);
|
||||
}
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnGameCreated(IGameCreatedEvent e)
|
||||
{
|
||||
if (_toggle.IsMuted(e.Game.Code))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_config.LiveRoomUpdates)
|
||||
{
|
||||
// The live message is created on the first player join instead: at creation time the
|
||||
@@ -69,6 +103,8 @@ public sealed class DiscordWebhookListener : IEventListener
|
||||
[EventListener]
|
||||
public void OnGameStarted(IGameStartedEvent e)
|
||||
{
|
||||
if (_toggle.IsMuted(e.Game.Code)) { return; }
|
||||
|
||||
if (!_config.LiveRoomUpdates) { SendOneShot("Game Started", ColorInGame, LegacyFields(e.Game)); return; }
|
||||
_ = UpdateLobbyAsync(e.Game, force: true);
|
||||
}
|
||||
@@ -76,6 +112,11 @@ public sealed class DiscordWebhookListener : IEventListener
|
||||
[EventListener]
|
||||
public void OnGameEnded(IGameEndedEvent e)
|
||||
{
|
||||
if (_toggle.IsMuted(e.Game.Code))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_config.LiveRoomUpdates)
|
||||
{
|
||||
SendOneShot("Game Ended", ColorEnded, new()
|
||||
@@ -162,7 +203,7 @@ public sealed class DiscordWebhookListener : IEventListener
|
||||
private async ValueTask CreateLiveMessageAsync(IGame game)
|
||||
{
|
||||
var url = _config.MatchmakerUrl;
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
if (string.IsNullOrWhiteSpace(url) || _toggle.IsMuted(game.Code))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -209,7 +250,7 @@ public sealed class DiscordWebhookListener : IEventListener
|
||||
|
||||
private async ValueTask UpdateLobbyAsync(IGame game, bool force = false, string? resultOverride = null)
|
||||
{
|
||||
if (!_config.LiveRoomUpdates || !_tracker.TryGet(game.Code, out var lobby))
|
||||
if (!_config.LiveRoomUpdates || _toggle.IsMuted(game.Code) || !_tracker.TryGet(game.Code, out var lobby))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -243,6 +284,8 @@ public sealed class DiscordWebhookListener : IEventListener
|
||||
|
||||
private async ValueTask DeleteLiveMessageAsync(int gameCode)
|
||||
{
|
||||
_toggle.Clear(gameCode);
|
||||
|
||||
if (!_tracker.Untrack(gameCode, out var lobby))
|
||||
{
|
||||
return;
|
||||
@@ -345,19 +388,37 @@ public sealed class DiscordWebhookListener : IEventListener
|
||||
TrimUrl(url) + (url.Contains('?') ? "&" : "?") + "wait=true",
|
||||
new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
|
||||
// Read the body regardless of status so failures carry diagnostic content
|
||||
// (proxies, captive portals and WAFs answer with arbitrary non-JSON text).
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("DiscordWebhook live message create returned {Status}", (int)response.StatusCode);
|
||||
_logger.LogWarning(
|
||||
"DiscordWebhook live message create returned {Status}: {Body}",
|
||||
(int)response.StatusCode, Truncate(body));
|
||||
return null;
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
return doc.RootElement.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String
|
||||
? id.GetString()
|
||||
: null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
return doc.RootElement.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String
|
||||
? id.GetString()
|
||||
: null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"DiscordWebhook live message create got a non-JSON body ({Bytes} bytes) from {Host}: {Body}",
|
||||
body.Length, response.RequestMessage?.RequestUri?.Host ?? "?", Truncate(body));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string s)
|
||||
=> s.Length <= 300 ? s : s.Substring(0, 300) + "…";
|
||||
|
||||
/// <summary>Returns true when the message no longer exists (404).</summary>
|
||||
private async ValueTask<bool> PatchLiveMessageAsync(TrackedLobby lobby, object embed)
|
||||
{
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Localization;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugins.DiscordWebhook;
|
||||
|
||||
[EmpostorPlugin("cn.hayashiume.discordwebhook", "Discord Webhook", "HayashiUme", "2.0.0")]
|
||||
[EmpostorPlugin(Owner, "Discord Webhook", "HayashiUme", "2.0.0")]
|
||||
public sealed class DiscordWebhookPlugin : PluginBase
|
||||
{
|
||||
public const string Owner = "cn.hayashiume.discordwebhook";
|
||||
private readonly ILocalizationRegistry _registry;
|
||||
private readonly ILogger<DiscordWebhookPlugin> _logger;
|
||||
|
||||
public DiscordWebhookPlugin(ILogger<DiscordWebhookPlugin> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private IDisposable? _registration;
|
||||
public DiscordWebhookPlugin(ILogger<DiscordWebhookPlugin> logger, ILocalizationRegistry registry) { _logger = logger; _registry = registry; }
|
||||
public override ValueTask EnableAsync()
|
||||
{
|
||||
_logger.LogInformation("[DiscordWebhook] Enabled. Configure URLs in the admin panel (Plugins → Discord Webhook).");
|
||||
if (!_registry.TryRegister(new LocalizationCatalog(Owner, typeof(DiscordWebhookPlugin).Assembly, "Empostor.Plugins.DiscordWebhook.Localization"), out _registration))
|
||||
_logger.LogWarning("Could not register localization resources for {Owner}", Owner);
|
||||
return default;
|
||||
}
|
||||
|
||||
public override ValueTask DisableAsync() => default;
|
||||
public override ValueTask DisableAsync() { _registration?.Dispose(); return default; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Empostor.Api.Admin;
|
||||
using Empostor.Api.Commands;
|
||||
using Empostor.Api.Events;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -14,8 +15,13 @@ public sealed class DiscordWebhookPluginStartup : IPluginStartup
|
||||
{
|
||||
services.AddHttpClient();
|
||||
services.AddSingleton<LiveGameTracker>();
|
||||
services.AddSingleton<LiveRoomToggle>();
|
||||
services.AddSingleton<DiscordWebhookStore>();
|
||||
services.AddSingleton<IEventListener, DiscordWebhookListener>();
|
||||
services.AddSingleton<DiscordWebhookListener>();
|
||||
services.AddSingleton<IEventListener>(sp => sp.GetRequiredService<DiscordWebhookListener>());
|
||||
services.AddSingleton<DiscordWebhookCommand>();
|
||||
services.AddSingleton<ICommand, DiscordWebhookCommand>(
|
||||
sp => sp.GetRequiredService<DiscordWebhookCommand>());
|
||||
services.AddSingleton<IAdminExtension, DiscordWebhookAdminExtension>();
|
||||
services.AddHostedService<WebhookOrphanCleanup>();
|
||||
}
|
||||
|
||||
@@ -8,4 +8,7 @@
|
||||
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Empostor.Plugins.DiscordWebhook;
|
||||
|
||||
public sealed class LiveRoomToggle
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, bool> _overrides = new();
|
||||
|
||||
public bool IsMuted(int gameCode) => _overrides.TryGetValue(gameCode, out var enabled) && !enabled;
|
||||
|
||||
public void Set(int gameCode, bool enabled) => _overrides[gameCode] = enabled;
|
||||
|
||||
public void Clear(int gameCode) => _overrides.TryRemove(gameCode, out _);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"command.dcwb.description":"Turn this room's Discord posting on or off. Host only.",
|
||||
"command.dcwb.usage":"dcwb <on|off>",
|
||||
"discordwebhook.host_only":"Only the host can toggle Discord posting.",
|
||||
"discordwebhook.usage":"Usage: #dcwb <on|off>",
|
||||
"discordwebhook.already":"Discord posting is already {0} for this room.",
|
||||
"discordwebhook.changed":"Discord posting {0} for this room.",
|
||||
"discordwebhook.state_on":"on",
|
||||
"discordwebhook.state_off":"off"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"command.dcwb.description":"开关本房间的 Discord 转发(仅房主)。",
|
||||
"command.dcwb.usage":"dcwb <on|off>",
|
||||
"discordwebhook.host_only":"只有房主可以开关 Discord 转发。",
|
||||
"discordwebhook.usage":"用法:#dcwb <on|off>",
|
||||
"discordwebhook.already":"本房间的 Discord 转发已经是{0}了。",
|
||||
"discordwebhook.changed":"本房间的 Discord 转发已{0}。",
|
||||
"discordwebhook.state_on":"开启",
|
||||
"discordwebhook.state_off":"关闭"
|
||||
}
|
||||
@@ -12,4 +12,7 @@
|
||||
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -7,4 +7,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -5,7 +5,7 @@ using System.Threading.Tasks;
|
||||
using Empostor.Api.Events;
|
||||
using Empostor.Api.Events.Client;
|
||||
using Empostor.Api.Innersloth;
|
||||
using Empostor.Api.Languages;
|
||||
using Empostor.Api.Localization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugins.FriendCodeValidator
|
||||
@@ -5367,11 +5367,11 @@ namespace Empostor.Plugins.FriendCodeValidator
|
||||
};
|
||||
|
||||
private readonly ILogger<FriendCodeValidationListener> _logger;
|
||||
private readonly LanguageService _lang;
|
||||
private readonly ILocalizationService _lang;
|
||||
|
||||
public FriendCodeValidationListener(
|
||||
ILogger<FriendCodeValidationListener> logger,
|
||||
LanguageService lang)
|
||||
ILocalizationService lang)
|
||||
{
|
||||
_logger = logger;
|
||||
_lang = lang;
|
||||
@@ -5387,7 +5387,7 @@ namespace Empostor.Plugins.FriendCodeValidator
|
||||
{
|
||||
_logger.LogWarning("[FriendCodeValidator] Invalid FC {FC} ({Name}) — kicking",
|
||||
fc, e.Client.Name);
|
||||
var reason = _lang.Get("friendcode.invalid_reason", e.Client.Language).Get();
|
||||
var reason = _lang.Get(new LocalizedMessageKey(FriendCodeValidatorPlugin.Owner, "friendcode.invalid_reason"), e.Client.Language);
|
||||
await e.Client.DisconnectAsync(DisconnectReason.Kicked, reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Localization;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugins.FriendCodeValidator
|
||||
namespace Empostor.Plugins.FriendCodeValidator;
|
||||
|
||||
[EmpostorPlugin(Owner, "Friend Code Validator", "MatchDuck & Empostor", "1.0.0")]
|
||||
public sealed class FriendCodeValidatorPlugin : PluginBase
|
||||
{
|
||||
// MatchDuck offered the Friendcode safenames.txt lol
|
||||
[EmpostorPlugin("duck.Empostor.friendcodevalidator", "Friend Code Validator", "MatchDuck & Empostor", "1.0.0")]
|
||||
public sealed class FriendCodeValidatorPlugin : PluginBase, IPluginLanguageProvider
|
||||
public const string Owner = "duck.Empostor.friendcodevalidator";
|
||||
private readonly ILocalizationRegistry _registry;
|
||||
private readonly ILogger<FriendCodeValidatorPlugin> _logger;
|
||||
private IDisposable? _registration;
|
||||
public FriendCodeValidatorPlugin(ILogger<FriendCodeValidatorPlugin> logger, ILocalizationRegistry registry) { _logger = logger; _registry = registry; }
|
||||
public override ValueTask EnableAsync()
|
||||
{
|
||||
private readonly ILogger<FriendCodeValidatorPlugin> _logger;
|
||||
|
||||
public FriendCodeValidatorPlugin(ILogger<FriendCodeValidatorPlugin> logger)
|
||||
=> _logger = logger;
|
||||
|
||||
public override ValueTask EnableAsync()
|
||||
{
|
||||
_logger.LogInformation("[FriendCodeValidator] Enabled.");
|
||||
return default;
|
||||
}
|
||||
|
||||
public override ValueTask DisableAsync() => default;
|
||||
|
||||
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> GetTranslations()
|
||||
{
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
["en"] = new Dictionary<string, string>
|
||||
{
|
||||
["friendcode.invalid_reason"] = "Your friend code contains inappropriate words and cannot be used on this server.",
|
||||
},
|
||||
["zh_CN"] = new Dictionary<string, string>
|
||||
{
|
||||
["friendcode.invalid_reason"] = "您的好友代码包含不当词汇,无法在此服务器使用。",
|
||||
},
|
||||
["zh_TW"] = new Dictionary<string, string>
|
||||
{
|
||||
["friendcode.invalid_reason"] = "您的好友代碼包含不當詞彙,無法在此伺服器使用。",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!_registry.TryRegister(new LocalizationCatalog(Owner, typeof(FriendCodeValidatorPlugin).Assembly, "Empostor.Plugins.FriendCodeValidator.Localization"), out _registration))
|
||||
_logger.LogWarning("Could not register localization resources for {Owner}", Owner);
|
||||
return default;
|
||||
}
|
||||
public override ValueTask DisableAsync() { _registration?.Dispose(); return default; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "friendcode.invalid_reason":"Your friend code contains inappropriate words and cannot be used on this server." }
|
||||
@@ -0,0 +1 @@
|
||||
{ "friendcode.invalid_reason":"您的好友代码非法,无法在此服务器使用。" }
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>Empostor.Plugins.Maintenance</AssemblyName>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Admin;
|
||||
|
||||
namespace Empostor.Plugins.Maintenance;
|
||||
|
||||
public sealed class MaintenanceAdminExtension : IAdminExtension
|
||||
{
|
||||
private readonly MaintenanceStore _store;
|
||||
private readonly MaintenanceListener _listener;
|
||||
|
||||
public MaintenanceAdminExtension(MaintenanceStore store, MaintenanceListener listener)
|
||||
{
|
||||
_store = store;
|
||||
_listener = listener;
|
||||
}
|
||||
|
||||
public string Id => "maintenance";
|
||||
|
||||
public string Title => "Maintenance";
|
||||
|
||||
public string Icon => "shield";
|
||||
|
||||
// The textbox holds a half-typed message most of the time, so the panel must not refetch itself.
|
||||
public bool AutoRefresh => false;
|
||||
|
||||
public void Build(AdminPanelBuilder b)
|
||||
{
|
||||
b.RegisterText(
|
||||
_store.Enabled
|
||||
? "Maintenance mode: ON — every player is turned away."
|
||||
: "Maintenance mode: OFF — the server is accepting players.",
|
||||
_store.Enabled ? "warning" : "default");
|
||||
|
||||
b.RegisterButton(button =>
|
||||
{
|
||||
button.Label = _store.Enabled ? "Turn maintenance OFF" : "Turn maintenance ON";
|
||||
button.Icon = _store.Enabled ? "refresh" : "shield";
|
||||
button.Style = _store.Enabled ? "primary" : "danger";
|
||||
button.OnClick(async ctx => await ToggleAsync(ctx));
|
||||
});
|
||||
|
||||
b.RegisterDivider();
|
||||
|
||||
b.RegisterTextbox(textbox =>
|
||||
{
|
||||
textbox.Label = "Kick message";
|
||||
textbox.Value = _store.Message;
|
||||
textbox.Multiline = true;
|
||||
textbox.OnSubmit(async ctx => await SaveMessageAsync(ctx));
|
||||
});
|
||||
}
|
||||
|
||||
private async ValueTask<AdminActionResult> ToggleAsync(AdminActionContext ctx)
|
||||
{
|
||||
var enabled = !_store.Enabled;
|
||||
await _listener.SetAsync(enabled);
|
||||
|
||||
return AdminActionResult.Ok(enabled
|
||||
? "Maintenance mode ON. Everyone in a lobby was disconnected; new players are refused."
|
||||
: "Maintenance mode OFF. The server is accepting players again.");
|
||||
}
|
||||
|
||||
private async ValueTask<AdminActionResult> SaveMessageAsync(AdminActionContext ctx)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ctx.Value))
|
||||
{
|
||||
return AdminActionResult.Ok("Kick message left unchanged, the box was empty.");
|
||||
}
|
||||
|
||||
_store.SetMessage(ctx.Value.Trim());
|
||||
await _store.SaveAsync();
|
||||
return AdminActionResult.Ok("Kick message saved.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Events;
|
||||
using Empostor.Api.Events.Client;
|
||||
using Empostor.Api.Games;
|
||||
using Empostor.Api.Games.Managers;
|
||||
using Empostor.Api.Innersloth;
|
||||
using Empostor.Api.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugins.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// While maintenance mode is on, every player is turned away: the ones already in a lobby are
|
||||
/// disconnected, and anyone connecting, creating a lobby or trying to join is rejected with the
|
||||
/// same message.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceListener : IEventListener
|
||||
{
|
||||
private readonly ILogger<MaintenanceListener> _logger;
|
||||
private readonly MaintenanceStore _store;
|
||||
private readonly IGameManager _games;
|
||||
|
||||
public MaintenanceListener(
|
||||
ILogger<MaintenanceListener> logger,
|
||||
MaintenanceStore store,
|
||||
IGameManager games)
|
||||
{
|
||||
_logger = logger;
|
||||
_store = store;
|
||||
_games = games;
|
||||
}
|
||||
|
||||
/// <summary>Turns maintenance on or off, disconnecting everyone currently in a lobby when on.</summary>
|
||||
public async ValueTask SetAsync(bool enabled)
|
||||
{
|
||||
_store.SetEnabled(enabled);
|
||||
await _store.SaveAsync();
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
_logger.LogInformation("MaintenanceMode disabled, the server is accepting players again");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning("MaintenanceMode enabled, disconnecting every player currently in a lobby");
|
||||
await KickAllAsync();
|
||||
}
|
||||
|
||||
private async ValueTask KickAllAsync()
|
||||
{
|
||||
foreach (var game in _games.Games.ToList())
|
||||
{
|
||||
foreach (var player in game.Players.ToList())
|
||||
{
|
||||
var client = player.Client;
|
||||
if (client == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await client.DisconnectAsync(DisconnectReason.Custom, _store.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Maintenance failed to disconnect client {Id}", client.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public async ValueTask OnClientConnected(IClientConnectedEvent e)
|
||||
{
|
||||
// A client that never reaches a lobby would otherwise sit connected forever.
|
||||
if (_store.Enabled)
|
||||
{
|
||||
await e.Client.DisconnectAsync(DisconnectReason.Custom, _store.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public async ValueTask OnGameCreating(IGameCreationEvent e)
|
||||
{
|
||||
if (_store.Enabled && e.Client != null)
|
||||
{
|
||||
await e.Client.DisconnectAsync(DisconnectReason.Custom, _store.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnPlayerJoining(IGamePlayerJoiningEvent e)
|
||||
{
|
||||
if (_store.Enabled)
|
||||
{
|
||||
e.JoinResult = GameJoinResult.CreateCustomError(_store.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugins.Maintenance;
|
||||
|
||||
[EmpostorPlugin("cn.hayashiume.maintenance")]
|
||||
public sealed class MaintenancePlugin : PluginBase
|
||||
{
|
||||
private readonly ILogger<MaintenancePlugin> _logger;
|
||||
|
||||
public MaintenancePlugin(ILogger<MaintenancePlugin> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override ValueTask EnableAsync()
|
||||
{
|
||||
_logger.LogInformation("[Maintenance] Plugin enabled. Use the admin panel (Plugins → Maintenance) to toggle maintenance mode.");
|
||||
return default;
|
||||
}
|
||||
|
||||
public override ValueTask DisableAsync() => default;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Empostor.Api.Admin;
|
||||
using Empostor.Api.Events;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Empostor.Plugins.Maintenance;
|
||||
|
||||
public sealed class MaintenanceStartup : IPluginStartup
|
||||
{
|
||||
public void ConfigureHost(IHostBuilder host) { }
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<MaintenanceStore>();
|
||||
services.AddSingleton<MaintenanceListener>();
|
||||
services.AddSingleton<IEventListener>(sp => sp.GetRequiredService<MaintenanceListener>());
|
||||
services.AddSingleton<IAdminExtension>(sp => new MaintenanceAdminExtension(
|
||||
sp.GetRequiredService<MaintenanceStore>(),
|
||||
sp.GetRequiredService<MaintenanceListener>()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Service;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugins.Maintenance;
|
||||
|
||||
public sealed class MaintenanceConfig
|
||||
{
|
||||
private const string FallbackMessage = "服务器正在维护中,请联系管理员获取支持。";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string? Message { get; set; }
|
||||
|
||||
// Kept so a config written before the two languages were merged still loads its text.
|
||||
public string? MessageZh { get; set; }
|
||||
|
||||
public string? MessageEn { get; set; }
|
||||
|
||||
public string MessageOrDefault => Message ?? MessageZh ?? MessageEn ?? FallbackMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds the maintenance flag and the kick message. Persisted so a restart does not
|
||||
/// silently drop the server back into service while an operator is mid-update.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceStore : JsonDataStore<MaintenanceConfig>
|
||||
{
|
||||
public MaintenanceStore(ILogger<MaintenanceStore> logger)
|
||||
: base(logger)
|
||||
{
|
||||
Load();
|
||||
}
|
||||
|
||||
public bool Enabled { get; private set; }
|
||||
|
||||
public string Message { get; private set; } = new MaintenanceConfig().MessageOrDefault;
|
||||
|
||||
public void SetEnabled(bool enabled) => Enabled = enabled;
|
||||
|
||||
public void SetMessage(string message) => Message = message;
|
||||
|
||||
public new async ValueTask SaveAsync() => await base.SaveAsync();
|
||||
|
||||
protected override MaintenanceConfig GetSnapshot() => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Message = Message,
|
||||
};
|
||||
|
||||
protected override void ApplySnapshot(MaintenanceConfig data)
|
||||
{
|
||||
Enabled = data.Enabled;
|
||||
Message = FirstNonBlank(data.Message, data.MessageZh, data.MessageEn)
|
||||
?? new MaintenanceConfig().MessageOrDefault;
|
||||
}
|
||||
|
||||
private static string? FirstNonBlank(params string?[] candidates)
|
||||
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net8.0": {
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.6.1",
|
||||
"contentHash": "vZsG2YILhthgRqO+ZVgRff4ZFKKTl0v7kqaVBLCtRvpREhfBP33pcWrdA3PRYgWuFL1RxiUFvjMUHTdBZlJcoA=="
|
||||
},
|
||||
"Empostor.Hazel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.2",
|
||||
"contentHash": "cblWCgvLcQ2xK6ztV04RVrghVEnJpCWe4N6G3LCNaJhuUq367a1/bbtQxUWAflqvgSvGwY3g3SClhh9eWTCBDA==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.1",
|
||||
"Empostor.Hazel.Abstractions": "1.0.2",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.6",
|
||||
"Microsoft.Extensions.ObjectPool": "8.0.1",
|
||||
"Serilog": "4.3.1"
|
||||
}
|
||||
},
|
||||
"Empostor.Hazel.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.2",
|
||||
"contentHash": "uJqGMASOGj+ELffmIMqubyvJ/FHjOXA1RtQdS38s1LRhP+Eu4zJMTTndr12+Q3vS0kHggFFyuExFmNxWyzWmig=="
|
||||
},
|
||||
"Microsoft.AspNetCore.Http.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.2.0",
|
||||
"contentHash": "Nxs7Z1q3f1STfLYKJSVXCs1iBl+Ya6E8o4Oy1bCxJ/rNI44E/0f6tbsrVqAWfB7jlnJfyaAtIalBVxPKUPQb4Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.AspNetCore.Http.Features": "2.2.0",
|
||||
"System.Text.Encodings.Web": "4.5.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.AspNetCore.Http.Features": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.2.0",
|
||||
"contentHash": "ziFz5zH8f33En4dX81LW84I6XrYXKf9jg6aM39cM+LffN9KJahViKZ61dGMSO2gd3e+qe5yBRwsesvyqlZaSMg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "2.2.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.6",
|
||||
"contentHash": "0Zn6nR/6g+90MxskZyOOMPQvnPnrrGu6bytPwkV+azDcTtCSuQ1+GJUrg8Klmnrjk1i6zMpw2lXijl+tw7Q3kA=="
|
||||
},
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.Options": "8.0.0",
|
||||
"System.Diagnostics.DiagnosticSource": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Hosting.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.ObjectPool": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.1",
|
||||
"contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA=="
|
||||
},
|
||||
"Microsoft.Extensions.Options": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
|
||||
},
|
||||
"Serilog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.1",
|
||||
"contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ=="
|
||||
},
|
||||
"System.Diagnostics.DiagnosticSource": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ=="
|
||||
},
|
||||
"System.Text.Encodings.Web": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.5.0",
|
||||
"contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g=="
|
||||
},
|
||||
"empostor.api": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Empostor.Hazel": "[1.0.2, )",
|
||||
"Microsoft.AspNetCore.Http.Abstractions": "[2.2.0, )",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "[8.0.0, )",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"command.votemap.description":"Vote for the next map.",
|
||||
"command.votemap.usage":"votemap <map>",
|
||||
"mapvote.disabled":"Map voting is currently disabled by the host.",
|
||||
"mapvote.unknown_map":"Unknown map: {0}. Available: Skeld, Mira, Polus, Airship, Fungle",
|
||||
"mapvote.voted":"{0} voted for {1}.",
|
||||
"mapvote.already_voted":"You already voted for {0}. Use #votemap <map> to change your vote.",
|
||||
"mapvote.no_votes":"No map votes yet. Use #votemap <map> to vote!",
|
||||
"mapvote.results_header":"=== Map Vote Results ===",
|
||||
"mapvote.results_entry":" {0}: {1} vote(s)",
|
||||
"mapvote.results_winner":"Winner: {0} ({1} votes)",
|
||||
"mapvote.results_random":"No votes cast, using random map: {0}",
|
||||
"mapvote.usage":"Usage: #votemap <map>\nMaps: Skeld, Mira, Polus, Airship, Fungle\nExample: #votemap polus",
|
||||
"mapvote.host_commands":"Host commands: #votemap public | close | enable | disable | results",
|
||||
"mapvote.host_only":"Only the host can use this command.",
|
||||
"mapvote.host_enabled":"Map voting enabled.",
|
||||
"mapvote.host_disabled":"Map voting disabled.",
|
||||
"mapvote.session_started":"Map vote session started! Players, use #votemap <map> to vote.\nMaps: Skeld, Mira, Polus, Airship, Fungle",
|
||||
"mapvote.map_set":"Map set to {0} by vote."
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"command.votemap.description":"投票选择下一张地图。",
|
||||
"command.votemap.usage":"votemap <地图名>",
|
||||
"mapvote.disabled":"投票选图功能已被房主禁用。",
|
||||
"mapvote.unknown_map":"未知地图:{0}。可选:Skeld, Mira, Polus, Airship, Fungle",
|
||||
"mapvote.voted":"{0} 投票选择了 {1}。",
|
||||
"mapvote.already_voted":"你已经投票了 {0}。使用 #votemap <地图> 来修改投票。",
|
||||
"mapvote.no_votes":"还没有人投票。使用 #votemap <地图> 来投票!",
|
||||
"mapvote.results_header":"=== 地图投票结果 ===",
|
||||
"mapvote.results_entry":" {0}:{1} 票",
|
||||
"mapvote.results_winner":"胜出:{0}({1} 票)",
|
||||
"mapvote.results_random":"无人投票,随机选择地图:{0}",
|
||||
"mapvote.usage":"用法:#votemap <地图>\n可选地图:Skeld, Mira, Polus, Airship, Fungle\n示例:#votemap polus",
|
||||
"mapvote.host_commands":"房主指令:#votemap public | close | enable | disable | results",
|
||||
"mapvote.host_only":"只有房主可以使用此指令。",
|
||||
"mapvote.host_enabled":"投票选图已启用。",
|
||||
"mapvote.host_disabled":"投票选图已禁用。",
|
||||
"mapvote.session_started":"地图投票已开始!请使用 #votemap <地图> 投票。\n可选地图:Skeld, Mira, Polus, Airship, Fungle",
|
||||
"mapvote.map_set":"投票结果,地图已设为 {0}。"
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public sealed class MapVoteCommand : ICommand
|
||||
}
|
||||
|
||||
public string Name => "votemap";
|
||||
public string LocalizationOwner => MapVotePlugin.Owner;
|
||||
public string[] Aliases => new[] { "vm" };
|
||||
public string Description => "Vote for the next map.";
|
||||
public string Usage => "votemap <map>";
|
||||
@@ -122,7 +123,7 @@ public sealed class MapVoteCommand : ICommand
|
||||
if (!_service.TryParseMap(ctx.RawArgs!, out var map))
|
||||
{
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "mapvote.unknown_map", "Unknown map.").Replace("{0}", ctx.RawArgs ?? "?"), ctx.PlayerControl);
|
||||
T(ctx, "mapvote.unknown_map", "Unknown map.", ctx.RawArgs ?? "?"), ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -130,9 +131,7 @@ public sealed class MapVoteCommand : ICommand
|
||||
_service.CastVote(gameCode, playerName, map);
|
||||
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "mapvote.voted", "{0} voted for {1}.")
|
||||
.Replace("{0}", playerName)
|
||||
.Replace("{1}", MapVoteService.MapDisplayName(map)),
|
||||
T(ctx, "mapvote.voted", "{0} voted for {1}.", playerName, MapVoteService.MapDisplayName(map)),
|
||||
ctx.PlayerControl);
|
||||
|
||||
return true;
|
||||
@@ -154,9 +153,7 @@ public sealed class MapVoteCommand : ICommand
|
||||
sb.AppendLine(T(ctx, "mapvote.results_header", "=== Map Vote Results ==="));
|
||||
foreach (var (map, count) in tally)
|
||||
{
|
||||
sb.AppendLine(T(ctx, "mapvote.results_entry", " {0}: {1} vote(s)")
|
||||
.Replace("{0}", MapVoteService.MapDisplayName(map))
|
||||
.Replace("{1}", count.ToString()));
|
||||
sb.AppendLine(T(ctx, "mapvote.results_entry", " {0}: {1} vote(s)", MapVoteService.MapDisplayName(map), count));
|
||||
}
|
||||
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(sb.ToString(), ctx.PlayerControl);
|
||||
@@ -165,25 +162,16 @@ public sealed class MapVoteCommand : ICommand
|
||||
private static async ValueTask ShowWinnerToAllAsync(CommandContext ctx, Api.Innersloth.MapTypes winner,
|
||||
IReadOnlyDictionary<Api.Innersloth.MapTypes, int> tally, int voterCount)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
if (tally.Count == 0)
|
||||
{
|
||||
sb.AppendLine(ctx.Lang.Get("mapvote.results_random", ctx.SenderLanguage)
|
||||
.Replace("{0}", MapVoteService.MapDisplayName(winner)));
|
||||
}
|
||||
else
|
||||
{
|
||||
var maxVotes = tally.Values.Max();
|
||||
sb.AppendLine(ctx.Lang.Get("mapvote.results_winner", ctx.SenderLanguage)
|
||||
.Replace("{0}", MapVoteService.MapDisplayName(winner))
|
||||
.Replace("{1}", maxVotes.ToString()));
|
||||
}
|
||||
|
||||
foreach (var player in ctx.Game.Players)
|
||||
{
|
||||
var ctrl = player.Character;
|
||||
if (ctrl != null)
|
||||
await ctrl.SendChatToPlayerAsync(sb.ToString(), ctrl);
|
||||
{
|
||||
var key = tally.Count == 0 ? "mapvote.results_random" : "mapvote.results_winner";
|
||||
var text = ctx.Localization.Get(new Empostor.Api.Localization.LocalizedMessageKey(MapVotePlugin.Owner, key),
|
||||
player.Client.Language, MapVoteService.MapDisplayName(winner), tally.Count == 0 ? 0 : tally.Values.Max());
|
||||
await ctrl.SendChatToPlayerAsync(text, ctrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,9 +184,8 @@ public sealed class MapVoteCommand : ICommand
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(sb.ToString(), ctx.PlayerControl);
|
||||
}
|
||||
|
||||
private static string T(CommandContext ctx, string key, string defaultText)
|
||||
private static string T(CommandContext ctx, string key, string defaultText, params object?[] arguments)
|
||||
{
|
||||
string result = ctx.Lang.Get(key, ctx.SenderLanguage);
|
||||
return result == key ? defaultText : result;
|
||||
return ctx.GetPluginString(MapVotePlugin.Owner, key, arguments);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Localization;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugins.MapVote;
|
||||
|
||||
[EmpostorPlugin("cn.hayashiume.mapvote")]
|
||||
public sealed class MapVotePlugin : PluginBase, IPluginLanguageProvider
|
||||
[EmpostorPlugin(Owner)]
|
||||
public sealed class MapVotePlugin : PluginBase
|
||||
{
|
||||
public const string Owner = "cn.hayashiume.mapvote";
|
||||
private readonly ILocalizationRegistry _registry;
|
||||
private readonly ILogger<MapVotePlugin> _logger;
|
||||
|
||||
public MapVotePlugin(ILogger<MapVotePlugin> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private IDisposable? _registration;
|
||||
public MapVotePlugin(ILogger<MapVotePlugin> logger, ILocalizationRegistry registry) { _logger = logger; _registry = registry; }
|
||||
public override ValueTask EnableAsync()
|
||||
{
|
||||
_logger.LogInformation("[MapVote] Plugin enabled.");
|
||||
if (!_registry.TryRegister(new LocalizationCatalog(Owner, typeof(MapVotePlugin).Assembly, "Empostor.Plugins.MapVote.Localization"), out _registration))
|
||||
_logger.LogWarning("Could not register localization resources for {Owner}", Owner);
|
||||
return default;
|
||||
}
|
||||
|
||||
public override ValueTask DisableAsync() => default;
|
||||
|
||||
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> GetTranslations()
|
||||
{
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
["en"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.votemap.description"] = "Vote for the next map.",
|
||||
["command.votemap.usage"] = "votemap <map>",
|
||||
["mapvote.disabled"] = "Map voting is currently disabled by the host.",
|
||||
["mapvote.unknown_map"] = "Unknown map: {0}. Available: Skeld, Mira, Polus, Airship, Fungle",
|
||||
["mapvote.voted"] = "{0} voted for {1}.",
|
||||
["mapvote.already_voted"] = "You already voted for {0}. Use #votemap <map> to change your vote.",
|
||||
["mapvote.no_votes"] = "No map votes yet. Use #votemap <map> to vote!",
|
||||
["mapvote.results_header"] = "=== Map Vote Results ===",
|
||||
["mapvote.results_entry"] = " {0}: {1} vote(s)",
|
||||
["mapvote.results_winner"] = "Winner: {0} ({1} votes)",
|
||||
["mapvote.results_random"] = "No votes cast, using random map: {0}",
|
||||
["mapvote.usage"] = "Usage: #votemap <map>\nMaps: Skeld, Mira, Polus, Airship, Fungle\nExample: #votemap polus",
|
||||
["mapvote.host_commands"] = "Host commands: #votemap public | close | enable | disable | results",
|
||||
["mapvote.host_only"] = "Only the host can use this command.",
|
||||
["mapvote.host_enabled"] = "Map voting enabled.",
|
||||
["mapvote.host_disabled"] = "Map voting disabled.",
|
||||
["mapvote.session_started"] = "Map vote session started! Players, use #votemap <map> to vote.\nMaps: Skeld, Mira, Polus, Airship, Fungle",
|
||||
["mapvote.map_set"] = "Map set to {0} by vote.",
|
||||
},
|
||||
["zh_CN"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.votemap.description"] = "投票选择下一张地图。",
|
||||
["command.votemap.usage"] = "votemap <地图名>",
|
||||
["mapvote.disabled"] = "投票选图功能已被房主禁用。",
|
||||
["mapvote.unknown_map"] = "未知地图:{0}。可选:Skeld, Mira, Polus, Airship, Fungle",
|
||||
["mapvote.voted"] = "{0} 投票选择了 {1}。",
|
||||
["mapvote.already_voted"] = "你已经投票了 {0}。使用 #votemap <地图> 来修改投票。",
|
||||
["mapvote.no_votes"] = "还没有人投票。使用 #votemap <地图> 来投票!",
|
||||
["mapvote.results_header"] = "=== 地图投票结果 ===",
|
||||
["mapvote.results_entry"] = " {0}:{1} 票",
|
||||
["mapvote.results_winner"] = "胜出:{0}({1} 票)",
|
||||
["mapvote.results_random"] = "无人投票,随机选择地图:{0}",
|
||||
["mapvote.usage"] = "用法:#votemap <地图>\n可选地图:Skeld, Mira, Polus, Airship, Fungle\n示例:#votemap polus",
|
||||
["mapvote.host_commands"] = "房主指令:#votemap public | close | enable | disable | results",
|
||||
["mapvote.host_only"] = "只有房主可以使用此指令。",
|
||||
["mapvote.host_enabled"] = "投票选图已启用。",
|
||||
["mapvote.host_disabled"] = "投票选图已禁用。",
|
||||
["mapvote.session_started"] = "地图投票已开始!请使用 #votemap <地图> 投票。\n可选地图:Skeld, Mira, Polus, Airship, Fungle",
|
||||
["mapvote.map_set"] = "投票结果,地图已设为 {0}。",
|
||||
},
|
||||
["zh_TW"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.votemap.description"] = "投票選擇下一張地圖。",
|
||||
["command.votemap.usage"] = "votemap <地圖名>",
|
||||
["mapvote.disabled"] = "投票選圖功能已被房主停用。",
|
||||
["mapvote.unknown_map"] = "未知地圖:{0}。可選:Skeld, Mira, Polus, Airship, Fungle",
|
||||
["mapvote.voted"] = "{0} 投票選擇了 {1}。",
|
||||
["mapvote.already_voted"] = "你已經投票了 {0}。使用 #votemap <地圖> 來修改投票。",
|
||||
["mapvote.no_votes"] = "還沒有人投票。使用 #votemap <地圖> 來投票!",
|
||||
["mapvote.results_header"] = "=== 地圖投票結果 ===",
|
||||
["mapvote.results_entry"] = " {0}:{1} 票",
|
||||
["mapvote.results_winner"] = "勝出:{0}({1} 票)",
|
||||
["mapvote.results_random"] = "無人投票,隨機選擇地圖:{0}",
|
||||
["mapvote.usage"] = "用法:#votemap <地圖>\n可選地圖:Skeld, Mira, Polus, Airship, Fungle\n範例:#votemap polus",
|
||||
["mapvote.host_commands"] = "房主指令:#votemap public | close | enable | disable | results",
|
||||
["mapvote.host_only"] = "只有房主可以使用此指令。",
|
||||
["mapvote.host_enabled"] = "投票選圖已啟用。",
|
||||
["mapvote.host_disabled"] = "投票選圖已停用。",
|
||||
["mapvote.session_started"] = "地圖投票已開始!請使用 #votemap <地圖> 投票。\n可選地圖:Skeld, Mira, Polus, Airship, Fungle",
|
||||
["mapvote.map_set"] = "投票結果,地圖已設為 {0}。",
|
||||
},
|
||||
};
|
||||
}
|
||||
public override ValueTask DisableAsync() { _registration?.Dispose(); return default; }
|
||||
}
|
||||
@@ -7,4 +7,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"command.msg.description":"Leave a message for a player by friend code.",
|
||||
"command.msg.usage":"msg <friendcode> <message>",
|
||||
"message.usage":"Usage: #msg <friendcode> <message>\nExample: #msg ABCDEFG Hey, let's play again!",
|
||||
"message.sent":"Message sent to {0}.",
|
||||
"message.too_long":"Message is too long. Max {0} characters.",
|
||||
"message.empty":"Message cannot be empty.",
|
||||
"message.invalid_fc":"Invalid friend code format.",
|
||||
"message.self":"You cannot send a message to yourself.",
|
||||
"message.full":"{0} already has too many pending messages. Try again later.",
|
||||
"message.delivery_header":"--- You have {0} pending message(s) ---",
|
||||
"message.delivery_entry":"[{0}] <{1}> {2}: {3}"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"command.msg.description":"通过好友代码给玩家留言。",
|
||||
"command.msg.usage":"msg <好友代码> <留言内容>",
|
||||
"message.usage":"用法:#msg <好友代码> <留言内容>\n示例:#msg ABCDEFG 下次一起玩!",
|
||||
"message.sent":"留言已发送给 {0}。",
|
||||
"message.too_long":"留言内容过长,最多 {0} 个字符。",
|
||||
"message.empty":"留言内容不能为空。",
|
||||
"message.invalid_fc":"好友代码格式无效。",
|
||||
"message.self":"不能给自己留言。",
|
||||
"message.full":"{0} 的待收留言已满,请稍后再试。",
|
||||
"message.delivery_header":"--- 你有 {0} 条待收留言 ---",
|
||||
"message.delivery_entry":"[{0}] <{1}> {2}: {3}"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public sealed class MessageCommand : ICommand
|
||||
}
|
||||
|
||||
public string Name => "msg";
|
||||
public string LocalizationOwner => MessagePlugin.Owner;
|
||||
public string[] Aliases => new[] { "message", "leave" };
|
||||
public string Description => "Leave a message for a player by friend code.";
|
||||
public string Usage => "msg <friendcode> <message>";
|
||||
@@ -59,8 +60,7 @@ public sealed class MessageCommand : ICommand
|
||||
if (content.Length > _config.MessageMaxLength)
|
||||
{
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "message.too_long", "Message is too long.")
|
||||
.Replace("{0}", _config.MessageMaxLength.ToString()),
|
||||
T(ctx, "message.too_long", "Message is too long.", _config.MessageMaxLength),
|
||||
ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
@@ -68,8 +68,7 @@ public sealed class MessageCommand : ICommand
|
||||
if (_store.Count(targetFc) >= _config.MaxMessagesPerTarget)
|
||||
{
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "message.full", "Already has too many pending messages.")
|
||||
.Replace("{0}", targetFc),
|
||||
T(ctx, "message.full", "Already has too many pending messages.", targetFc),
|
||||
ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
@@ -90,15 +89,14 @@ public sealed class MessageCommand : ICommand
|
||||
_store.Add(msg);
|
||||
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "message.sent", "Message sent to {0}.").Replace("{0}", targetFc),
|
||||
T(ctx, "message.sent", "Message sent to {0}.", targetFc),
|
||||
ctx.PlayerControl);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string T(CommandContext ctx, string key, string defaultText)
|
||||
private static string T(CommandContext ctx, string key, string defaultText, params object?[] arguments)
|
||||
{
|
||||
string result = ctx.Lang.Get(key, ctx.SenderLanguage);
|
||||
return result == key ? defaultText : result;
|
||||
return ctx.GetPluginString(MessagePlugin.Owner, key, arguments);
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,19 @@ using System.Threading.Tasks;
|
||||
using Empostor.Api.Events;
|
||||
using Empostor.Api.Events.Game.Player;
|
||||
using Empostor.Api.Events.Player;
|
||||
using Empostor.Api.Localization;
|
||||
|
||||
namespace Empostor.Plugins.Message;
|
||||
|
||||
public sealed class MessageEventListener : IEventListener
|
||||
{
|
||||
private readonly MessageStore _store;
|
||||
private readonly ILocalizationService _localization;
|
||||
|
||||
public MessageEventListener(MessageStore store)
|
||||
public MessageEventListener(MessageStore store, ILocalizationService localization)
|
||||
{
|
||||
_store = store;
|
||||
_localization = localization;
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
@@ -26,12 +29,13 @@ public sealed class MessageEventListener : IEventListener
|
||||
if (messages.Count == 0) return;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(string.Format("--- You have {0} pending message(s) ---", messages.Count));
|
||||
sb.AppendLine(_localization.Get(new LocalizedMessageKey(MessagePlugin.Owner, "message.delivery_header"), e.ClientPlayer.Client.Language, messages.Count));
|
||||
|
||||
foreach (var msg in messages.OrderBy(m => m.Timestamp))
|
||||
{
|
||||
var time = msg.Timestamp.ToLocalTime().ToString("MM-dd HH:mm");
|
||||
sb.AppendLine($"[{time}] <{msg.SenderName}> {msg.Content}");
|
||||
sb.AppendLine(_localization.Get(new LocalizedMessageKey(MessagePlugin.Owner, "message.delivery_entry"), e.ClientPlayer.Client.Language,
|
||||
time, msg.SenderName, "", msg.Content));
|
||||
}
|
||||
|
||||
await e.PlayerControl.SendChatToPlayerAsync(sb.ToString(), e.PlayerControl);
|
||||
|
||||
@@ -1,74 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Localization;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugins.Message;
|
||||
|
||||
[EmpostorPlugin("cn.hayashiume.message")]
|
||||
public sealed class MessagePlugin : PluginBase, IPluginLanguageProvider
|
||||
[EmpostorPlugin(Owner)]
|
||||
public sealed class MessagePlugin : PluginBase
|
||||
{
|
||||
public const string Owner = "cn.hayashiume.message";
|
||||
private readonly ILocalizationRegistry _registry;
|
||||
private readonly ILogger<MessagePlugin> _logger;
|
||||
|
||||
public MessagePlugin(ILogger<MessagePlugin> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private IDisposable? _registration;
|
||||
public MessagePlugin(ILogger<MessagePlugin> logger, ILocalizationRegistry registry) { _logger = logger; _registry = registry; }
|
||||
public override ValueTask EnableAsync()
|
||||
{
|
||||
_logger.LogInformation("[Message] Plugin enabled.");
|
||||
if (!_registry.TryRegister(new LocalizationCatalog(Owner, typeof(MessagePlugin).Assembly, "Empostor.Plugins.Message.Localization"), out _registration))
|
||||
_logger.LogWarning("Could not register localization resources for {Owner}", Owner);
|
||||
return default;
|
||||
}
|
||||
|
||||
public override ValueTask DisableAsync() => default;
|
||||
|
||||
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> GetTranslations()
|
||||
{
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
["en"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.msg.description"] = "Leave a message for a player by friend code.",
|
||||
["command.msg.usage"] = "msg <friendcode> <message>",
|
||||
["message.usage"] = "Usage: #msg <friendcode> <message>\nExample: #msg ABCDEFG Hey, let's play again!",
|
||||
["message.sent"] = "Message sent to {0}.",
|
||||
["message.too_long"] = "Message is too long. Max {0} characters.",
|
||||
["message.empty"] = "Message cannot be empty.",
|
||||
["message.invalid_fc"] = "Invalid friend code format.",
|
||||
["message.self"] = "You cannot send a message to yourself.",
|
||||
["message.full"] = "{0} already has too many pending messages. Try again later.",
|
||||
["message.delivery_header"] = "--- You have {0} pending message(s) ---",
|
||||
["message.delivery_entry"] = "[{0}] <{1}> {2}: {3}",
|
||||
},
|
||||
["zh_CN"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.msg.description"] = "通过好友代码给玩家留言。",
|
||||
["command.msg.usage"] = "msg <好友代码> <留言内容>",
|
||||
["message.usage"] = "用法:#msg <好友代码> <留言内容>\n示例:#msg ABCDEFG 下次一起玩!",
|
||||
["message.sent"] = "留言已发送给 {0}。",
|
||||
["message.too_long"] = "留言内容过长,最多 {0} 个字符。",
|
||||
["message.empty"] = "留言内容不能为空。",
|
||||
["message.invalid_fc"] = "好友代码格式无效。",
|
||||
["message.self"] = "不能给自己留言。",
|
||||
["message.full"] = "{0} 的待收留言已满,请稍后再试。",
|
||||
["message.delivery_header"] = "--- 你有 {0} 条待收留言 ---",
|
||||
["message.delivery_entry"] = "[{0}] <{1}> {2}: {3}",
|
||||
},
|
||||
["zh_TW"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.msg.description"] = "透過好友代碼給玩家留言。",
|
||||
["command.msg.usage"] = "msg <好友代碼> <留言內容>",
|
||||
["message.usage"] = "用法:#msg <好友代碼> <留言內容>\n範例:#msg ABCDEFG 下次一起玩!",
|
||||
["message.sent"] = "留言已發送給 {0}。",
|
||||
["message.too_long"] = "留言內容過長,最多 {0} 個字元。",
|
||||
["message.empty"] = "留言內容不能為空。",
|
||||
["message.invalid_fc"] = "好友代碼格式無效。",
|
||||
["message.self"] = "不能給自己留言。",
|
||||
["message.full"] = "{0} 的待收留言已滿,請稍後再試。",
|
||||
["message.delivery_header"] = "--- 你有 {0} 條待收留言 ---",
|
||||
["message.delivery_entry"] = "[{0}] <{1}> {2}: {3}",
|
||||
},
|
||||
};
|
||||
}
|
||||
public override ValueTask DisableAsync() { _registration?.Dispose(); return default; }
|
||||
}
|
||||
@@ -9,6 +9,6 @@
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="..\..\marketplace" SkipUnchangedFiles="true" />
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"command.channel.description":"Send a message to your player channel.",
|
||||
"command.channel.usage":"channel <message>",
|
||||
"playerchannel.unknown_friendcode":"[Refuse Channel] Unknown Friendcode",
|
||||
"playerchannel.not_in_channel":"[Refuse Channel] Not in any channel",
|
||||
"playerchannel.message_format":"[{0}] {1}"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"command.channel.description":"向你的玩家频道发送消息。",
|
||||
"command.channel.usage":"channel <消息>",
|
||||
"playerchannel.unknown_friendcode":"[Refuse Channel] 未知的好友代码",
|
||||
"playerchannel.not_in_channel":"[Refuse Channel] 不在任何频道中",
|
||||
"playerchannel.message_format":"[{0}] {1}"
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public sealed class PlayerChannelCommand : ICommand
|
||||
}
|
||||
|
||||
public string Name => "channel";
|
||||
public string LocalizationOwner => PlayerChannelPlugin.Owner;
|
||||
|
||||
public string Description => "Send a message to your player channel.";
|
||||
|
||||
@@ -42,9 +43,7 @@ public sealed class PlayerChannelCommand : ICommand
|
||||
return true;
|
||||
}
|
||||
|
||||
var prefixed = T(ctx, "playerchannel.message_format", "[{0}] {1}")
|
||||
.Replace("{0}", channel.Name)
|
||||
.Replace("{1}", text);
|
||||
var prefixed = T(ctx, "playerchannel.message_format", "[{0}] {1}", channel.Name, text);
|
||||
|
||||
foreach (var player in ctx.Game.Players)
|
||||
{
|
||||
@@ -78,9 +77,8 @@ public sealed class PlayerChannelCommand : ICommand
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string T(CommandContext ctx, string key, string defaultText)
|
||||
private static string T(CommandContext ctx, string key, string defaultText, params object?[] arguments)
|
||||
{
|
||||
string result = ctx.Lang.Get(key, ctx.SenderLanguage);
|
||||
return result == key ? defaultText : result;
|
||||
return ctx.GetPluginString(PlayerChannelPlugin.Owner, key, arguments);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Localization;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugins.PlayerChannel;
|
||||
|
||||
[EmpostorPlugin("cn.hayashiume.playerchannel", "Player Channel", "HayashiUme", "1.0.0")]
|
||||
public sealed class PlayerChannelPlugin : PluginBase, IPluginLanguageProvider
|
||||
[EmpostorPlugin(Owner, "Player Channel", "HayashiUme", "1.0.0")]
|
||||
public sealed class PlayerChannelPlugin : PluginBase
|
||||
{
|
||||
public const string Owner = "cn.hayashiume.playerchannel";
|
||||
private readonly ILocalizationRegistry _registry;
|
||||
private readonly ILogger<PlayerChannelPlugin> _logger;
|
||||
|
||||
public PlayerChannelPlugin(ILogger<PlayerChannelPlugin> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private IDisposable? _registration;
|
||||
public PlayerChannelPlugin(ILogger<PlayerChannelPlugin> logger, ILocalizationRegistry registry) { _logger = logger; _registry = registry; }
|
||||
public override ValueTask EnableAsync()
|
||||
{
|
||||
_logger.LogInformation("[PlayerChannel] Enabled.");
|
||||
if (!_registry.TryRegister(new LocalizationCatalog(Owner, typeof(PlayerChannelPlugin).Assembly, "Empostor.Plugins.PlayerChannel.Localization"), out _registration))
|
||||
_logger.LogWarning("Could not register localization resources for {Owner}", Owner);
|
||||
return default;
|
||||
}
|
||||
|
||||
public override ValueTask DisableAsync() => default;
|
||||
|
||||
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> GetTranslations()
|
||||
{
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
["en"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.channel.description"] = "Send a message to your player channel.",
|
||||
["command.channel.usage"] = "channel <message>",
|
||||
["playerchannel.unknown_friendcode"] = "[Refuse Channel] Unknown Friendcode",
|
||||
["playerchannel.not_in_channel"] = "[Refuse Channel] Not in any channel",
|
||||
["playerchannel.message_format"] = "[{0}] {1}",
|
||||
},
|
||||
["zh_CN"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.channel.description"] = "向你的玩家频道发送消息。",
|
||||
["command.channel.usage"] = "channel <消息>",
|
||||
["playerchannel.unknown_friendcode"] = "[Refuse Channel] 未知的好友代码",
|
||||
["playerchannel.not_in_channel"] = "[Refuse Channel] 不在任何频道中",
|
||||
["playerchannel.message_format"] = "[{0}] {1}",
|
||||
},
|
||||
["zh_TW"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.channel.description"] = "向你的玩家頻道發送訊息。",
|
||||
["command.channel.usage"] = "channel <訊息>",
|
||||
["playerchannel.unknown_friendcode"] = "[Refuse Channel] 未知的好友代碼",
|
||||
["playerchannel.not_in_channel"] = "[Refuse Channel] 不在任何頻道中",
|
||||
["playerchannel.message_format"] = "[{0}] {1}",
|
||||
},
|
||||
};
|
||||
}
|
||||
public override ValueTask DisableAsync() { _registration?.Dispose(); return default; }
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="..\..\marketplace" SkipUnchangedFiles="true" />
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -7,4 +7,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -47,7 +47,7 @@ public sealed class StatCommand : ICommand
|
||||
|
||||
async Task Send(string key, params object[] args)
|
||||
=> await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
ctx.GetString(key).Format(args), ctx.PlayerControl);
|
||||
ctx.GetString(key, args), ctx.PlayerControl);
|
||||
|
||||
await Send("command.stat.header");
|
||||
await Send("command.stat.games", stats.GamesPlayed);
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToMarketplace" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="..\..\marketplace" SkipUnchangedFiles="true" />
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"command.verify.description":"Verify your QQ number for group access.",
|
||||
"command.verify.usage":"verify <QQ number>",
|
||||
"qqverify.chinese_only":"This command is only available for Chinese language players.\n此功能仅支持简体/繁体中文玩家使用。",
|
||||
"qqverify.no_friendcode":"Unable to get your friend code. Please make sure you are logged in.",
|
||||
"qqverify.usage_message":"Usage: #verify <your QQ number>\nExample: #verify 123456789",
|
||||
"qqverify.recorded":"Verification request recorded! Send /验证 {0} to the QQ bot.\nNote: The code is valid for 10 minutes."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"command.verify.description":"验证你的QQ号以获取群组访问权限。",
|
||||
"command.verify.usage":"verify <QQ号>",
|
||||
"qqverify.chinese_only":"此功能仅支持简体/繁体中文玩家使用。\nThis command is only available for Chinese language players.",
|
||||
"qqverify.no_friendcode":"无法获取你的好友代码,请确保已登录账号。",
|
||||
"qqverify.usage_message":"用法:#verify <你的QQ号>\n示例:#verify 123456789",
|
||||
"qqverify.recorded":"已记录验证请求!请私聊QQ机器人发送:/验证 {0}\n注意:验证码10分钟内有效。"
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public sealed class QqVerifyCommand : ICommand
|
||||
}
|
||||
|
||||
public string Name => "verify";
|
||||
public string LocalizationOwner => QqVerifyPlugin.Owner;
|
||||
|
||||
public string[] Aliases => new[] { "ver" };
|
||||
|
||||
@@ -56,16 +57,14 @@ public sealed class QqVerifyCommand : ICommand
|
||||
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "qqverify.recorded",
|
||||
"Verification request recorded! Send /验证 {0} to the QQ bot.\nNote: The code is valid for 10 minutes.")
|
||||
.Replace("{0}", fc),
|
||||
"Verification request recorded! Send /验证 {0} to the QQ bot.\nNote: The code is valid for 10 minutes.", fc),
|
||||
ctx.PlayerControl);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string T(CommandContext ctx, string key, string defaultText)
|
||||
private static string T(CommandContext ctx, string key, string defaultText, params object?[] arguments)
|
||||
{
|
||||
string result = ctx.Lang.Get(key, ctx.SenderLanguage);
|
||||
return result == key ? defaultText : result;
|
||||
return ctx.GetPluginString(QqVerifyPlugin.Owner, key, arguments);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Localization;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugins.QqVerify;
|
||||
|
||||
[EmpostorPlugin("cn.hayashiume.qqverify", "QQ Verify", "ELinmei", "1.0.0")]
|
||||
public sealed class QqVerifyPlugin : PluginBase, IPluginLanguageProvider
|
||||
[EmpostorPlugin(Owner, "QQ Verify", "ELinmei", "1.0.0")]
|
||||
public sealed class QqVerifyPlugin : PluginBase
|
||||
{
|
||||
public const string Owner = "cn.hayashiume.qqverify";
|
||||
private readonly ILocalizationRegistry _registry;
|
||||
private readonly ILogger<QqVerifyPlugin> _logger;
|
||||
|
||||
public QqVerifyPlugin(ILogger<QqVerifyPlugin> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private IDisposable? _registration;
|
||||
public QqVerifyPlugin(ILogger<QqVerifyPlugin> logger, ILocalizationRegistry registry) { _logger = logger; _registry = registry; }
|
||||
public override ValueTask EnableAsync()
|
||||
{
|
||||
_logger.LogInformation("[QQVerify] Plugin enabled. #verify command registered.");
|
||||
if (!_registry.TryRegister(new LocalizationCatalog(Owner, typeof(QqVerifyPlugin).Assembly, "Empostor.Plugins.QqVerify.Localization"), out _registration))
|
||||
_logger.LogWarning("Could not register localization resources for {Owner}", Owner);
|
||||
return default;
|
||||
}
|
||||
|
||||
public override ValueTask DisableAsync() => default;
|
||||
|
||||
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> GetTranslations()
|
||||
{
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>
|
||||
{
|
||||
["en"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.verify.description"] = "Verify your QQ number for group access.",
|
||||
["command.verify.usage"] = "verify <QQ number>",
|
||||
["qqverify.chinese_only"] = "This command is only available for Chinese language players.\n此功能仅支持简体/繁体中文玩家使用。",
|
||||
["qqverify.no_friendcode"] = "Unable to get your friend code. Please make sure you are logged in.",
|
||||
["qqverify.usage_message"] = "Usage: #verify <your QQ number>\nExample: #verify 123456789",
|
||||
["qqverify.recorded"] = "Verification request recorded! Send /验证 {0} to the QQ bot.\nNote: The code is valid for 10 minutes.",
|
||||
},
|
||||
["zh_CN"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.verify.description"] = "验证你的QQ号以获取群组访问权限。",
|
||||
["command.verify.usage"] = "verify <QQ号>",
|
||||
["qqverify.chinese_only"] = "此功能仅支持简体/繁体中文玩家使用。\nThis command is only available for Chinese language players.",
|
||||
["qqverify.no_friendcode"] = "无法获取你的好友代码,请确保已登录账号。",
|
||||
["qqverify.usage_message"] = "用法:#verify <你的QQ号>\n示例:#verify 123456789",
|
||||
["qqverify.recorded"] = "已记录验证请求!请私聊QQ机器人发送:/验证 {0}\n注意:验证码10分钟内有效。",
|
||||
},
|
||||
["zh_TW"] = new Dictionary<string, string>
|
||||
{
|
||||
["command.verify.description"] = "驗證你的QQ號以獲取群組存取權限。",
|
||||
["command.verify.usage"] = "verify <QQ號>",
|
||||
["qqverify.chinese_only"] = "此功能僅支援簡體/繁體中文玩家使用。\nThis command is only available for Chinese language players.",
|
||||
["qqverify.no_friendcode"] = "無法獲取你的好友代碼,請確保已登入帳號。",
|
||||
["qqverify.usage_message"] = "用法:#verify <你的QQ號>\n範例:#verify 123456789",
|
||||
["qqverify.recorded"] = "已記錄驗證請求!請私聊QQ機器人發送:/驗證 {0}\n注意:驗證碼10分鐘內有效。",
|
||||
},
|
||||
};
|
||||
}
|
||||
public override ValueTask DisableAsync() { _registration?.Dispose(); return default; }
|
||||
}
|
||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user