Add EOSToken interface

This commit is contained in:
linmeideli committed 2026-10-18 09:32:08 +08:00
1 parent 846ccb19e3
commit 18d87048b2
54 files changed
+32 -2929

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-52
View File
@@ -64,19 +64,6 @@
}
]
},
{
"id": "cn.Empostor.titles",
"name": "Custom Title",
"description": "Assigns a title based on their friend code and show in front of their name.",
"author": "HayashiUme",
"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",
@@ -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",
+5
View File
@@ -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")]
+5
View File
@@ -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; }
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>Empostor.Plugin.Narrator</AssemblyName>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
</ItemGroup>
<Target Name="CopyToMarketplace" AfterTargets="Build">
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
</Target>
</Project>
@@ -1,16 +0,0 @@
{
"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?"
}
@@ -1,16 +0,0 @@
{
"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,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,152 +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 LocalizationOwner => NarratorPlugin.Owner;
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}", statusText, max);
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.", limit),
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, params object?[] arguments)
{
return ctx.GetPluginString(NarratorPlugin.Owner, key, arguments);
}
}
@@ -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,24 +0,0 @@
using System;
using System.Threading.Tasks;
using Empostor.Api.Localization;
using Empostor.Api.Plugins;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugin.Narrator;
[EmpostorPlugin(Owner)]
public sealed class NarratorPlugin : PluginBase
{
public const string Owner = "cn.hayashiume.narrator";
private readonly ILocalizationRegistry _registry;
private readonly ILogger<NarratorPlugin> _logger;
private IDisposable? _registration;
public NarratorPlugin(ILogger<NarratorPlugin> logger, ILocalizationRegistry registry) { _logger = logger; _registry = registry; }
public override ValueTask EnableAsync()
{
if (!_registry.TryRegister(new LocalizationCatalog(Owner, typeof(NarratorPlugin).Assembly, "Empostor.Plugin.Narrator.Localization"), out _registration))
_logger.LogWarning("Could not register localization resources for {Owner}", Owner);
return default;
}
public override ValueTask DisableAsync() { _registration?.Dispose(); return default; }
}
@@ -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.ConfirmImpostor ? "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));
}
}
@@ -1,140 +0,0 @@
{
"version": 1,
"dependencies": {
"net8.0": {
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.1",
"contentHash": "vZsG2YILhthgRqO+ZVgRff4ZFKKTl0v7kqaVBLCtRvpREhfBP33pcWrdA3PRYgWuFL1RxiUFvjMUHTdBZlJcoA=="
},
"Empostor.Hazel": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "cblWCgvLcQ2xK6ztV04RVrghVEnJpCWe4N6G3LCNaJhuUq367a1/bbtQxUWAflqvgSvGwY3g3SClhh9eWTCBDA==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.1",
"Empostor.Hazel.Abstractions": "1.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.6",
"Microsoft.Extensions.ObjectPool": "8.0.1",
"Serilog": "4.3.1"
}
},
"Empostor.Hazel.Abstractions": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "uJqGMASOGj+ELffmIMqubyvJ/FHjOXA1RtQdS38s1LRhP+Eu4zJMTTndr12+Q3vS0kHggFFyuExFmNxWyzWmig=="
},
"Microsoft.AspNetCore.Http.Abstractions": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "Nxs7Z1q3f1STfLYKJSVXCs1iBl+Ya6E8o4Oy1bCxJ/rNI44E/0f6tbsrVqAWfB7jlnJfyaAtIalBVxPKUPQb4Q==",
"dependencies": {
"Microsoft.AspNetCore.Http.Features": "2.2.0",
"System.Text.Encodings.Web": "4.5.0"
}
},
"Microsoft.AspNetCore.Http.Features": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "ziFz5zH8f33En4dX81LW84I6XrYXKf9jg6aM39cM+LffN9KJahViKZ61dGMSO2gd3e+qe5yBRwsesvyqlZaSMg==",
"dependencies": {
"Microsoft.Extensions.Primitives": "2.2.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.6",
"contentHash": "0Zn6nR/6g+90MxskZyOOMPQvnPnrrGu6bytPwkV+azDcTtCSuQ1+GJUrg8Klmnrjk1i6zMpw2lXijl+tw7Q3kA=="
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"System.Diagnostics.DiagnosticSource": "8.0.0"
}
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0",
"Microsoft.Extensions.FileProviders.Abstractions": "8.0.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
},
"Serilog": {
"type": "Transitive",
"resolved": "4.3.1",
"contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ=="
},
"System.Diagnostics.DiagnosticSource": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g=="
},
"empostor.api": {
"type": "Project",
"dependencies": {
"Empostor.Hazel": "[1.0.2, )",
"Microsoft.AspNetCore.Http.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Hosting.Abstractions": "[8.0.0, )",
"Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )"
}
}
}
}
}
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>Empostor.Plugins.MapVote</AssemblyName>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
</ItemGroup>
<Target Name="CopyToMarketplace" AfterTargets="Build">
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
</Target>
</Project>
@@ -1,20 +0,0 @@
{
"command.votemap.description":"Vote for the next map.",
"command.votemap.usage":"votemap <map>",
"mapvote.disabled":"Map voting is currently disabled by the host.",
"mapvote.unknown_map":"Unknown map: {0}. Available: Skeld, Mira, Polus, Airship, Fungle",
"mapvote.voted":"{0} voted for {1}.",
"mapvote.already_voted":"You already voted for {0}. Use #votemap <map> to change your vote.",
"mapvote.no_votes":"No map votes yet. Use #votemap <map> to vote!",
"mapvote.results_header":"=== Map Vote Results ===",
"mapvote.results_entry":" {0}: {1} vote(s)",
"mapvote.results_winner":"Winner: {0} ({1} votes)",
"mapvote.results_random":"No votes cast, using random map: {0}",
"mapvote.usage":"Usage: #votemap <map>\nMaps: Skeld, Mira, Polus, Airship, Fungle\nExample: #votemap polus",
"mapvote.host_commands":"Host commands: #votemap public | close | enable | disable | results",
"mapvote.host_only":"Only the host can use this command.",
"mapvote.host_enabled":"Map voting enabled.",
"mapvote.host_disabled":"Map voting disabled.",
"mapvote.session_started":"Map vote session started! Players, use #votemap <map> to vote.\nMaps: Skeld, Mira, Polus, Airship, Fungle",
"mapvote.map_set":"Map set to {0} by vote."
}
@@ -1,20 +0,0 @@
{
"command.votemap.description":"投票选择下一张地图。",
"command.votemap.usage":"votemap <地图名>",
"mapvote.disabled":"投票选图功能已被房主禁用。",
"mapvote.unknown_map":"未知地图:{0}。可选:Skeld, Mira, Polus, Airship, Fungle",
"mapvote.voted":"{0} 投票选择了 {1}。",
"mapvote.already_voted":"你已经投票了 {0}。使用 #votemap <地图> 来修改投票。",
"mapvote.no_votes":"还没有人投票。使用 #votemap <地图> 来投票!",
"mapvote.results_header":"=== 地图投票结果 ===",
"mapvote.results_entry":" {0}:{1} 票",
"mapvote.results_winner":"胜出:{0}({1} 票)",
"mapvote.results_random":"无人投票,随机选择地图:{0}",
"mapvote.usage":"用法:#votemap <地图>\n可选地图:Skeld, Mira, Polus, Airship, Fungle\n示例:#votemap polus",
"mapvote.host_commands":"房主指令:#votemap public | close | enable | disable | results",
"mapvote.host_only":"只有房主可以使用此指令。",
"mapvote.host_enabled":"投票选图已启用。",
"mapvote.host_disabled":"投票选图已禁用。",
"mapvote.session_started":"地图投票已开始!请使用 #votemap <地图> 投票。\n可选地图:Skeld, Mira, Polus, Airship, Fungle",
"mapvote.map_set":"投票结果,地图已设为 {0}。"
}
@@ -1,191 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Empostor.Api.Commands;
namespace Empostor.Plugins.MapVote;
public sealed class MapVoteCommand : ICommand
{
private readonly MapVoteService _service;
private readonly MapVoteConfig _config;
public MapVoteCommand(MapVoteService service, MapVoteConfig config)
{
_service = service;
_config = config;
}
public string Name => "votemap";
public string LocalizationOwner => MapVotePlugin.Owner;
public string[] Aliases => new[] { "vm" };
public string Description => "Vote for the next map.";
public string Usage => "votemap <map>";
public async ValueTask<bool> ExecuteAsync(CommandContext ctx)
{
var gameCode = ctx.Game.Code.ToString();
// Host control commands
if (ctx.Args.Length > 0)
{
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 <map> 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)
{
await ctx.PlayerControl.SendChatToPlayerAsync(
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)
{
await ctx.PlayerControl.SendChatToPlayerAsync(
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;
}
if (sub == "results" || sub == "result")
{
await ShowResultsAsync(ctx);
return true;
}
}
// Check if voting is enabled
if (!_service.IsEnabled(gameCode) && !ctx.Sender.IsHost)
{
await ctx.PlayerControl.SendChatToPlayerAsync(
T(ctx, "mapvote.disabled", "Map voting is currently disabled by the host."), ctx.PlayerControl);
return true;
}
// Voting requires a map argument
if (ctx.Args.Length == 0)
{
await ShowUsageAsync(ctx);
return true;
}
if (!_service.TryParseMap(ctx.RawArgs!, out var map))
{
await ctx.PlayerControl.SendChatToPlayerAsync(
T(ctx, "mapvote.unknown_map", "Unknown map.", ctx.RawArgs ?? "?"), ctx.PlayerControl);
return true;
}
var playerName = ctx.Sender.Character?.PlayerInfo?.PlayerName ?? ctx.Sender.Client.Name ?? "Unknown";
_service.CastVote(gameCode, playerName, map);
await ctx.PlayerControl.SendChatToPlayerAsync(
T(ctx, "mapvote.voted", "{0} voted for {1}.", playerName, MapVoteService.MapDisplayName(map)),
ctx.PlayerControl);
return true;
}
private async ValueTask ShowResultsAsync(CommandContext ctx)
{
var gameCode = ctx.Game.Code.ToString();
var tally = _service.TallyVotes(gameCode);
if (tally.Count == 0)
{
await ctx.PlayerControl.SendChatToPlayerAsync(
T(ctx, "mapvote.no_votes", "No map votes yet."), ctx.PlayerControl);
return;
}
var sb = new StringBuilder();
sb.AppendLine(T(ctx, "mapvote.results_header", "=== Map Vote Results ==="));
foreach (var (map, count) in tally)
{
sb.AppendLine(T(ctx, "mapvote.results_entry", " {0}: {1} vote(s)", MapVoteService.MapDisplayName(map), count));
}
await ctx.PlayerControl.SendChatToPlayerAsync(sb.ToString(), ctx.PlayerControl);
}
private static async ValueTask ShowWinnerToAllAsync(CommandContext ctx, Api.Innersloth.MapTypes winner,
IReadOnlyDictionary<Api.Innersloth.MapTypes, int> tally, int voterCount)
{
foreach (var player in ctx.Game.Players)
{
var ctrl = player.Character;
if (ctrl != null)
{
var key = tally.Count == 0 ? "mapvote.results_random" : "mapvote.results_winner";
var text = ctx.Localization.Get(new Empostor.Api.Localization.LocalizedMessageKey(MapVotePlugin.Owner, key),
player.Client.Language, MapVoteService.MapDisplayName(winner), tally.Count == 0 ? 0 : tally.Values.Max());
await ctrl.SendChatToPlayerAsync(text, ctrl);
}
}
}
private async ValueTask ShowUsageAsync(CommandContext ctx)
{
var sb = new StringBuilder();
sb.AppendLine(T(ctx, "mapvote.usage", "Usage: #votemap <map>\nMaps: Skeld, Mira, Polus, Airship, Fungle\nExample: #votemap polus"));
if (ctx.Sender.IsHost)
sb.AppendLine(T(ctx, "mapvote.host_commands", "Host commands: #votemap public | close | enable | disable | results"));
await ctx.PlayerControl.SendChatToPlayerAsync(sb.ToString(), ctx.PlayerControl);
}
private static string T(CommandContext ctx, string key, string defaultText, params object?[] arguments)
{
return ctx.GetPluginString(MapVotePlugin.Owner, key, arguments);
}
}
@@ -1,15 +0,0 @@
using System.Text.Json.Serialization;
namespace Empostor.Plugins.MapVote;
public sealed class MapVoteConfig
{
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = true;
[JsonPropertyName("require_majority")]
public bool RequireMajority { get; set; } = false;
[JsonPropertyName("allow_host_override")]
public bool AllowHostOverride { get; set; } = true;
}
@@ -1,60 +0,0 @@
using System.Threading.Tasks;
using Empostor.Api.Events;
using Empostor.Api.Events.Game;
namespace Empostor.Plugins.MapVote;
public sealed class MapVoteEventListener : IEventListener
{
private readonly MapVoteService _service;
private readonly MapVoteConfig _config;
public MapVoteEventListener(MapVoteService service, MapVoteConfig config)
{
_service = service;
_config = config;
}
[EventListener]
public async ValueTask OnGameStarting(IGameStartingEvent e)
{
if (!_config.Enabled) return;
var gameCode = e.Game.Code.ToString();
if (!_service.IsEnabled(gameCode)) return;
var winner = _service.GetWinner(gameCode);
// 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)
{
_service.ResetVotes(gameCode);
return;
}
}
e.Game.Options.Map = winner;
await e.Game.SyncSettingsAsync();
// Notify players
foreach (var player in e.Game.Players)
{
var ctrl = player.Character;
if (ctrl == null) continue;
await ctrl.SendChatToPlayerAsync(
$"Map set to {MapVoteService.MapDisplayName(winner)} by vote.", ctrl);
}
_service.ResetVotes(gameCode);
}
[EventListener]
public void OnGameDestroyed(IGameDestroyedEvent e)
{
_service.Remove(e.Game.Code.ToString());
}
}
@@ -1,24 +0,0 @@
using System;
using System.Threading.Tasks;
using Empostor.Api.Localization;
using Empostor.Api.Plugins;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugins.MapVote;
[EmpostorPlugin(Owner)]
public sealed class MapVotePlugin : PluginBase
{
public const string Owner = "cn.hayashiume.mapvote";
private readonly ILocalizationRegistry _registry;
private readonly ILogger<MapVotePlugin> _logger;
private IDisposable? _registration;
public MapVotePlugin(ILogger<MapVotePlugin> logger, ILocalizationRegistry registry) { _logger = logger; _registry = registry; }
public override ValueTask EnableAsync()
{
if (!_registry.TryRegister(new LocalizationCatalog(Owner, typeof(MapVotePlugin).Assembly, "Empostor.Plugins.MapVote.Localization"), out _registration))
_logger.LogWarning("Could not register localization resources for {Owner}", Owner);
return default;
}
public override ValueTask DisableAsync() { _registration?.Dispose(); return default; }
}
@@ -1,137 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Empostor.Api.Innersloth;
namespace Empostor.Plugins.MapVote;
public sealed class MapVoteService
{
private static readonly MapTypes[] AllMaps =
{
MapTypes.Skeld, MapTypes.MiraHQ, MapTypes.Polus, MapTypes.Airship, MapTypes.Fungle
};
private static readonly Dictionary<string, MapTypes> MapAliases = new(StringComparer.OrdinalIgnoreCase)
{
["skeld"] = MapTypes.Skeld,
["the skeld"] = MapTypes.Skeld,
["mira"] = MapTypes.MiraHQ,
["mirahq"] = MapTypes.MiraHQ,
["mira hq"] = MapTypes.MiraHQ,
["polus"] = MapTypes.Polus,
["airship"] = MapTypes.Airship,
["fungle"] = MapTypes.Fungle,
// Chinese aliases
["骷髅"] = MapTypes.Skeld,
["米拉"] = MapTypes.MiraHQ,
["波鲁斯"] = MapTypes.Polus,
["飞船"] = MapTypes.Airship,
["真菌"] = MapTypes.Fungle,
};
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, MapTypes>> _votes = new();
private readonly ConcurrentDictionary<string, bool> _enabled = new();
private readonly ConcurrentDictionary<string, bool> _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))
return true;
if (int.TryParse(input.Trim(), out var id) && Enum.IsDefined(typeof(MapTypes), (byte)id))
{
map = (MapTypes)(byte)id;
if (map != MapTypes.Dleks)
return true;
}
return false;
}
public static string MapDisplayName(MapTypes map) => map switch
{
MapTypes.Skeld => "The Skeld",
MapTypes.MiraHQ => "Mira HQ",
MapTypes.Polus => "Polus",
MapTypes.Airship => "Airship",
MapTypes.Fungle => "Fungle",
_ => map.ToString(),
};
public void CastVote(string gameCode, string playerName, MapTypes map)
{
var gameVotes = _votes.GetOrAdd(gameCode, _ => new ConcurrentDictionary<string, MapTypes>());
gameVotes[playerName] = map;
}
public MapTypes? GetPlayerVote(string gameCode, string playerName)
{
if (_votes.TryGetValue(gameCode, out var gameVotes) && gameVotes.TryGetValue(playerName, out var map))
return map;
return null;
}
public IReadOnlyDictionary<MapTypes, int> TallyVotes(string gameCode)
{
if (!_votes.TryGetValue(gameCode, out var gameVotes) || gameVotes.IsEmpty)
return new Dictionary<MapTypes, int>();
return gameVotes.Values
.GroupBy(m => m)
.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);
if (tally.Count == 0)
{
var rng = new Random();
return AllMaps[rng.Next(AllMaps.Length)];
}
var maxVotes = tally.Values.Max();
var winners = tally.Where(kv => kv.Value == maxVotes).Select(kv => kv.Key).ToList();
if (winners.Count == 1)
return winners[0];
var rng2 = new Random();
return winners[rng2.Next(winners.Count)];
}
public void ResetVotes(string gameCode)
{
_votes.TryRemove(gameCode, out _);
}
internal void Remove(string gameCode)
{
_votes.TryRemove(gameCode, out _);
_enabled.TryRemove(gameCode, out _);
_sessionActive.TryRemove(gameCode, out _);
}
}
@@ -1,26 +0,0 @@
using Empostor.Api.Commands;
using Empostor.Api.Events;
using Empostor.Api.Plugins;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Empostor.Plugins.MapVote;
public sealed class MapVoteStartup : IPluginStartup
{
private string _configPath = "config";
public void SetConfigPath(string configPath) => _configPath = configPath;
public void ConfigureHost(IHostBuilder host) { }
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton(_ => PluginConfigLoader.Load<MapVoteConfig>(_configPath));
services.AddSingleton<MapVoteService>();
services.AddSingleton<MapVoteCommand>();
services.AddSingleton<ICommand, MapVoteCommand>(sp => sp.GetRequiredService<MapVoteCommand>());
services.AddSingleton<MapVoteEventListener>();
services.AddSingleton<IEventListener, MapVoteEventListener>(sp => sp.GetRequiredService<MapVoteEventListener>());
}
}
@@ -1,140 +0,0 @@
{
"version": 1,
"dependencies": {
"net8.0": {
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.1",
"contentHash": "vZsG2YILhthgRqO+ZVgRff4ZFKKTl0v7kqaVBLCtRvpREhfBP33pcWrdA3PRYgWuFL1RxiUFvjMUHTdBZlJcoA=="
},
"Empostor.Hazel": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "cblWCgvLcQ2xK6ztV04RVrghVEnJpCWe4N6G3LCNaJhuUq367a1/bbtQxUWAflqvgSvGwY3g3SClhh9eWTCBDA==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.1",
"Empostor.Hazel.Abstractions": "1.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.6",
"Microsoft.Extensions.ObjectPool": "8.0.1",
"Serilog": "4.3.1"
}
},
"Empostor.Hazel.Abstractions": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "uJqGMASOGj+ELffmIMqubyvJ/FHjOXA1RtQdS38s1LRhP+Eu4zJMTTndr12+Q3vS0kHggFFyuExFmNxWyzWmig=="
},
"Microsoft.AspNetCore.Http.Abstractions": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "Nxs7Z1q3f1STfLYKJSVXCs1iBl+Ya6E8o4Oy1bCxJ/rNI44E/0f6tbsrVqAWfB7jlnJfyaAtIalBVxPKUPQb4Q==",
"dependencies": {
"Microsoft.AspNetCore.Http.Features": "2.2.0",
"System.Text.Encodings.Web": "4.5.0"
}
},
"Microsoft.AspNetCore.Http.Features": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "ziFz5zH8f33En4dX81LW84I6XrYXKf9jg6aM39cM+LffN9KJahViKZ61dGMSO2gd3e+qe5yBRwsesvyqlZaSMg==",
"dependencies": {
"Microsoft.Extensions.Primitives": "2.2.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.6",
"contentHash": "0Zn6nR/6g+90MxskZyOOMPQvnPnrrGu6bytPwkV+azDcTtCSuQ1+GJUrg8Klmnrjk1i6zMpw2lXijl+tw7Q3kA=="
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"System.Diagnostics.DiagnosticSource": "8.0.0"
}
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0",
"Microsoft.Extensions.FileProviders.Abstractions": "8.0.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
},
"Serilog": {
"type": "Transitive",
"resolved": "4.3.1",
"contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ=="
},
"System.Diagnostics.DiagnosticSource": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g=="
},
"empostor.api": {
"type": "Project",
"dependencies": {
"Empostor.Hazel": "[1.0.2, )",
"Microsoft.AspNetCore.Http.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Hosting.Abstractions": "[8.0.0, )",
"Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )"
}
}
}
}
}
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>Empostor.Plugins.PlayerLog</AssemblyName>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
</ItemGroup>
<Target Name="CopyToMarketplace" AfterTargets="Build">
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
</Target>
</Project>
@@ -1,176 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Empostor.Api.Admin;
namespace Empostor.Plugins.PlayerLog;
/// <summary>
/// Admin panel page for the player log. The selected player, page and clear range live on this
/// singleton, so every rebuild of the panel reflects the last choice.
/// </summary>
public sealed class PlayerLogAdminExtension : IAdminExtension
{
private const int PageSize = 100;
private static readonly string[] TypeColors = { "Chat", "Report", "Murder", "Exile", "Vote", "Task", "Vent", "Meeting" };
private readonly PlayerLogStore _store;
private int? _clientId;
private int _page = 1;
private string _clearRange = "all";
public PlayerLogAdminExtension(PlayerLogStore store)
{
_store = store;
}
public string Id => "player-log";
public string Title => "Player Log";
public string Icon => "list";
public string Section => "Server";
/// <summary>The chat feed mirrors live server state, so keep the 1-second rebuild.</summary>
public bool AutoRefresh => true;
public void Build(AdminPanelBuilder b)
{
var players = _store.GetLoggedClientIds();
if (_clientId.HasValue && !players.Contains(_clientId.Value))
{
_clientId = null;
}
var total = _clientId.HasValue ? _store.GetCountByClient(_clientId.Value) : _store.GetCount();
var totalPages = Math.Max(1, (int)Math.Ceiling(total / (double)PageSize));
_page = Math.Clamp(_page, 1, totalPages);
var entries = _clientId.HasValue
? _store.GetPageByClient(_clientId.Value, _page, PageSize)
: _store.GetPage(_page, PageSize);
b.RegisterSelect(sel =>
{
sel.Label = "Player";
sel.Options = new List<AdminOption> { new("All players", string.Empty) };
foreach (var id in players)
{
var name = _store.GetLatestName(id) ?? $"Player#{id}";
var fc = _store.GetLatestFriendCode(id) ?? "—";
sel.Options.Add(new AdminOption($"#{id} {name} ({fc})", id.ToString()));
}
sel.Value = _clientId?.ToString() ?? string.Empty;
sel.OnChange(ctx =>
{
_clientId = int.TryParse(ctx.Value, out var id) ? id : null;
_page = 1;
return ValueTask.FromResult(AdminActionResult.Ok(null));
});
});
b.RegisterSelect(sel =>
{
sel.Label = "Page";
sel.Options = Enumerable.Range(1, totalPages)
.Select(p => new AdminOption($"{p} / {totalPages}", p.ToString()))
.ToList();
sel.Value = _page.ToString();
sel.OnChange(ctx =>
{
if (int.TryParse(ctx.Value, out var page))
{
_page = page;
}
return ValueTask.FromResult(AdminActionResult.Ok(null));
});
});
b.RegisterTable(t =>
{
t.Columns = new List<string> { "Time", "Type", "Player", "Friend Code", "Game", "Detail" };
foreach (var entry in entries)
{
t.Rows.Add(new List<AdminTableCell>
{
new() { Text = entry.Time.ToLocalTime().ToString("MM-dd HH:mm:ss") },
new() { Text = entry.Type, Tone = TypeColors.Contains(entry.Type) ? "accent" : "default" },
new() { Text = entry.PlayerName ?? "—" },
new() { Text = entry.FriendCode ?? "—", Monospace = true },
new() { Text = entry.GameCode ?? "—", Monospace = true },
new() { Text = entry.Detail ?? "—" },
});
}
});
b.RegisterText(txt =>
{
txt.Content = total == 0
? "No log entries recorded yet."
: $"Showing {entries.Count} of {total} entr{(total == 1 ? "y" : "ies")} · export: /player-log/export";
txt.Tone = "muted";
});
b.RegisterButton(btn =>
{
btn.Label = "Export as JSON";
btn.Icon = "download";
btn.Style = "secondary";
btn.OnClick(ctx =>
{
var bytes = _clientId.HasValue ? _store.ExportJson(_clientId.Value) : _store.ExportJson();
var scope = _clientId.HasValue ? $"client {_clientId}" : "all players";
return ValueTask.FromResult(AdminActionResult.Ok(
$"{bytes.Length} bytes ready for {scope}. Open /player-log/export to download."));
});
});
b.RegisterSelect(sel =>
{
sel.Label = "Clear logs";
sel.Options = new List<AdminOption>
{
new("All logs", "all"),
new("Older than 1 hour", "1h"),
new("Older than 24 hours", "24h"),
new("Older than 7 days", "7d"),
new("Older than 30 days", "30d"),
};
sel.Value = _clearRange;
sel.OnChange(ctx =>
{
_clearRange = string.IsNullOrWhiteSpace(ctx.Value) ? "all" : ctx.Value!;
return ValueTask.FromResult(AdminActionResult.Ok(null));
});
});
b.RegisterButton(btn =>
{
btn.Label = "Clear Logs";
btn.Icon = "trash";
btn.Style = "danger";
btn.OnClick(ctx =>
{
var cutoff = _clearRange switch
{
"1h" => DateTime.UtcNow.AddHours(-1),
"24h" => DateTime.UtcNow.AddDays(-1),
"7d" => DateTime.UtcNow.AddDays(-7),
"30d" => DateTime.UtcNow.AddDays(-30),
_ => (DateTime?)null,
};
_store.Clear(cutoff);
_page = 1;
var scope = cutoff.HasValue ? $"older than {_clearRange}" : "all";
return ValueTask.FromResult(AdminActionResult.Ok($"Cleared {scope} log entries."));
});
});
}
}
@@ -1,96 +0,0 @@
using Empostor.Api.Events;
using Empostor.Api.Events.Client;
using Empostor.Api.Events.Meeting;
using Empostor.Api.Events.Player;
using Empostor.Api.Innersloth;
namespace Empostor.Plugins.PlayerLog;
internal sealed class PlayerLogListener : IEventListener
{
private readonly PlayerLogStore _store;
public PlayerLogListener(PlayerLogStore store) => _store = store;
private static string? Gc(IGameEvent e) => e.Game != null ? GameCodeParser.IntToGameName(e.Game.Code) : null;
private static string? Pn(IPlayerEvent e) => e.ClientPlayer?.Client?.Name;
private static string? Pf(IPlayerEvent e) => e.ClientPlayer?.Client?.FriendCode;
private static int? Pid(IPlayerEvent e) => e.ClientPlayer?.Client?.Id;
// Chat
[EventListener]
public void OnChat(IPlayerChatEvent e) =>
_store.Add("Chat", Pid(e), Pn(e), Pf(e), Gc(e), e.Message);
// RPC-like events
[EventListener]
public void OnReport(IPlayerReportEvent e) =>
_store.Add("Report", Pid(e), Pn(e), Pf(e), Gc(e), $"Reported: {e.ReportedClient?.Name ?? "body"}");
[EventListener]
public void OnMurder(IPlayerMurderEvent e) =>
_store.Add("Murder", Pid(e), Pn(e), Pf(e), Gc(e), $"Victim: {e.Victim?.PlayerInfo?.PlayerName ?? "unknown"}");
[EventListener]
public void OnExile(IPlayerExileEvent e) =>
_store.Add("Exile", Pid(e), Pn(e), Pf(e), Gc(e), "Ejected");
[EventListener]
public void OnVote(IPlayerVotedEvent e) =>
_store.Add("Vote", Pid(e), Pn(e), Pf(e), Gc(e), $"Voted: {e.VotedFor?.PlayerInfo?.PlayerName ?? "skipped"}");
[EventListener]
public void OnTask(IPlayerCompletedTaskEvent e) =>
_store.Add("Task", Pid(e), Pn(e), Pf(e), Gc(e), null);
[EventListener]
public void OnEnterVent(IPlayerEnterVentEvent e) =>
_store.Add("Vent", Pid(e), Pn(e), Pf(e), Gc(e), "Enter vent");
[EventListener]
public void OnExitVent(IPlayerExitVentEvent e) =>
_store.Add("Vent", Pid(e), Pn(e), Pf(e), Gc(e), "Exit vent");
// Meeting events (game-level, not player-level)
[EventListener]
public void OnMeetingStarted(IMeetingStartedEvent e) =>
_store.Add("Meeting", null, null, null, Gc(e), "Meeting started");
[EventListener]
public void OnMeetingEnded(IMeetingEndedEvent e) =>
_store.Add("Meeting", null, null, null, Gc(e), "Meeting ended");
// Connection events
[EventListener]
public void OnClientConnected(IClientConnectedEvent e) =>
_store.Add("Connect", e.Client.Id, e.Client.Name, e.Client.FriendCode, null, null);
// Game lifecycle
[EventListener]
public void OnGameCreated(IGameCreatedEvent e) =>
_store.Add("Game", null, null, null, Gc(e), "Game created");
[EventListener]
public void OnGameStarted(IGameStartedEvent e) =>
_store.Add("Game", null, null, null, Gc(e), "Game started");
[EventListener]
public void OnGameEnded(IGameEndedEvent e) =>
_store.Add("Game", null, null, null, Gc(e), $"Game ended: {e.GameOverReason}");
[EventListener]
public void OnGameDestroyed(IGameDestroyedEvent e) =>
_store.Add("Game", null, null, null, Gc(e), "Game destroyed");
// Player join/leave
[EventListener]
public void OnPlayerJoined(IGamePlayerJoinedEvent e) =>
_store.Add("Join", e.Player.Client.Id, e.Player.Client.Name, e.Player.Client.FriendCode, Gc(e), null);
[EventListener]
public void OnPlayerLeft(IGamePlayerLeftEvent e) =>
_store.Add("Leave", e.Player.Client.Id, e.Player.Client.Name, e.Player.Client.FriendCode, Gc(e), e.IsBan ? "Banned" : "Left");
}
@@ -1,24 +0,0 @@
using System.Threading.Tasks;
using Empostor.Api.Plugins;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugins.PlayerLog;
[EmpostorPlugin("gg.empostor.playerlog")]
public sealed class PlayerLogPlugin : PluginBase
{
private readonly ILogger<PlayerLogPlugin> _logger;
public PlayerLogPlugin(ILogger<PlayerLogPlugin> logger)
{
_logger = logger;
}
public override ValueTask EnableAsync()
{
_logger.LogInformation("[PlayerLog] Enabled. Player activity logs are available in the admin panel.");
return default;
}
public override ValueTask DisableAsync() => default;
}
@@ -1,61 +0,0 @@
using System;
using System.Security.Cryptography;
using System.Text;
using Empostor.Api.Admin;
using Empostor.Api.Config;
using Empostor.Api.Events;
using Empostor.Api.Plugins;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace Empostor.Plugins.PlayerLog;
public sealed class PlayerLogPluginStartup : IPluginStartup, IPluginHttpStartup
{
public void ConfigureHost(IHostBuilder host) { }
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<PlayerLogStore>();
services.AddSingleton<IEventListener, PlayerLogListener>();
services.AddSingleton<IAdminExtension, PlayerLogAdminExtension>();
}
public void ConfigureWebApplication(IApplicationBuilder app)
{
app.Map("/player-log/export", branch => branch.Run(async context =>
{
if (!IsAdmin(context))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
var store = context.RequestServices.GetRequiredService<PlayerLogStore>();
var raw = context.Request.Query["clientId"].ToString();
var bytes = int.TryParse(raw, out var clientId) ? store.ExportJson(clientId) : store.ExportJson();
context.Response.ContentType = "application/json";
context.Response.Headers["Content-Disposition"] = "attachment; filename=player_logs.json";
await context.Response.Body.WriteAsync(bytes);
}));
}
private static bool IsAdmin(HttpContext context)
{
var config = context.RequestServices.GetRequiredService<IOptions<AdminConfig>>().Value;
if (string.IsNullOrEmpty(config.Password))
{
return false;
}
return context.Request.Cookies.TryGetValue("empostor_admin", out var value)
&& string.Equals(value, Hash(config.Password), StringComparison.Ordinal);
}
private static string Hash(string input)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input)));
}
@@ -1,162 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using Empostor.Api.Service;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugins.PlayerLog;
public sealed class PlayerLogEntry
{
public DateTime Time { get; init; }
public string Type { get; init; } = string.Empty;
public int? ClientId { get; init; }
public string? PlayerName { get; init; }
public string? FriendCode { get; init; }
public string? GameCode { get; init; }
public string? Detail { get; init; }
}
public sealed class PlayerLogStore : JsonDataStore<List<PlayerLogEntry>>
{
private const int MaxEntries = 10000;
private readonly ConcurrentQueue<PlayerLogEntry> _entries = new();
private int _count;
public PlayerLogStore(ILogger<PlayerLogStore> logger)
: base(logger, legacyPath: "Data/player_logs.json")
{
Load();
}
public void Add(string type, int? clientId, string? playerName, string? friendCode, string? gameCode, string? detail)
{
var entry = new PlayerLogEntry
{
Time = DateTime.UtcNow,
Type = type,
ClientId = clientId,
PlayerName = playerName,
FriendCode = friendCode,
GameCode = gameCode,
Detail = detail,
};
_entries.Enqueue(entry);
if (Interlocked.Increment(ref _count) > MaxEntries)
{
_entries.TryDequeue(out _);
Interlocked.Decrement(ref _count);
}
SaveFireAndForget();
}
public List<PlayerLogEntry> GetAll() => _entries.ToList();
public List<PlayerLogEntry> GetByClient(int clientId) =>
_entries.Where(e => e.ClientId == clientId).ToList();
public int GetCount() => _count;
public int GetCountByClient(int clientId) =>
_entries.Count(e => e.ClientId == clientId);
public List<PlayerLogEntry> GetPage(int page, int pageSize)
{
var skip = (page - 1) * pageSize;
return _entries.Skip(skip).Take(pageSize).ToList();
}
public List<PlayerLogEntry> GetPageByClient(int clientId, int page, int pageSize)
{
var skip = (page - 1) * pageSize;
return _entries.Where(e => e.ClientId == clientId).Skip(skip).Take(pageSize).ToList();
}
public List<int> GetLoggedClientIds() =>
_entries.Where(e => e.ClientId.HasValue).Select(e => e.ClientId!.Value).Distinct().ToList();
public int GetMaxClientId() =>
_entries.Where(e => e.ClientId.HasValue)
.Select(e => e.ClientId!.Value)
.DefaultIfEmpty(0)
.Max();
public string? GetLatestName(int clientId) =>
_entries.Where(e => e.ClientId == clientId)
.OrderByDescending(e => e.Time)
.Select(e => e.PlayerName)
.FirstOrDefault(n => !string.IsNullOrEmpty(n));
public string? GetLatestFriendCode(int clientId) =>
_entries.Where(e => e.ClientId == clientId)
.OrderByDescending(e => e.Time)
.Select(e => e.FriendCode)
.FirstOrDefault(f => !string.IsNullOrEmpty(f));
public void Clear(DateTime? olderThan = null)
{
if (olderThan.HasValue)
{
var kept = _entries.Where(e => e.Time >= olderThan.Value).ToList();
_entries.Clear();
_count = 0;
foreach (var entry in kept)
{
_entries.Enqueue(entry);
Interlocked.Increment(ref _count);
}
}
else
{
_entries.Clear();
_count = 0;
}
SaveFireAndForget();
}
public byte[] ExportJson()
{
var json = JsonSerializer.Serialize(_entries.ToList(), JsonOpts);
return Encoding.UTF8.GetBytes(json);
}
public byte[] ExportJson(int clientId)
{
var list = _entries.Where(e => e.ClientId == clientId).ToList();
var json = JsonSerializer.Serialize(list, JsonOpts);
return Encoding.UTF8.GetBytes(json);
}
protected override List<PlayerLogEntry> GetSnapshot() => _entries.ToList();
protected override void ApplySnapshot(List<PlayerLogEntry> data)
{
_entries.Clear();
_count = 0;
foreach (var entry in data)
{
_entries.Enqueue(entry);
Interlocked.Increment(ref _count);
}
while (_count > MaxEntries)
{
_entries.TryDequeue(out _);
Interlocked.Decrement(ref _count);
}
}
}
@@ -1,140 +0,0 @@
{
"version": 1,
"dependencies": {
"net8.0": {
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.1",
"contentHash": "vZsG2YILhthgRqO+ZVgRff4ZFKKTl0v7kqaVBLCtRvpREhfBP33pcWrdA3PRYgWuFL1RxiUFvjMUHTdBZlJcoA=="
},
"Empostor.Hazel": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "cblWCgvLcQ2xK6ztV04RVrghVEnJpCWe4N6G3LCNaJhuUq367a1/bbtQxUWAflqvgSvGwY3g3SClhh9eWTCBDA==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.1",
"Empostor.Hazel.Abstractions": "1.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.6",
"Microsoft.Extensions.ObjectPool": "8.0.1",
"Serilog": "4.3.1"
}
},
"Empostor.Hazel.Abstractions": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "uJqGMASOGj+ELffmIMqubyvJ/FHjOXA1RtQdS38s1LRhP+Eu4zJMTTndr12+Q3vS0kHggFFyuExFmNxWyzWmig=="
},
"Microsoft.AspNetCore.Http.Abstractions": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "Nxs7Z1q3f1STfLYKJSVXCs1iBl+Ya6E8o4Oy1bCxJ/rNI44E/0f6tbsrVqAWfB7jlnJfyaAtIalBVxPKUPQb4Q==",
"dependencies": {
"Microsoft.AspNetCore.Http.Features": "2.2.0",
"System.Text.Encodings.Web": "4.5.0"
}
},
"Microsoft.AspNetCore.Http.Features": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "ziFz5zH8f33En4dX81LW84I6XrYXKf9jg6aM39cM+LffN9KJahViKZ61dGMSO2gd3e+qe5yBRwsesvyqlZaSMg==",
"dependencies": {
"Microsoft.Extensions.Primitives": "2.2.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.6",
"contentHash": "0Zn6nR/6g+90MxskZyOOMPQvnPnrrGu6bytPwkV+azDcTtCSuQ1+GJUrg8Klmnrjk1i6zMpw2lXijl+tw7Q3kA=="
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"System.Diagnostics.DiagnosticSource": "8.0.0"
}
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0",
"Microsoft.Extensions.FileProviders.Abstractions": "8.0.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
},
"Serilog": {
"type": "Transitive",
"resolved": "4.3.1",
"contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ=="
},
"System.Diagnostics.DiagnosticSource": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g=="
},
"empostor.api": {
"type": "Project",
"dependencies": {
"Empostor.Hazel": "[1.0.2, )",
"Microsoft.AspNetCore.Http.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Hosting.Abstractions": "[8.0.0, )",
"Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )"
}
}
}
}
}
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>Empostor.Plugins.Titles</AssemblyName>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
</ItemGroup>
<Target Name="CopyToMarketplace" AfterTargets="Build">
<Copy SourceFiles="$(TargetPath)" DestinationFolder="E:\WindowsFile\Empostor\Empostor\marketplace" />
</Target>
</Project>
@@ -1,6 +0,0 @@
{
"titles.display_format": "[{0}] {1}",
"command.title.too_long": "Title too long. Max 12 characters.",
"command.title.cleared": "Title removed.",
"command.title.set": "Title set: {0}"
}
@@ -1,6 +0,0 @@
{
"titles.display_format": "[{0}] {1}",
"command.title.too_long": "头衔过长,最多 12 个字符。",
"command.title.cleared": "头衔已移除。",
"command.title.set": "头衔已设置:{0}"
}
@@ -1,78 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Empostor.Api.Events;
using Empostor.Api.Events.Client;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugins.Titles.Service;
public sealed class FriendCodeTitleListener : IEventListener
{
private readonly ILogger<FriendCodeTitleListener> _logger;
private readonly TitlesConfig _config;
private readonly TitleStore _store;
private Dictionary<string, string> _map;
public FriendCodeTitleListener(
ILogger<FriendCodeTitleListener> logger,
TitlesConfig config,
TitleStore store)
{
_logger = logger;
_config = config;
_store = store;
_map = BuildMap();
_logger.LogInformation("[FriendCodeTitles] Loaded {Count} title mapping(s).", _map.Count);
}
/// <summary>
/// Reloads the title mappings from config. Called by the API middleware
/// after a new title is added.
/// </summary>
public void Reload()
{
_map = BuildMap();
_logger.LogInformation("[FriendCodeTitles] Reloaded {Count} title mapping(s).", _map.Count);
}
[EventListener]
public void OnClientConnected(IClientConnectedEvent e)
{
var fc = e.Client.FriendCode;
if (string.IsNullOrEmpty(fc)) return;
if (!_map.TryGetValue(fc, out var title)) return;
try
{
// Store title so TitleEventListener can apply it in-game via SetNameAsync
_store.Set(e.Client.Id, title);
_logger.LogInformation("[FriendCodeTitles] Stored [{Title}] for {Name} ({FC})",
title, e.Client.Name, fc);
// Remove applied title so it is only used once
_map.Remove(fc);
// Also remove from config so it persists across reloads
var entry = _config.Titles.FirstOrDefault(t =>
string.Equals(t.FriendCode, fc, StringComparison.OrdinalIgnoreCase));
if (entry != null)
{
_config.Titles.Remove(entry);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[FriendCodeTitles] Failed to apply title for {FC}", fc);
}
}
private Dictionary<string, string> BuildMap()
{
return _config.Titles
.Where(t => !string.IsNullOrWhiteSpace(t.FriendCode) && !string.IsNullOrWhiteSpace(t.Title))
.ToDictionary(t => t.FriendCode, t => t.Title, StringComparer.OrdinalIgnoreCase);
}
}
@@ -1,108 +0,0 @@
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugins.Titles.Service;
public sealed class TitleApiMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TitleApiMiddleware> _logger;
private readonly TitlesConfig _config;
private static readonly string ConfigPath = Path.Combine(
Path.GetDirectoryName(typeof(TitleApiMiddleware).Assembly.Location) ?? ".",
"[Title System]Config.json");
public TitleApiMiddleware(
RequestDelegate next,
ILogger<TitleApiMiddleware> logger,
TitlesConfig config)
{
_next = next;
_logger = logger;
_config = config;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Path == "/api/title/add"
&& context.Request.Method == "POST")
{
await HandleAddTitleAsync(context);
return;
}
await _next(context);
}
private async Task HandleAddTitleAsync(HttpContext context)
{
try
{
using var reader = new StreamReader(context.Request.Body);
var body = await reader.ReadToEndAsync();
var request = JsonSerializer.Deserialize<TitleAddRequest>(body);
if (request == null
|| string.IsNullOrWhiteSpace(request.FriendCode)
|| string.IsNullOrWhiteSpace(request.Title))
{
context.Response.StatusCode = 400;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(
"{\"ok\":false,\"error\":\"friendCode and title are required.\"}");
return;
}
// Add to config
_config.Titles.Add(new FriendCodeTitle
{
FriendCode = request.FriendCode.Trim(),
Title = request.Title.Trim()
});
// Persist to file
var json = JsonSerializer.Serialize(_config, new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
});
await File.WriteAllTextAsync(ConfigPath, json);
// Reload mappings in the listener
var listener = context.RequestServices.GetService<FriendCodeTitleListener>();
listener?.Reload();
var addedBy = request.AddedBy ?? "unknown";
_logger.LogInformation(
"[TitleApi] Added title [{Title}] for friend code {FC} (added by {AddedBy})",
request.Title, request.FriendCode, addedBy);
context.Response.StatusCode = 200;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{\"ok\":true}");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[TitleApi] Failed to add title");
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(
"{\"ok\":false,\"error\":\"Internal server error.\"}");
}
}
private sealed record TitleAddRequest(
[property: JsonPropertyName("friendCode")]
string? FriendCode,
[property: JsonPropertyName("title")]
string? Title,
[property: JsonPropertyName("addedBy")]
string? AddedBy);
}
@@ -1,55 +0,0 @@
using System;
using System.Threading.Tasks;
using Empostor.Api.Events;
using Empostor.Api.Events.Player;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugins.Titles.Service;
public sealed class TitleEventListener : IEventListener
{
private readonly ILogger<TitleEventListener> _logger;
private readonly TitleStore _store;
public TitleEventListener(ILogger<TitleEventListener> logger, TitleStore store)
{
_logger = logger;
_store = store;
}
[EventListener]
public async ValueTask OnPlayerSpawned(IPlayerSpawnedEvent e)
{
var clientId = e.ClientPlayer.Client.Id;
var title = _store.Get(clientId);
if (title == null) return;
// Guard: connection must still be alive
if (e.ClientPlayer.Client.Connection == null
|| !e.ClientPlayer.Client.Connection.IsConnected)
return;
try
{
var displayName = TitleStore.BuildDisplayName(title, e.ClientPlayer.Client.Name);
await e.PlayerControl.SetNameAsync(displayName);
// Title applied in-game, clear from store
_store.Clear(clientId);
_logger.LogDebug("[Titles] In-game name set [{Title}] for client {Id}",
title, clientId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Titles] Failed to apply title for client {Id}", clientId);
}
}
[EventListener]
public void OnPlayerDestroyed(IPlayerDestroyedEvent e)
{
// Clean up any lingering title
//_store.Clear(e.ClientPlayer.Client.Id);
}
}
-28
View File
@@ -1,28 +0,0 @@
using System.Collections.Concurrent;
namespace Empostor.Plugins.Titles;
public sealed class TitleStore
{
private readonly ConcurrentDictionary<int, string> _titles = new();
public void Set(int clientId, string? title)
{
if (string.IsNullOrWhiteSpace(title))
{
_titles.TryRemove(clientId, out _);
}
else
{
_titles[clientId] = title.Trim();
}
}
public string? Get(int clientId)
=> _titles.TryGetValue(clientId, out var t) ? t : null;
public void Clear(int clientId) => _titles.TryRemove(clientId, out _);
public static string BuildDisplayName(string title, string playerName)
=> $"[{title}] {playerName}";
}
@@ -1,68 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Empostor.Api.Admin;
using Empostor.Api.Plugins;
using Empostor.Plugins.Titles.Service;
namespace Empostor.Plugins.Titles;
public sealed class TitlesAdminExtension : IAdminExtension
{
private readonly TitlesConfig _config;
private readonly string _configPath;
private readonly FriendCodeTitleListener _listener;
public TitlesAdminExtension(TitlesConfig config, string configPath, FriendCodeTitleListener listener)
{
_config = config;
_configPath = configPath;
_listener = listener;
}
public string Id => "titles";
public string Title => "Title System";
public string Icon => "list";
public void Build(AdminPanelBuilder b)
{
b.RegisterTable(table =>
{
table.Columns = new List<string> { "Friend Code", "Title" };
table.Rows = _config.Titles.Select(t => new List<AdminTableCell>
{
new() { Text = t.FriendCode, Monospace = true },
new() { Text = t.Title },
}).ToList();
});
b.RegisterTextbox(t =>
{
t.Label = "Edit titles (JSON)";
t.Multiline = true;
t.Value = JsonSerializer.Serialize(_config.Titles, new JsonSerializerOptions { WriteIndented = true });
t.OnSubmit(async ctx =>
{
try
{
var list = JsonSerializer.Deserialize<List<FriendCodeTitle>>(ctx.Value ?? "[]");
if (list == null)
{
return AdminActionResult.Fail("Invalid JSON.");
}
_config.Titles = list;
PluginConfigLoader.Save(_configPath, _config);
_listener.Reload();
return AdminActionResult.Ok($"Saved {list.Count} title(s).");
}
catch (JsonException ex)
{
return AdminActionResult.Fail("Invalid JSON: " + ex.Message);
}
});
});
}
}
@@ -1,22 +0,0 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Empostor.Plugins.Titles;
public sealed class TitlesConfig
{
[JsonPropertyName("titles")]
public List<FriendCodeTitle> Titles { get; set; } = new()
{
new FriendCodeTitle { FriendCode = "aideproof#8388", Title = "Empostor" },
};
}
public sealed class FriendCodeTitle
{
[JsonPropertyName("friendCode")]
public string FriendCode { get; set; } = string.Empty;
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
}
@@ -1,24 +0,0 @@
using System;
using System.Threading.Tasks;
using Empostor.Api.Localization;
using Empostor.Api.Plugins;
using Microsoft.Extensions.Logging;
namespace Empostor.Plugins.Titles;
[EmpostorPlugin(Owner, "Title System", "Empostor", "1.1.0")]
public sealed class TitlesPlugin : PluginBase
{
public const string Owner = "cn.Empostor.titles";
private readonly ILocalizationRegistry _registry;
private readonly ILogger<TitlesPlugin> _logger;
private IDisposable? _registration;
public TitlesPlugin(ILogger<TitlesPlugin> logger, ILocalizationRegistry registry) { _logger = logger; _registry = registry; }
public override ValueTask EnableAsync()
{
if (!_registry.TryRegister(new LocalizationCatalog(Owner, typeof(TitlesPlugin).Assembly, "Empostor.Plugins.Titles.Localization"), out _registration))
_logger.LogWarning("Could not register localization resources for {Owner}", Owner);
return default;
}
public override ValueTask DisableAsync() { _registration?.Dispose(); return default; }
}
@@ -1,35 +0,0 @@
using Empostor.Api.Admin;
using Empostor.Api.Events;
using Empostor.Api.Plugins;
using Empostor.Plugins.Titles.Service;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Empostor.Plugins.Titles;
public sealed class TitlesPluginStartup : IPluginHttpStartup
{
private string _configPath = "[Title System]Config.json";
public void SetConfigPath(string configPath) => _configPath = configPath;
public void ConfigureHost(IHostBuilder host) { }
public void ConfigureServices(IServiceCollection services)
{
var config = PluginConfigLoader.Load<TitlesConfig>(_configPath);
services.AddSingleton(config);
services.AddSingleton<TitleStore>();
services.AddSingleton<FriendCodeTitleListener>();
services.AddSingleton<IEventListener>(sp => sp.GetRequiredService<FriendCodeTitleListener>());
services.AddSingleton<IEventListener, TitleEventListener>();
services.AddSingleton<IAdminExtension>(
sp => new TitlesAdminExtension(config, _configPath, sp.GetRequiredService<FriendCodeTitleListener>()));
}
public void ConfigureWebApplication(IApplicationBuilder builder)
{
builder.UseMiddleware<TitleApiMiddleware>();
}
}
@@ -1,140 +0,0 @@
{
"version": 1,
"dependencies": {
"net8.0": {
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.1",
"contentHash": "vZsG2YILhthgRqO+ZVgRff4ZFKKTl0v7kqaVBLCtRvpREhfBP33pcWrdA3PRYgWuFL1RxiUFvjMUHTdBZlJcoA=="
},
"Empostor.Hazel": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "cblWCgvLcQ2xK6ztV04RVrghVEnJpCWe4N6G3LCNaJhuUq367a1/bbtQxUWAflqvgSvGwY3g3SClhh9eWTCBDA==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.1",
"Empostor.Hazel.Abstractions": "1.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.6",
"Microsoft.Extensions.ObjectPool": "8.0.1",
"Serilog": "4.3.1"
}
},
"Empostor.Hazel.Abstractions": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "uJqGMASOGj+ELffmIMqubyvJ/FHjOXA1RtQdS38s1LRhP+Eu4zJMTTndr12+Q3vS0kHggFFyuExFmNxWyzWmig=="
},
"Microsoft.AspNetCore.Http.Abstractions": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "Nxs7Z1q3f1STfLYKJSVXCs1iBl+Ya6E8o4Oy1bCxJ/rNI44E/0f6tbsrVqAWfB7jlnJfyaAtIalBVxPKUPQb4Q==",
"dependencies": {
"Microsoft.AspNetCore.Http.Features": "2.2.0",
"System.Text.Encodings.Web": "4.5.0"
}
},
"Microsoft.AspNetCore.Http.Features": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "ziFz5zH8f33En4dX81LW84I6XrYXKf9jg6aM39cM+LffN9KJahViKZ61dGMSO2gd3e+qe5yBRwsesvyqlZaSMg==",
"dependencies": {
"Microsoft.Extensions.Primitives": "2.2.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.6",
"contentHash": "0Zn6nR/6g+90MxskZyOOMPQvnPnrrGu6bytPwkV+azDcTtCSuQ1+GJUrg8Klmnrjk1i6zMpw2lXijl+tw7Q3kA=="
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"System.Diagnostics.DiagnosticSource": "8.0.0"
}
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0",
"Microsoft.Extensions.FileProviders.Abstractions": "8.0.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
},
"Serilog": {
"type": "Transitive",
"resolved": "4.3.1",
"contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ=="
},
"System.Diagnostics.DiagnosticSource": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g=="
},
"empostor.api": {
"type": "Project",
"dependencies": {
"Empostor.Hazel": "[1.0.2, )",
"Microsoft.AspNetCore.Http.Abstractions": "[2.2.0, )",
"Microsoft.Extensions.Hosting.Abstractions": "[8.0.0, )",
"Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )"
}
}
}
}
}
+4 -7
View File
@@ -78,16 +78,12 @@ public sealed class TokenController : ControllerBase
return Unauthorized(new { error = "Invalid token content" });
}
_logger.LogInformation("TokenController request for eosToken={eosToken}", eosToken);
var clientIp = GetClientIp();
var friendCode = await GetFriendCodeAsync(eosToken, productUserId, clientIp);
var matchmakerToken = GenerateMatchmakerToken(productUserId);
// Over quota: never take a pool port for a join that cannot succeed
// — one port per blocked request would let a single IP drain the
// whole delta range. The client is pointed at the shared reject
// port instead, whose listener answers with the localized hint: no
// game, no black screen, one socket. Port 0 (fixed-port mode) means
// the main port, which rejects it the same way.
if (_rateLimit.IsLimited(clientIp, out var joinRetryAfter))
{
var rejectPort = await _deltaListenerManager.GetRateLimitRejectPortAsync();
@@ -117,7 +113,7 @@ public sealed class TokenController : ControllerBase
productUserId, clientIp);
}
_authCache.Store(productUserId, matchmakerToken, friendCode, clientIp);
_authCache.Store(productUserId, matchmakerToken, friendCode, clientIp, eosToken);
// Allocate a delta UDP port to match the TCP auth session to the
// subsequent UDP connection. In fixed-port mode (pool disabled) no
@@ -130,6 +126,7 @@ public sealed class TokenController : ControllerBase
MatchmakerToken = matchmakerToken,
FriendCode = friendCode ?? string.Empty,
ClientIp = clientIp != null ? NormalizeIpString(clientIp) : null,
EosToken = eosToken,
CreatedAt = DateTime.UtcNow,
};
+3
View File
@@ -43,6 +43,9 @@ namespace Empostor.Server.Net
/// <inheritdoc />
public string? ProductUserId { get; set; }
/// <inheritdoc />
public string? EosToken { get; set; }
public GameVersion GameVersion { get; }
public IHazelConnection Connection { get; }
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using Empostor.Api.Net.Inner.Objects;
namespace Empostor.Server.Net.Inner.Objects
@@ -6,5 +7,7 @@ namespace Empostor.Server.Net.Inner.Objects
internal partial class InnerPlayerInfo : InnerNetObject, IInnerPlayerInfo
{
IEnumerable<ITaskInfo> IInnerPlayerInfo.Tasks => Tasks;
string? IInnerPlayerInfo.EosToken => Game.Players.FirstOrDefault(p => p.Client.Id == ClientId)?.Client.EosToken;
}
}
@@ -223,6 +223,7 @@ namespace Empostor.Server.Net.Manager
var client = _clientFactory.Create(connection, name, clientVersion, language, chatMode, platformSpecificData);
client.FriendCode = string.IsNullOrEmpty(friendCode) ? null : friendCode;
client.ProductUserId = authInfo?.ProductUserId;
client.EosToken = authInfo?.EosToken;
client.DeltaPort = deltaPort;
client.Id = id;
@@ -27,6 +27,8 @@ namespace Empostor.Server.Net.State
public ClientBase Client { get; }
public string? EosToken => Client.EosToken;
public Game Game { get; }
public LimboStates Limbo { get; set; }
@@ -35,7 +35,7 @@ public sealed class AuthCacheService : IDisposable
TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
}
public void Store(string productUserId, string matchmakerToken, string? friendCode, IPAddress? clientIp)
public void Store(string productUserId, string matchmakerToken, string? friendCode, IPAddress? clientIp, string? eosToken = null)
{
if (string.IsNullOrEmpty(productUserId) || string.IsNullOrEmpty(matchmakerToken))
{
@@ -48,6 +48,7 @@ public sealed class AuthCacheService : IDisposable
MatchmakerToken = matchmakerToken,
FriendCode = friendCode ?? string.Empty,
ClientIp = clientIp != null ? NormalizeIp(clientIp) : null,
EosToken = eosToken,
CreatedAt = DateTime.UtcNow,
};
@@ -162,6 +163,8 @@ public sealed class UserAuthInfo
public string FriendCode { get; set; } = string.Empty;
public string? EosToken { get; set; }
public string Name { get; set; } = string.Empty;
public string? ClientIp { get; set; }