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 | ||
|
|
e6bf3af1c9 | ||
|
|
2c6c51724a | ||
|
|
8883529f0c | ||
|
|
cb3d78ace8 | ||
|
|
d4f898b92f | ||
|
|
ae8cf7fc27 | ||
|
|
2f50097250 | ||
|
|
91ac52626b | ||
|
|
24c6097cbe | ||
|
|
6a98d63db2 |
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,65 @@
|
||||
[
|
||||
{
|
||||
"id": "a/",
|
||||
"name": "Anthropic",
|
||||
"description": "The theme like anthropic design",
|
||||
"author": "Empostor",
|
||||
"downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/anthropic.json"
|
||||
},
|
||||
{
|
||||
"id": "nord",
|
||||
"name": "Nord",
|
||||
"description": "Cool polar blues with muted arctic greys. Calm, low-contrast and easy on the eyes.",
|
||||
"author": "Empostor",
|
||||
"downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/nord.json"
|
||||
},
|
||||
{
|
||||
"id": "dracula",
|
||||
"name": "Dracula",
|
||||
"description": "Purple-accented dark palette with rounded cards and soft glow highlights.",
|
||||
"author": "Empostor",
|
||||
"downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/dracula.json"
|
||||
},
|
||||
{
|
||||
"id": "solarized",
|
||||
"name": "Solarized",
|
||||
"description": "Precision-tuned warm beige and deep teal pair with a monospace typeface.",
|
||||
"author": "Empostor",
|
||||
"downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/solarized.json"
|
||||
},
|
||||
{
|
||||
"id": "gruvbox",
|
||||
"name": "Gruvbox",
|
||||
"description": "Retro warm earth tones, chunky two-pixel borders and bold labels.",
|
||||
"author": "Empostor",
|
||||
"downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/gruvbox.json"
|
||||
},
|
||||
{
|
||||
"id": "tokyo-night",
|
||||
"name": "Tokyo Night",
|
||||
"description": "Neon-lit midnight blues with gradient surfaces and a soft accent glow.",
|
||||
"author": "Empostor",
|
||||
"downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/tokyo-night.json"
|
||||
},
|
||||
{
|
||||
"id": "catppuccin",
|
||||
"name": "Catppuccin",
|
||||
"description": "Pastel palette with heavily rounded pill buttons and gentle shadows.",
|
||||
"author": "Empostor",
|
||||
"downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/catppuccin.json"
|
||||
},
|
||||
{
|
||||
"id": "rose-pine",
|
||||
"name": "Rosé Pine",
|
||||
"description": "Dusty rose and iris accents on a warm serif-heavy layout.",
|
||||
"author": "Empostor",
|
||||
"downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/rose-pine.json"
|
||||
},
|
||||
{
|
||||
"id": "midnight",
|
||||
"name": "Midnight OLED",
|
||||
"description": "True-black AMOLED panels with a high-contrast accent for late-night shifts.",
|
||||
"author": "Empostor",
|
||||
"downloadUrl": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes/midnight.json"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Id": "anthropic",
|
||||
"Name": "Anthropic",
|
||||
"Extends": null,
|
||||
"Tokens": {
|
||||
"--bg": "#FAF9F5",
|
||||
"--s": "#F1EFE8",
|
||||
"--b": "#E4E0D4",
|
||||
"--t": "#1F1E1B",
|
||||
"--m": "#6E6A61",
|
||||
"--a": "#CC785C",
|
||||
"--g": "#3D7A4E",
|
||||
"--y": "#B7791F",
|
||||
"--r": "#C2412D",
|
||||
"--p": "#7C5CBF",
|
||||
"--o": "#C2603F",
|
||||
"--field": "#FFFFFF",
|
||||
"--hover": "rgba(31,30,27,.05)",
|
||||
"--on-a": "#FFFFFF"
|
||||
},
|
||||
"DarkTokens": {
|
||||
"--bg": "#1A1917",
|
||||
"--s": "#242321",
|
||||
"--b": "#34312D",
|
||||
"--t": "#ECEAE4",
|
||||
"--m": "#A8A49B",
|
||||
"--a": "#D97757",
|
||||
"--g": "#6FB37E",
|
||||
"--y": "#D4A017",
|
||||
"--r": "#E0654F",
|
||||
"--p": "#A78BFA",
|
||||
"--o": "#E08A5F",
|
||||
"--field": "#242321",
|
||||
"--hover": "rgba(255,255,255,.05)",
|
||||
"--on-a": "#FFFFFF"
|
||||
},
|
||||
"CustomCss": "body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;letter-spacing:.1px}\nh1,h2,h3,.nlbl{font-family:ui-serif,Georgia,'Iowan Old Style','Times New Roman',serif;font-weight:600;letter-spacing:.2px}\nh1,h2{color:var(--t)}\nh2{padding-bottom:10px;border-bottom:1px solid var(--b);margin-bottom:16px}\n.nav,.form,.sc,table,.field{border-radius:10px}\n.nav{background:var(--s)}\n.ni{border-radius:8px;transition:background .15s ease,color .15s ease,transform .12s ease}\n.ni:hover{transform:translateX(2px)}\n.ni.active{background:var(--hover);color:var(--a);font-weight:600}\n.ni.active svg{color:var(--a)}\n.form,.sc{border:1px solid var(--b);box-shadow:0 1px 2px rgba(31,30,27,.03);transition:border-color .15s ease,box-shadow .15s ease}\n.form:hover,.sc:hover{border-color:var(--a);box-shadow:0 2px 10px rgba(204,120,92,.08)}\n.pnl.active{animation:antIn .28s ease}\n@keyframes antIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:8px;font-weight:600;letter-spacing:.2px;transition:transform .1s ease,box-shadow .12s ease,opacity .12s ease}\nbutton.bp:hover,button.bd:hover,button.bw:hover,button.bsm:hover{transform:translateY(-1px)}\nbutton.bp{background:var(--a);box-shadow:0 1px 3px rgba(204,120,92,.35)}\nbutton.bp:hover{box-shadow:0 3px 10px rgba(204,120,92,.4)}\ninput,select,textarea{border-radius:7px}\ninput:focus,select:focus,textarea:focus{border-color:var(--a);box-shadow:0 0 0 3px rgba(204,120,92,.18)}\ntable{border:1px solid var(--b);overflow:hidden}\nth{background:var(--s);font-weight:600}\ntd,th{border-bottom:1px solid var(--b)}\n.sc .v{color:var(--a);font-family:ui-serif,Georgia,serif}\n.theme-toggle,.theme-sel{border-radius:8px}\n.msg.ok{border-left:3px solid var(--g)}\n.msg.err{border-left:3px solid var(--r)}"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Id": "catppuccin",
|
||||
"Name": "Catppuccin",
|
||||
"Extends": null,
|
||||
"Tokens": {
|
||||
"--bg": "#EFF1F5",
|
||||
"--s": "#E6E9EF",
|
||||
"--b": "#DCE0E8",
|
||||
"--t": "#4C4F69",
|
||||
"--m": "#6C6F85",
|
||||
"--a": "#1E66F5",
|
||||
"--g": "#40A02B",
|
||||
"--y": "#DF8E1D",
|
||||
"--r": "#D20F39",
|
||||
"--p": "#8839EF",
|
||||
"--o": "#FE640B",
|
||||
"--field": "#FFFFFF",
|
||||
"--hover": "rgba(76,79,105,.05)",
|
||||
"--on-a": "#FFFFFF"
|
||||
},
|
||||
"DarkTokens": {
|
||||
"--bg": "#1E1E2E",
|
||||
"--s": "#313244",
|
||||
"--b": "#45475A",
|
||||
"--t": "#CDD6F4",
|
||||
"--m": "#A6ADC8",
|
||||
"--a": "#89B4FA",
|
||||
"--g": "#A6E3A1",
|
||||
"--y": "#F9E2AF",
|
||||
"--r": "#F38BA8",
|
||||
"--p": "#CBA6F7",
|
||||
"--o": "#FAB387",
|
||||
"--field": "#181825",
|
||||
"--hover": "rgba(205,214,244,.06)",
|
||||
"--on-a": "#1E1E2E"
|
||||
},
|
||||
"CustomCss": "body{letter-spacing:.15px}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:14px}\n.form,.sc{border:1px solid var(--b);box-shadow:0 1px 3px rgba(76,79,105,.05)}\n.form:hover,.sc:hover{border-color:var(--a);box-shadow:0 4px 16px rgba(137,180,250,.14)}\n.badge{border-radius:999px;padding:3px 10px;font-weight:600}\n.chip{border-radius:999px}\n.ni{border-radius:0 12px 12px 0;transition:background .15s ease,color .15s ease}\n.ni:hover{background:var(--hover)}\n.ni.active{background:rgba(137,180,250,.14);font-weight:600}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:999px;font-weight:600;padding-left:18px;padding-right:18px;transition:transform .12s ease,box-shadow .12s ease}\nbutton.bp:hover,button.bd:hover,button.bw:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(76,79,105,.18)}\ninput,select,textarea{border-radius:10px}\ninput:focus,select:focus,textarea:focus{border-color:var(--a);box-shadow:0 0 0 3px rgba(137,180,250,.18)}\nth{background:var(--s);font-weight:600}"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Id": "dracula",
|
||||
"Name": "Dracula",
|
||||
"Extends": null,
|
||||
"Tokens": {
|
||||
"--bg": "#F7F7FB",
|
||||
"--s": "#EFEFF6",
|
||||
"--b": "#DFDFEC",
|
||||
"--t": "#282A36",
|
||||
"--m": "#6272A4",
|
||||
"--a": "#7C4DFF",
|
||||
"--g": "#1E9E52",
|
||||
"--y": "#B08400",
|
||||
"--r": "#D63031",
|
||||
"--p": "#C2185B",
|
||||
"--o": "#C4601A",
|
||||
"--field": "#FFFFFF",
|
||||
"--hover": "rgba(40,42,54,.05)",
|
||||
"--on-a": "#FFFFFF"
|
||||
},
|
||||
"DarkTokens": {
|
||||
"--bg": "#282A36",
|
||||
"--s": "#323444",
|
||||
"--b": "#44475A",
|
||||
"--t": "#F8F8F2",
|
||||
"--m": "#A3ACCC",
|
||||
"--a": "#BD93F9",
|
||||
"--g": "#50FA7B",
|
||||
"--y": "#F1FA8C",
|
||||
"--r": "#FF5555",
|
||||
"--p": "#FF79C6",
|
||||
"--o": "#FFB86C",
|
||||
"--field": "#21222C",
|
||||
"--hover": "rgba(248,248,242,.06)",
|
||||
"--on-a": "#282A36"
|
||||
},
|
||||
"CustomCss": "body{letter-spacing:.15px}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:12px}\n.form,.sc{box-shadow:0 1px 2px rgba(40,42,54,.06)}\n.form:hover{border-color:var(--a);box-shadow:0 3px 14px rgba(189,147,249,.14)}\n.ni{border-radius:10px;transition:background .15s ease,color .15s ease}\n.ni:hover{background:var(--hover)}\n.ni.active{background:rgba(189,147,249,.14);color:var(--a);font-weight:600}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:9px;font-weight:600;transition:transform .1s ease}\nbutton.bp:hover,button.bd:hover,button.bw:hover{transform:translateY(-1px)}\ninput,select,textarea{border-radius:9px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(189,147,249,.22)}\n.pnl.active{animation:fadeIn .25s ease}\n@keyframes fadeIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Id": "gruvbox",
|
||||
"Name": "Gruvbox",
|
||||
"Extends": null,
|
||||
"Tokens": {
|
||||
"--bg": "#FBF1C7",
|
||||
"--s": "#F2E5BC",
|
||||
"--b": "#E0CFA0",
|
||||
"--t": "#3C3836",
|
||||
"--m": "#7C6F64",
|
||||
"--a": "#AF3A03",
|
||||
"--g": "#79740E",
|
||||
"--y": "#B57614",
|
||||
"--r": "#9D0006",
|
||||
"--p": "#8F3F71",
|
||||
"--o": "#D65D0E",
|
||||
"--field": "#FFFBF0",
|
||||
"--hover": "rgba(60,56,54,.06)",
|
||||
"--on-a": "#FBF1C7"
|
||||
},
|
||||
"DarkTokens": {
|
||||
"--bg": "#282828",
|
||||
"--s": "#3C3836",
|
||||
"--b": "#504945",
|
||||
"--t": "#EBDBB2",
|
||||
"--m": "#A89984",
|
||||
"--a": "#FE8019",
|
||||
"--g": "#B8BB26",
|
||||
"--y": "#FABD2F",
|
||||
"--r": "#FB4934",
|
||||
"--p": "#D3869B",
|
||||
"--o": "#D65D0E",
|
||||
"--field": "#1D2021",
|
||||
"--hover": "rgba(235,219,178,.06)",
|
||||
"--on-a": "#282828"
|
||||
},
|
||||
"CustomCss": "body{letter-spacing:.1px}\nh2{padding-bottom:8px;border-bottom:2px solid var(--b)}\n.form,.sc,.ig,table{border-radius:6px;border-width:2px}\n.ni{border-radius:0 8px 8px 0}\n.ni.active{font-weight:700}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:6px;font-weight:700;text-transform:none}\ninput,select,textarea{border-radius:6px;border-width:2px}\nth{font-weight:700;letter-spacing:.6px}\n.badge{border-radius:6px}\n.code{font-weight:700}"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Id": "midnight",
|
||||
"Name": "Midnight OLED",
|
||||
"Extends": null,
|
||||
"Tokens": {
|
||||
"--bg": "#F5F7FA",
|
||||
"--s": "#EBEFF5",
|
||||
"--b": "#D9E0EA",
|
||||
"--t": "#0B1220",
|
||||
"--m": "#5A6B85",
|
||||
"--a": "#0A84FF",
|
||||
"--g": "#1FA84F",
|
||||
"--y": "#B57A00",
|
||||
"--r": "#E5484D",
|
||||
"--p": "#8E4EC6",
|
||||
"--o": "#C4601A",
|
||||
"--field": "#FFFFFF",
|
||||
"--hover": "rgba(11,18,32,.05)",
|
||||
"--on-a": "#FFFFFF"
|
||||
},
|
||||
"DarkTokens": {
|
||||
"--bg": "#000000",
|
||||
"--s": "#0B0F14",
|
||||
"--b": "#1C232C",
|
||||
"--t": "#FFFFFF",
|
||||
"--m": "#8B98A9",
|
||||
"--a": "#4C9AFF",
|
||||
"--g": "#3DDC84",
|
||||
"--y": "#FFD60A",
|
||||
"--r": "#FF453A",
|
||||
"--p": "#BF5AF2",
|
||||
"--o": "#FF9F0A",
|
||||
"--field": "#080B10",
|
||||
"--hover": "rgba(255,255,255,.05)",
|
||||
"--on-a": "#000000"
|
||||
},
|
||||
"CustomCss": "[data-theme=\"dark\"] body,body{background:var(--bg)}\n[data-theme=\"dark\"] header,[data-theme=\"dark\"] nav{background:#050709}\n[data-theme=\"dark\"] .sc,[data-theme=\"dark\"] .form,[data-theme=\"dark\"] table{border-color:#1C232C}\n[data-theme=\"dark\"] .ni.active{background:rgba(76,154,255,.16)}\n[data-theme=\"dark\"] .code{color:#4C9AFF;text-shadow:0 0 10px rgba(76,154,255,.5)}\n[data-theme=\"dark\"] .dot{box-shadow:0 0 8px var(--g)}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:8px}\n.ni{border-left-width:3px}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:6px;font-weight:600}\ninput,select,textarea{border-radius:6px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(76,154,255,.22)}"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Id": "nord",
|
||||
"Name": "Nord",
|
||||
"Extends": null,
|
||||
"Tokens": {
|
||||
"--bg": "#ECEFF4",
|
||||
"--s": "#E5E9F0",
|
||||
"--b": "#D8DEE9",
|
||||
"--t": "#2E3440",
|
||||
"--m": "#4C566A",
|
||||
"--a": "#5E81AC",
|
||||
"--g": "#4C8C4A",
|
||||
"--y": "#B58A28",
|
||||
"--r": "#BF616A",
|
||||
"--p": "#8F6FA8",
|
||||
"--o": "#D08770",
|
||||
"--field": "#FFFFFF",
|
||||
"--hover": "rgba(46,52,64,.05)",
|
||||
"--on-a": "#FFFFFF"
|
||||
},
|
||||
"DarkTokens": {
|
||||
"--bg": "#2E3440",
|
||||
"--s": "#3B4252",
|
||||
"--b": "#4C566A",
|
||||
"--t": "#ECEFF4",
|
||||
"--m": "#97A1B5",
|
||||
"--a": "#88C0D0",
|
||||
"--g": "#A3BE8C",
|
||||
"--y": "#EBCB8B",
|
||||
"--r": "#BF616A",
|
||||
"--p": "#B48EAD",
|
||||
"--o": "#D08770",
|
||||
"--field": "#2B3140",
|
||||
"--hover": "rgba(236,239,244,.06)",
|
||||
"--on-a": "#2E3440"
|
||||
},
|
||||
"CustomCss": "body{letter-spacing:.1px}\nh2{border-bottom:1px solid var(--b);padding-bottom:8px}\n.form,.sc,.ig,table{border-radius:8px}\n.ni{border-radius:0 8px 8px 0}\nbutton.bp,button.bd,button.bw{border-radius:7px;letter-spacing:.2px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(94,129,172,.2)}\n.hdr{background:var(--s)}"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Id": "rose-pine",
|
||||
"Name": "Rosé Pine",
|
||||
"Extends": null,
|
||||
"Tokens": {
|
||||
"--bg": "#FAF4ED",
|
||||
"--s": "#F2E9E1",
|
||||
"--b": "#E4DAD2",
|
||||
"--t": "#575279",
|
||||
"--m": "#797593",
|
||||
"--a": "#286983",
|
||||
"--g": "#56949F",
|
||||
"--y": "#EA9D34",
|
||||
"--r": "#B4637A",
|
||||
"--p": "#907AA9",
|
||||
"--o": "#D7827E",
|
||||
"--field": "#FFFCF9",
|
||||
"--hover": "rgba(87,82,121,.05)",
|
||||
"--on-a": "#FFFFFF"
|
||||
},
|
||||
"DarkTokens": {
|
||||
"--bg": "#191724",
|
||||
"--s": "#1F1D2E",
|
||||
"--b": "#26233A",
|
||||
"--t": "#E0DEF4",
|
||||
"--m": "#908CAA",
|
||||
"--a": "#C4A7E7",
|
||||
"--g": "#31748F",
|
||||
"--y": "#F6C177",
|
||||
"--r": "#EB6F92",
|
||||
"--p": "#C4A7E7",
|
||||
"--o": "#EA9D34",
|
||||
"--field": "#12111A",
|
||||
"--hover": "rgba(224,222,244,.06)",
|
||||
"--on-a": "#191724"
|
||||
},
|
||||
"CustomCss": "body{font-family:'Georgia','Iowan Old Style',ui-serif,serif;letter-spacing:.15px}\nh1,h2,h3,.nlbl{font-family:ui-serif,Georgia,serif}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:16px}\n.form,.sc{box-shadow:0 1px 4px rgba(87,82,121,.05)}\n.ni{border-radius:0 12px 12px 0}\n.ni.active{background:rgba(196,167,231,.14);font-weight:600}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:12px;font-weight:600}\ninput,select,textarea{border-radius:12px}\ninput:focus,select:focus,textarea:focus{border-color:var(--a);box-shadow:0 0 0 3px rgba(196,167,231,.2)}\nth{font-style:italic;font-weight:600}\n.pnl.active{animation:rpIn .3s ease}\n@keyframes rpIn{from{opacity:0}to{opacity:1}}"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Id": "solarized",
|
||||
"Name": "Solarized",
|
||||
"Extends": null,
|
||||
"Tokens": {
|
||||
"--bg": "#FDF6E3",
|
||||
"--s": "#EEE8D5",
|
||||
"--b": "#DDD6C1",
|
||||
"--t": "#073642",
|
||||
"--m": "#657B83",
|
||||
"--a": "#268BD2",
|
||||
"--g": "#7A8C00",
|
||||
"--y": "#B58900",
|
||||
"--r": "#DC322F",
|
||||
"--p": "#6C71C4",
|
||||
"--o": "#CB4B16",
|
||||
"--field": "#FFFBF0",
|
||||
"--hover": "rgba(7,54,66,.05)",
|
||||
"--on-a": "#FFFFFF"
|
||||
},
|
||||
"DarkTokens": {
|
||||
"--bg": "#002B36",
|
||||
"--s": "#073642",
|
||||
"--b": "#14505F",
|
||||
"--t": "#EEE8D5",
|
||||
"--m": "#93A1A1",
|
||||
"--a": "#3FA3E8",
|
||||
"--g": "#B4C43A",
|
||||
"--y": "#D4A72C",
|
||||
"--r": "#E85550",
|
||||
"--p": "#8B90E0",
|
||||
"--o": "#E5763F",
|
||||
"--field": "#00252E",
|
||||
"--hover": "rgba(238,232,213,.06)",
|
||||
"--on-a": "#002B36"
|
||||
},
|
||||
"CustomCss": "body{font-family:'Menlo','Consolas','DejaVu Sans Mono',ui-monospace,monospace;font-size:13px}\nh1,h2{font-weight:600}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b)}\n.form,.sc,.ig,table{border-radius:4px}\n.ni{border-left-width:3px}\nth{background:var(--s);font-weight:700}\nbutton.bp,button.bd,button.bw{border-radius:4px;font-weight:600}\ninput,select,textarea{border-radius:4px}\n.code,.ip,td.mono{font-weight:600}"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Id": "tokyo-night",
|
||||
"Name": "Tokyo Night",
|
||||
"Extends": null,
|
||||
"Tokens": {
|
||||
"--bg": "#F6F7FB",
|
||||
"--s": "#EDEFF7",
|
||||
"--b": "#DCE0EC",
|
||||
"--t": "#1A1B26",
|
||||
"--m": "#565A6E",
|
||||
"--a": "#2E5FA3",
|
||||
"--g": "#3D7A33",
|
||||
"--y": "#8F5E15",
|
||||
"--r": "#8C4351",
|
||||
"--p": "#5A4A78",
|
||||
"--o": "#965027",
|
||||
"--field": "#FFFFFF",
|
||||
"--hover": "rgba(26,27,38,.05)",
|
||||
"--on-a": "#FFFFFF"
|
||||
},
|
||||
"DarkTokens": {
|
||||
"--bg": "#1A1B26",
|
||||
"--s": "#24283B",
|
||||
"--b": "#343B58",
|
||||
"--t": "#C0CAF5",
|
||||
"--m": "#7C86B0",
|
||||
"--a": "#7AA2F7",
|
||||
"--g": "#9ECE6A",
|
||||
"--y": "#E0AF68",
|
||||
"--r": "#F7768E",
|
||||
"--p": "#BB9AF7",
|
||||
"--o": "#FF9E64",
|
||||
"--field": "#16161E",
|
||||
"--hover": "rgba(192,202,245,.06)",
|
||||
"--on-a": "#1A1B26"
|
||||
},
|
||||
"CustomCss": "body{letter-spacing:.1px}\nh2{padding-bottom:8px;border-bottom:1px solid var(--b);letter-spacing:.6px}\n.form,.sc,.ig,table{border-radius:10px}\n.sc{background:linear-gradient(160deg,var(--s),transparent)}\n.ni{border-radius:8px;transition:background .18s ease,color .18s ease}\n.ni:hover{background:var(--hover);transform:translateX(2px)}\n.ni.active{background:rgba(122,162,247,.12)}\n.form:hover{border-color:var(--a)}\nbutton.bp,button.bd,button.bw,button.bsm{border-radius:8px;font-weight:600;transition:transform .12s ease,box-shadow .12s ease}\nbutton.bp:hover,button.bd:hover,button.bw:hover{transform:translateY(-1px)}\nbutton.bp{box-shadow:0 1px 3px rgba(122,162,247,.3)}\ninput,select,textarea{border-radius:8px}\ninput:focus,select:focus,textarea:focus{box-shadow:0 0 0 3px rgba(122,162,247,.2)}\n.code{text-shadow:0 0 8px rgba(122,162,247,.35)}\n.pnl.active{animation:tnIn .25s ease}\n@keyframes tnIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)/Localization')">
|
||||
<None Remove="Localization/**/*.json" />
|
||||
<Content Remove="Localization/**/*.json" />
|
||||
<EmbeddedResource Include="Localization/*.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -57,6 +57,8 @@ public sealed class AdminPanelBuilder
|
||||
|
||||
public AdminPanelBuilder RegisterChips(Action<AdminChips> configure) => Register(configure);
|
||||
|
||||
public AdminPanelBuilder RegisterEntries(Action<AdminEntries> configure) => Register(configure);
|
||||
|
||||
public AdminPanelBuilder RegisterDivider()
|
||||
{
|
||||
Widgets.Add(new AdminDivider());
|
||||
@@ -96,6 +98,27 @@ public sealed class AdminPanelBuilder
|
||||
}
|
||||
}
|
||||
|
||||
if (widget is AdminEntries entries)
|
||||
{
|
||||
if (entries.AddHandler != null)
|
||||
{
|
||||
entries.AddAction = NextAction();
|
||||
_actions[entries.AddAction] = entries.AddHandler;
|
||||
}
|
||||
|
||||
if (entries.EditHandler != null)
|
||||
{
|
||||
entries.EditAction = NextAction();
|
||||
_actions[entries.EditAction] = entries.EditHandler;
|
||||
}
|
||||
|
||||
if (entries.RemoveHandler != null)
|
||||
{
|
||||
entries.RemoveAction = NextAction();
|
||||
_actions[entries.RemoveAction] = entries.RemoveHandler;
|
||||
}
|
||||
}
|
||||
|
||||
if (widget is AdminBlock block)
|
||||
{
|
||||
foreach (var child in block.Children)
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace Empostor.Api.Admin;
|
||||
[JsonDerivedType(typeof(AdminTable), "table")]
|
||||
[JsonDerivedType(typeof(AdminChips), "chips")]
|
||||
[JsonDerivedType(typeof(AdminDivider), "divider")]
|
||||
[JsonDerivedType(typeof(AdminEntries), "entries")]
|
||||
public abstract class AdminWidget
|
||||
{
|
||||
public string? Action { get; set; }
|
||||
@@ -163,6 +164,67 @@ public sealed class AdminDivider : AdminWidget
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>One editable column of an <see cref="AdminEntries"/> widget.</summary>
|
||||
public sealed class AdminEntryField
|
||||
{
|
||||
/// <summary>Row value key. The <b>first</b> field is the row identity used by edit/remove actions.</summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public bool Monospace { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An editable list of structured entries (search + add + per-row edit/delete), backed by
|
||||
/// three actions: add (payload carries the new row), edit (value = old identity, payload carries
|
||||
/// the replacement row) and remove (value = row identity).
|
||||
/// </summary>
|
||||
public sealed class AdminEntries : AdminWidget
|
||||
{
|
||||
public List<AdminEntryField> Fields { get; set; } = new();
|
||||
|
||||
/// <summary>Rows keyed by field key; the first field's value identifies the row.</summary>
|
||||
public List<Dictionary<string, string>> Rows { get; set; } = new();
|
||||
|
||||
public string? SearchPlaceholder { get; set; }
|
||||
|
||||
public string? AddLabel { get; set; }
|
||||
|
||||
public string AddAction { get; set; } = string.Empty;
|
||||
|
||||
public string EditAction { get; set; } = string.Empty;
|
||||
|
||||
public string RemoveAction { get; set; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public AdminAction? AddHandler { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public AdminAction? EditHandler { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public AdminAction? RemoveHandler { get; set; }
|
||||
|
||||
public AdminEntries OnAdd(AdminAction handler)
|
||||
{
|
||||
AddHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AdminEntries OnEdit(AdminAction handler)
|
||||
{
|
||||
EditHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AdminEntries OnRemove(AdminAction handler)
|
||||
{
|
||||
RemoveHandler = handler;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdminOption(string Label, string Value);
|
||||
|
||||
public sealed class AdminTableCell
|
||||
|
||||
@@ -17,6 +17,13 @@ public interface IAdminExtension
|
||||
/// <summary>Sidebar grouping label. Defaults to "Plugins".</summary>
|
||||
string Section => "Plugins";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the panel should be re-fetched once per second while it is visible. Only panels
|
||||
/// that mirror live server state (e.g. an in-game chat feed) need this; static panels keep
|
||||
/// their DOM stable, which matters for widgets with in-progress typing or row editing.
|
||||
/// </summary>
|
||||
bool AutoRefresh => false;
|
||||
|
||||
/// <summary>Declare the panel's widgets and callbacks.</summary>
|
||||
void Build(AdminPanelBuilder builder);
|
||||
}
|
||||
@@ -32,6 +32,12 @@ public enum CheatCategory
|
||||
/// <summary>A packet was sent that exceeded the maximum allowed RPC size.</summary>
|
||||
PacketSize,
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@ namespace Empostor.Api.Config
|
||||
public string MarketplaceUrl { get; set; } =
|
||||
"https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/plugins.json";
|
||||
|
||||
public string ThemeMarketplaceUrl { get; set; } =
|
||||
"https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/themes.json";
|
||||
|
||||
public string GitHubToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Active admin theme id. Themes live under <c>Pages/themes/{Id}</c> or as plugins.</summary>
|
||||
public string Theme { get; set; } = "default";
|
||||
|
||||
|
||||
@@ -20,12 +20,22 @@ namespace Empostor.Api.Config
|
||||
|
||||
public bool EnableNameLimitChecks { get; set; } = true;
|
||||
|
||||
public bool EnableItemLimitChecks { get; set; } = true;
|
||||
|
||||
public bool EnableOwnershipChecks { get; set; } = true;
|
||||
|
||||
public bool EnableRoleChecks { get; set; } = true;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
namespace Empostor.Api.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration for the HPLP (HTTP Public Lobby List Protocol) endpoint,
|
||||
/// which allows Starlight clients to discover games hosted on this server.
|
||||
/// HPLP settings, which let Starlight clients discover games hosted on this server.
|
||||
/// </summary>
|
||||
public class HplpConfig
|
||||
{
|
||||
public const string Section = "HPLP";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the HPLP endpoint (<c>GET /x-api/games</c>) is enabled.
|
||||
/// </summary>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
<VersionPrefix>2.0.0</VersionPrefix>
|
||||
<VersionPrefix>2.0.1</VersionPrefix>
|
||||
<AssemblyName>Empostor.Api</AssemblyName>
|
||||
<AssemblyTitle>Empostor.Api</AssemblyTitle>
|
||||
<Authors>AeonLucid</Authors>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -13246,6 +13246,19 @@
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Visor",
|
||||
"name": "visor_impostorShine",
|
||||
"free": false,
|
||||
"storeName": "",
|
||||
"beanCost": 0,
|
||||
"starCost": 0,
|
||||
"bundleId": "",
|
||||
"chipOffset": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Visor",
|
||||
"name": "visor_cosmic_alien",
|
||||
|
||||
@@ -27,5 +27,6 @@
|
||||
"NoisemakerAlertDuration": 1600,
|
||||
"ViperDissolveTime": 1601,
|
||||
"DetectiveSuspectLimit": 1602,
|
||||
"JudgeTaskRequirementPercentage": 1620
|
||||
"JudgeTaskRequirementPercentage": 1620,
|
||||
"SpiritGuideCooldownSeconds": 1640
|
||||
}
|
||||
@@ -12,5 +12,6 @@
|
||||
"Tracker": 10,
|
||||
"Detective": 12,
|
||||
"Viper": 18,
|
||||
"Judge": 19
|
||||
"Judge": 19,
|
||||
"SpiritGuide": 21
|
||||
}
|
||||
@@ -60,5 +60,6 @@
|
||||
"StartVanish": 63,
|
||||
"CheckAppear": 64,
|
||||
"StartAppear": 65,
|
||||
"QueueOverruleVotes": 66
|
||||
"QueueOverruleVotes": 66,
|
||||
"SpiritGuideMessage": 67
|
||||
}
|
||||
@@ -1662,5 +1662,27 @@
|
||||
"QCJudgeOverrule": 4105,
|
||||
"QCJudgePlease": 4106,
|
||||
"QCJudgeWillOverrule": 4107,
|
||||
"JudgeBeatYouToIt": 4108
|
||||
"JudgeBeatYouToIt": 4108,
|
||||
"SpiritGuideRole": 4120,
|
||||
"SpiritGuideAbility": 4121,
|
||||
"SpiritGuideBlurb": 4122,
|
||||
"SpiritGuideBlurbMed": 4123,
|
||||
"SpiritGuideBlurbLong": 4124,
|
||||
"SpiritGuideCooldown": 4125,
|
||||
"SpiritGuideAbilityCancel": 4126,
|
||||
"SpiritGuideAbilitySend": 4127,
|
||||
"SpiritGuideAbilityRefresh": 4128,
|
||||
"StatsSpiritGuideSentMessage": 4129,
|
||||
"RolesHelp_SpiritGuide_01": 4130,
|
||||
"RolesHelp_SpiritGuide_02": 4131,
|
||||
"QCInfluencerMessage": 4132,
|
||||
"QCBecauseInfluencer": 4133,
|
||||
"QCAnyInfluencerMessage": 4134,
|
||||
"QCInfluencers": 4135,
|
||||
"RedeemCodeLabel": 4200,
|
||||
"CodeLengthLabel": 4201,
|
||||
"CodeErrorLabel": 4202,
|
||||
"CodeAlreadyRedeemedLabel": 4203,
|
||||
"CodeRedeemPopupLabel": 4204,
|
||||
"HorseModeToggle": 4300
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"dumpostorVersion": "1.0.1-dev",
|
||||
"gameVersion": "2026.8.18",
|
||||
"dumpostorVersion": "1.0.2-dev",
|
||||
"gameVersion": "2026.9.29",
|
||||
"platformType": "StandaloneItch"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -2823,7 +2823,30 @@
|
||||
"QCJudgeWillOverrule": "I will Overrule {0}",
|
||||
"MatchInfoGuideTitle": "Match Info Guide",
|
||||
"MatchInfoGuidePlayersLabel": "Players",
|
||||
"MatchInfoGuideNoRolesMessage": "No Roles Enabled"
|
||||
"MatchInfoGuideNoRolesMessage": "No Roles Enabled",
|
||||
"Cosmetic.visor_impostorShine": "Totally Innocent",
|
||||
"RedeemCodeLabel": "Redeem Reward",
|
||||
"CodeLengthLabel": "Enter your 14-character redemption code:",
|
||||
"CodeErrorLabel": "Redemption code not found. Please make sure it\u0027s been entered in correctly.",
|
||||
"CodeAlreadyRedeemedLabel": "The redemption code you entered has already been redeemed on another account.",
|
||||
"CodeRedeemPopupLabel": "{0} unlocked! Item added to your inventory.",
|
||||
"SpiritGuideRole": "Influencer",
|
||||
"SpiritGuideAbility": "Message",
|
||||
"SpiritGuideBlurb": "Send messages to living Crewmates",
|
||||
"SpiritGuideBlurbMed": "Send messages to living Crewmates.",
|
||||
"SpiritGuideBlurbLong": "Send messages to living Crewmates.",
|
||||
"SpiritGuideCooldown": "Message Cooldown",
|
||||
"SpiritGuideAbilityCancel": "Cancel",
|
||||
"SpiritGuideAbilitySend": "Send",
|
||||
"SpiritGuideAbilityRefresh": "Refresh",
|
||||
"StatsSpiritGuideSentMessage": "Influencer Messages Sent:",
|
||||
"RolesHelp_SpiritGuide_01": "The Influencer is a Crewmate ghost equipped with a camera that can send unique images to the living.",
|
||||
"RolesHelp_SpiritGuide_02": "Use the images to give Crewmates important information like who killed you and where they\u0027re running off to!",
|
||||
"QCInfluencerMessage": "I got an Influencer message",
|
||||
"QCBecauseInfluencer": "Because of an Influencer message",
|
||||
"QCAnyInfluencerMessage": "Any Influencer messages?",
|
||||
"QCInfluencers": "Influencers?",
|
||||
"HorseModeToggle": "Horse?"
|
||||
},
|
||||
"Latam": {
|
||||
"LocalButton": "Local",
|
||||
@@ -5649,7 +5672,30 @@
|
||||
"QCJudgeWillOverrule": "Usar\u00E9 Objeci\u00F3n con {0}",
|
||||
"MatchInfoGuideTitle": "Gu\u00EDa de informaci\u00F3n de partida",
|
||||
"MatchInfoGuidePlayersLabel": "Jugadores",
|
||||
"MatchInfoGuideNoRolesMessage": "Roles desactivados"
|
||||
"MatchInfoGuideNoRolesMessage": "Roles desactivados",
|
||||
"Cosmetic.visor_impostorShine": "Inocente por seguro",
|
||||
"RedeemCodeLabel": "Canjear recompensa",
|
||||
"CodeLengthLabel": "Ingresa tu c\u00F3digo de canje de 14 caracteres:",
|
||||
"CodeErrorLabel": "C\u00F3digo de canje no encontrado. Aseg\u00FArate de escribirlo bien.",
|
||||
"CodeAlreadyRedeemedLabel": "El c\u00F3digo de canje que ingresaste ya se utiliz\u00F3 en otra cuenta.",
|
||||
"CodeRedeemPopupLabel": "\u00A1Desbloqueaste {0}! Se a\u00F1adi\u00F3 a tu inventario.",
|
||||
"SpiritGuideRole": "Influencer",
|
||||
"SpiritGuideAbility": "Mensaje",
|
||||
"SpiritGuideBlurb": "Env\u00EDa mensajes a los tripulantes vivos.",
|
||||
"SpiritGuideBlurbMed": "Env\u00EDa mensajes a los tripulantes vivos.",
|
||||
"SpiritGuideBlurbLong": "Env\u00EDa mensajes a los tripulantes vivos.",
|
||||
"SpiritGuideCooldown": "Enfriamiento de mensaje",
|
||||
"SpiritGuideAbilityCancel": "Cancelar",
|
||||
"SpiritGuideAbilitySend": "Enviar",
|
||||
"SpiritGuideAbilityRefresh": "Actualizar",
|
||||
"StatsSpiritGuideSentMessage": "Mensajes enviados como Influencer",
|
||||
"RolesHelp_SpiritGuide_01": "El Influencer es un fantasma de tripulante que lleva una c\u00E1mara, y que puede enviar im\u00E1genes \u00FAnicas a los que siguen vivos.",
|
||||
"RolesHelp_SpiritGuide_02": "Usa las im\u00E1genes para darles informaci\u00F3n importante a los tripulantes sobre qui\u00E9n te mat\u00F3 y a d\u00F3nde huy\u00F3.",
|
||||
"QCInfluencerMessage": "Recib\u00ED un mensaje de un Influencer",
|
||||
"QCBecauseInfluencer": "Por el mensaje de un Influencer",
|
||||
"QCAnyInfluencerMessage": "\u00BFHay mensajes de alg\u00FAn Influencer?",
|
||||
"QCInfluencers": "\u00BFInfluencers?",
|
||||
"HorseModeToggle": "\u00BFEquino?"
|
||||
},
|
||||
"Brazilian": {
|
||||
"LocalButton": "Local",
|
||||
@@ -8475,7 +8521,30 @@
|
||||
"QCJudgeWillOverrule": "Eu vou Anular {0}",
|
||||
"MatchInfoGuideTitle": "Guia de infos da partida",
|
||||
"MatchInfoGuidePlayersLabel": "Jogadores",
|
||||
"MatchInfoGuideNoRolesMessage": "Fun\u00E7\u00F5es desativadas"
|
||||
"MatchInfoGuideNoRolesMessage": "Fun\u00E7\u00F5es desativadas",
|
||||
"Cosmetic.visor_impostorShine": "Superinocente",
|
||||
"RedeemCodeLabel": "Resgatar pr\u00EAmio",
|
||||
"CodeLengthLabel": "Digite seu c\u00F3digo de 14 caracteres para resgatar:",
|
||||
"CodeErrorLabel": "C\u00F3digo n\u00E3o encontrado. Confirme se os caracteres est\u00E3o corretos.",
|
||||
"CodeAlreadyRedeemedLabel": "O c\u00F3digo de resgate que voc\u00EA inseriu j\u00E1 foi utilizado em outra conta.",
|
||||
"CodeRedeemPopupLabel": "Desbloqueou {0}! Item adicionado ao invent\u00E1rio.",
|
||||
"SpiritGuideRole": "Influencer",
|
||||
"SpiritGuideAbility": "Mensagem",
|
||||
"SpiritGuideBlurb": "Envie mensagens para Tripulantes vivos.",
|
||||
"SpiritGuideBlurbMed": "Envie mensagens para Tripulantes vivos.",
|
||||
"SpiritGuideBlurbLong": "Envie mensagens para Tripulantes vivos.",
|
||||
"SpiritGuideCooldown": "Tempo de espera",
|
||||
"SpiritGuideAbilityCancel": "Cancelar",
|
||||
"SpiritGuideAbilitySend": "Enviar",
|
||||
"SpiritGuideAbilityRefresh": "Atualizar",
|
||||
"StatsSpiritGuideSentMessage": "Mensagens de Influencer enviadas:",
|
||||
"RolesHelp_SpiritGuide_01": "O Influencer \u00E9 um Tripulante fantasma com uma c\u00E2mera que pode enviar fotos para os vivos.",
|
||||
"RolesHelp_SpiritGuide_02": "Use as fotos para mostrar informa\u00E7\u00F5es importantes para os Tripulantes, como quem matou voc\u00EA!",
|
||||
"QCInfluencerMessage": "Recebi uma mensagem do Influencer",
|
||||
"QCBecauseInfluencer": "Por causa de uma mensagem do Influencer",
|
||||
"QCAnyInfluencerMessage": "Alguma mensagem do Influencer?",
|
||||
"QCInfluencers": "Influencers?",
|
||||
"HorseModeToggle": "Cavalo?"
|
||||
},
|
||||
"Portuguese": {
|
||||
"LocalButton": "Local",
|
||||
@@ -11301,7 +11370,30 @@
|
||||
"QCJudgeWillOverrule": "Vou sobrepor-me a {0}",
|
||||
"MatchInfoGuideTitle": "Guia das informa\u00E7\u00F5es da partida",
|
||||
"MatchInfoGuidePlayersLabel": "Jogadores",
|
||||
"MatchInfoGuideNoRolesMessage": "Sem fun\u00E7\u00F5es ativas"
|
||||
"MatchInfoGuideNoRolesMessage": "Sem fun\u00E7\u00F5es ativas",
|
||||
"Cosmetic.visor_impostorShine": "Totalmente Inocente",
|
||||
"RedeemCodeLabel": "Resgatar recompensa",
|
||||
"CodeLengthLabel": "Introduz o teu c\u00F3digo de resgate de 14 caracteres:",
|
||||
"CodeErrorLabel": "C\u00F3digo de resgate n\u00E3o encontrado. Certifica-te de que o introduziste corretamente.",
|
||||
"CodeAlreadyRedeemedLabel": "O c\u00F3digo de resgate que introduziste j\u00E1 foi resgatado noutra conta.",
|
||||
"CodeRedeemPopupLabel": "Desbloqueaste {0}! O item foi adicionado ao teu invent\u00E1rio.",
|
||||
"SpiritGuideRole": "Influenciador",
|
||||
"SpiritGuideAbility": "Mensagem",
|
||||
"SpiritGuideBlurb": "Envia mensagens para os Tripulantes vivos.",
|
||||
"SpiritGuideBlurbMed": "Envia mensagens para os Tripulantes vivos.",
|
||||
"SpiritGuideBlurbLong": "Envia mensagens para os Tripulantes vivos.",
|
||||
"SpiritGuideCooldown": "Tempo de espera da mensagem",
|
||||
"SpiritGuideAbilityCancel": "Cancelar",
|
||||
"SpiritGuideAbilitySend": "Enviar",
|
||||
"SpiritGuideAbilityRefresh": "Atualizar",
|
||||
"StatsSpiritGuideSentMessage": "Mensagens de Influenciador enviadas:",
|
||||
"RolesHelp_SpiritGuide_01": "O Influenciador \u00E9 o fantasma de um Tripulante equipado com uma c\u00E2mara capaz de enviar imagens exclusivas para os vivos.",
|
||||
"RolesHelp_SpiritGuide_02": "Utiliza as imagens para mostrar aos Tripulantes informa\u00E7\u00F5es importantes, tais como quem te matou e para onde foram!",
|
||||
"QCInfluencerMessage": "Recebi uma mensagem de um Influenciador",
|
||||
"QCBecauseInfluencer": "Gra\u00E7as a uma mensagem de um Influenciador",
|
||||
"QCAnyInfluencerMessage": "Recebeste alguma mensagem de um Influenciador?",
|
||||
"QCInfluencers": "Influenciadores?",
|
||||
"HorseModeToggle": "Cavalo?"
|
||||
},
|
||||
"Korean": {
|
||||
"LocalButton": "\uB85C\uCEEC",
|
||||
@@ -14127,7 +14219,30 @@
|
||||
"QCJudgeWillOverrule": "{0} \uD310\uACB0\uC744 \uB4A4\uC9D1\uACA0\uC2B5\uB2C8\uB2E4",
|
||||
"MatchInfoGuideTitle": "\uB9E4\uCE58 \uC815\uBCF4 \uAC00\uC774\uB4DC",
|
||||
"MatchInfoGuidePlayersLabel": "\uD50C\uB808\uC774\uC5B4",
|
||||
"MatchInfoGuideNoRolesMessage": "\uBAA8\uB4E0 \uC5ED\uD560 \uBE44\uD65C\uC131\uD654 \uC0C1\uD0DC"
|
||||
"MatchInfoGuideNoRolesMessage": "\uBAA8\uB4E0 \uC5ED\uD560 \uBE44\uD65C\uC131\uD654 \uC0C1\uD0DC",
|
||||
"Cosmetic.visor_impostorShine": "\uC644\uC804 \uBB34\uC8C4",
|
||||
"RedeemCodeLabel": "\uBCF4\uC0C1 \uAD50\uD658",
|
||||
"CodeLengthLabel": "14\uC790\uB9AC \uAD50\uD658 \uCF54\uB4DC \uC785\uB825:",
|
||||
"CodeErrorLabel": "\uAD50\uD658 \uCF54\uB4DC\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uC785\uB825\uD55C \uCF54\uB4DC\uAC00 \uC815\uD655\uD55C\uC9C0 \uD655\uC778\uD558\uC138\uC694.",
|
||||
"CodeAlreadyRedeemedLabel": "\uC785\uB825\uD55C \uAD50\uD658 \uCF54\uB4DC\uAC00 \uB2E4\uB978 \uACC4\uC815\uC5D0\uC11C \uC774\uBBF8 \uC0AC\uC6A9\uB418\uC5C8\uC2B5\uB2C8\uB2E4.",
|
||||
"CodeRedeemPopupLabel": "{0} \uC7A0\uAE08 \uD574\uC81C! \uC544\uC774\uD15C\uC774 \uC778\uBCA4\uD1A0\uB9AC\uC5D0 \uCD94\uAC00\uB418\uC5C8\uC2B5\uB2C8\uB2E4.",
|
||||
"SpiritGuideRole": "\uC778\uD50C\uB8E8\uC5B8\uC11C",
|
||||
"SpiritGuideAbility": "\uBA54\uC2DC\uC9C0",
|
||||
"SpiritGuideBlurb": "\uC0B4\uC544 \uC788\uB294 \uD06C\uB8E8\uC6D0\uC5D0\uAC8C \uBA54\uC2DC\uC9C0\uB97C \uBCF4\uB0C5\uB2C8\uB2E4.",
|
||||
"SpiritGuideBlurbMed": "\uC0B4\uC544 \uC788\uB294 \uD06C\uB8E8\uC6D0\uC5D0\uAC8C \uBA54\uC2DC\uC9C0\uB97C \uBCF4\uB0C5\uB2C8\uB2E4.",
|
||||
"SpiritGuideBlurbLong": "\uC0B4\uC544 \uC788\uB294 \uD06C\uB8E8\uC6D0\uC5D0\uAC8C \uBA54\uC2DC\uC9C0\uB97C \uBCF4\uB0C5\uB2C8\uB2E4.",
|
||||
"SpiritGuideCooldown": "\uBA54\uC2DC\uC9C0 \uCFE8\uB2E4\uC6B4",
|
||||
"SpiritGuideAbilityCancel": "\uCDE8\uC18C",
|
||||
"SpiritGuideAbilitySend": "\uC804\uC1A1",
|
||||
"SpiritGuideAbilityRefresh": "\uC0C8\uB85C \uACE0\uCE68",
|
||||
"StatsSpiritGuideSentMessage": "\uC778\uD50C\uB8E8\uC5B8\uC11C\uAC00 \uBCF4\uB0B8 \uBA54\uC2DC\uC9C0:",
|
||||
"RolesHelp_SpiritGuide_01": "\uC778\uD50C\uB8E8\uC5B8\uC11C\uB294 \uCE74\uBA54\uB77C\uB97C \uAC00\uC9C4 \uD06C\uB8E8\uC6D0\uC758 \uC720\uB839\uC73C\uB85C, \uC0B4\uC544 \uC788\uB294 \uD06C\uB8E8\uC6D0\uB4E4\uC5D0\uAC8C \uD2B9\uC815 \uC774\uBBF8\uC9C0\uB97C \uBCF4\uB0BC \uC218 \uC788\uC2B5\uB2C8\uB2E4.",
|
||||
"RolesHelp_SpiritGuide_02": "\uC774\uBBF8\uC9C0\uB97C \uC0AC\uC6A9\uD574\uC11C \uB204\uAC00 \uB2F9\uC2E0\uC744 \uC8FD\uC600\uB294\uC9C0, \uC5B4\uB514\uB85C \uB3C4\uB9DD\uAC00\uB294\uC9C0 \uAC19\uC740 \uC911\uC694\uD55C \uC815\uBCF4\uB97C \uD06C\uB8E8\uC6D0\uB4E4\uC5D0\uAC8C \uC54C\uB824\uC8FC\uC138\uC694!",
|
||||
"QCInfluencerMessage": "\uC778\uD50C\uB8E8\uC5B8\uC11C\uC758 \uBA54\uC2DC\uC9C0\uB97C \uBC1B\uC558\uC74C",
|
||||
"QCBecauseInfluencer": "\uC778\uD50C\uB8E8\uC5B8\uC11C\uC758 \uBA54\uC2DC\uC9C0 \uB54C\uBB38\uC5D0",
|
||||
"QCAnyInfluencerMessage": "\uC778\uD50C\uB8E8\uC5B8\uC11C\uC758 \uBA54\uC2DC\uC9C0\uAC00 \uC788\uC5B4?",
|
||||
"QCInfluencers": "\uC778\uD50C\uB8E8\uC5B8\uC11C\uB294?",
|
||||
"HorseModeToggle": "\uB9D0?"
|
||||
},
|
||||
"Russian": {
|
||||
"LocalButton": "\u041B\u043E\u043A\u0430\u043B\u044C\u043D\u043E",
|
||||
@@ -16935,7 +17050,7 @@
|
||||
"Cosmetic.nameplate_parasite_Impostor": "\u041E\u043D \u0443 \u0442\u0435\u0431\u044F \u0437\u0430 \u0441\u043F\u0438\u043D\u043E\u0439!",
|
||||
"JudgeRole": "\u0421\u0443\u0434\u044C\u044F",
|
||||
"JudgeBlurb": "\u0420\u0443\u043A\u043E\u0432\u043E\u0434\u0438\u0442\u0435 \u0441\u043E\u0431\u0440\u0430\u043D\u0438\u0435\u043C",
|
||||
"JudgeBlurbMed": "\u0412\u043E\u0437\u044C\u043C\u0438\u0442\u0435 \u0441\u043E\u0431\u0440\u0430\u043D\u0438\u0435 \u043F\u043E\u0434 \u0441\u0432\u043E\u0439 \u043A\u043E\u043D\u0442\u0440\u043E\u043B\u044C, \u0447\u0442\u043E\u0431\u044B \u0432\u044B\u0431\u0440\u043E\u0441\u0438\u0442\u044C \u043F\u0440\u0435\u0434\u0430\u0442\u0435\u043B\u044F.\n\u0415\u0441\u043B\u0438 \u0432\u044B \u043E\u0448\u0438\u0431\u0451\u0442\u0435\u0441\u044C, \u0442\u043E \u0431\u0443\u0434\u0435\u0442\u0435 \u0432\u044B\u0431\u0440\u043E\u0448\u0435\u043D\u044B.",
|
||||
"JudgeBlurbMed": "\u0412\u043E\u0437\u044C\u043C\u0438\u0442\u0435 \u0441\u043E\u0431\u0440\u0430\u043D\u0438\u0435 \u043F\u043E\u0434 \u0441\u0432\u043E\u0439 \u043A\u043E\u043D\u0442\u0440\u043E\u043B\u044C, \u0447\u0442\u043E\u0431\u044B\n\u0432\u044B\u0431\u0440\u043E\u0441\u0438\u0442\u044C \u043F\u0440\u0435\u0434\u0430\u0442\u0435\u043B\u044F. \u0415\u0441\u043B\u0438 \u0432\u044B \u043E\u0448\u0438\u0431\u0451\u0442\u0435\u0441\u044C, \u0442\u043E \u0431\u0443\u0434\u0435\u0442\u0435 \u0432\u044B\u0431\u0440\u043E\u0448\u0435\u043D\u044B.",
|
||||
"JudgeBlurbLong": "\u0412\u0437\u044F\u0442\u044C \u0433\u043E\u043B\u043E\u0441\u043E\u0432\u0430\u043D\u0438\u0435 \u043F\u043E\u0434 \u043A\u043E\u043D\u0442\u0440\u043E\u043B\u044C",
|
||||
"JudgeTaskRequirement": "% \u043E\u0442\u043A\u0440\u044B\u0442\u044B\u0445 \u0437\u0430\u0434\u0430\u043D\u0438\u0439",
|
||||
"RolesHelp_Judge_01": "\u0421\u043F\u043E\u0441\u043E\u0431\u043D\u043E\u0441\u0442\u044C \u0441\u0443\u0434\u044C\u0438 \u043F\u043E\u0434 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435\u043C \u00AB\u041F\u0440\u0438\u0433\u043E\u0432\u043E\u0440\u00BB \u043F\u043E\u0437\u0432\u043E\u043B\u044F\u0435\u0442 \u0435\u043C\u0443 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \u0441\u043F\u0435\u0446\u0438\u0430\u043B\u044C\u043D\u044B\u0439 \u0433\u043E\u043B\u043E\u0441 \u043F\u0440\u043E\u0442\u0438\u0432 \u043E\u0434\u043D\u043E\u0433\u043E \u0438\u0437 \u0447\u043B\u0435\u043D\u043E\u0432 \u044D\u043A\u0438\u043F\u0430\u0436\u0430, \u043A\u043E\u0442\u043E\u0440\u043E\u0433\u043E \u0441\u0443\u0434\u044C\u044F \u0441\u0447\u0438\u0442\u0430\u0435\u0442 \u043F\u0440\u0435\u0434\u0430\u0442\u0435\u043B\u0435\u043C.",
|
||||
@@ -16953,7 +17068,30 @@
|
||||
"QCJudgeWillOverrule": "\u042F \u0432\u044B\u043D\u0435\u0441\u0443 \u043F\u0440\u0438\u0433\u043E\u0432\u043E\u0440 {0}",
|
||||
"MatchInfoGuideTitle": "\u0418\u043D\u0444\u043E\u0440\u043C\u0430\u0446\u0438\u044F \u043E \u043C\u0430\u0442\u0447\u0435",
|
||||
"MatchInfoGuidePlayersLabel": "\u0418\u0433\u0440\u043E\u043A\u0438",
|
||||
"MatchInfoGuideNoRolesMessage": "\u041D\u0435\u0442 \u0430\u043A\u0442\u0438\u0432\u043D\u044B\u0445 \u0440\u043E\u043B\u0435\u0439"
|
||||
"MatchInfoGuideNoRolesMessage": "\u041D\u0435\u0442 \u0430\u043A\u0442\u0438\u0432\u043D\u044B\u0445 \u0440\u043E\u043B\u0435\u0439",
|
||||
"Cosmetic.visor_impostorShine": "\u0421\u043E\u0432\u0435\u0440\u0448\u0435\u043D\u043D\u043E \u043D\u0435\u0432\u0438\u043D\u043E\u0432\u0435\u043D",
|
||||
"RedeemCodeLabel": "\u0410\u043A\u0442\u0438\u0432\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043A\u043E\u0434",
|
||||
"CodeLengthLabel": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 14-\u0437\u043D\u0430\u0447\u043D\u044B\u0439 \u043A\u043E\u0434 \u0430\u043A\u0442\u0438\u0432\u0430\u0446\u0438\u0438:",
|
||||
"CodeErrorLabel": "\u041A\u043E\u0434 \u0430\u043A\u0442\u0438\u0432\u0430\u0446\u0438\u0438 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044C, \u0447\u0442\u043E \u043E\u043D \u0432\u0432\u0435\u0434\u0451\u043D \u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u043E.",
|
||||
"CodeAlreadyRedeemedLabel": "\u0412\u0432\u0435\u0434\u0451\u043D\u043D\u044B\u0439 \u0432\u0430\u043C\u0438 \u043A\u043E\u0434 \u0430\u043A\u0442\u0438\u0432\u0430\u0446\u0438\u0438 \u0443\u0436\u0435 \u0431\u044B\u043B \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u043D \u0432 \u0434\u0440\u0443\u0433\u043E\u0439 \u0443\u0447\u0451\u0442\u043D\u043E\u0439 \u0437\u0430\u043F\u0438\u0441\u0438.",
|
||||
"CodeRedeemPopupLabel": "\u041E\u0442\u043A\u0440\u044B\u0442\u0430 \u043D\u0430\u0433\u0440\u0430\u0434\u0430: {0}! \u041F\u0440\u0435\u0434\u043C\u0435\u0442 \u0434\u043E\u0431\u0430\u0432\u043B\u0435\u043D \u0432 \u0432\u0430\u0448 \u0438\u043D\u0432\u0435\u043D\u0442\u0430\u0440\u044C.",
|
||||
"SpiritGuideRole": "\u0418\u043D\u0444\u043B\u044E\u044D\u043D\u0441\u0435\u0440",
|
||||
"SpiritGuideAbility": "\u0421\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435",
|
||||
"SpiritGuideBlurb": "\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u044F\u0439\u0442\u0435 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F \u0436\u0438\u0432\u044B\u043C \u0447\u043B\u0435\u043D\u0430\u043C \u044D\u043A\u0438\u043F\u0430\u0436\u0430.",
|
||||
"SpiritGuideBlurbMed": "\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u044F\u0439\u0442\u0435 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F \u0436\u0438\u0432\u044B\u043C \u0447\u043B\u0435\u043D\u0430\u043C \u044D\u043A\u0438\u043F\u0430\u0436\u0430.",
|
||||
"SpiritGuideBlurbLong": "\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u044F\u0439\u0442\u0435 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F \u0436\u0438\u0432\u044B\u043C \u0447\u043B\u0435\u043D\u0430\u043C \u044D\u043A\u0438\u043F\u0430\u0436\u0430.",
|
||||
"SpiritGuideCooldown": "\u041F\u0435\u0440\u0435\u0440\u044B\u0432 \u043C\u0435\u0436\u0434\u0443 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F\u043C\u0438",
|
||||
"SpiritGuideAbilityCancel": "\u041E\u0442\u043C\u0435\u043D\u0430",
|
||||
"SpiritGuideAbilitySend": "\u041E\u0442\u043F\u0440\u0430\u0432\u0438\u0442\u044C",
|
||||
"SpiritGuideAbilityRefresh": "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C",
|
||||
"StatsSpiritGuideSentMessage": "\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043E \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0439 \u0418\u043D\u0444\u043B\u044E\u044D\u043D\u0441\u0435\u0440\u043E\u043C:",
|
||||
"RolesHelp_SpiritGuide_01": "\u0418\u043D\u0444\u043B\u044E\u044D\u043D\u0441\u0435\u0440 \u2014 \u044D\u0442\u043E \u043F\u0440\u0438\u0437\u0440\u0430\u043A \u0441 \u0444\u043E\u0442\u043E\u0430\u043F\u043F\u0430\u0440\u0430\u0442\u043E\u043C, \u043A\u043E\u0442\u043E\u0440\u044B\u0439 \u043C\u043E\u0436\u0435\u0442 \u043E\u0442\u043F\u0440\u0430\u0432\u043B\u044F\u0442\u044C \u0443\u043D\u0438\u043A\u0430\u043B\u044C\u043D\u044B\u0435 \u0438\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u044F \u0436\u0438\u0432\u044B\u043C \u0447\u043B\u0435\u043D\u0430\u043C \u044D\u043A\u0438\u043F\u0430\u0436\u0430.",
|
||||
"RolesHelp_SpiritGuide_02": "\u0421 \u043F\u043E\u043C\u043E\u0449\u044C\u044E \u0438\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u0439 \u0434\u0430\u0432\u0430\u0439\u0442\u0435 \u0447\u043B\u0435\u043D\u0430\u043C \u044D\u043A\u0438\u043F\u0430\u0436\u0430 \u0432\u0430\u0436\u043D\u0443\u044E \u0438\u043D\u0444\u043E\u0440\u043C\u0430\u0446\u0438\u044E \u043E \u0442\u043E\u043C, \u043A\u0442\u043E \u0432\u0430\u0441 \u0443\u0431\u0438\u043B \u0438 \u043A\u0443\u0434\u0430 \u043E\u043D \u043D\u0430\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u0442\u0441\u044F!",
|
||||
"QCInfluencerMessage": "\u0423 \u043C\u0435\u043D\u044F \u0435\u0441\u0442\u044C \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435 \u043E\u0442 \u0418\u043D\u0444\u043B\u044E\u044D\u043D\u0441\u0435\u0440\u0430",
|
||||
"QCBecauseInfluencer": "\u0418\u0437-\u0437\u0430 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F \u043E\u0442 \u0418\u043D\u0444\u043B\u044E\u044D\u043D\u0441\u0435\u0440\u0430",
|
||||
"QCAnyInfluencerMessage": "\u041A\u0442\u043E-\u0442\u043E \u043F\u043E\u043B\u0443\u0447\u0430\u043B \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F \u043E\u0442 \u0418\u043D\u0444\u043B\u044E\u044D\u043D\u0441\u0435\u0440\u0430?",
|
||||
"QCInfluencers": "\u0415\u0441\u0442\u044C \u0418\u043D\u0444\u043B\u044E\u044D\u043D\u0441\u0435\u0440\u044B?",
|
||||
"HorseModeToggle": "\u041B\u043E\u0448\u0430\u0434\u044C?"
|
||||
},
|
||||
"Dutch": {
|
||||
"LocalButton": "Lokaal",
|
||||
@@ -19779,7 +19917,30 @@
|
||||
"QCJudgeWillOverrule": "Ik verwerp {0}",
|
||||
"MatchInfoGuideTitle": "Match-infogids",
|
||||
"MatchInfoGuidePlayersLabel": "Spelers",
|
||||
"MatchInfoGuideNoRolesMessage": "Geen Rollen ingeschakeld"
|
||||
"MatchInfoGuideNoRolesMessage": "Geen Rollen ingeschakeld",
|
||||
"Cosmetic.visor_impostorShine": "Volkomen onschuldig",
|
||||
"RedeemCodeLabel": "Beloning verzilveren",
|
||||
"CodeLengthLabel": "Voer je verzilveringscode van 14 tekens in:",
|
||||
"CodeErrorLabel": "Verzilveringscode niet gevonden. Controleer of je deze correct hebt ingevoerd.",
|
||||
"CodeAlreadyRedeemedLabel": "De ingevoerde verzilveringscode is al verzilverd op een ander account.",
|
||||
"CodeRedeemPopupLabel": "{0} ontgrendeld! Item toegevoegd aan je inventaris.",
|
||||
"SpiritGuideRole": "Influencer",
|
||||
"SpiritGuideAbility": "Bericht",
|
||||
"SpiritGuideBlurb": "Stuur berichten naar levende Bemanningsleden.",
|
||||
"SpiritGuideBlurbMed": "Stuur berichten naar levende Bemanningsleden.",
|
||||
"SpiritGuideBlurbLong": "Stuur berichten naar levende Bemanningsleden.",
|
||||
"SpiritGuideCooldown": "Afkoelperiode bericht",
|
||||
"SpiritGuideAbilityCancel": "Annuleren",
|
||||
"SpiritGuideAbilitySend": "Verzenden",
|
||||
"SpiritGuideAbilityRefresh": "Vernieuwen",
|
||||
"StatsSpiritGuideSentMessage": "Verzonden berichten door influencer:",
|
||||
"RolesHelp_SpiritGuide_01": "De Influencer is een Spook-bemanningslid met een camera die unieke afbeeldingen naar levende Bemanningsleden kan sturen.",
|
||||
"RolesHelp_SpiritGuide_02": "Gebruik de afbeeldingen om Bemanningsleden belangrijke informatie te geven, zoals wie je heeft vermoord en waar ze naartoe vluchtten!",
|
||||
"QCInfluencerMessage": "Ik heb een bericht van een influencer gekregen",
|
||||
"QCBecauseInfluencer": "Vanwege een bericht van een influencer",
|
||||
"QCAnyInfluencerMessage": "Zijn er berichten van influencers?",
|
||||
"QCInfluencers": "Influencers?",
|
||||
"HorseModeToggle": "Paard?"
|
||||
},
|
||||
"Filipino": {
|
||||
"LocalButton": "Lokal",
|
||||
@@ -22587,7 +22748,7 @@
|
||||
"Cosmetic.nameplate_parasite_Impostor": "Naa sa Imong Luyo!",
|
||||
"JudgeRole": "Maghuhukom",
|
||||
"JudgeBlurb": "Kontrolaha ang meeting",
|
||||
"JudgeBlurbMed": "Gamita ang Overrule sa meeting aron mapagawas ang usa ka Impostor.\nKung sayop ka, ikaw ang mapagawas.",
|
||||
"JudgeBlurbMed": "Gamita ang Overrule sa meeting aron mapagawas ang usa\nka Impostor. Kung sayop ka, ikaw ang mapagawas.",
|
||||
"JudgeBlurbLong": "Ikaw ang mokontrol sa boto",
|
||||
"JudgeTaskRequirement": "% sa Task nga na-unlock",
|
||||
"RolesHelp_Judge_01": "Ang Maghuhukom adunay kusganong abilidad sa meeting nga gitawag og Overrule. Gitugotan siya niini sa paggamit og espesyal nga boto batok sa usa ka Crewmate nga iyang gidudahang Impostor.",
|
||||
@@ -22605,7 +22766,30 @@
|
||||
"QCJudgeWillOverrule": "Ako ang Mo-overrule {0}",
|
||||
"MatchInfoGuideTitle": "Giya nga Impormasyon sa Match",
|
||||
"MatchInfoGuidePlayersLabel": "Mga Mandudula",
|
||||
"MatchInfoGuideNoRolesMessage": "Walay Na-enable nga Mga Role"
|
||||
"MatchInfoGuideNoRolesMessage": "Walay Na-enable nga Mga Role",
|
||||
"Cosmetic.visor_impostorShine": "Talagang Walang Sala",
|
||||
"RedeemCodeLabel": "I-redeem ang Reward",
|
||||
"CodeLengthLabel": "Ilagay ang iyong 14-character na redemption code:",
|
||||
"CodeErrorLabel": "Hindi nakita ang redemption code. Tiyaking tama ang pagkakalagay nito.",
|
||||
"CodeAlreadyRedeemedLabel": "Na-redeem na sa ibang account ang redemption code na inilagay mo.",
|
||||
"CodeRedeemPopupLabel": "Na-unlock na ang {0}! Idinagdag ang item sa iyong inventory.",
|
||||
"SpiritGuideRole": "Influencer",
|
||||
"SpiritGuideAbility": "Mensahe",
|
||||
"SpiritGuideBlurb": "Pagpadala og mga mensahe ngadto sa buhing mga Crewmate.",
|
||||
"SpiritGuideBlurbMed": "Pagpadala og mga mensahe ngadto sa buhing mga Crewmate.",
|
||||
"SpiritGuideBlurbLong": "Pagpadala og mga mensahe ngadto sa buhing mga Crewmate.",
|
||||
"SpiritGuideCooldown": "Cooldown sa Mensahe",
|
||||
"SpiritGuideAbilityCancel": "Kanselaha",
|
||||
"SpiritGuideAbilitySend": "Ipadala",
|
||||
"SpiritGuideAbilityRefresh": "I-refresh",
|
||||
"StatsSpiritGuideSentMessage": "Mga Mensahe nga Gipadala sa Influencer:",
|
||||
"RolesHelp_SpiritGuide_01": "Ang Influencer usa ka multo nga Crewmate nga adunay kamera nga makapadala og talagsaong mga hulagway ngadto sa mga buhing Crewmate.",
|
||||
"RolesHelp_SpiritGuide_02": "Gamita ang mga hulagway aron mahatagan ang mga Crewmate og importanteng impormasyon, sama sa kung kinsa ang mipatay kanimo ug asa sila mikalagiw!",
|
||||
"QCInfluencerMessage": "Nakadawat ko og mensahe gikan sa Influencer",
|
||||
"QCBecauseInfluencer": "Tungod sa usa ka mensahe gikan sa Influencer",
|
||||
"QCAnyInfluencerMessage": "Aduna bay mga mensahe gikan sa Influencer?",
|
||||
"QCInfluencers": "Mga Influencer?",
|
||||
"HorseModeToggle": "Kabayo?"
|
||||
},
|
||||
"French": {
|
||||
"LocalButton": "Local",
|
||||
@@ -25413,9 +25597,9 @@
|
||||
"Cosmetic.nameplate_parasite_Impostor": "Juste derri\u00E8re toi\u00A0!",
|
||||
"JudgeRole": "Juge",
|
||||
"JudgeBlurb": "Prenez le contr\u00F4le de la r\u00E9union",
|
||||
"JudgeBlurbMed": "Prenez le contr\u00F4le du vote d\u0027une r\u00E9union pour \u00E9jecter un imposteur.\nSi vous avez tort, vous vous faites \u00E9jecter.",
|
||||
"JudgeBlurbMed": "Prenez le contr\u00F4le du vote d\u0027une r\u00E9union pour \u00E9jecter\nun imposteur. Si vous avez tort, vous vous faites \u00E9jecter.",
|
||||
"JudgeBlurbLong": "Prenez le contr\u00F4le du vote",
|
||||
"JudgeTaskRequirement": "T\u00E2ches pour d\u00E9bloquer le r\u00F4le %",
|
||||
"JudgeTaskRequirement": "%\u00A0t\u00E2ches pour d\u00E9bloquer le r\u00F4le",
|
||||
"RolesHelp_Judge_01": "Le juge dispose de Condamnation, une comp\u00E9tence puissante en r\u00E9union. Elle lui permet de lancer un vote sp\u00E9cial pour accuser un \u00E9quipier d\u0027\u00EAtre l\u0027imposteur.",
|
||||
"RolesHelp_Judge_02": "Lorsqu\u0027il utilise sa comp\u00E9tence, seul son vote \u00E0 lui compte. Si le Juge a raison, l\u0027imposteur est \u00E9ject\u00E9. Si le Juge se trompe, c\u0027est lui qui se fait \u00E9jecter \u00E0 la place\u00A0!",
|
||||
"JudgeAbilityUnlocked": "Comp\u00E9tence de Juge d\u00E9bloqu\u00E9e",
|
||||
@@ -25431,7 +25615,30 @@
|
||||
"QCJudgeWillOverrule": "Je vais condamner {0}",
|
||||
"MatchInfoGuideTitle": "Guide d\u0027informations de la partie",
|
||||
"MatchInfoGuidePlayersLabel": "Joueurs",
|
||||
"MatchInfoGuideNoRolesMessage": "Aucun r\u00F4le actif"
|
||||
"MatchInfoGuideNoRolesMessage": "Aucun r\u00F4le actif",
|
||||
"Cosmetic.visor_impostorShine": "Carr\u00E9ment innocent",
|
||||
"RedeemCodeLabel": "R\u00E9cup\u00E9rer la r\u00E9compense",
|
||||
"CodeLengthLabel": "Saisissez votre code d\u0027activation \u00E0 14\u00A0caract\u00E8res\u00A0:",
|
||||
"CodeErrorLabel": "Code d\u0027activation introuvable. Veuillez vous assurer qu\u0027il ait \u00E9t\u00E9 saisi correctement.",
|
||||
"CodeAlreadyRedeemedLabel": "Le code d\u0027activation que vous avez saisi a d\u00E9j\u00E0 \u00E9t\u00E9 utilis\u00E9 par un autre compte.",
|
||||
"CodeRedeemPopupLabel": "{0} d\u00E9bloqu\u00E9\u00A0! Objet ajout\u00E9 \u00E0 votre inventaire.",
|
||||
"SpiritGuideRole": "Influenceur",
|
||||
"SpiritGuideAbility": "Message",
|
||||
"SpiritGuideBlurb": "Envoyez des messages aux co\u00E9quipiers encore en vie.",
|
||||
"SpiritGuideBlurbMed": "Envoyez des messages aux co\u00E9quipiers encore en vie.",
|
||||
"SpiritGuideBlurbLong": "Envoyez des messages aux co\u00E9quipiers encore en vie.",
|
||||
"SpiritGuideCooldown": "Temps mort avant le prochain message",
|
||||
"SpiritGuideAbilityCancel": "Annuler",
|
||||
"SpiritGuideAbilitySend": "Envoyer",
|
||||
"SpiritGuideAbilityRefresh": "Actualiser",
|
||||
"StatsSpiritGuideSentMessage": "Messages d\u0027influenceur envoy\u00E9s\u00A0:",
|
||||
"RolesHelp_SpiritGuide_01": "L\u0027influenceur est un co\u00E9quipier fant\u00F4me \u00E9quip\u00E9 d\u0027une cam\u00E9ra. Il est capable d\u0027envoyer des images uniques au monde des vivants.",
|
||||
"RolesHelp_SpiritGuide_02": "Servez-vous des images pour donner des informations importantes aux co\u00E9quipiers, comme l\u0027identit\u00E9 de la personne qui vous a tu\u00E9 et la direction dans laquelle elle s\u0027est enfuie\u00A0!",
|
||||
"QCInfluencerMessage": "J\u0027ai re\u00E7u un message d\u0027influenceur",
|
||||
"QCBecauseInfluencer": "Parce que j\u0027ai re\u00E7u un message d\u0027influenceur",
|
||||
"QCAnyInfluencerMessage": "Des messages d\u0027influenceur\u00A0?",
|
||||
"QCInfluencers": "Influenceurs\u00A0?",
|
||||
"HorseModeToggle": "Chevaux\u00A0?"
|
||||
},
|
||||
"German": {
|
||||
"LocalButton": "Lokal",
|
||||
@@ -28239,7 +28446,7 @@
|
||||
"Cosmetic.nameplate_parasite_Impostor": "Bin direkt hinter dir!",
|
||||
"JudgeRole": "Richter",
|
||||
"JudgeBlurb": "Treffen \u00FCbernehmen",
|
||||
"JudgeBlurbMed": "Rei\u00DF dir das Notfall-Treffen unter den Nagel, um einen Verr\u00E4ter zu entlarven.\nLiegst du falsch, wirst du vom Schiff geworfen.",
|
||||
"JudgeBlurbMed": "Rei\u00DF dir das Notfall-Treffen unter den Nagel, um einen Verr\u00E4ter\nzu entlarven. Liegst du falsch, wirst du vom Schiff geworfen.",
|
||||
"JudgeBlurbLong": "Verf\u00FCge \u00FCber die Abstimmung",
|
||||
"JudgeTaskRequirement": "Aufgaben bis zum Freischalten %",
|
||||
"RolesHelp_Judge_01": "Der Richter verf\u00FCgt \u00FCber eine m\u00E4chtige F\u00E4higkeit f\u00FCr Treffen namens \u201E\u00DCberstimmen\u201C. Damit kann er eine Sonderstimme f\u00FCr ein Besatzungsmitglied abgeben, das er f\u00FCr einen Verr\u00E4ter h\u00E4lt.",
|
||||
@@ -28257,7 +28464,30 @@
|
||||
"QCJudgeWillOverrule": "Ich \u00FCberstimme {0}",
|
||||
"MatchInfoGuideTitle": "Match-Informationen",
|
||||
"MatchInfoGuidePlayersLabel": "Spieler",
|
||||
"MatchInfoGuideNoRolesMessage": "Keine Rollen aktiviert"
|
||||
"MatchInfoGuideNoRolesMessage": "Keine Rollen aktiviert",
|
||||
"Cosmetic.visor_impostorShine": "Definitiv unschuldig",
|
||||
"RedeemCodeLabel": "Belohnung erhalten",
|
||||
"CodeLengthLabel": "Gib den 14-stelligen Einl\u00F6sungscode ein:",
|
||||
"CodeErrorLabel": "Einl\u00F6sungscode nicht gefunden.",
|
||||
"CodeAlreadyRedeemedLabel": "Der von dir eingegebene Code wurde bereits f\u00FCr ein anderes Konto eingel\u00F6st.",
|
||||
"CodeRedeemPopupLabel": "{0} freigeschaltet! Das Objekt wurde deinem Inventar hinzugef\u00FCgt.",
|
||||
"SpiritGuideRole": "Meinungsmacher",
|
||||
"SpiritGuideAbility": "Nachricht",
|
||||
"SpiritGuideBlurb": "Schicke eine Nachricht an lebende Besatzungsmitglieder.",
|
||||
"SpiritGuideBlurbMed": "Schicke eine Nachricht an lebende Besatzungsmitglieder.",
|
||||
"SpiritGuideBlurbLong": "Schicke eine Nachricht an lebende Besatzungsmitglieder.",
|
||||
"SpiritGuideCooldown": "Abklingzeit f\u00FCr Nachricht",
|
||||
"SpiritGuideAbilityCancel": "Abbrechen",
|
||||
"SpiritGuideAbilitySend": "Schicken",
|
||||
"SpiritGuideAbilityRefresh": "Aktualisieren",
|
||||
"StatsSpiritGuideSentMessage": "Als Meinungsmacher geschickte Nachrichten:",
|
||||
"RolesHelp_SpiritGuide_01": "Der Meinungsmacher ist der Geist eines Besatzungsmitglieds, der \u00FCber eine Kamera verf\u00FCgt und besondere Bilder an die Lebenden schicken kann.",
|
||||
"RolesHelp_SpiritGuide_02": "Mit den Bildern kannst du Besatzungsmitgliedern wichtige Informationen zukommen lassen, zum Beispiel, wer dich get\u00F6tet hat und wohin der T\u00E4ter fl\u00FCchtet!",
|
||||
"QCInfluencerMessage": "Ich habe eine Nachricht von einem Meinungsmacher erhalten.",
|
||||
"QCBecauseInfluencer": "Wegen der Nachricht eines Meinungsmachers.",
|
||||
"QCAnyInfluencerMessage": "Gibt es Nachrichten von Meinungsmachern?",
|
||||
"QCInfluencers": "Meinungsmacher?",
|
||||
"HorseModeToggle": "Pferd?"
|
||||
},
|
||||
"Italian": {
|
||||
"LocalButton": "Locale",
|
||||
@@ -31083,7 +31313,30 @@
|
||||
"QCJudgeWillOverrule": "Mi impongo contro {0}",
|
||||
"MatchInfoGuideTitle": "Guida info partita",
|
||||
"MatchInfoGuidePlayersLabel": "Giocatori",
|
||||
"MatchInfoGuideNoRolesMessage": "Nessun ruolo abilitato"
|
||||
"MatchInfoGuideNoRolesMessage": "Nessun ruolo abilitato",
|
||||
"Cosmetic.visor_impostorShine": "Completamente innocente",
|
||||
"RedeemCodeLabel": "Riscatta ricompensa",
|
||||
"CodeLengthLabel": "Inserisci il codice di riscatto a 14 caratteri",
|
||||
"CodeErrorLabel": "Codice di riscatto non trovato. Assicurati di averlo inserito correttamente.",
|
||||
"CodeAlreadyRedeemedLabel": "Il codice di riscatto inserito \u00E8 gi\u00E0 stato utilizzato su un altro account.",
|
||||
"CodeRedeemPopupLabel": "Hai sbloccato {0}! L\u2019oggetto \u00E8 stato aggiunto al tuo inventario.",
|
||||
"SpiritGuideRole": "Persuasore",
|
||||
"SpiritGuideAbility": "Visione",
|
||||
"SpiritGuideBlurb": "Invia visioni agli astronauti in vita.",
|
||||
"SpiritGuideBlurbMed": "Invia visioni agli astronauti in vita.",
|
||||
"SpiritGuideBlurbLong": "Invia visioni agli astronauti in vita.",
|
||||
"SpiritGuideCooldown": "Ricarica visione",
|
||||
"SpiritGuideAbilityCancel": "Annulla",
|
||||
"SpiritGuideAbilitySend": "Invia",
|
||||
"SpiritGuideAbilityRefresh": "Aggiorna",
|
||||
"StatsSpiritGuideSentMessage": "Visioni Persuasore inviate:",
|
||||
"RolesHelp_SpiritGuide_01": "Il Persuasore \u00E8 un astronauta fantasma munito di fotocamera che pu\u00F2 inviare immagini singole a chi \u00E8 ancora in vita.",
|
||||
"RolesHelp_SpiritGuide_02": "Sfrutta le immagini per fornire agli astronauti informazioni importanti tipo chi ti ha ucciso e dove \u00E8 fuggito!",
|
||||
"QCInfluencerMessage": "Ho ricevuto una visione dal Persuasore",
|
||||
"QCBecauseInfluencer": "Grazie alla visione del Persuasore",
|
||||
"QCAnyInfluencerMessage": "Ci sono visioni del Persuasore?",
|
||||
"QCInfluencers": "Persuasori?",
|
||||
"HorseModeToggle": "Cavallo?"
|
||||
},
|
||||
"Japanese": {
|
||||
"LocalButton": "\u30ED\u30FC\u30AB\u30EB",
|
||||
@@ -33909,7 +34162,30 @@
|
||||
"QCJudgeWillOverrule": "{0}\u3092\u5374\u4E0B\u3059\u308B",
|
||||
"MatchInfoGuideTitle": "\u30DE\u30C3\u30C1\u60C5\u5831\u30AC\u30A4\u30C9",
|
||||
"MatchInfoGuidePlayersLabel": "\u30D7\u30EC\u30A4\u30E4\u30FC",
|
||||
"MatchInfoGuideNoRolesMessage": "\u5F79\u5272\u304C\u7121\u52B9\u306B\u306A\u3063\u3066\u3044\u307E\u3059"
|
||||
"MatchInfoGuideNoRolesMessage": "\u5F79\u5272\u304C\u7121\u52B9\u306B\u306A\u3063\u3066\u3044\u307E\u3059",
|
||||
"Cosmetic.visor_impostorShine": "\u602A\u3057\u3044\u8005\u3058\u3083\u306A\u3044\u3088",
|
||||
"RedeemCodeLabel": "\u5831\u916C\u3092\u53D6\u5F97\u3059\u308B",
|
||||
"CodeLengthLabel": "14\u6587\u5B57\u306E\u53D6\u5F97\u30B3\u30FC\u30C9\u3092\u5165\u529B\u3057\u3066\u304F\u3060\u3055\u3044\uFF1A",
|
||||
"CodeErrorLabel": "\u53D6\u5F97\u30B3\u30FC\u30C9\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3067\u3057\u305F\u3002\u6B63\u3057\u3044\u30B3\u30FC\u30C9\u304C\u5165\u529B\u3055\u308C\u3066\u3044\u308B\u304B\u3054\u78BA\u8A8D\u304F\u3060\u3055\u3044\u3002",
|
||||
"CodeAlreadyRedeemedLabel": "\u5165\u529B\u3057\u305F\u30B3\u30FC\u30C9\u306F\u3059\u3067\u306B\u5225\u306E\u30A2\u30AB\u30A6\u30F3\u30C8\u304C\u53D6\u5F97\u6E08\u307F\u3067\u3059\u3002",
|
||||
"CodeRedeemPopupLabel": "{0}\u304C\u30A2\u30F3\u30ED\u30C3\u30AF\u3055\u308C\u307E\u3057\u305F\uFF01\u30A2\u30A4\u30C6\u30E0\u306F\u6301\u3061\u7269\u306B\u8FFD\u52A0\u3055\u308C\u307E\u3057\u305F\u3002",
|
||||
"SpiritGuideRole": "\u30A4\u30F3\u30D5\u30EB\u30A8\u30F3\u30B5\u30FC",
|
||||
"SpiritGuideAbility": "\u30E1\u30C3\u30BB\u30FC\u30B8",
|
||||
"SpiritGuideBlurb": "\u751F\u5B58\u3057\u3066\u3044\u308B\u30AF\u30EB\u30FC\u30E1\u30A4\u30C8\u306B\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u9001\u308B\u3002",
|
||||
"SpiritGuideBlurbMed": "\u751F\u5B58\u3057\u3066\u3044\u308B\u30AF\u30EB\u30FC\u30E1\u30A4\u30C8\u306B\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u9001\u308B\u3002",
|
||||
"SpiritGuideBlurbLong": "\u751F\u5B58\u3057\u3066\u3044\u308B\u30AF\u30EB\u30FC\u30E1\u30A4\u30C8\u306B\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u9001\u308B\u3002",
|
||||
"SpiritGuideCooldown": "\u30E1\u30C3\u30BB\u30FC\u30B8\u30AF\u30FC\u30EB\u30C0\u30A6\u30F3",
|
||||
"SpiritGuideAbilityCancel": "\u30AD\u30E3\u30F3\u30BB\u30EB",
|
||||
"SpiritGuideAbilitySend": "\u9001\u4FE1",
|
||||
"SpiritGuideAbilityRefresh": "\u66F4\u65B0",
|
||||
"StatsSpiritGuideSentMessage": "\u30A4\u30F3\u30D5\u30EB\u30A8\u30F3\u30B5\u30FC\u30E1\u30C3\u30BB\u30FC\u30B8\u9001\u4FE1\u5B8C\u4E86",
|
||||
"RolesHelp_SpiritGuide_01": "\u30A4\u30F3\u30D5\u30EB\u30A8\u30F3\u30B5\u30FC\u306F\u30AB\u30E1\u30E9\u3092\u88C5\u5099\u3057\u305F\u30AF\u30EB\u30FC\u30E1\u30A4\u30C8\u306E\u30B4\u30FC\u30B9\u30C8\u3067\u3001\u751F\u5B58\u8005\u306B\u56FA\u6709\u306E\u753B\u50CF\u3092\u9001\u308B\u3053\u3068\u304C\u3067\u304D\u307E\u3059\u3002",
|
||||
"RolesHelp_SpiritGuide_02": "\u753B\u50CF\u3092\u4F7F\u3063\u3066\u81EA\u5206\u3092\u30AD\u30EB\u3057\u305F\u4EBA\u7269\u3084\u3001\u72AF\u4EBA\u306E\u884C\u65B9\u306A\u3069\u306E\u91CD\u8981\u306A\u60C5\u5831\u3092\u30AF\u30EB\u30FC\u30E1\u30A4\u30C8\u306B\u4F1D\u3048\u3088\u3046\uFF01",
|
||||
"QCInfluencerMessage": "\u30A4\u30F3\u30D5\u30EB\u30A8\u30F3\u30B5\u30FC\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u53D7\u4FE1",
|
||||
"QCBecauseInfluencer": "\u57FA\u306B\u306A\u3063\u305F\u30A4\u30F3\u30D5\u30EB\u30A8\u30F3\u30B5\u30FC\u30E1\u30C3\u30BB\u30FC\u30B8",
|
||||
"QCAnyInfluencerMessage": "\u30A4\u30F3\u30D5\u30EB\u30A8\u30F3\u30B5\u30FC\u30E1\u30C3\u30BB\u30FC\u30B8\u306F\u9001\u3063\u305F\uFF1F",
|
||||
"QCInfluencers": "\u30A4\u30F3\u30D5\u30EB\u30A8\u30F3\u30B5\u30FC\uFF1F",
|
||||
"HorseModeToggle": "\u99AC\uFF1F"
|
||||
},
|
||||
"Spanish": {
|
||||
"LocalButton": "Local",
|
||||
@@ -36735,7 +37011,30 @@
|
||||
"QCJudgeWillOverrule": "Voy a sentenciar a {0}",
|
||||
"MatchInfoGuideTitle": "Gu\u00EDa de inf. de partida",
|
||||
"MatchInfoGuidePlayersLabel": "Jugadores",
|
||||
"MatchInfoGuideNoRolesMessage": "Ning\u00FAn rol activado"
|
||||
"MatchInfoGuideNoRolesMessage": "Ning\u00FAn rol activado",
|
||||
"Cosmetic.visor_impostorShine": "Totalmente inocente",
|
||||
"RedeemCodeLabel": "Canjear recompensa",
|
||||
"CodeLengthLabel": "Introduce el c\u00F3digo de 14 caracteres:",
|
||||
"CodeErrorLabel": "C\u00F3digo no encontrado. Aseg\u00FArate de que lo has escrito correctamente.",
|
||||
"CodeAlreadyRedeemedLabel": "El c\u00F3digo que has introducido ya se ha usado en otra cuenta.",
|
||||
"CodeRedeemPopupLabel": "Desbloqueado: \u00A1{0}! Objeto a\u00F1adido a tu inventario.",
|
||||
"SpiritGuideRole": "Influencer",
|
||||
"SpiritGuideAbility": "Mensaje",
|
||||
"SpiritGuideBlurb": "Env\u00EDa mensajes a los tripulantes vivos.",
|
||||
"SpiritGuideBlurbMed": "Env\u00EDa mensajes a los tripulantes vivos.",
|
||||
"SpiritGuideBlurbLong": "Env\u00EDa mensajes a los tripulantes vivos.",
|
||||
"SpiritGuideCooldown": "Tiempo de espera de mensaje",
|
||||
"SpiritGuideAbilityCancel": "Cancelar",
|
||||
"SpiritGuideAbilitySend": "Enviar",
|
||||
"SpiritGuideAbilityRefresh": "Actualizar",
|
||||
"StatsSpiritGuideSentMessage": "Mensajes de influencer enviados:",
|
||||
"RolesHelp_SpiritGuide_01": "El influencer es un fantasma tripulante equipado con una c\u00E1mara que puede enviar im\u00E1genes especiales a los vivos.",
|
||||
"RolesHelp_SpiritGuide_02": "\u00A1Usa las im\u00E1genes para darles a los tripulantes informaci\u00F3n importante sobre qui\u00E9n te ha matado y ad\u00F3nde escapan!",
|
||||
"QCInfluencerMessage": "Tengo un mensaje de un influencer",
|
||||
"QCBecauseInfluencer": "Por un mensaje de un influencer",
|
||||
"QCAnyInfluencerMessage": "\u00BFAlg\u00FAn mensaje de un influencer?",
|
||||
"QCInfluencers": "\u00BFInfluencers?",
|
||||
"HorseModeToggle": "\u00BFCaballo?"
|
||||
},
|
||||
"SChinese": {
|
||||
"LocalButton": "\u672C\u5730",
|
||||
@@ -39561,7 +39860,30 @@
|
||||
"QCJudgeWillOverrule": "\u6211\u8981\u4E00\u7968\u5B9A\u7F6A{0}",
|
||||
"MatchInfoGuideTitle": "\u6E38\u620F\u4FE1\u606F\u6307\u5357",
|
||||
"MatchInfoGuidePlayersLabel": "\u73A9\u5BB6",
|
||||
"MatchInfoGuideNoRolesMessage": "\u672A\u542F\u7528\u89D2\u8272"
|
||||
"MatchInfoGuideNoRolesMessage": "\u672A\u542F\u7528\u89D2\u8272",
|
||||
"Cosmetic.visor_impostorShine": "\u6E05\u6E05\u767D\u767D",
|
||||
"RedeemCodeLabel": "\u5151\u6362\u5956\u52B1",
|
||||
"CodeLengthLabel": "\u8BF7\u8F93\u5165 14 \u4F4D\u5151\u6362\u7801\uFF1A",
|
||||
"CodeErrorLabel": "\u672A\u627E\u5230\u8BE5\u5151\u6362\u7801\u3002\u8BF7\u786E\u8BA4\u662F\u5426\u8F93\u5165\u6B63\u786E\u3002",
|
||||
"CodeAlreadyRedeemedLabel": "\u8F93\u5165\u7684\u5151\u6362\u7801\u5DF2\u5728\u5176\u4ED6\u8D26\u53F7\u4E0A\u5B8C\u6210\u5151\u6362\u3002",
|
||||
"CodeRedeemPopupLabel": "{0}\u5DF2\u89E3\u9501\uFF01\u7269\u54C1\u5DF2\u52A0\u5165\u4ED3\u5E93\u3002",
|
||||
"SpiritGuideRole": "\u7F51\u7EA2",
|
||||
"SpiritGuideAbility": "\u62A5\u4FE1",
|
||||
"SpiritGuideBlurb": "\u7ED9\u6D3B\u7740\u7684\u8239\u5458\u53D1\u9001\u4FE1\u606F\u3002",
|
||||
"SpiritGuideBlurbMed": "\u7ED9\u6D3B\u7740\u7684\u8239\u5458\u53D1\u9001\u4FE1\u606F\u3002",
|
||||
"SpiritGuideBlurbLong": "\u7ED9\u6D3B\u7740\u7684\u8239\u5458\u53D1\u9001\u4FE1\u606F\u3002",
|
||||
"SpiritGuideCooldown": "\u62A5\u4FE1\u51B7\u5374\u65F6\u95F4",
|
||||
"SpiritGuideAbilityCancel": "\u53D6\u6D88",
|
||||
"SpiritGuideAbilitySend": "\u53D1\u9001",
|
||||
"SpiritGuideAbilityRefresh": "\u5237\u65B0",
|
||||
"StatsSpiritGuideSentMessage": "\u7F51\u7EA2\u5DF2\u53D1\u9001\u4FE1\u606F\uFF1A",
|
||||
"RolesHelp_SpiritGuide_01": "\u7F51\u7EA2\u662F\u88C5\u5907\u4E86\u76F8\u673A\u7684\u5E7D\u7075\u8239\u5458\uFF0C\u53EF\u4EE5\u7ED9\u751F\u8005\u53D1\u9001\u7279\u522B\u7684\u56FE\u7247\u3002",
|
||||
"RolesHelp_SpiritGuide_02": "\u901A\u8FC7\u56FE\u7247\u7ED9\u8239\u5458\u63D0\u4F9B\u91CD\u8981\u4FE1\u606F\uFF0C\u4F8B\u5982\u662F\u8C01\u6740\u4E86\u4F60\uFF0C\u4EE5\u53CA\u51F6\u624B\u8DD1\u5411\u4E86\u4F55\u5904\uFF01",
|
||||
"QCInfluencerMessage": "\u6211\u6536\u5230\u4E86\u7F51\u7EA2\u4FE1\u606F",
|
||||
"QCBecauseInfluencer": "\u56E0\u4E3A\u6536\u5230\u4E86\u7F51\u7EA2\u4FE1\u606F",
|
||||
"QCAnyInfluencerMessage": "\u6709\u6536\u5230\u7F51\u7EA2\u4FE1\u606F\u5417\uFF1F",
|
||||
"QCInfluencers": "\u6709\u7F51\u7EA2\u5417\uFF1F",
|
||||
"HorseModeToggle": "\u80FD\u53D8\u9A6C\u5417\uFF1F"
|
||||
},
|
||||
"TChinese": {
|
||||
"LocalButton": "\u672C\u5730",
|
||||
@@ -42387,7 +42709,30 @@
|
||||
"QCJudgeWillOverrule": "\u6211\u6703\u99C1\u56DE\u8868\u6C7A\u7D50\u679C\u4E26\u9010\u51FA\uFF1A{0}",
|
||||
"MatchInfoGuideTitle": "\u5C0D\u6230\u8CC7\u8A0A\u6307\u5357",
|
||||
"MatchInfoGuidePlayersLabel": "\u73A9\u5BB6",
|
||||
"MatchInfoGuideNoRolesMessage": "\u672A\u555F\u7528\u4EFB\u4F55\u7684\u8077\u696D"
|
||||
"MatchInfoGuideNoRolesMessage": "\u672A\u555F\u7528\u4EFB\u4F55\u7684\u8077\u696D",
|
||||
"Cosmetic.visor_impostorShine": "\u7D55\u5C0D\u4E0D\u662F\u6211",
|
||||
"RedeemCodeLabel": "\u514C\u63DB\u734E\u52F5",
|
||||
"CodeLengthLabel": "\u8ACB\u8F38\u5165\u4EE5 14 \u500B\u5B57\u5143\u6240\u7D44\u6210\u7684\u514C\u63DB\u4EE3\u78BC\uFF1A",
|
||||
"CodeErrorLabel": "\u627E\u4E0D\u5230\u9019\u7D44\u514C\u63DB\u4EE3\u78BC\u3002\u8ACB\u78BA\u8A8D\u4F60\u6240\u8F38\u5165\u7684\u4EE3\u78BC\u662F\u5426\u6B63\u78BA\u3002",
|
||||
"CodeAlreadyRedeemedLabel": "\u5DF2\u6709\u5176\u4ED6\u7684\u5E33\u865F\u514C\u63DB\u904E\u9019\u7D44\u514C\u63DB\u4EE3\u78BC\u4E86\u3002",
|
||||
"CodeRedeemPopupLabel": "\u5DF2\u6210\u529F\u89E3\u9396\uFF1A{0}\uFF01\u5DF2\u5C07\u8A72\u7269\u54C1\u5B58\u5165\u5230\u4F60\u7684\u7269\u54C1\u5EAB\u4E4B\u4E2D\u3002",
|
||||
"SpiritGuideRole": "\u9748\u80FD\u8005",
|
||||
"SpiritGuideAbility": "\u50B3\u9001\u8A0A\u606F",
|
||||
"SpiritGuideBlurb": "\u50B3\u9001\u8A0A\u606F\u7D66\u9084\u6D3B\u8457\u7684\u8239\u54E1\u3002",
|
||||
"SpiritGuideBlurbMed": "\u50B3\u9001\u8A0A\u606F\u7D66\u9084\u6D3B\u8457\u7684\u8239\u54E1\u3002",
|
||||
"SpiritGuideBlurbLong": "\u50B3\u9001\u8A0A\u606F\u7D66\u9084\u6D3B\u8457\u7684\u8239\u54E1\u3002",
|
||||
"SpiritGuideCooldown": "\u300C\u50B3\u9001\u8A0A\u606F\u300D\u7684\u51B7\u537B\u6642\u9593",
|
||||
"SpiritGuideAbilityCancel": "\u53D6\u6D88",
|
||||
"SpiritGuideAbilitySend": "\u50B3\u9001",
|
||||
"SpiritGuideAbilityRefresh": "\u91CD\u65B0\u6574\u7406",
|
||||
"StatsSpiritGuideSentMessage": "\u9748\u80FD\u8005\u7684\u8A0A\u606F\u50B3\u9001\u6B21\u6578\uFF1A",
|
||||
"RolesHelp_SpiritGuide_01": "\u9748\u80FD\u8005\u662F\u9B3C\u9B42\u5F62\u614B\u7684\u8239\u54E1\uFF1B\u9748\u80FD\u8005\u80FD\u5920\u4F7F\u7528\u4E00\u53F0\u76F8\u6A5F\uFF0C\u4F86\u628A\u4ED6\u5011\u8A8D\u70BA\u5F88\u91CD\u8981\u7684\u5F71\u50CF\u50B3\u9001\u7D66\u9084\u6D3B\u8457\u7684\u8239\u54E1\u3002",
|
||||
"RolesHelp_SpiritGuide_02": "\u5584\u7528\u5F71\u50CF\u4F86\u544A\u8A34\u8239\u54E1\u91CD\u8981\u7684\u60C5\u5831\uFF0C\u50CF\u662F\u8AB0\u6BBA\u4E86\u4F60\u3001\u5147\u624B\u53C8\u5F80\u54EA\u88E1\u9003\uFF01",
|
||||
"QCInfluencerMessage": "\u6211\u6536\u5230\u9748\u80FD\u8005\u7684\u8A0A\u606F\u4E86",
|
||||
"QCBecauseInfluencer": "\u662F\u9748\u80FD\u8005\u7D66\u6211\u7684\u8CC7\u8A0A",
|
||||
"QCAnyInfluencerMessage": "\u6709\u4EBA\u6536\u5230\u9748\u80FD\u8005\u7684\u8A0A\u606F\u4E86\u55CE\uFF1F",
|
||||
"QCInfluencers": "\u9748\u80FD\u8005\uFF1F",
|
||||
"HorseModeToggle": "\u54EA\u5C3C\uFF1F\u99AC\uFF1F"
|
||||
},
|
||||
"Irish": {
|
||||
"LocalButton": "Log\u00E1nta",
|
||||
@@ -42606,7 +42951,7 @@
|
||||
"ReturnToGame": "Fill ar an gCluiche",
|
||||
"LocalHelp": "Fadhbanna ceangail?\n\n\u25CF Cinntigh go bhfuil gach duine ceangailte leis an WiFi c\u00E9anna\n\u25CF Cinntigh go bhfuil an leagan ionann ag gach duine (f\u00E9ach ar an taobh deas th\u00EDos)\n\u25CF Cinntigh go bhfuil ceangal WiFi l\u00E1idir agat\n\u25CF Scait\u00ED, cabhra\u00EDonn s\u00E9 m\u00E1 \u00F3st\u00E1lann duine eile\n\u25CF Scait\u00ED, cabhra\u00EDonn s\u00E9 m\u00E1 atosa\u00EDonn t\u00FA an aip... \n\u25CF Scait\u00ED, cabhra\u00EDonn s\u00E9 m\u00E1 atosa\u00EDonn t\u00FA d\u0027fh\u00F3n...\n\u25CF Fadhbanna f\u00F3s agat? T\u00E9igh i dteangmh\u00E1il linn support@innersloth.com",
|
||||
"OnlineHelp": "Conas a oibr\u00EDonn s\u00E9?\n\n\u25CF Nuair a \u00F3st\u00E1lann t\u00FA cluiche, b\u00EDonn c\u00F3d imeartha ar phain\u00E9al an tseomra.\n\u25CF Roinn an c\u00F3d le cairde chun cluiche pr\u00EDobh\u00E1ideach a chruth\u00FA.\n\u25CF N\u00F3 clice\u00E1il ar \u0022Pr\u00EDobh\u00E1ideach\u0022 chun cluiche poibl\u00ED a chruth\u00FA.\n\u25CF Is f\u00E9idir leis an \u00F3stach imreoir\u00ED a dh\u00EDbirt n\u00F3 cosc a chur orthu agus an comhr\u00E1 ar si\u00FAl.\n\u25CF An bhfuil fadhbanna f\u00F3s agat? T\u00E9igh i dteangmh\u00E1il linn support@innersloth.com",
|
||||
"EmergencyCount": "\n\n\n\n AN L\u00CDON CRUINNITHE \u00C9IGEAND\u00C1LA AT\u00C1 F\u00C1GTHA AG LOINGSEOIR {0}",
|
||||
"EmergencyCount": "\n\n\n\n CRUINNI\u00DA \u00C9IGEAND\u00C1LA AT\u00C1 FANTA AG LOINGSEOIR {0}",
|
||||
"EmergencyNotReady": "CAITHFIDH AN CRI\u00DA FANACHT\n\n\n\nGO DTAGA AN CH\u00C9AD CHRUINNI\u00DA EILE",
|
||||
"EmergencyDuringCrisis": "N\u00CD F\u00C9IDIR LEAT CRUINNI\u00DA A CHUR AR BUN\nI RITH GEARCH\u00C9IME",
|
||||
"EmergencyRequested": "CRUINNI\u00DA IARRTHA\nAG FANACHT LEIS AN \u00D3STACH",
|
||||
@@ -45213,6 +45558,29 @@
|
||||
"QCJudgeWillOverrule": "Cuirfidh m\u00E9 v\u00F3ta {0} ar neamhn\u00ED",
|
||||
"MatchInfoGuideTitle": "Treoir Eolais an Chluiche",
|
||||
"MatchInfoGuidePlayersLabel": "Imreoir\u00ED",
|
||||
"MatchInfoGuideNoRolesMessage": "N\u00EDl Aon R\u00F3l Curtha i bhFeidhm"
|
||||
"MatchInfoGuideNoRolesMessage": "N\u00EDl Aon R\u00F3l Curtha i bhFeidhm",
|
||||
"Cosmetic.visor_impostorShine": "Go hIoml\u00E1n Soineanta",
|
||||
"RedeemCodeLabel": "Faigh Duais",
|
||||
"CodeLengthLabel": "Cuir isteach do ch\u00F3d fuascailte 14 carachtar:",
|
||||
"CodeErrorLabel": "N\u00EDor th\u00E1ngthas ar an gc\u00F3d a cuireadh isteach. Cinntigh gur cuireadh isteach i gceart \u00E9, le do thoil.",
|
||||
"CodeAlreadyRedeemedLabel": "T\u00E1 an c\u00F3d fuascailte a chuir t\u00FA isteach tar \u00E9is a bheith fuascailte ag cuntas eile.",
|
||||
"CodeRedeemPopupLabel": "{0} fuascailte! Cuireadh an t-earra le d\u0027fhardal.",
|
||||
"SpiritGuideRole": "Taibhse Idirghabh\u00E1la",
|
||||
"SpiritGuideAbility": "F\u00E1g Teachtaireacht",
|
||||
"SpiritGuideBlurb": "Seol teachtaireachta\u00ED chuig Loingseoir\u00ED at\u00E1 f\u00F3s ina mbeatha.",
|
||||
"SpiritGuideBlurbMed": "Seol teachtaireachta\u00ED chuig Loingseoir\u00ED at\u00E1 f\u00F3s ina mbeatha.",
|
||||
"SpiritGuideBlurbLong": "Seol teachtaireachta\u00ED chuig Loingseoir\u00ED at\u00E1 f\u00F3s ina mbeatha.",
|
||||
"SpiritGuideCooldown": "Aga Athnuachana F\u00E1gtha na dTeachtaireachta\u00ED",
|
||||
"SpiritGuideAbilityCancel": "Cealaigh",
|
||||
"SpiritGuideAbilitySend": "Seol Teachtaireacht",
|
||||
"SpiritGuideAbilityRefresh": "Athnuaigh",
|
||||
"StatsSpiritGuideSentMessage": "Teachtaireachta\u00ED a sheol an Taibhse Idirghabh\u00E1la:",
|
||||
"RolesHelp_SpiritGuide_01": "Taibhse Loingseora is ea an Taibhse Idirghabh\u00E1la a bhfuil ceamra aige ar f\u00E9idir leis \u00EDomh\u00E1nna uath\u00FAla a sheoladh chucu si\u00FAd at\u00E1 ina mbeatha.",
|
||||
"RolesHelp_SpiritGuide_02": "\u00DAs\u00E1id na h\u00EDomh\u00E1nna chun eolas t\u00E1bhachtach a thabhairt do na loingseoir\u00ED - eolas cos\u00FAil le c\u00E9 a mharaigh th\u00FA agus c\u00E1 bhfuil siad ag rith!",
|
||||
"QCInfluencerMessage": "T\u00E1 teachtaireacht faighte agam \u00F3 Thaibhse Idirghabh\u00E1la",
|
||||
"QCBecauseInfluencer": "\u00D3 theachtaireacht Taibhse Idirghabh\u00E1la a fuair m\u00E9 an t-eolas seo.",
|
||||
"QCAnyInfluencerMessage": "Aon teachtaireachta\u00ED \u00F3 Thaibhs\u00ED Idirghabh\u00E1la?",
|
||||
"QCInfluencers": "An bhfuil na Taibhs\u00ED Idirghabh\u00E1la linn?",
|
||||
"HorseModeToggle": "Capaill?"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ namespace Empostor.Api.Innersloth.GameOptions;
|
||||
|
||||
public class HideNSeekGameOptions : IGameOptions
|
||||
{
|
||||
public const int LatestVersion = 11;
|
||||
public const int LatestVersion = 12;
|
||||
|
||||
public HideNSeekGameOptions(byte version = LatestVersion)
|
||||
{
|
||||
@@ -48,7 +48,7 @@ public class HideNSeekGameOptions : IGameOptions
|
||||
public float CrewLightMod { get; set; } = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Light modifier for the players that are Empostors as a multiplier value.
|
||||
/// Gets or sets the Light modifier for the players that are Impostors as a multiplier value.
|
||||
/// </summary>
|
||||
public float ImpostorLightMod { get; set; } = 1f;
|
||||
|
||||
@@ -75,7 +75,7 @@ public class HideNSeekGameOptions : IGameOptions
|
||||
|
||||
public float CrewmateFlashlightSize { get; set; } = 0.35f;
|
||||
|
||||
public float EmpostorFlashlightSize { get; set; } = 0.25f;
|
||||
public float ImpostorFlashlightSize { get; set; } = 0.25f;
|
||||
|
||||
public bool UseFlashlight { get; set; } = true;
|
||||
|
||||
@@ -132,7 +132,7 @@ public class HideNSeekGameOptions : IGameOptions
|
||||
CrewmateVentUses = reader.ReadInt32();
|
||||
HidingTime = reader.ReadSingle();
|
||||
CrewmateFlashlightSize = reader.ReadSingle();
|
||||
EmpostorFlashlightSize = reader.ReadSingle();
|
||||
ImpostorFlashlightSize = reader.ReadSingle();
|
||||
UseFlashlight = reader.ReadBoolean();
|
||||
FinalHideSeekMap = reader.ReadBoolean();
|
||||
FinalHideTime = reader.ReadSingle();
|
||||
@@ -171,7 +171,7 @@ public class HideNSeekGameOptions : IGameOptions
|
||||
writer.Write(CrewmateVentUses);
|
||||
writer.Write(HidingTime);
|
||||
writer.Write(CrewmateFlashlightSize);
|
||||
writer.Write(EmpostorFlashlightSize);
|
||||
writer.Write(ImpostorFlashlightSize);
|
||||
writer.Write(UseFlashlight);
|
||||
writer.Write(FinalHideSeekMap);
|
||||
writer.Write(FinalHideTime);
|
||||
|
||||
@@ -53,12 +53,12 @@ public class LegacyGameOptionsData : IGameOptions
|
||||
public float CrewLightMod { get; set; } = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Light modifier for the players that are Empostors as a multiplier value.
|
||||
/// Gets or sets the Light modifier for the players that are Impostors as a multiplier value.
|
||||
/// </summary>
|
||||
public float ImpostorLightMod { get; set; } = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Empostor cooldown to kill in seconds.
|
||||
/// Gets or sets the Impostor cooldown to kill in seconds.
|
||||
/// </summary>
|
||||
public float KillCooldown { get; set; } = 15f;
|
||||
|
||||
@@ -115,7 +115,7 @@ public class LegacyGameOptionsData : IGameOptions
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an ejected player is an impostor or not.
|
||||
/// </summary>
|
||||
public bool ConfirmEmpostor { get; set; } = true;
|
||||
public bool ConfirmImpostor { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether players are able to see tasks being performed by other players.
|
||||
@@ -186,7 +186,7 @@ public class LegacyGameOptionsData : IGameOptions
|
||||
|
||||
if (Version >= 3)
|
||||
{
|
||||
ConfirmEmpostor = reader.ReadBoolean();
|
||||
ConfirmImpostor = reader.ReadBoolean();
|
||||
VisualTasks = reader.ReadBoolean();
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ public class LegacyGameOptionsData : IGameOptions
|
||||
|
||||
if (Version >= 3)
|
||||
{
|
||||
writer.Write((bool)ConfirmEmpostor);
|
||||
writer.Write((bool)ConfirmImpostor);
|
||||
writer.Write((bool)VisualTasks);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Empostor.Api.Innersloth.GameOptions;
|
||||
|
||||
public class NormalGameOptions : IGameOptions
|
||||
{
|
||||
public const int LatestVersion = 11;
|
||||
public const int LatestVersion = 12;
|
||||
|
||||
public NormalGameOptions(byte version = LatestVersion)
|
||||
{
|
||||
@@ -51,12 +51,12 @@ public class NormalGameOptions : IGameOptions
|
||||
public float CrewLightMod { get; set; } = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Light modifier for the players that are Empostors as a multiplier value.
|
||||
/// Gets or sets the Light modifier for the players that are Impostors as a multiplier value.
|
||||
/// </summary>
|
||||
public float ImpostorLightMod { get; set; } = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Empostor cooldown to kill in seconds.
|
||||
/// Gets or sets the Impostor cooldown to kill in seconds.
|
||||
/// </summary>
|
||||
public float KillCooldown { get; set; } = 15f;
|
||||
|
||||
@@ -108,7 +108,7 @@ public class NormalGameOptions : IGameOptions
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an ejected player is an impostor or not.
|
||||
/// </summary>
|
||||
public bool ConfirmEmpostor { get; set; } = true;
|
||||
public bool ConfirmImpostor { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether players are able to see tasks being performed by other players.
|
||||
@@ -178,7 +178,7 @@ public class NormalGameOptions : IGameOptions
|
||||
IsDefaults = reader.ReadBoolean();
|
||||
|
||||
EmergencyCooldown = reader.ReadByte();
|
||||
ConfirmEmpostor = reader.ReadBoolean();
|
||||
ConfirmImpostor = reader.ReadBoolean();
|
||||
VisualTasks = reader.ReadBoolean();
|
||||
AnonymousVotes = reader.ReadBoolean();
|
||||
TaskBarUpdate = (TaskBarUpdate)reader.ReadByte();
|
||||
@@ -222,7 +222,7 @@ public class NormalGameOptions : IGameOptions
|
||||
writer.Write(IsDefaults);
|
||||
|
||||
writer.Write((byte)EmergencyCooldown);
|
||||
writer.Write(ConfirmEmpostor);
|
||||
writer.Write(ConfirmImpostor);
|
||||
writer.Write(VisualTasks);
|
||||
writer.Write(AnonymousVotes);
|
||||
writer.Write((byte)TaskBarUpdate);
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GuardianAngelRoleOptions : IRoleOptions
|
||||
|
||||
public byte ProtectionDurationSeconds { get; set; } = 10;
|
||||
|
||||
public bool EmpostorsCanSeeProtect { get; set; }
|
||||
public bool ImpostorsCanSeeProtect { get; set; }
|
||||
|
||||
public static GuardianAngelRoleOptions Deserialize(IMessageReader reader, byte version)
|
||||
{
|
||||
@@ -23,7 +23,7 @@ public class GuardianAngelRoleOptions : IRoleOptions
|
||||
|
||||
options.Cooldown = reader.ReadByte();
|
||||
options.ProtectionDurationSeconds = reader.ReadByte();
|
||||
options.EmpostorsCanSeeProtect = reader.ReadBoolean();
|
||||
options.ImpostorsCanSeeProtect = reader.ReadBoolean();
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -32,6 +32,6 @@ public class GuardianAngelRoleOptions : IRoleOptions
|
||||
{
|
||||
writer.Write(Cooldown);
|
||||
writer.Write(ProtectionDurationSeconds);
|
||||
writer.Write(EmpostorsCanSeeProtect);
|
||||
writer.Write(ImpostorsCanSeeProtect);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public class LegacyRoleOptionsData
|
||||
|
||||
public byte GuardianAngelCooldown { get; set; } = 60;
|
||||
|
||||
public bool EmpostorsCanSeeProtect { get; set; }
|
||||
public bool ImpostorsCanSeeProtect { get; set; }
|
||||
|
||||
public byte ProtectionDurationSeconds { get; set; } = 10;
|
||||
|
||||
@@ -46,7 +46,7 @@ public class LegacyRoleOptionsData
|
||||
roleOptionsData.EngineerInVentMaxTime = reader.ReadByte();
|
||||
roleOptionsData.ScientistBatteryCharge = reader.ReadByte();
|
||||
roleOptionsData.ProtectionDurationSeconds = reader.ReadByte();
|
||||
roleOptionsData.EmpostorsCanSeeProtect = reader.ReadBoolean();
|
||||
roleOptionsData.ImpostorsCanSeeProtect = reader.ReadBoolean();
|
||||
return roleOptionsData;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,6 @@ public class LegacyRoleOptionsData
|
||||
writer.Write(EngineerInVentMaxTime);
|
||||
writer.Write(ScientistBatteryCharge);
|
||||
writer.Write(ProtectionDurationSeconds);
|
||||
writer.Write(EmpostorsCanSeeProtect);
|
||||
writer.Write(ImpostorsCanSeeProtect);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ public class NoisemakerRoleOptions : IRoleOptions
|
||||
|
||||
public RoleTypes Type => RoleTypes.Noisemaker;
|
||||
|
||||
public bool EmpostorAlert { get; set; } = true;
|
||||
public bool ImpostorAlert { get; set; } = true;
|
||||
|
||||
public byte AlertDuration { get; set; } = 10;
|
||||
|
||||
@@ -20,7 +20,7 @@ public class NoisemakerRoleOptions : IRoleOptions
|
||||
var options = new NoisemakerRoleOptions(version);
|
||||
|
||||
options.AlertDuration = reader.ReadByte();
|
||||
options.EmpostorAlert = reader.ReadBoolean();
|
||||
options.ImpostorAlert = reader.ReadBoolean();
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -28,6 +28,6 @@ public class NoisemakerRoleOptions : IRoleOptions
|
||||
public void Serialize(IMessageWriter writer)
|
||||
{
|
||||
writer.Write(AlertDuration);
|
||||
writer.Write(EmpostorAlert);
|
||||
writer.Write(ImpostorAlert);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ public class RoleOptionsCollection
|
||||
RoleTypes.Detective => DetectiveRoleOptions.Deserialize(roleOptionsReader, Version),
|
||||
RoleTypes.Viper => ViperRoleOptions.Deserialize(roleOptionsReader, Version),
|
||||
RoleTypes.Judge => JudgeRoleOptions.Deserialize(roleOptionsReader, Version),
|
||||
RoleTypes.SpiritGuide => SpiritGuideRoleOptions.Deserialize(roleOptionsReader, Version),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(roleType), roleType, null),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Empostor.Api.Innersloth.GameOptions.RoleOptions;
|
||||
|
||||
public class SpiritGuideRoleOptions : IRoleOptions
|
||||
{
|
||||
public SpiritGuideRoleOptions(byte version)
|
||||
{
|
||||
Version = version;
|
||||
}
|
||||
|
||||
public byte Version { get; }
|
||||
|
||||
public RoleTypes Type => RoleTypes.SpiritGuide;
|
||||
|
||||
public float SpiritGuideCooldownSeconds { get; set; } = 20f;
|
||||
|
||||
public static SpiritGuideRoleOptions Deserialize(IMessageReader reader, byte version)
|
||||
{
|
||||
var options = new SpiritGuideRoleOptions(version);
|
||||
|
||||
options.SpiritGuideCooldownSeconds = reader.ReadByte();
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public void Serialize(IMessageWriter writer)
|
||||
{
|
||||
writer.Write((byte)SpiritGuideCooldownSeconds);
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
namespace Empostor.Api.Innersloth
|
||||
namespace Empostor.Api.Innersloth
|
||||
{
|
||||
public static class TextBox
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace Empostor.Api.Net.Messages.Rpcs
|
||||
{
|
||||
public static class Rpc67SpiritGuideMessage
|
||||
{
|
||||
public static void Serialize(IMessageWriter writer, byte[] images)
|
||||
{
|
||||
writer.WriteBytesAndSize(images);
|
||||
}
|
||||
|
||||
public static void Deserialize(IMessageReader reader, out ReadOnlyMemory<byte> images)
|
||||
{
|
||||
images = reader.ReadBytesAndSize();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -93,12 +93,16 @@ public abstract class JsonDataStore<TData> : IDisposable
|
||||
/// Load data from the file on disk. If the file does not exist or fails to parse,
|
||||
/// the store remains in its default (constructor-initialized) state.
|
||||
/// </summary>
|
||||
protected void Load()
|
||||
/// <returns>
|
||||
/// <c>true</c> when a persisted snapshot was applied, <c>false</c> when the store kept its
|
||||
/// constructor defaults (no file yet or unreadable). Callers use this to seed from config.
|
||||
/// </returns>
|
||||
protected bool Load()
|
||||
{
|
||||
if (!File.Exists(FilePath))
|
||||
{
|
||||
Logger.LogDebug("{Name} no data file at {Path}, using defaults", GetType().Name, FilePath);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -109,12 +113,15 @@ public abstract class JsonDataStore<TData> : IDisposable
|
||||
{
|
||||
ApplySnapshot(data);
|
||||
Logger.LogInformation("{Name} loaded from {Path}", GetType().Name, FilePath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "{Name} failed to load {Path}, using defaults", GetType().Name, FilePath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Admin;
|
||||
|
||||
namespace Empostor.Plugin.Chat;
|
||||
|
||||
public sealed class ChatAdminExtension : IAdminExtension
|
||||
{
|
||||
private const int MessagePreviewLength = 220;
|
||||
|
||||
private readonly ChatStore _store;
|
||||
private readonly ChatConfig _config;
|
||||
|
||||
private string _room = string.Empty;
|
||||
private bool _paused;
|
||||
private List<ChatEntry> _frozen = new();
|
||||
|
||||
public ChatAdminExtension(ChatStore store)
|
||||
{
|
||||
_store = store;
|
||||
_config = ChatConfig.Load();
|
||||
}
|
||||
|
||||
public string Id => "chat-monitor";
|
||||
|
||||
public string Title => "Chat Monitor";
|
||||
|
||||
public string Icon => "chat";
|
||||
|
||||
public string Section => "Server";
|
||||
|
||||
public void Build(AdminPanelBuilder b)
|
||||
{
|
||||
var visible = Math.Max(10, _config.MaxVisibleMessages);
|
||||
var entries = _paused ? _frozen : _store.Recent(_room, visible);
|
||||
|
||||
b.RegisterSelect(sel =>
|
||||
{
|
||||
sel.Label = "Room";
|
||||
sel.Options = new List<AdminOption> { new("All rooms", string.Empty) };
|
||||
foreach (var room in _store.Rooms())
|
||||
{
|
||||
sel.Options.Add(new AdminOption(room, room));
|
||||
}
|
||||
|
||||
sel.Value = _room;
|
||||
sel.OnChange(ctx =>
|
||||
{
|
||||
_room = ctx.Value ?? string.Empty;
|
||||
if (_paused)
|
||||
{
|
||||
_frozen = _store.Recent(_room, visible);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(AdminActionResult.Ok(null));
|
||||
});
|
||||
});
|
||||
|
||||
b.RegisterToggle(t =>
|
||||
{
|
||||
t.Label = "Pause auto-refresh (the list stops moving while you read)";
|
||||
t.Value = _paused;
|
||||
t.OnChange(ctx =>
|
||||
{
|
||||
var pause = string.Equals(ctx.Value, "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (pause && !_paused)
|
||||
{
|
||||
_frozen = _store.Recent(_room, visible);
|
||||
}
|
||||
|
||||
_paused = pause;
|
||||
return ValueTask.FromResult(AdminActionResult.Ok(
|
||||
pause ? "Paused. Press Refresh to pull the latest lines." : "Resumed — updating every second."));
|
||||
});
|
||||
});
|
||||
|
||||
b.RegisterTable(t =>
|
||||
{
|
||||
t.Columns = new List<string> { "Time", "Room", "Player", "Channel", "Message" };
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
t.Rows.Add(new List<AdminTableCell>
|
||||
{
|
||||
new() { Text = entry.Time.ToLocalTime().ToString("HH:mm:ss"), Monospace = true },
|
||||
new() { Text = entry.GameCode, Monospace = true, Tone = "accent" },
|
||||
new()
|
||||
{
|
||||
Text = string.IsNullOrEmpty(entry.FriendCode)
|
||||
? entry.PlayerName
|
||||
: $"{entry.PlayerName} ({entry.FriendCode})",
|
||||
},
|
||||
new() { Text = entry.Channel, Tone = entry.Blocked ? "danger" : "muted" },
|
||||
new() { Text = Preview(entry.Message) },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
b.RegisterText(txt =>
|
||||
{
|
||||
txt.Tone = "muted";
|
||||
txt.Content = _store.Count == 0
|
||||
? "No chat captured yet. Lines appear here as players talk in game."
|
||||
: $"Showing {entries.Count} of {_store.Count} buffered line(s), buffer limit {_store.Capacity}"
|
||||
+ (_paused ? " · PAUSED" : " · live");
|
||||
});
|
||||
|
||||
b.RegisterButton(btn =>
|
||||
{
|
||||
btn.Label = "Refresh";
|
||||
btn.Icon = "refresh";
|
||||
btn.Style = "secondary";
|
||||
btn.OnClick(ctx =>
|
||||
{
|
||||
if (!_paused)
|
||||
{
|
||||
return ValueTask.FromResult(AdminActionResult.Ok("Live view — already updating every second."));
|
||||
}
|
||||
|
||||
_frozen = _store.Recent(_room, visible);
|
||||
return ValueTask.FromResult(AdminActionResult.Ok("Snapshot refreshed."));
|
||||
});
|
||||
});
|
||||
|
||||
b.RegisterButton(btn =>
|
||||
{
|
||||
btn.Label = "Clear Buffer";
|
||||
btn.Icon = "trash";
|
||||
btn.Style = "danger";
|
||||
btn.OnClick(ctx =>
|
||||
{
|
||||
_store.Clear();
|
||||
_frozen = new List<ChatEntry>();
|
||||
return ValueTask.FromResult(AdminActionResult.Ok("Chat buffer cleared."));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Keeps long or multi-line messages from blowing up the table layout.</summary>
|
||||
private static string Preview(string message)
|
||||
{
|
||||
var flat = message.Replace('\r', ' ').Replace('\n', ' ').Trim();
|
||||
return flat.Length > MessagePreviewLength ? flat.Substring(0, MessagePreviewLength) + "…" : flat;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Empostor.Plugin.Chat;
|
||||
|
||||
public sealed class ChatConfig
|
||||
@@ -7,4 +11,55 @@ public sealed class ChatConfig
|
||||
public int HostMaxMessageLength { get; set; } = 1200;
|
||||
|
||||
public string TooLongMessage { get; set; } = "[SERVER] Couldn't send your message, it was too long.";
|
||||
|
||||
/// <summary>Keep a rolling copy of in-game chat for the admin panel's Chat Monitor page.</summary>
|
||||
public bool CaptureMessages { get; set; } = true;
|
||||
|
||||
/// <summary>How many chat lines to keep in memory. Oldest lines are dropped first.</summary>
|
||||
public int MaxBufferedMessages { get; set; } = 500;
|
||||
|
||||
/// <summary>How many lines the Chat Monitor renders per refresh.</summary>
|
||||
public int MaxVisibleMessages { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>boot_chat.json</c> next to the server executable, writing the file with defaults
|
||||
/// when it is missing. Shared by <see cref="ChatService"/> and <see cref="ChatStore"/>.
|
||||
/// </summary>
|
||||
public static ChatConfig Load()
|
||||
{
|
||||
var path = Path.Combine(Directory.GetCurrentDirectory(), "boot_chat.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(
|
||||
path,
|
||||
JsonSerializer.Serialize(new { Chat = new ChatConfig() }, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Read-only volume: fall through to the in-memory defaults.
|
||||
}
|
||||
|
||||
return new ChatConfig();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
if (doc.RootElement.TryGetProperty("Chat", out var chatEl))
|
||||
{
|
||||
var cfg = JsonSerializer.Deserialize<ChatConfig>(chatEl.GetRawText());
|
||||
if (cfg != null)
|
||||
{
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
}
|
||||
|
||||
return new ChatConfig();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Empostor.Api.Events.Player;
|
||||
using Empostor.Api.Innersloth;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugin.Chat;
|
||||
|
||||
public sealed class ChatService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
|
||||
|
||||
private readonly ILogger<ChatService> _logger;
|
||||
private readonly ChatConfig _config;
|
||||
private readonly ChatStore _store;
|
||||
|
||||
public ChatService(ILogger<ChatService> logger)
|
||||
public ChatService(ILogger<ChatService> logger, ChatStore store)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = LoadConfig();
|
||||
_config = ChatConfig.Load();
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public void HandleChatMessage(IPlayerChatEvent e)
|
||||
@@ -44,6 +43,7 @@ public sealed class ChatService
|
||||
|
||||
var isHost = e.ClientPlayer.IsHost;
|
||||
var maxLength = isHost ? _config.HostMaxMessageLength : _config.PlayerMaxMessageLength;
|
||||
var blocked = false;
|
||||
|
||||
if (e.Message.Length > maxLength)
|
||||
{
|
||||
@@ -53,33 +53,21 @@ public sealed class ChatService
|
||||
|
||||
e.PlayerControl.SendChatToPlayerAsync(_config.TooLongMessage, e.PlayerControl);
|
||||
e.IsCancelled = true;
|
||||
blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static ChatConfig LoadConfig()
|
||||
{
|
||||
var path = Path.Combine(Directory.GetCurrentDirectory(), "boot_chat.json");
|
||||
if (!File.Exists(path))
|
||||
// Feed the admin panel's Chat Monitor. Off by default only if an operator opts out.
|
||||
if (_config.CaptureMessages)
|
||||
{
|
||||
var defaults = new ChatConfig();
|
||||
var json = JsonSerializer.Serialize(new { Chat = defaults }, JsonOpts);
|
||||
File.WriteAllText(path, json);
|
||||
return defaults;
|
||||
_store.Add(new ChatEntry(
|
||||
DateTime.UtcNow,
|
||||
e.Game != null ? GameCodeParser.IntToGameName(e.Game.Code) : "—",
|
||||
playerName,
|
||||
e.ClientPlayer.Client.FriendCode,
|
||||
e.ClientPlayer.Client.Id,
|
||||
blocked ? "Blocked" : channelName,
|
||||
e.Message,
|
||||
blocked));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("Chat", out var chatEl))
|
||||
{
|
||||
var cfg = JsonSerializer.Deserialize<ChatConfig>(chatEl.GetRawText());
|
||||
if (cfg != null)
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
catch (JsonException) { }
|
||||
|
||||
return new ChatConfig();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Empostor.Api.Admin;
|
||||
using Empostor.Api.Events;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -11,7 +12,9 @@ public sealed class ChatStartup : IPluginStartup
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ChatStore>();
|
||||
services.AddSingleton<ChatService>();
|
||||
services.AddSingleton<IEventListener, ChatEventListener>();
|
||||
services.AddSingleton<IAdminExtension, ChatAdminExtension>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Empostor.Plugin.Chat;
|
||||
|
||||
/// <summary>One captured in-game chat line.</summary>
|
||||
public sealed record ChatEntry(
|
||||
DateTime Time,
|
||||
string GameCode,
|
||||
string PlayerName,
|
||||
string? FriendCode,
|
||||
int ClientId,
|
||||
string Channel,
|
||||
string Message,
|
||||
bool Blocked);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory ring buffer of chat lines, fed by <see cref="ChatService"/>. Nothing is written to
|
||||
/// disk — the admin panel is the only reader and the history is meant to be short-lived.
|
||||
/// </summary>
|
||||
public sealed class ChatStore
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly LinkedList<ChatEntry> _entries = new();
|
||||
private readonly int _capacity;
|
||||
|
||||
public ChatStore()
|
||||
{
|
||||
var config = ChatConfig.Load();
|
||||
_capacity = Math.Max(50, config.MaxBufferedMessages);
|
||||
}
|
||||
|
||||
/// <summary>Oldest lines are dropped once the buffer is full.</summary>
|
||||
public int Capacity => _capacity;
|
||||
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _entries.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(ChatEntry entry)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.AddLast(entry);
|
||||
while (_entries.Count > _capacity)
|
||||
{
|
||||
_entries.RemoveFirst();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Newest first, optionally limited to one room.</summary>
|
||||
public List<ChatEntry> Recent(string? gameCode, int limit)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var query = _entries.AsEnumerable();
|
||||
if (!string.IsNullOrWhiteSpace(gameCode))
|
||||
{
|
||||
query = query.Where(x => string.Equals(x.GameCode, gameCode, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return query.Reverse().Take(Math.Max(1, limit)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Room codes that currently have buffered lines, so the panel can offer a filter.</summary>
|
||||
public List<string> Rooms()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _entries
|
||||
.Select(x => x.GameCode)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,14 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Admin;
|
||||
using Empostor.Api.Games;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugin.Code;
|
||||
|
||||
/// <summary>
|
||||
/// Admin panel page for the room-code pool: a searchable, editable list of every code
|
||||
/// (available and in use), an add row, and a reload button for Boot.Codes word lists.
|
||||
/// </summary>
|
||||
public sealed class CodeAdminExtension : IAdminExtension
|
||||
{
|
||||
private readonly IGameCodeManager _manager;
|
||||
private readonly ILogger<CodeAdminExtension> _logger;
|
||||
|
||||
public CodeAdminExtension(IGameCodeManager manager)
|
||||
public CodeAdminExtension(IGameCodeManager manager, ILogger<CodeAdminExtension> logger)
|
||||
{
|
||||
_manager = manager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Id => "game-codes";
|
||||
@@ -19,12 +32,128 @@ public sealed class CodeAdminExtension : IAdminExtension
|
||||
|
||||
public void Build(AdminPanelBuilder b)
|
||||
{
|
||||
b.RegisterText(
|
||||
$"Loaded {_manager.SixCharCodes} six-character and {_manager.FourCharCodes} four-character code(s).",
|
||||
"default");
|
||||
b.RegisterText($"Source directory: {_manager.Path}", "muted", monospace: true);
|
||||
b.RegisterText(
|
||||
"Codes are read from .txt files at startup. Add or edit files in the directory, then restart the server to reload.",
|
||||
"muted");
|
||||
var entries = _manager.Entries;
|
||||
var inUse = entries.Count(e => e.InUse);
|
||||
|
||||
b.RegisterEntries(ent =>
|
||||
{
|
||||
ent.Fields = new List<AdminEntryField>
|
||||
{
|
||||
new() { Key = "code", Label = "Room Code", Monospace = true },
|
||||
};
|
||||
ent.Rows = entries
|
||||
.Select(e => new Dictionary<string, string> { ["code"] = e.Code.Code })
|
||||
.ToList();
|
||||
ent.SearchPlaceholder = $"Search {entries.Count} code(s)...";
|
||||
ent.AddLabel = "Add code";
|
||||
ent.OnAdd(OnAddEntry);
|
||||
ent.OnEdit(OnEditEntry);
|
||||
ent.OnRemove(OnRemoveEntry);
|
||||
});
|
||||
|
||||
b.RegisterText(t =>
|
||||
{
|
||||
t.Tone = "muted";
|
||||
t.Content = $"{_manager.FourCharCodes} four-char + {_manager.SixCharCodes} six-char code(s) loaded "
|
||||
+ $"({entries.Count} total: {entries.Count - inUse} available, {inUse} in use). "
|
||||
+ $"Source directory: {_manager.Path} — codes added here are also saved to 00-admin-added.txt.";
|
||||
});
|
||||
|
||||
b.RegisterButton(btn =>
|
||||
{
|
||||
btn.Label = "Reload from Boot.Codes";
|
||||
btn.Icon = "refresh";
|
||||
btn.Style = "secondary";
|
||||
btn.OnClick(ctx =>
|
||||
{
|
||||
var count = _manager.Reload();
|
||||
return ValueTask.FromResult(AdminActionResult.Ok(
|
||||
$"{count} code(s) loaded from Boot.Codes (codes in use were kept)."));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private ValueTask<AdminActionResult> OnAddEntry(AdminActionContext ctx)
|
||||
{
|
||||
var code = ReadCode(ctx);
|
||||
if (code == null)
|
||||
{
|
||||
return ValueTask.FromResult(AdminActionResult.Fail("Enter a room code (4 or 6 letters)."));
|
||||
}
|
||||
|
||||
var (ok, message) = _manager.Add(code);
|
||||
return ValueTask.FromResult(ok ? AdminActionResult.Ok(message) : AdminActionResult.Fail(message));
|
||||
}
|
||||
|
||||
/// <summary>Editing a code = add the replacement, then drop the old one.</summary>
|
||||
private ValueTask<AdminActionResult> OnEditEntry(AdminActionContext ctx)
|
||||
{
|
||||
var newCode = ReadCode(ctx);
|
||||
if (newCode == null)
|
||||
{
|
||||
return ValueTask.FromResult(AdminActionResult.Fail("Enter a room code (4 or 6 letters)."));
|
||||
}
|
||||
|
||||
var oldText = ctx.Value?.Trim().ToUpperInvariant();
|
||||
if (string.Equals(oldText, newCode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValueTask.FromResult(AdminActionResult.Ok("Code unchanged."));
|
||||
}
|
||||
|
||||
var (addOk, addMessage) = _manager.Add(newCode);
|
||||
if (!addOk)
|
||||
{
|
||||
return ValueTask.FromResult(AdminActionResult.Fail(addMessage));
|
||||
}
|
||||
|
||||
var note = string.Empty;
|
||||
if (!string.IsNullOrEmpty(oldText))
|
||||
{
|
||||
var oldCode = new GameCode(oldText);
|
||||
if (!oldCode.IsInvalid)
|
||||
{
|
||||
var (_, removeMessage) = _manager.Remove(oldCode);
|
||||
note = removeMessage;
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(AdminActionResult.Ok($"Code changed to {newCode}." + note));
|
||||
}
|
||||
|
||||
private ValueTask<AdminActionResult> OnRemoveEntry(AdminActionContext ctx)
|
||||
{
|
||||
var text = ctx.Value?.Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return ValueTask.FromResult(AdminActionResult.Fail("Missing room code."));
|
||||
}
|
||||
|
||||
var code = new GameCode(text);
|
||||
if (code.IsInvalid)
|
||||
{
|
||||
return ValueTask.FromResult(AdminActionResult.Fail("Not a valid room code."));
|
||||
}
|
||||
|
||||
var (ok, message) = _manager.Remove(code);
|
||||
return ValueTask.FromResult(ok ? AdminActionResult.Ok(message) : AdminActionResult.Fail(message));
|
||||
}
|
||||
|
||||
private static string? ReadCode(AdminActionContext ctx)
|
||||
{
|
||||
if (ctx.Payload == null || ctx.Payload.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ctx.Payload.Value.TryGetProperty("row", out var rowEl)
|
||||
|| rowEl.ValueKind != JsonValueKind.Object
|
||||
|| !rowEl.TryGetProperty("code", out var codeEl)
|
||||
|| codeEl.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var code = codeEl.GetString()?.Trim();
|
||||
return string.IsNullOrWhiteSpace(code) ? null : code;
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,18 @@ public sealed class GameCodeManager : IGameCodeManager
|
||||
|
||||
private readonly ILogger<GameCodeManager> _logger;
|
||||
private readonly IGameCodeFactory _codeFactory;
|
||||
private readonly List<GameCode> _codes;
|
||||
private readonly HashSet<GameCode> _inUse;
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<GameCode, string> _source = new();
|
||||
private List<GameCode> _codes = new();
|
||||
private HashSet<GameCode> _inUse = new();
|
||||
|
||||
private string AdminFilePath => System.IO.Path.Combine(Path, "00-admin-added.txt");
|
||||
|
||||
public string Path => System.IO.Path.GetFullPath("Boot.Codes");
|
||||
|
||||
public int SixCharCodes { get; }
|
||||
public int SixCharCodes { get; private set; }
|
||||
|
||||
public int FourCharCodes { get; }
|
||||
public int FourCharCodes { get; private set; }
|
||||
|
||||
public GameCodeManager(ILogger<GameCodeManager> logger, IGameCodeFactory codeFactory)
|
||||
{
|
||||
@@ -57,6 +60,7 @@ public sealed class GameCodeManager : IGameCodeManager
|
||||
}
|
||||
|
||||
var seen = new HashSet<GameCode>();
|
||||
_source.Clear();
|
||||
|
||||
foreach (var fileInfo in dirInfo.GetFiles())
|
||||
{
|
||||
@@ -78,7 +82,10 @@ public sealed class GameCodeManager : IGameCodeManager
|
||||
|
||||
var item = new GameCode(codeText);
|
||||
if (!item.IsInvalid)
|
||||
{
|
||||
seen.Add(item);
|
||||
_source[item] = fileInfo.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,4 +121,119 @@ public sealed class GameCodeManager : IGameCodeManager
|
||||
_codes.Add(code);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<PoolEntry> Entries
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var entries = new List<PoolEntry>(_codes.Count + _inUse.Count);
|
||||
foreach (var code in _codes) entries.Add(new PoolEntry(code, false, SourceLabel(code)));
|
||||
foreach (var code in _inUse) entries.Add(new PoolEntry(code, true, SourceLabel(code)));
|
||||
entries.Sort((a, b) => string.Compare(a.Code.Code, b.Code.Code, StringComparison.OrdinalIgnoreCase));
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public (bool Ok, string Message) Add(string codeText)
|
||||
{
|
||||
var text = codeText.Trim().ToUpperInvariant();
|
||||
if (text.Length != 4 && text.Length != 6)
|
||||
return (false, "Room code must be 4 or 6 letters.");
|
||||
|
||||
if (!text.All(c => V2Chars.Contains(c)))
|
||||
return (false, "Room code may only contain letters.");
|
||||
|
||||
var code = new GameCode(text);
|
||||
if (code.IsInvalid)
|
||||
return (false, "Not a valid room code.");
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (_codes.Contains(code) || _inUse.Contains(code))
|
||||
return (false, $"{text} is already in the pool.");
|
||||
|
||||
_codes.Add(code);
|
||||
_source[code] = AdminFilePath;
|
||||
}
|
||||
|
||||
// Persist so admin-added codes survive a restart (Read() merges this file back in).
|
||||
try
|
||||
{
|
||||
File.AppendAllLines(AdminFilePath, new[] { text });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[Code] Failed to append {Code} to {File}", text, AdminFilePath);
|
||||
}
|
||||
|
||||
return (true, $"Code {text} added to the pool.");
|
||||
}
|
||||
|
||||
public (bool Ok, string Message) Remove(GameCode code)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var fromAvailable = _codes.Remove(code);
|
||||
var fromInUse = _inUse.Remove(code);
|
||||
if (!fromAvailable && !fromInUse)
|
||||
return (false, "That code is not in the pool.");
|
||||
|
||||
var note = string.Empty;
|
||||
if (_source.TryGetValue(code, out var source) && source != AdminFilePath)
|
||||
note = $" It comes from {source} — remove it there too, or it will return on reload.";
|
||||
|
||||
_source.Remove(code);
|
||||
|
||||
try
|
||||
{
|
||||
RemoveFromAdminFile(code.Code);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[Code] Failed to remove {Code} from {File}", code.Code, AdminFilePath);
|
||||
}
|
||||
|
||||
return (true, $"Code {code.Code} removed." + note);
|
||||
}
|
||||
}
|
||||
|
||||
public int Reload()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var kept = new List<GameCode>(_inUse);
|
||||
_source.Clear();
|
||||
|
||||
var list = Read();
|
||||
foreach (var code in kept)
|
||||
{
|
||||
if (!list.Contains(code)) list.Add(code);
|
||||
if (!_source.ContainsKey(code)) _source[code] = "(in use)";
|
||||
}
|
||||
|
||||
Extensions.Shuffle(list);
|
||||
FourCharCodes = list.Count(c => c.Code.Length == 4);
|
||||
SixCharCodes = list.Count(c => c.Code.Length == 6);
|
||||
_codes = list;
|
||||
_inUse = new HashSet<GameCode>(kept);
|
||||
return _codes.Count;
|
||||
}
|
||||
}
|
||||
|
||||
private string SourceLabel(GameCode code)
|
||||
=> _source.TryGetValue(code, out var source) ? source : string.Empty;
|
||||
|
||||
private void RemoveFromAdminFile(string code)
|
||||
{
|
||||
var file = AdminFilePath;
|
||||
if (!File.Exists(file)) return;
|
||||
|
||||
var kept = File.ReadAllLines(file)
|
||||
.Where(l => !l.Trim().Equals(code, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
File.WriteAllLines(file, kept);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using Empostor.Api.Games;
|
||||
|
||||
namespace Empostor.Plugin.Code;
|
||||
|
||||
/// <summary>One room code in the pool, for the admin panel's entry list.</summary>
|
||||
public sealed record PoolEntry(GameCode Code, bool InUse, string Source);
|
||||
|
||||
public interface IGameCodeManager
|
||||
{
|
||||
int SixCharCodes { get; }
|
||||
@@ -13,4 +17,22 @@ public interface IGameCodeManager
|
||||
GameCode Get();
|
||||
|
||||
void Release(GameCode code);
|
||||
|
||||
/// <summary>Every tracked code (available + in use), sorted by code text.</summary>
|
||||
IReadOnlyList<PoolEntry> Entries { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds a code to the available pool and appends it to <c>Boot.Codes/00-admin-added.txt</c>
|
||||
/// so it survives a restart. Returns (true, confirmation) or (false, reason).
|
||||
/// </summary>
|
||||
(bool Ok, string Message) Add(string codeText);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a code from the pool. Returns (true, message) — the message may carry a note
|
||||
/// when the code comes from a hand-maintained word list file.
|
||||
/// </summary>
|
||||
(bool Ok, string Message) Remove(GameCode code);
|
||||
|
||||
/// <summary>Re-reads <c>Boot.Codes/*.txt</c>. Codes currently in use are kept.</summary>
|
||||
int Reload();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using Empostor.Api.Admin;
|
||||
|
||||
namespace Empostor.Plugins.DiscordWebhook;
|
||||
@@ -47,5 +48,50 @@ public sealed class DiscordWebhookAdminExtension : IAdminExtension
|
||||
return AdminActionResult.Ok("Admin webhook saved.");
|
||||
}));
|
||||
});
|
||||
|
||||
b.RegisterBlock(block =>
|
||||
{
|
||||
block.Title = "Live room tracking";
|
||||
block.Description = "With live room updates on, each lobby gets one Discord message that is edited as the "
|
||||
+ "lobby changes and deleted when the lobby closes. Player Banned / Player Reported always post "
|
||||
+ "one-shot messages to the Admin URL.";
|
||||
|
||||
block.Children.Add(new AdminToggle
|
||||
{
|
||||
Label = "Live room updates",
|
||||
Value = _store.LiveRoomUpdates,
|
||||
}.OnChange(async ctx =>
|
||||
{
|
||||
_store.LiveRoomUpdates = string.Equals(ctx.Value, "true", StringComparison.OrdinalIgnoreCase);
|
||||
await _store.SaveAsync();
|
||||
return AdminActionResult.Ok($"Live room updates {(_store.LiveRoomUpdates ? "enabled" : "disabled")}.");
|
||||
}));
|
||||
|
||||
block.Children.Add(new AdminToggle
|
||||
{
|
||||
Label = "Delete the message when the lobby closes",
|
||||
Value = _store.DeleteOnClose,
|
||||
}.OnChange(async ctx =>
|
||||
{
|
||||
_store.DeleteOnClose = string.Equals(ctx.Value, "true", StringComparison.OrdinalIgnoreCase);
|
||||
await _store.SaveAsync();
|
||||
return AdminActionResult.Ok($"Delete on close {(_store.DeleteOnClose ? "enabled" : "disabled")}.");
|
||||
}));
|
||||
|
||||
block.Children.Add(new AdminNumber
|
||||
{
|
||||
Label = "Minimum seconds between two live updates",
|
||||
Value = _store.MinUpdateIntervalSeconds,
|
||||
Min = 1,
|
||||
Max = 60,
|
||||
}.OnChange(async ctx =>
|
||||
{
|
||||
_store.MinUpdateIntervalSeconds = int.TryParse(ctx.Value, out var seconds)
|
||||
? Math.Clamp(seconds, 1, 60)
|
||||
: 3;
|
||||
await _store.SaveAsync();
|
||||
return AdminActionResult.Ok($"Minimum live update interval set to {_store.MinUpdateIntervalSeconds}s.");
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,16 @@ public sealed class DiscordWebhookConfig
|
||||
|
||||
/// <summary>Legacy single-webhook fallback used when both URLs are empty.</summary>
|
||||
public string WebhookUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Mirror each lobby with one persistent message that gets edited as the lobby changes,
|
||||
/// instead of posting a separate message per event.
|
||||
/// </summary>
|
||||
public bool LiveRoomUpdates { get; set; } = true;
|
||||
|
||||
/// <summary>Delete the live message when its lobby is destroyed.</summary>
|
||||
public bool DeleteOnClose { get; set; } = true;
|
||||
|
||||
/// <summary>Minimum seconds between two edits of the same lobby message (Discord rate limits).</summary>
|
||||
public int MinUpdateIntervalSeconds { get; set; } = 3;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
@@ -7,6 +8,7 @@ using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Events;
|
||||
using Empostor.Api.Events.Player;
|
||||
using Empostor.Api.Games;
|
||||
using Empostor.Api.Innersloth;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -14,29 +16,80 @@ namespace Empostor.Plugins.DiscordWebhook;
|
||||
|
||||
public sealed class DiscordWebhookListener : IEventListener
|
||||
{
|
||||
private const int ColorLobby = 3447003;
|
||||
private const int ColorInGame = 3066993;
|
||||
private const int ColorEnded = 15105570;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };
|
||||
|
||||
private readonly ILogger<DiscordWebhookListener> _logger;
|
||||
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();
|
||||
private readonly ConcurrentDictionary<int, byte> _flushScheduled = new();
|
||||
|
||||
public DiscordWebhookListener(
|
||||
ILogger<DiscordWebhookListener> logger,
|
||||
IHttpClientFactory http,
|
||||
DiscordWebhookStore config)
|
||||
DiscordWebhookStore config,
|
||||
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)
|
||||
{
|
||||
var url = _config.MatchmakerUrl;
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
if (_toggle.IsMuted(e.Game.Code))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = SendAsync(url, "Game Created", 3066993, new()
|
||||
if (_config.LiveRoomUpdates)
|
||||
{
|
||||
// The live message is created on the first player join instead: at creation time the
|
||||
// host has not joined yet, so the embed would be born as an empty 0/15 lobby.
|
||||
return;
|
||||
}
|
||||
|
||||
SendOneShot("Game Created", ColorLobby, new()
|
||||
{
|
||||
["Host"] = e.Host?.Name ?? "—",
|
||||
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
||||
@@ -50,68 +103,91 @@ public sealed class DiscordWebhookListener : IEventListener
|
||||
[EventListener]
|
||||
public void OnGameStarted(IGameStartedEvent e)
|
||||
{
|
||||
var url = _config.MatchmakerUrl;
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
if (_toggle.IsMuted(e.Game.Code)) { return; }
|
||||
|
||||
_ = SendAsync(url, "Game Started", 3447003, new()
|
||||
{
|
||||
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
||||
["Map"] = e.Game.Options.Map.ToString(),
|
||||
["Players"] = e.Game.PlayerCount.ToString(),
|
||||
["Impostors"] = e.Game.Options.NumImpostors.ToString(),
|
||||
});
|
||||
if (!_config.LiveRoomUpdates) { SendOneShot("Game Started", ColorInGame, LegacyFields(e.Game)); return; }
|
||||
_ = UpdateLobbyAsync(e.Game, force: true);
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnGameEnded(IGameEndedEvent e)
|
||||
{
|
||||
var url = _config.MatchmakerUrl;
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
|
||||
_ = SendAsync(url, "Game Ended", 10181046, new()
|
||||
if (_toggle.IsMuted(e.Game.Code))
|
||||
{
|
||||
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
||||
["Result"] = e.GameOverReason.ToString(),
|
||||
["Players"] = e.Game.PlayerCount.ToString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_config.LiveRoomUpdates)
|
||||
{
|
||||
SendOneShot("Game Ended", ColorEnded, new()
|
||||
{
|
||||
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
||||
["Result"] = e.GameOverReason.ToString(),
|
||||
["Players"] = e.Game.PlayerCount.ToString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_ = UpdateLobbyAsync(e.Game, force: true, resultOverride: e.GameOverReason.ToString());
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnGameDestroyed(IGameDestroyedEvent e)
|
||||
{
|
||||
var url = _config.MatchmakerUrl;
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
|
||||
_ = SendAsync(url, "Game Destroyed", 15158332, new()
|
||||
if (!_config.LiveRoomUpdates)
|
||||
{
|
||||
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
||||
["Players"] = e.Game.PlayerCount.ToString(),
|
||||
});
|
||||
SendOneShot("Game Destroyed", 15158332, new()
|
||||
{
|
||||
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
||||
["Players"] = e.Game.PlayerCount.ToString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_ = DeleteLiveMessageAsync(e.Game.Code);
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnPlayerJoined(IGamePlayerJoinedEvent e)
|
||||
{
|
||||
if (!_config.LiveRoomUpdates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The first join is the host creating their own lobby entry — that is the moment the live
|
||||
// message is born, with a real host name and player count.
|
||||
if (!_tracker.TryGet(e.Game.Code, out _))
|
||||
{
|
||||
_ = CreateLiveMessageAsync(e.Game);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = UpdateLobbyAsync(e.Game);
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnPlayerLeft(IGamePlayerLeftEvent e)
|
||||
{
|
||||
if (!e.IsBan) return;
|
||||
|
||||
var url = _config.AdminUrl;
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
|
||||
_ = SendAsync(url, "Player Banned", 15158332, new()
|
||||
if (e.IsBan) { SendAdminMessage("Player Banned", 15158332, new()
|
||||
{
|
||||
["Player"] = e.Player.Client.Name,
|
||||
["Friend Code"] = e.Player.Client.FriendCode ?? "—",
|
||||
["Game"] = GameCodeParser.IntToGameName(e.Game.Code),
|
||||
});
|
||||
}); }
|
||||
|
||||
_ = UpdateLobbyAsync(e.Game);
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnHostChanged(IGameHostChangedEvent e) => _ = UpdateLobbyAsync(e.Game);
|
||||
|
||||
// ---- Admin events (always one-shot messages on the admin webhook) ----
|
||||
|
||||
[EventListener]
|
||||
public void OnPlayerReport(IPlayerReportEvent e)
|
||||
{
|
||||
var url = _config.AdminUrl;
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
|
||||
_ = SendAsync(url, "Player Reported", 16776960, new()
|
||||
SendAdminMessage("Player Reported", 16776960, new()
|
||||
{
|
||||
["Reporter"] = e.ClientPlayer.Client.Name,
|
||||
["Reporter FC"] = e.ClientPlayer.Client.FriendCode ?? "—",
|
||||
@@ -122,8 +198,274 @@ public sealed class DiscordWebhookListener : IEventListener
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Live message lifecycle ----
|
||||
|
||||
private async ValueTask CreateLiveMessageAsync(IGame game)
|
||||
{
|
||||
var url = _config.MatchmakerUrl;
|
||||
if (string.IsNullOrWhiteSpace(url) || _toggle.IsMuted(game.Code))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var messageId = await PostLiveMessageAsync(url, BuildLobbyEmbed(game));
|
||||
if (messageId == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_tracker.Track(new TrackedLobby
|
||||
{
|
||||
GameCode = game.Code,
|
||||
GameCodeName = GameCodeParser.IntToGameName(game.Code),
|
||||
WebhookUrl = url,
|
||||
MessageId = messageId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Game = game,
|
||||
});
|
||||
_lastEdit[game.Code] = DateTime.UtcNow;
|
||||
|
||||
_logger.LogInformation(
|
||||
"DiscordWebhook live message created for {Code} (message {MessageId})",
|
||||
GameCodeParser.IntToGameName(game.Code), messageId);
|
||||
|
||||
// The POST round-trip may have straddled a game start / player change; re-render once
|
||||
// shortly after creation so the message never stays on stale creation-time data.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||
if (_tracker.TryGet(game.Code, out var tracked) && tracked.Game != null)
|
||||
{
|
||||
await UpdateLobbyAsync(tracked.Game, force: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "DiscordWebhook failed to create the live message");
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask UpdateLobbyAsync(IGame game, bool force = false, string? resultOverride = null)
|
||||
{
|
||||
if (!_config.LiveRoomUpdates || _toggle.IsMuted(game.Code) || !_tracker.TryGet(game.Code, out var lobby))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var last = _lastEdit.TryGetValue(game.Code, out var previous) ? previous : DateTime.MinValue;
|
||||
if (!force && now - last < TimeSpan.FromSeconds(Math.Max(1, _config.MinUpdateIntervalSeconds)))
|
||||
{
|
||||
ScheduleFlush(game.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
_lastEdit[game.Code] = now;
|
||||
|
||||
try
|
||||
{
|
||||
var embed = BuildLobbyEmbed(game, resultOverride);
|
||||
var gone = await PatchLiveMessageAsync(lobby, embed);
|
||||
if (gone)
|
||||
{
|
||||
// The message was deleted out-of-band (manual cleanup, webhook reset, ...);
|
||||
// stop tracking instead of hammering a 404 forever.
|
||||
_tracker.Untrack(game.Code, out _);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "DiscordWebhook failed to update the live message for {Code}", lobby.GameCodeName);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask DeleteLiveMessageAsync(int gameCode)
|
||||
{
|
||||
_toggle.Clear(gameCode);
|
||||
|
||||
if (!_tracker.Untrack(gameCode, out var lobby))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastEdit.TryRemove(gameCode, out _);
|
||||
|
||||
if (!_config.DeleteOnClose)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = _http.CreateClient();
|
||||
using var response = await client.DeleteAsync($"{TrimUrl(lobby.WebhookUrl)}/messages/{lobby.MessageId}");
|
||||
_logger.LogInformation(
|
||||
"DiscordWebhook deleted the live message for {Code} ({Status})", lobby.GameCodeName, (int)response.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "DiscordWebhook failed to delete the live message for {Code}", lobby.GameCodeName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-renders a lobby whose edit was skipped by the throttle.</summary>
|
||||
private void ScheduleFlush(int gameCode)
|
||||
{
|
||||
if (!_flushScheduled.TryAdd(gameCode, 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Max(1, _config.MinUpdateIntervalSeconds)) + TimeSpan.FromMilliseconds(250));
|
||||
_flushScheduled.TryRemove(gameCode, out _);
|
||||
if (_tracker.TryGet(gameCode, out var lobby) && lobby.Game != null)
|
||||
{
|
||||
await UpdateLobbyAsync(lobby.Game, force: true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "DiscordWebhook deferred flush failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Discord payload / transport ----
|
||||
|
||||
private static object BuildLobbyEmbed(IGame game, string? resultOverride = null)
|
||||
{
|
||||
var code = GameCodeParser.IntToGameName(game.Code);
|
||||
var (status, color) = game.GameState switch
|
||||
{
|
||||
GameStates.Started => ("In Game", ColorInGame),
|
||||
GameStates.Ended => ("Ended", ColorEnded),
|
||||
GameStates.Destroyed => ("Closed", ColorEnded),
|
||||
_ => ("In Lobby", ColorLobby),
|
||||
};
|
||||
|
||||
var fields = new List<object>
|
||||
{
|
||||
new { name = "Status", value = status, inline = true },
|
||||
new { name = "Host", value = game.Host?.Client.Name ?? "—", inline = true },
|
||||
new { name = "Map", value = game.Options.Map.ToString(), inline = true },
|
||||
new { name = "Players", value = $"{game.PlayerCount}/{game.Options.MaxPlayers}", inline = true },
|
||||
new { name = "Impostors", value = game.Options.NumImpostors.ToString(), inline = true },
|
||||
};
|
||||
|
||||
if (resultOverride != null)
|
||||
{
|
||||
fields.Add(new { name = "Result", value = resultOverride, inline = false });
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
embeds = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
title = $"Lobby {code}",
|
||||
color,
|
||||
fields,
|
||||
timestamp = DateTime.UtcNow.ToString("o"),
|
||||
footer = new { text = "Empostor" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async ValueTask<string?> PostLiveMessageAsync(string url, object embed)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(embed, JsonOpts);
|
||||
using var client = _http.CreateClient();
|
||||
using var response = await client.PostAsync(
|
||||
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}: {Body}",
|
||||
(int)response.StatusCode, Truncate(body));
|
||||
return 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)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(embed, JsonOpts);
|
||||
using var client = _http.CreateClient();
|
||||
using var response = await client.PatchAsync(
|
||||
$"{TrimUrl(lobby.WebhookUrl)}/messages/{lobby.MessageId}",
|
||||
new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("DiscordWebhook live message update returned {Status}", (int)response.StatusCode);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- Shared helpers ----
|
||||
|
||||
private static Dictionary<string, string> LegacyFields(IGame game) => new()
|
||||
{
|
||||
["Game"] = GameCodeParser.IntToGameName(game.Code),
|
||||
["Map"] = game.Options.Map.ToString(),
|
||||
["Players"] = game.PlayerCount.ToString(),
|
||||
["Impostors"] = game.Options.NumImpostors.ToString(),
|
||||
};
|
||||
|
||||
private static string TrimUrl(string url) => url.TrimEnd('/');
|
||||
|
||||
private void SendOneShot(string title, int color, Dictionary<string, string> fields)
|
||||
=> _ = SendAsync(_config.MatchmakerUrl, title, color, fields);
|
||||
|
||||
private void SendAdminMessage(string title, int color, Dictionary<string, string> fields)
|
||||
=> _ = SendAsync(_config.AdminUrl, title, color, fields);
|
||||
|
||||
private async ValueTask SendAsync(string url, string title, int color, Dictionary<string, string> fields)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var embed = new
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user