diff --git a/.dockerignore b/.dockerignore index 2138431..ace52dd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,15 +24,15 @@ docs/ *.md # Docker -Dockerfile* .dockerignore -compose.yml -docker-compose*.yml # Logs Log/ *.log +# Data +Data/ + # Temp tmp/ temp/ diff --git a/Dockerfile b/Dockerfile index ed27d7f..7da2602 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,103 +1,28 @@ # ============================================ -# Stage 1: Build server + plugins from source +# Usage: +# 1. Publish the server: +# dotnet publish src/Empostor.Server -c Release -o publish +# 2. Publish plugins: +# dotnet publish src/Empostor.Plugins.Welcome -c Release -o publish/plugins +# (repeat for each plugin) +# 3. Build and run: +# docker compose up -d # ============================================ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build - -ARG TARGETARCH -WORKDIR /src - -# Copy solution and project files first (layer caching) -COPY src/Empostor.sln ./ -COPY src/Directory.Build.props ./ 2>/dev/null || true -COPY src/ProjectRules.ruleset ./ 2>/dev/null || true - -# Server + API + Hazel -COPY src/Empostor.Server/Empostor.Server.csproj ./Empostor.Server/ -COPY src/Empostor.Api/Empostor.Api.csproj ./Empostor.Api/ -COPY src/Empostor.Api.Innersloth.Generator/Empostor.Api.Innersloth.Generator.csproj ./Empostor.Api.Innersloth.Generator/ -COPY src/NextFast.Hazel-New/Next.Hazel/Next.Hazel.csproj ./NextFast.Hazel-New/Next.Hazel/ -COPY src/NextFast.Hazel-New/Next.Hazel.Abstractions/Next.Hazel.Abstractions.csproj ./NextFast.Hazel-New/Next.Hazel.Abstractions/ - -# Plugins -COPY src/Empostor.Plugin.Chat/Empostor.Plugin.Chat.csproj ./Empostor.Plugin.Chat/ -COPY src/Empostor.Plugin.Code/Empostor.Plugin.Code.csproj ./Empostor.Plugin.Code/ -COPY src/Empostor.Plugin.Narrator/Empostor.Plugin.Narrator.csproj ./Empostor.Plugin.Narrator/ -COPY src/Empostor.Plugins.Example/Empostor.Plugins.Example.csproj ./Empostor.Plugins.Example/ -COPY src/Empostor.Plugins.FixedCode/Empostor.Plugins.FixedCode.csproj ./Empostor.Plugins.FixedCode/ -COPY src/Empostor.Plugins.FriendCodeValidator/Empostor.Plugins.FriendCodeValidator.csproj ./Empostor.Plugins.FriendCodeValidator/ -COPY src/Empostor.Plugins.PlayerChannel/Empostor.Plugins.PlayerChannel.csproj ./Empostor.Plugins.PlayerChannel/ -COPY src/Empostor.Plugins.QqVerify/Empostor.Plugins.QqVerify.csproj ./Empostor.Plugins.QqVerify/ -COPY src/Empostor.Plugins.Titles/Empostor.Plugins.Titles.csproj ./Empostor.Plugins.Titles/ -COPY src/Empostor.Plugins.Welcome/Empostor.Plugins.Welcome.csproj ./Empostor.Plugins.Welcome/ -COPY src/Empostor.Plugins.MapVote/Empostor.Plugins.MapVote.csproj ./Empostor.Plugins.MapVote/ -COPY src/Empostor.Plugins.Message/Empostor.Plugins.Message.csproj ./Empostor.Plugins.Message/ - -# Restore dependencies -RUN dotnet restore Empostor.Server/Empostor.Server.csproj -a $TARGETARCH - -# Copy all source -COPY src/ ./ - -# Publish server (self-contained single file) -RUN dotnet publish Empostor.Server/Empostor.Server.csproj \ - -c Release \ - -a $TARGETARCH \ - --self-contained false \ - -o /publish/server - -# Publish plugins -RUN dotnet publish Empostor.Plugin.Chat/Empostor.Plugin.Chat.csproj \ - -c Release -a $TARGETARCH --self-contained false -o /publish/plugins --no-restore && \ - dotnet publish Empostor.Plugin.Code/Empostor.Plugin.Code.csproj \ - -c Release -a $TARGETARCH --self-contained false -o /publish/plugins --no-restore && \ - dotnet publish Empostor.Plugin.Narrator/Empostor.Plugin.Narrator.csproj \ - -c Release -a $TARGETARCH --self-contained false -o /publish/plugins --no-restore && \ - dotnet publish Empostor.Plugins.FixedCode/Empostor.Plugins.FixedCode.csproj \ - -c Release -a $TARGETARCH --self-contained false -o /publish/plugins --no-restore && \ - dotnet publish Empostor.Plugins.FriendCodeValidator/Empostor.Plugins.FriendCodeValidator.csproj \ - -c Release -a $TARGETARCH --self-contained false -o /publish/plugins --no-restore && \ - dotnet publish Empostor.Plugins.PlayerChannel/Empostor.Plugins.PlayerChannel.csproj \ - -c Release -a $TARGETARCH --self-contained false -o /publish/plugins --no-restore && \ - dotnet publish Empostor.Plugins.QqVerify/Empostor.Plugins.QqVerify.csproj \ - -c Release -a $TARGETARCH --self-contained false -o /publish/plugins --no-restore && \ - dotnet publish Empostor.Plugins.Titles/Empostor.Plugins.Titles.csproj \ - -c Release -a $TARGETARCH --self-contained false -o /publish/plugins --no-restore && \ - dotnet publish Empostor.Plugins.Welcome/Empostor.Plugins.Welcome.csproj \ - -c Release -a $TARGETARCH --self-contained false -o /publish/plugins --no-restore && \ - dotnet publish Empostor.Plugins.MapVote/Empostor.Plugins.MapVote.csproj \ - -c Release -a $TARGETARCH --self-contained false -o /publish/plugins --no-restore && \ - dotnet publish Empostor.Plugins.Message/Empostor.Plugins.Message.csproj \ - -c Release -a $TARGETARCH --self-contained false -o /publish/plugins --no-restore - -# ============================================ -# Stage 2: Runtime image -# ============================================ -FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /app -# Create directory structure -RUN mkdir -p /app/plugins /app/libraries /app/Languages /app/Log /app/config +# Copy pre-published server output +COPY ./publish/ ./ -# Copy server -COPY --from=build /publish/server/ ./ +# Create runtime directories +RUN mkdir -p /app/plugins /app/libraries /app/Languages /app/Log /app/config /app/Data -# Copy plugins (only .dll files, skip dependency DLLs already in server) -COPY --from=build /publish/plugins/Empostor.Plugin.Chat.dll ./plugins/ -COPY --from=build /publish/plugins/Empostor.Plugin.Code.dll ./plugins/ -COPY --from=build /publish/plugins/Empostor.Plugin.Narrator.dll ./plugins/ -COPY --from=build /publish/plugins/Empostor.Plugins.Example.dll ./plugins/ -COPY --from=build /publish/plugins/Empostor.Plugins.FixedCode.dll ./plugins/ -COPY --from=build /publish/plugins/Empostor.Plugins.FriendCodeValidator.dll ./plugins/ -COPY --from=build /publish/plugins/Empostor.Plugins.PlayerChannel.dll ./plugins/ -COPY --from=build /publish/plugins/Empostor.Plugins.QqVerify.dll ./plugins/ -COPY --from=build /publish/plugins/Empostor.Plugins.Titles.dll ./plugins/ -COPY --from=build /publish/plugins/Empostor.Plugins.Welcome.dll ./plugins/ -COPY --from=build /publish/plugins/Empostor.Plugins.MapVote.dll ./plugins/ -COPY --from=build /publish/plugins/Empostor.Plugins.Message.dll ./plugins/ +# Copy plugins from build output +COPY ./plugins/ ./plugins/ 2>/dev/null || true # Copy marketplace data -COPY marketplace/ ./marketplace/ +COPY ./marketplace/ ./marketplace/ 2>/dev/null || true ENV ASPNETCORE_URLS=http://0.0.0.0:80 ENV EMPOSTOR_Server__PublicIp=0.0.0.0 @@ -106,6 +31,6 @@ EXPOSE 80/tcp EXPOSE 22023/tcp EXPOSE 22023/udp -VOLUME ["/app/config", "/app/plugins", "/app/Languages", "/app/Log", "/app/marketplace"] +VOLUME ["/app/config", "/app/plugins", "/app/Languages", "/app/Log", "/app/Data"] ENTRYPOINT ["./Empostor.Server"] diff --git a/Dockerfile.publish b/Dockerfile.publish deleted file mode 100644 index 113234b..0000000 --- a/Dockerfile.publish +++ /dev/null @@ -1,34 +0,0 @@ -# ============================================ -# Usage: -# 1. Publish the server: -# dotnet publish src/Empostor.Server -c Release -o publish -# 2. Build and run: -# docker compose -f compose.publish.yml up -d -# ============================================ -FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0 - -WORKDIR /app - -# Copy pre-published server output -COPY ./publish/ ./ - -# Create runtime directories -RUN mkdir -p /app/plugins /app/libraries /app/Languages /app/Log - -# Copy plugins from build output (built separately) -# Place your plugin DLLs in ./plugins/ before building the image -COPY ./plugins/ ./plugins/ 2>/dev/null || true - -# Copy marketplace data -COPY ./marketplace/ ./marketplace/ 2>/dev/null || true - -ENV ASPNETCORE_URLS=http://0.0.0.0:80 -ENV EMPOSTOR_Server__PublicIp=0.0.0.0 - -EXPOSE 80/tcp -EXPOSE 22023/tcp -EXPOSE 22023/udp - -VOLUME ["/app/config", "/app/plugins", "/app/Languages", "/app/Log"] - -ENTRYPOINT ["./Empostor.Server"] diff --git a/compose.yml b/compose.yml index 51e9170..9227831 100644 --- a/compose.yml +++ b/compose.yml @@ -14,12 +14,7 @@ services: - ./plugins:/app/plugins - ./Languages:/app/Languages - ./marketplace:/app/marketplace + - ./Data:/app/Data environment: - ASPNETCORE_URLS=http://0.0.0.0:80 - EMPOSTOR_Server__PublicIp=0.0.0.0 - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:80/api/admin/update/check"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s diff --git a/src/Empostor.Api/Languages/LanguageService.cs b/src/Empostor.Api/Languages/LanguageService.cs index 8801829..9e1681b 100644 --- a/src/Empostor.Api/Languages/LanguageService.cs +++ b/src/Empostor.Api/Languages/LanguageService.cs @@ -182,6 +182,8 @@ public sealed class LanguageService "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}.", @@ -235,6 +237,8 @@ public sealed class LanguageService "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}。", @@ -288,6 +292,8 @@ public sealed class LanguageService "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}。", @@ -341,6 +347,8 @@ public sealed class LanguageService "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}명으로 설정되었습니다.", @@ -380,6 +388,8 @@ public sealed class LanguageService "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}.", @@ -419,6 +429,8 @@ public sealed class LanguageService "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.", @@ -458,6 +470,8 @@ public sealed class LanguageService "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}.", @@ -497,6 +511,8 @@ public sealed class LanguageService "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} に設定しました。", @@ -536,6 +552,8 @@ public sealed class LanguageService "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}.", diff --git a/src/Empostor.Plugins.MapVote/MapVoteCommand.cs b/src/Empostor.Plugins.MapVote/MapVoteCommand.cs index 2a613d7..c0faace 100644 --- a/src/Empostor.Plugins.MapVote/MapVoteCommand.cs +++ b/src/Empostor.Plugins.MapVote/MapVoteCommand.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading.Tasks; using Empostor.Api.Commands; @@ -30,25 +32,68 @@ public sealed class MapVoteCommand : ICommand { var sub = ctx.Args[0].ToLowerInvariant(); + if (sub == "public" || sub == "start") + { + if (!ctx.Sender.IsHost) + { + await ctx.PlayerControl.SendChatToPlayerAsync( + T(ctx, "mapvote.host_only", "Only the host can start a map vote."), ctx.PlayerControl); + return true; + } + + _service.StartSession(gameCode); + + await ctx.PlayerControl.SendChatToPlayerAsync( + T(ctx, "mapvote.session_started", "Map vote session started! Players, use #votemap to vote.\nMaps: Skeld, Mira, Polus, Airship, Fungle"), ctx.PlayerControl); + return true; + } + + if (sub == "close" || sub == "end") + { + if (!ctx.Sender.IsHost) + { + await ctx.PlayerControl.SendChatToPlayerAsync( + T(ctx, "mapvote.host_only", "Only the host can close the vote."), ctx.PlayerControl); + return true; + } + + var winner = _service.GetWinner(gameCode); + var tally = _service.TallyVotes(gameCode); + var voterCount = _service.VoterCount(gameCode); + + await ShowWinnerToAllAsync(ctx, winner, tally, voterCount); + + _service.StopSession(gameCode); + return true; + } + if (sub == "enable" || sub == "on") { - if (ctx.Sender.IsHost) + if (!ctx.Sender.IsHost) { - _service.SetEnabled(gameCode, true); await ctx.PlayerControl.SendChatToPlayerAsync( - T(ctx, "mapvote.host_enabled", "Map voting enabled."), ctx.PlayerControl); + T(ctx, "mapvote.host_only", "Only the host can change settings."), ctx.PlayerControl); + return true; } + + _service.SetEnabled(gameCode, true); + await ctx.PlayerControl.SendChatToPlayerAsync( + T(ctx, "mapvote.host_enabled", "Map voting enabled."), ctx.PlayerControl); return true; } if (sub == "disable" || sub == "off") { - if (ctx.Sender.IsHost) + if (!ctx.Sender.IsHost) { - _service.SetEnabled(gameCode, false); await ctx.PlayerControl.SendChatToPlayerAsync( - T(ctx, "mapvote.host_disabled", "Map voting disabled."), ctx.PlayerControl); + T(ctx, "mapvote.host_only", "Only the host can change settings."), ctx.PlayerControl); + return true; } + + _service.SetEnabled(gameCode, false); + await ctx.PlayerControl.SendChatToPlayerAsync( + T(ctx, "mapvote.host_disabled", "Map voting disabled."), ctx.PlayerControl); return true; } @@ -114,16 +159,40 @@ public sealed class MapVoteCommand : ICommand .Replace("{1}", count.ToString())); } - await ctx.PlayerControl.SendChatToPlayerAsync(sb.ToString(), ctx.PlayerControl); } + private static async ValueTask ShowWinnerToAllAsync(CommandContext ctx, Api.Innersloth.MapTypes winner, + IReadOnlyDictionary tally, int voterCount) + { + var sb = new StringBuilder(); + if (tally.Count == 0) + { + sb.AppendLine(ctx.Lang.Get("mapvote.results_random", ctx.SenderLanguage) + .Replace("{0}", MapVoteService.MapDisplayName(winner))); + } + else + { + var maxVotes = tally.Values.Max(); + sb.AppendLine(ctx.Lang.Get("mapvote.results_winner", ctx.SenderLanguage) + .Replace("{0}", MapVoteService.MapDisplayName(winner)) + .Replace("{1}", maxVotes.ToString())); + } + + foreach (var player in ctx.Game.Players) + { + var ctrl = player.Character; + if (ctrl != null) + await ctrl.SendChatToPlayerAsync(sb.ToString(), ctrl); + } + } + private async ValueTask ShowUsageAsync(CommandContext ctx) { var sb = new StringBuilder(); - sb.AppendLine(T(ctx, "mapvote.usage", "Usage: #votemap ")); + sb.AppendLine(T(ctx, "mapvote.usage", "Usage: #votemap \nMaps: Skeld, Mira, Polus, Airship, Fungle\nExample: #votemap polus")); if (ctx.Sender.IsHost) - sb.AppendLine(T(ctx, "mapvote.host_commands", "Host commands: ...")); + sb.AppendLine(T(ctx, "mapvote.host_commands", "Host commands: #votemap public | close | enable | disable | results")); await ctx.PlayerControl.SendChatToPlayerAsync(sb.ToString(), ctx.PlayerControl); } diff --git a/src/Empostor.Plugins.MapVote/MapVoteEventListener.cs b/src/Empostor.Plugins.MapVote/MapVoteEventListener.cs index 49a27a2..b809c3f 100644 --- a/src/Empostor.Plugins.MapVote/MapVoteEventListener.cs +++ b/src/Empostor.Plugins.MapVote/MapVoteEventListener.cs @@ -1,4 +1,3 @@ -using System.Text; using System.Threading.Tasks; using Empostor.Api.Events; using Empostor.Api.Events.Game; @@ -27,13 +26,12 @@ public sealed class MapVoteEventListener : IEventListener var winner = _service.GetWinner(gameCode); - // If host set a specific map and override is allowed, respect it + // If host set a specific map and override is allowed, respect it when no votes cast if (_config.AllowHostOverride && e.Game.Options.Map != winner) { var tally = _service.TallyVotes(gameCode); if (tally.Count == 0) { - // No votes at all — host's choice stands _service.ResetVotes(gameCode); return; } diff --git a/src/Empostor.Plugins.MapVote/MapVotePlugin.cs b/src/Empostor.Plugins.MapVote/MapVotePlugin.cs index b8306cd..ae88702 100644 --- a/src/Empostor.Plugins.MapVote/MapVotePlugin.cs +++ b/src/Empostor.Plugins.MapVote/MapVotePlugin.cs @@ -41,9 +41,11 @@ public sealed class MapVotePlugin : PluginBase, IPluginLanguageProvider ["mapvote.results_winner"] = "Winner: {0} ({1} votes)", ["mapvote.results_random"] = "No votes cast, using random map: {0}", ["mapvote.usage"] = "Usage: #votemap \nMaps: Skeld, Mira, Polus, Airship, Fungle\nExample: #votemap polus", - ["mapvote.host_commands"] = "Host commands: #votemap enable | disable | results", + ["mapvote.host_commands"] = "Host commands: #votemap public | close | enable | disable | results", + ["mapvote.host_only"] = "Only the host can use this command.", ["mapvote.host_enabled"] = "Map voting enabled.", ["mapvote.host_disabled"] = "Map voting disabled.", + ["mapvote.session_started"] = "Map vote session started! Players, use #votemap to vote.\nMaps: Skeld, Mira, Polus, Airship, Fungle", ["mapvote.map_set"] = "Map set to {0} by vote.", }, ["zh_CN"] = new Dictionary @@ -60,10 +62,12 @@ public sealed class MapVotePlugin : PluginBase, IPluginLanguageProvider ["mapvote.results_winner"] = "胜出:{0}({1} 票)", ["mapvote.results_random"] = "无人投票,随机选择地图:{0}", ["mapvote.usage"] = "用法:#votemap <地图>\n可选地图:Skeld, Mira, Polus, Airship, Fungle\n示例:#votemap polus", - ["mapvote.host_commands"] = "房主指令:#votemap enable | disable | results", + ["mapvote.host_commands"] = "房主指令:#votemap public | close | enable | disable | results", + ["mapvote.host_only"] = "只有房主可以使用此指令。", ["mapvote.host_enabled"] = "投票选图已启用。", ["mapvote.host_disabled"] = "投票选图已禁用。", - ["mapvote.map_set"] = "投票结果为 {0}。", + ["mapvote.session_started"] = "地图投票已开始!请使用 #votemap <地图> 投票。\n可选地图:Skeld, Mira, Polus, Airship, Fungle", + ["mapvote.map_set"] = "投票结果,地图已设为 {0}。", }, ["zh_TW"] = new Dictionary { @@ -79,10 +83,12 @@ public sealed class MapVotePlugin : PluginBase, IPluginLanguageProvider ["mapvote.results_winner"] = "勝出:{0}({1} 票)", ["mapvote.results_random"] = "無人投票,隨機選擇地圖:{0}", ["mapvote.usage"] = "用法:#votemap <地圖>\n可選地圖:Skeld, Mira, Polus, Airship, Fungle\n範例:#votemap polus", - ["mapvote.host_commands"] = "房主指令:#votemap enable | disable | results", + ["mapvote.host_commands"] = "房主指令:#votemap public | close | enable | disable | results", + ["mapvote.host_only"] = "只有房主可以使用此指令。", ["mapvote.host_enabled"] = "投票選圖已啟用。", ["mapvote.host_disabled"] = "投票選圖已停用。", - ["mapvote.map_set"] = "投票結果為 {0}。", + ["mapvote.session_started"] = "地圖投票已開始!請使用 #votemap <地圖> 投票。\n可選地圖:Skeld, Mira, Polus, Airship, Fungle", + ["mapvote.map_set"] = "投票結果,地圖已設為 {0}。", }, }; } diff --git a/src/Empostor.Plugins.MapVote/MapVoteService.cs b/src/Empostor.Plugins.MapVote/MapVoteService.cs index 6226ede..2d4250a 100644 --- a/src/Empostor.Plugins.MapVote/MapVoteService.cs +++ b/src/Empostor.Plugins.MapVote/MapVoteService.cs @@ -33,11 +33,22 @@ public sealed class MapVoteService private readonly ConcurrentDictionary> _votes = new(); private readonly ConcurrentDictionary _enabled = new(); + private readonly ConcurrentDictionary _sessionActive = new(); public bool IsEnabled(string gameCode) => _enabled.GetValueOrDefault(gameCode, true); public void SetEnabled(string gameCode, bool enabled) => _enabled[gameCode] = enabled; + public bool IsSessionActive(string gameCode) => _sessionActive.GetValueOrDefault(gameCode, false); + + public void StartSession(string gameCode) => _sessionActive[gameCode] = true; + + public void StopSession(string gameCode) + { + _sessionActive.TryRemove(gameCode, out _); + _votes.TryRemove(gameCode, out _); + } + public bool TryParseMap(string input, out MapTypes map) { if (MapAliases.TryGetValue(input.Trim(), out map)) @@ -46,7 +57,7 @@ public sealed class MapVoteService if (int.TryParse(input.Trim(), out var id) && Enum.IsDefined(typeof(MapTypes), (byte)id)) { map = (MapTypes)(byte)id; - if (map != MapTypes.Dleks) // Exclude Dleks (mirrored Skeld) + if (map != MapTypes.Dleks) return true; } @@ -86,6 +97,13 @@ public sealed class MapVoteService .ToDictionary(g => g.Key, g => g.Count()); } + public int VoterCount(string gameCode) + { + if (!_votes.TryGetValue(gameCode, out var gameVotes)) + return 0; + return gameVotes.Count; + } + public MapTypes GetWinner(string gameCode) { var tally = TallyVotes(gameCode); @@ -114,5 +132,6 @@ public sealed class MapVoteService { _votes.TryRemove(gameCode, out _); _enabled.TryRemove(gameCode, out _); + _sessionActive.TryRemove(gameCode, out _); } } diff --git a/src/Empostor.Plugins.Welcome/Service/WelcomeEventListener.cs b/src/Empostor.Plugins.Welcome/Service/WelcomeEventListener.cs index 0afade8..f0f261f 100644 --- a/src/Empostor.Plugins.Welcome/Service/WelcomeEventListener.cs +++ b/src/Empostor.Plugins.Welcome/Service/WelcomeEventListener.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; +using Empostor.Api.Data; using Empostor.Api.Events; using Empostor.Api.Events.Game.Player; using Empostor.Api.Innersloth; @@ -48,10 +49,10 @@ public sealed class WelcomeEventListener : IEventListener } } - var message = File.ReadAllText(filePath); - var formattedMessage = string.Format(message, player.Client.Name, e.Game.Code.Code); + var template = File.ReadAllText(filePath); + var message = FormatMessage(template, player); - await playerCtrl.SendChatToPlayerAsync(formattedMessage, playerCtrl); + await playerCtrl.SendChatToPlayerAsync(message, playerCtrl); } catch (Exception ex) { @@ -59,4 +60,17 @@ public sealed class WelcomeEventListener : IEventListener } }); } + + private static string FormatMessage(string template, Api.Net.IClientPlayer player) + { + var lastConnect = player.Client.ProductUserId != null + ? PlayerConnectStore.GetLastConnectString(player.Client.ProductUserId) + : null; + + return template + .Replace("{Name}", player.Client.Name ?? "Player") + .Replace("{Room}", player.Game.Code.Code) + .Replace("{FriendCode}", player.Client.FriendCode ?? "None") + .Replace("{LastConnect}", lastConnect ?? "First time!"); + } } diff --git a/src/Empostor.Plugins.Welcome/WelcomePlugin.cs b/src/Empostor.Plugins.Welcome/WelcomePlugin.cs index b47b3a4..a55eece 100644 --- a/src/Empostor.Plugins.Welcome/WelcomePlugin.cs +++ b/src/Empostor.Plugins.Welcome/WelcomePlugin.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging; namespace Empostor.Plugins.Welcome; -[EmpostorPlugin("cn.hayashiume.welcome", "Welcome Messages", "HayashiUme", "1.0.0")] +[EmpostorPlugin("cn.hayashiume.welcome", "Welcome Messages", "HayashiUme", "1.1.0")] public sealed class WelcomePlugin : PluginBase { private const string TextDir = "Message"; @@ -38,22 +38,22 @@ public sealed class WelcomePlugin : PluginBase { var dir = Path.Combine(Directory.GetCurrentDirectory(), TextDir); - WriteIfMissing(dir, "EnglishHelloWord.txt", "Welcome, {0}! Room: {1}"); - WriteIfMissing(dir, "SChineseHelloWord.txt", "欢迎,{0}!房间:{1}"); - WriteIfMissing(dir, "TChineseHelloWord.txt", "歡迎,{0}!房間:{1}"); - WriteIfMissing(dir, "KoreanHelloWord.txt", "환영합니다, {0}! 방: {1}"); - WriteIfMissing(dir, "RussianHelloWord.txt", "Добро пожаловать, {0}! Комната: {1}"); - WriteIfMissing(dir, "PortugueseHelloWord.txt", "Bem-vindo, {0}! Sala: {1}"); - WriteIfMissing(dir, "BrazilianHelloWord.txt", "Bem-vindo, {0}! Sala: {1}"); - WriteIfMissing(dir, "FrenchHelloWord.txt", "Bienvenue, {0}! Salle : {1}"); - WriteIfMissing(dir, "GermanHelloWord.txt", "Willkommen, {0}! Raum: {1}"); - WriteIfMissing(dir, "SpanishHelloWord.txt", "¡Bienvenido, {0}! Sala: {1}"); - WriteIfMissing(dir, "LatamHelloWord.txt", "¡Bienvenido, {0}! Sala: {1}"); - WriteIfMissing(dir, "JapaneseHelloWord.txt", "ようこそ、{0}!ルーム:{1}"); - WriteIfMissing(dir, "ItalianHelloWord.txt", "Benvenuto, {0}! Stanza: {1}"); - WriteIfMissing(dir, "DutchHelloWord.txt", "Welkom, {0}! Kamer: {1}"); - WriteIfMissing(dir, "FilipinoHelloWord.txt", "Maligayang pagdating, {0}! Silid: {1}"); - WriteIfMissing(dir, "IrishHelloWord.txt", "Fáilte, {0}! Seomra: {1}"); + WriteIfMissing(dir, "EnglishHelloWord.txt", "Welcome, {Name}! Room: {Room}"); + WriteIfMissing(dir, "SChineseHelloWord.txt", "欢迎,{Name}!房间:{Room}"); + WriteIfMissing(dir, "TChineseHelloWord.txt", "歡迎,{Name}!房間:{Room}"); + WriteIfMissing(dir, "KoreanHelloWord.txt", "환영합니다, {Name}! 방: {Room}"); + WriteIfMissing(dir, "RussianHelloWord.txt", "Добро пожаловать, {Name}! Комната: {Room}"); + WriteIfMissing(dir, "PortugueseHelloWord.txt", "Bem-vindo, {Name}! Sala: {Room}"); + WriteIfMissing(dir, "BrazilianHelloWord.txt", "Bem-vindo, {Name}! Sala: {Room}"); + WriteIfMissing(dir, "FrenchHelloWord.txt", "Bienvenue, {Name}! Salle : {Room}"); + WriteIfMissing(dir, "GermanHelloWord.txt", "Willkommen, {Name}! Raum: {Room}"); + WriteIfMissing(dir, "SpanishHelloWord.txt", "¡Bienvenido, {Name}! Sala: {Room}"); + WriteIfMissing(dir, "LatamHelloWord.txt", "¡Bienvenido, {Name}! Sala: {Room}"); + WriteIfMissing(dir, "JapaneseHelloWord.txt", "ようこそ、{Name}!ルーム:{Room}"); + WriteIfMissing(dir, "ItalianHelloWord.txt", "Benvenuto, {Name}! Stanza: {Room}"); + WriteIfMissing(dir, "DutchHelloWord.txt", "Welkom, {Name}! Kamer: {Room}"); + WriteIfMissing(dir, "FilipinoHelloWord.txt", "Maligayang pagdating, {Name}! Silid: {Room}"); + WriteIfMissing(dir, "IrishHelloWord.txt", "Fáilte, {Name}! Seomra: {Room}"); } private void WriteIfMissing(string dir, string fileName, string content)