mirror of
https://github.com/Empostor/Empostor.git
synced 2026-10-21 21:43:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d87048b2 | ||
|
|
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.
+21
-60
@@ -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,31 +52,18 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"name": "Custom Title",
|
||||
"description": "Assigns a title based on their friend code and show in front of their name.",
|
||||
"author": "HayashiUme",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"empostor_version": "2.0.0",
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Empostor.Plugins.Titles.dll"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cn.hayashiume.playerchannel",
|
||||
"name": "Player Channel",
|
||||
@@ -103,32 +90,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cn.hayashiume.narrator",
|
||||
"name": "Narrator",
|
||||
"description": "AI-powered meeting assistant using DeepSeek. Use #narrator during meetings to get strategic advice. Host commands: #narrator enable/disable/limit.",
|
||||
"author": "ELinmei",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"empostor_version": "2.0.0",
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/Empostor.Plugin.Narrator.dll"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cn.hayashiume.mapvote",
|
||||
"name": "Map Vote",
|
||||
"description": "using #votemap <map_name> to choose a map for lobby",
|
||||
"author": "ELinmei",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"empostor_version": "2.0.0",
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/Empostor.Plugins.MapVote.dll"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cn.hayashiume.message",
|
||||
"name": "Leave a Message",
|
||||
@@ -150,7 +111,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 +137,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"
|
||||
}
|
||||
]
|
||||
@@ -194,19 +155,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gg.empostor.playerlog",
|
||||
"name": "Player Log",
|
||||
"description": "Logs all player activity (chat, kills, meetings, joins, leaves) and provides an admin panel to view, search, and export logs.",
|
||||
"author": "Empostor",
|
||||
"versions": [
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"empostor_version": "2.0.0",
|
||||
"download_url": "https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/Empostor.Plugins.PlayerLog.dll"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gg.empostor.monitor",
|
||||
"name": "Monitor",
|
||||
@@ -232,5 +180,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);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,11 @@ namespace Empostor.Api.Net
|
||||
/// </summary>
|
||||
string? ProductUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the raw EOS token the player authenticated with.
|
||||
/// </summary>
|
||||
string? EosToken { get; set; }
|
||||
|
||||
ValueTask<bool> ReportCheatAsync(CheatContext context, CheatCategory category, string message);
|
||||
|
||||
[Obsolete("Please use the overload that adds a cheat category")]
|
||||
|
||||
@@ -15,6 +15,11 @@ namespace Empostor.Api.Net
|
||||
/// </summary>
|
||||
IClient Client { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the EOS token the player authenticated with.
|
||||
/// </summary>
|
||||
string? EosToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the game where the <see cref="IClientPlayer" /> belongs to.
|
||||
/// </summary>
|
||||
|
||||
@@ -38,6 +38,11 @@ namespace Empostor.Api.Net.Inner.Objects
|
||||
/// </summary>
|
||||
DeathReason LastDeathReason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the EOS token the owning player authenticated with.
|
||||
/// </summary>
|
||||
string? EosToken { get; }
|
||||
|
||||
IEnumerable<ITaskInfo> Tasks { get; }
|
||||
|
||||
DateTimeOffset LastMurder { get; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using Empostor.Api.Admin;
|
||||
using Empostor.Api.Plugins;
|
||||
|
||||
namespace Empostor.Plugin.Narrator;
|
||||
|
||||
public sealed class NarratorAdminExtension : IAdminExtension
|
||||
{
|
||||
private readonly NarratorConfig _config;
|
||||
private readonly string _configPath;
|
||||
|
||||
public NarratorAdminExtension(NarratorConfig config, string configPath)
|
||||
{
|
||||
_config = config;
|
||||
_configPath = configPath;
|
||||
}
|
||||
|
||||
public string Id => "narrator";
|
||||
|
||||
public string Title => "Narrator";
|
||||
|
||||
public string Icon => "chat";
|
||||
|
||||
public void Build(AdminPanelBuilder b)
|
||||
{
|
||||
b.RegisterTextbox(t =>
|
||||
{
|
||||
t.Label = "DeepSeek API key";
|
||||
t.Value = _config.ApiKey;
|
||||
t.Secret = true;
|
||||
t.Placeholder = "sk-...";
|
||||
t.OnSubmit(async ctx =>
|
||||
{
|
||||
_config.ApiKey = ctx.Value ?? string.Empty;
|
||||
PluginConfigLoader.Save(_configPath, _config);
|
||||
return AdminActionResult.Ok("API key saved.");
|
||||
});
|
||||
});
|
||||
|
||||
b.RegisterTextbox(t =>
|
||||
{
|
||||
t.Label = "Model";
|
||||
t.Value = _config.Model;
|
||||
t.OnSubmit(async ctx =>
|
||||
{
|
||||
_config.Model = ctx.Value ?? string.Empty;
|
||||
PluginConfigLoader.Save(_configPath, _config);
|
||||
return AdminActionResult.Ok("Model saved.");
|
||||
});
|
||||
});
|
||||
|
||||
b.RegisterTextbox(t =>
|
||||
{
|
||||
t.Label = "API endpoint";
|
||||
t.Value = _config.ApiEndpoint;
|
||||
t.OnSubmit(async ctx =>
|
||||
{
|
||||
_config.ApiEndpoint = ctx.Value ?? string.Empty;
|
||||
PluginConfigLoader.Save(_configPath, _config);
|
||||
return AdminActionResult.Ok("API endpoint saved.");
|
||||
});
|
||||
});
|
||||
|
||||
b.RegisterNumber(n =>
|
||||
{
|
||||
n.Label = "Max uses per game";
|
||||
n.Value = _config.MaxUsesPerGame;
|
||||
n.Min = 0;
|
||||
n.OnChange(async ctx =>
|
||||
{
|
||||
if (int.TryParse(ctx.Value, out var v) && v >= 0)
|
||||
{
|
||||
_config.MaxUsesPerGame = v;
|
||||
PluginConfigLoader.Save(_configPath, _config);
|
||||
}
|
||||
|
||||
return AdminActionResult.Ok("Max uses per game saved (applies to new games).");
|
||||
});
|
||||
});
|
||||
|
||||
b.RegisterNumber(n =>
|
||||
{
|
||||
n.Label = "Max uses per meeting";
|
||||
n.Value = _config.MaxUsesPerMeeting;
|
||||
n.Min = 0;
|
||||
n.OnChange(async ctx =>
|
||||
{
|
||||
if (int.TryParse(ctx.Value, out var v) && v >= 0)
|
||||
{
|
||||
_config.MaxUsesPerMeeting = v;
|
||||
PluginConfigLoader.Save(_configPath, _config);
|
||||
}
|
||||
|
||||
return AdminActionResult.Ok("Max uses per meeting saved (applies to new games).");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Commands;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugin.Narrator;
|
||||
|
||||
public sealed class NarratorCommand : ICommand
|
||||
{
|
||||
private readonly NarratorService _service;
|
||||
private readonly ILogger<NarratorCommand> _logger;
|
||||
|
||||
public NarratorCommand(NarratorService service, ILogger<NarratorCommand> logger)
|
||||
{
|
||||
_service = service;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Name => "narrator";
|
||||
|
||||
public string[] Aliases => new[] { "nar", "n" };
|
||||
|
||||
public string Description => "Ask the narrator for advice during a meeting.";
|
||||
|
||||
public string Usage => "narrator <your question or statement>";
|
||||
|
||||
public async ValueTask<bool> ExecuteAsync(CommandContext ctx)
|
||||
{
|
||||
var args = ctx.Args;
|
||||
|
||||
// Host commands
|
||||
if (args.Length > 0 && ctx.Sender.IsHost)
|
||||
{
|
||||
var sub = args[0].ToLowerInvariant();
|
||||
|
||||
if (sub == "enable" || sub == "on")
|
||||
{
|
||||
_service.SetEnabled(ctx.Game, true);
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "narrator.host_enabled", "Narrator enabled for this game."), ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub == "disable" || sub == "off")
|
||||
{
|
||||
_service.SetEnabled(ctx.Game, false);
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "narrator.host_disabled", "Narrator disabled for this game."), ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub == "status")
|
||||
{
|
||||
var enabled = _service.IsEnabled(ctx.Game);
|
||||
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());
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(msg, ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub == "limit")
|
||||
{
|
||||
if (args.Length < 2 || !int.TryParse(args[1], out var limit) || limit < 0)
|
||||
{
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "narrator.host_limit_usage", "Usage: #narrator limit <number>"), ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
_service.SetMaxUses(ctx.Game, limit);
|
||||
if (limit == 0)
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
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()),
|
||||
ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Show host commands if no args provided
|
||||
if (args.Length == 0)
|
||||
{
|
||||
await ShowUsageAsync(ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check game state — meeting must be active
|
||||
if (!_service.IsMeetingActive(ctx.Game))
|
||||
{
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "narrator.meeting_only", "You can only use #narrator during a meeting."), ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if narrator is enabled
|
||||
if (!_service.IsEnabled(ctx.Game))
|
||||
{
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
T(ctx, "narrator.disabled", "The narrator is currently disabled by the host."), ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
var playerName = ctx.PlayerControl.PlayerInfo.PlayerName;
|
||||
|
||||
// Check usage limits
|
||||
var (allowed, error) = _service.TryUse(ctx.Game, playerName);
|
||||
if (!allowed)
|
||||
{
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
$"[Narrator] {error}", ctx.PlayerControl);
|
||||
return true;
|
||||
}
|
||||
|
||||
var playerMessage = ctx.RawArgs?.Trim();
|
||||
if (string.IsNullOrEmpty(playerMessage))
|
||||
{
|
||||
await ShowUsageAsync(ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogInformation("[Narrator] {player} asks: {message}", playerName, playerMessage);
|
||||
|
||||
var context = _service.BuildContext(ctx.Game, ctx.PlayerControl);
|
||||
var reply = await _service.AskNarratorAsync(context, playerMessage);
|
||||
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(
|
||||
$"[Narrator] {reply}", ctx.PlayerControl);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async ValueTask ShowUsageAsync(CommandContext ctx)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(T(ctx, "narrator.usage",
|
||||
"Usage: #narrator <your question or statement>"));
|
||||
if (ctx.Sender.IsHost)
|
||||
sb.AppendLine(T(ctx, "narrator.host_commands",
|
||||
"Host commands: #narrator enable | disable | limit <number>"));
|
||||
await ctx.PlayerControl.SendChatToPlayerAsync(sb.ToString(), ctx.PlayerControl);
|
||||
}
|
||||
|
||||
private static string T(CommandContext ctx, string key, string defaultText)
|
||||
{
|
||||
string result = ctx.Lang.Get(key, ctx.SenderLanguage);
|
||||
return result == key ? defaultText : result;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Empostor.Plugin.Narrator;
|
||||
|
||||
public sealed class NarratorConfig
|
||||
{
|
||||
[JsonPropertyName("apiKey")]
|
||||
public string ApiKey { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public string Model { get; set; } = "deepseek-v4-flash";
|
||||
|
||||
[JsonPropertyName("apiEndpoint")]
|
||||
public string ApiEndpoint { get; set; } = "https://api.deepseek.com/";
|
||||
|
||||
[JsonPropertyName("maxUsesPerGame")]
|
||||
public int MaxUsesPerGame { get; set; } = 3;
|
||||
|
||||
[JsonPropertyName("maxUsesPerMeeting")]
|
||||
public int MaxUsesPerMeeting { get; set; } = 1;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using Empostor.Api.Events;
|
||||
using Empostor.Api.Events.Player;
|
||||
using Empostor.Api.Events.Meeting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugin.Narrator;
|
||||
|
||||
public sealed class NarratorEventListener : IEventListener
|
||||
{
|
||||
private readonly NarratorService _service;
|
||||
private readonly ILogger<NarratorEventListener> _logger;
|
||||
|
||||
public NarratorEventListener(NarratorService service, ILogger<NarratorEventListener> logger)
|
||||
{
|
||||
_service = service;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnGameStarted(IGameStartedEvent e)
|
||||
{
|
||||
_service.RecordGameStart(e.Game);
|
||||
_logger.LogDebug("[Narrator] Game started: {code}", e.Game.Code);
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnGameEnded(IGameEndedEvent e)
|
||||
{
|
||||
_service.RecordGameEnd(e.Game);
|
||||
_logger.LogDebug("[Narrator] Game ended: {code}", e.Game.Code);
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnPlayerCompletedTask(IPlayerCompletedTaskEvent e)
|
||||
{
|
||||
_service.RecordTask(e);
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnPlayerMurder(IPlayerMurderEvent e)
|
||||
{
|
||||
_service.RecordMurder(e);
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnPlayerExile(IPlayerExileEvent e)
|
||||
{
|
||||
_service.RecordExile(e);
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnPlayerReport(IPlayerReportEvent e)
|
||||
{
|
||||
_service.RecordReport(e);
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnMeetingStarted(IMeetingStartedEvent e)
|
||||
{
|
||||
_service.RecordMeetingStart(e);
|
||||
_logger.LogDebug("[Narrator] Meeting started");
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnMeetingEnded(IMeetingEndedEvent e)
|
||||
{
|
||||
_service.RecordMeetingEnd(e);
|
||||
_logger.LogDebug("[Narrator] Meeting ended");
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnPlayerChat(IPlayerChatEvent e)
|
||||
{
|
||||
_service.RecordChat(e);
|
||||
}
|
||||
|
||||
[EventListener]
|
||||
public void OnPlayerVoted(IPlayerVotedEvent e)
|
||||
{
|
||||
_service.RecordVote(e);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugin.Narrator;
|
||||
|
||||
[EmpostorPlugin("cn.hayashiume.narrator")]
|
||||
public sealed class NarratorPlugin : PluginBase, IPluginLanguageProvider
|
||||
{
|
||||
private readonly ILogger<NarratorPlugin> _logger;
|
||||
|
||||
public NarratorPlugin(ILogger<NarratorPlugin> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override ValueTask EnableAsync()
|
||||
{
|
||||
_logger.LogInformation("[Narrator] Plugin enabled. #narrator command registered.");
|
||||
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 我被指控了但我有可視任務,怎麼證明自己?",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Empostor.Api.Events.Player;
|
||||
using Empostor.Api.Events.Meeting;
|
||||
using Empostor.Api.Games;
|
||||
using Empostor.Api.Innersloth.GameOptions;
|
||||
using Empostor.Api.Net.Inner.Objects;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Empostor.Plugin.Narrator;
|
||||
|
||||
public sealed class NarratorService
|
||||
{
|
||||
private readonly NarratorConfig _config;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<NarratorService> _logger;
|
||||
private readonly ConcurrentDictionary<string, GameState> _games = new();
|
||||
private readonly ConcurrentDictionary<string, MeetingContext> _meetings = new();
|
||||
|
||||
public NarratorService(NarratorConfig config, ILogger<NarratorService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
_httpClient = new HttpClient();
|
||||
}
|
||||
|
||||
// ---- Game lifecycle ----
|
||||
|
||||
public void RecordGameStart(IGame game)
|
||||
{
|
||||
_games[game.Code.ToString()] = new GameState
|
||||
{
|
||||
Enabled = true,
|
||||
MaxUsesPerGame = _config.MaxUsesPerGame,
|
||||
MaxUsesPerMeeting = _config.MaxUsesPerMeeting
|
||||
};
|
||||
}
|
||||
|
||||
public void RecordGameEnd(IGame game)
|
||||
{
|
||||
_games.TryRemove(game.Code.ToString(), out _);
|
||||
_meetings.TryRemove(game.Code.ToString(), out _);
|
||||
}
|
||||
|
||||
// ---- Host controls ----
|
||||
|
||||
public bool IsEnabled(IGame game)
|
||||
{
|
||||
return _games.TryGetValue(game.Code.ToString(), out var state) && state.Enabled;
|
||||
}
|
||||
|
||||
public void SetEnabled(IGame game, bool enabled)
|
||||
{
|
||||
if (_games.TryGetValue(game.Code.ToString(), out var state))
|
||||
state.Enabled = enabled;
|
||||
}
|
||||
|
||||
public void SetMaxUses(IGame game, int max)
|
||||
{
|
||||
if (_games.TryGetValue(game.Code.ToString(), out var state))
|
||||
state.MaxUsesPerGame = Math.Max(0, max);
|
||||
}
|
||||
|
||||
public int GetMaxUses(IGame game)
|
||||
{
|
||||
return _games.TryGetValue(game.Code.ToString(), out var state) ? state.MaxUsesPerGame : _config.MaxUsesPerGame;
|
||||
}
|
||||
|
||||
// ---- Usage limits ----
|
||||
|
||||
public (bool allowed, string? error) TryUse(IGame game, string playerName)
|
||||
{
|
||||
var code = game.Code.ToString();
|
||||
if (!_games.TryGetValue(code, out var state))
|
||||
return (false, "Game state not found.");
|
||||
|
||||
if (!state.Enabled)
|
||||
return (false, "Narrator is disabled for this game by the host.");
|
||||
|
||||
if (!IsMeetingActive(game))
|
||||
return (false, "You can only use this command during a meeting.");
|
||||
|
||||
// Check per-meeting limit
|
||||
var meetingUses = state.CurrentMeetingPlayerUses.GetValueOrDefault(playerName, 0);
|
||||
if (meetingUses >= state.MaxUsesPerMeeting)
|
||||
return (false, $"You've used the narrator {meetingUses} time(s) this meeting (limit: {state.MaxUsesPerMeeting}).");
|
||||
|
||||
// Check per-game limit
|
||||
var gameUses = state.PlayerUseCounts.GetValueOrDefault(playerName, 0);
|
||||
if (gameUses >= state.MaxUsesPerGame)
|
||||
return (false, $"You've used the narrator {gameUses} time(s) this game (limit: {state.MaxUsesPerGame}).");
|
||||
|
||||
// Record usage
|
||||
state.PlayerUseCounts[playerName] = gameUses + 1;
|
||||
state.CurrentMeetingPlayerUses[playerName] = meetingUses + 1;
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
// ---- Event recording ----
|
||||
|
||||
public void RecordTask(IPlayerCompletedTaskEvent e)
|
||||
{
|
||||
var gameCode = e.Game.Code.ToString();
|
||||
if (!_games.TryGetValue(gameCode, out var state)) return;
|
||||
|
||||
var playerName = e.PlayerControl.PlayerInfo.PlayerName;
|
||||
if (!state.PlayerTasks.TryGetValue(playerName, out var tasks))
|
||||
{
|
||||
tasks = new List<TaskRecord>();
|
||||
state.PlayerTasks[playerName] = tasks;
|
||||
}
|
||||
|
||||
var taskData = e.Task.Task;
|
||||
tasks.Add(new TaskRecord
|
||||
{
|
||||
Name = taskData?.Name ?? $"Task#{e.Task.Id}",
|
||||
IsVisual = taskData?.IsVisual ?? false
|
||||
});
|
||||
}
|
||||
|
||||
public void RecordMurder(IPlayerMurderEvent e)
|
||||
{
|
||||
var gameCode = e.Game.Code.ToString();
|
||||
if (!_games.TryGetValue(gameCode, out var state)) return;
|
||||
|
||||
var victimName = e.Victim.PlayerInfo.PlayerName;
|
||||
state.DeadPlayers.Add(victimName);
|
||||
|
||||
var killerName = e.PlayerControl.PlayerInfo.PlayerName;
|
||||
state.Murders.Add(new MurderRecord { Killer = killerName, Victim = victimName });
|
||||
}
|
||||
|
||||
public void RecordExile(IPlayerExileEvent e)
|
||||
{
|
||||
var gameCode = e.Game.Code.ToString();
|
||||
if (!_games.TryGetValue(gameCode, out var state)) return;
|
||||
|
||||
state.DeadPlayers.Add(e.PlayerControl.PlayerInfo.PlayerName);
|
||||
}
|
||||
|
||||
public void RecordReport(IPlayerReportEvent e)
|
||||
{
|
||||
_logger.LogDebug("[Narrator] {reporter} reported a body", e.PlayerControl.PlayerInfo.PlayerName);
|
||||
}
|
||||
|
||||
public void RecordMeetingStart(IMeetingStartedEvent e)
|
||||
{
|
||||
var gameCode = e.Game.Code.ToString();
|
||||
|
||||
// Reset per-meeting usage counters
|
||||
if (_games.TryGetValue(gameCode, out var state))
|
||||
state.CurrentMeetingPlayerUses.Clear();
|
||||
|
||||
var meeting = new MeetingContext
|
||||
{
|
||||
IsActive = true,
|
||||
Reporter = e.MeetingHud.Reporter?.PlayerName
|
||||
};
|
||||
_meetings[gameCode] = meeting;
|
||||
}
|
||||
|
||||
public void RecordMeetingEnd(IMeetingEndedEvent e)
|
||||
{
|
||||
var gameCode = e.Game.Code.ToString();
|
||||
if (_meetings.TryGetValue(gameCode, out var meeting))
|
||||
meeting.IsActive = false;
|
||||
}
|
||||
|
||||
public void RecordChat(IPlayerChatEvent e)
|
||||
{
|
||||
var gameCode = e.Game.Code.ToString();
|
||||
if (!_meetings.TryGetValue(gameCode, out var meeting) || !meeting.IsActive) return;
|
||||
|
||||
meeting.ChatMessages.Add(new ChatMessage
|
||||
{
|
||||
PlayerName = e.PlayerControl.PlayerInfo.PlayerName,
|
||||
Message = e.Message
|
||||
});
|
||||
}
|
||||
|
||||
public void RecordVote(IPlayerVotedEvent e)
|
||||
{
|
||||
var gameCode = e.Game.Code.ToString();
|
||||
if (!_meetings.TryGetValue(gameCode, out var meeting) || !meeting.IsActive) return;
|
||||
|
||||
var voterName = e.PlayerControl.PlayerInfo.PlayerName;
|
||||
var votedFor = e.VotedFor?.PlayerInfo.PlayerName ?? "Skipped";
|
||||
meeting.Votes.Add(new VoteRecord { Voter = voterName, VotedFor = votedFor });
|
||||
}
|
||||
|
||||
public bool IsMeetingActive(IGame game)
|
||||
{
|
||||
return _meetings.TryGetValue(game.Code.ToString(), out var meeting) && meeting.IsActive;
|
||||
}
|
||||
|
||||
// ---- Context & AI ----
|
||||
|
||||
public string BuildContext(IGame game, IInnerPlayerControl player)
|
||||
{
|
||||
var gameCode = game.Code.ToString();
|
||||
_games.TryGetValue(gameCode, out var state);
|
||||
_meetings.TryGetValue(gameCode, out var meeting);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var playerName = player.PlayerInfo.PlayerName;
|
||||
|
||||
sb.AppendLine($"Your name: {playerName}");
|
||||
sb.AppendLine($"Your role: {(player.PlayerInfo.IsImpostor ? "Impostor" : "Crewmate")}");
|
||||
|
||||
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($"Total impostors: {game.Options.NumImpostors}");
|
||||
|
||||
if (state != null && state.PlayerTasks.TryGetValue(playerName, out var tasks) && tasks.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Your completed tasks:");
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
var visualTag = task.IsVisual ? " [VISUAL TASK - others can see you do this!]" : "";
|
||||
sb.AppendLine($" - {task.Name}{visualTag}");
|
||||
}
|
||||
}
|
||||
|
||||
if (state != null && state.DeadPlayers.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Dead players ({state.DeadPlayers.Count}):");
|
||||
foreach (var dead in state.DeadPlayers)
|
||||
sb.AppendLine($" - {dead}");
|
||||
}
|
||||
|
||||
if (meeting != null && meeting.IsActive)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- Current Meeting ---");
|
||||
if (meeting.Reporter != null)
|
||||
sb.AppendLine($"Meeting started by: {meeting.Reporter}");
|
||||
|
||||
if (meeting.ChatMessages.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Chat log:");
|
||||
foreach (var msg in meeting.ChatMessages)
|
||||
sb.AppendLine($" {msg.PlayerName}: {msg.Message}");
|
||||
}
|
||||
|
||||
if (meeting.Votes.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Votes so far:");
|
||||
foreach (var v in meeting.Votes)
|
||||
sb.AppendLine($" {v.Voter} -> {v.VotedFor}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public async Task<string> AskNarratorAsync(string context, string playerMessage)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_config.ApiKey))
|
||||
return "Narrator is not configured. Please set your DeepSeek API key in the plugin config.";
|
||||
|
||||
var systemPrompt = "You are a helpful narrator/assistant whispering advice to a player during an Among Us meeting. "
|
||||
+ "You help them formulate their defense and strategy. "
|
||||
+ "RULES: "
|
||||
+ "1. Only use information the player would know from their own perspective. "
|
||||
+ "2. If the player is Crewmate, help prove innocence — mention their visual tasks, alibis, logical arguments. "
|
||||
+ "3. If the player is Impostor, help them blend in with plausible lies consistent with known info. "
|
||||
+ "4. Be concise and natural, like a friend whispering advice. Keep it to 1-3 sentences. "
|
||||
+ "5. Never mention you are an AI or that this is a game. Just give the advice directly.";
|
||||
|
||||
try
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
model = _config.Model,
|
||||
messages = new[]
|
||||
{
|
||||
new { role = "system", content = systemPrompt },
|
||||
new { role = "user", content = $"Context:\n{context}\n\nThe player says: \"{playerMessage}\"\n\nGive them a short, helpful reply they can use in the meeting:" }
|
||||
},
|
||||
max_tokens = 256,
|
||||
temperature = 0.8
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, _config.ApiEndpoint)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Add("Authorization", $"Bearer {_config.ApiKey}");
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("[Narrator] DeepSeek API error: {status} {body}", response.StatusCode, responseBody);
|
||||
return "Sorry, the narrator is having trouble thinking right now. Try again in a moment.";
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var reply = doc.RootElement
|
||||
.GetProperty("choices")[0]
|
||||
.GetProperty("message")
|
||||
.GetProperty("content")
|
||||
.GetString();
|
||||
|
||||
return reply?.Trim() ?? "Sorry, I couldn't come up with a response.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Narrator] Failed to call DeepSeek API");
|
||||
return "Sorry, the narrator encountered an error. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Private state classes ----
|
||||
|
||||
private sealed class GameState
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int MaxUsesPerGame { get; set; } = 3;
|
||||
public int MaxUsesPerMeeting { get; set; } = 1;
|
||||
public Dictionary<string, int> PlayerUseCounts { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, int> CurrentMeetingPlayerUses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, List<TaskRecord>> PlayerTasks { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public HashSet<string> DeadPlayers { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<MurderRecord> Murders { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class MeetingContext
|
||||
{
|
||||
public bool IsActive { get; set; }
|
||||
public string? Reporter { get; set; }
|
||||
public List<ChatMessage> ChatMessages { get; } = new();
|
||||
public List<VoteRecord> Votes { get; } = new();
|
||||
}
|
||||
|
||||
private sealed record TaskRecord
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public bool IsVisual { get; set; }
|
||||
}
|
||||
|
||||
private sealed record ChatMessage
|
||||
{
|
||||
public string PlayerName { get; set; } = "";
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
|
||||
private sealed record VoteRecord
|
||||
{
|
||||
public string Voter { get; set; } = "";
|
||||
public string VotedFor { get; set; } = "";
|
||||
}
|
||||
|
||||
private sealed record MurderRecord
|
||||
{
|
||||
public string Killer { get; set; } = "";
|
||||
public string Victim { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using Empostor.Api.Admin;
|
||||
using Empostor.Api.Commands;
|
||||
using Empostor.Api.Events;
|
||||
using Empostor.Api.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Empostor.Plugin.Narrator;
|
||||
|
||||
public sealed class NarratorStartup : IPluginStartup
|
||||
{
|
||||
private string _configPath = "[Narrator]Config.json";
|
||||
|
||||
public void SetConfigPath(string configPath) => _configPath = configPath;
|
||||
|
||||
public void ConfigureHost(IHostBuilder host) { }
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
var config = PluginConfigLoader.Load<NarratorConfig>(_configPath);
|
||||
services.AddSingleton(config);
|
||||
services.AddSingleton<NarratorService>();
|
||||
services.AddSingleton<NarratorCommand>();
|
||||
services.AddSingleton<ICommand, NarratorCommand>(
|
||||
sp => sp.GetRequiredService<NarratorCommand>());
|
||||
services.AddSingleton<NarratorEventListener>();
|
||||
services.AddSingleton<IEventListener, NarratorEventListener>(
|
||||
sp => sp.GetRequiredService<NarratorEventListener>());
|
||||
services.AddSingleton<IAdminExtension, NarratorAdminExtension>(
|
||||
_ => new NarratorAdminExtension(config, _configPath));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user