diff --git a/docs/admin-messages.md b/docs/admin-messages.md
new file mode 100644
index 0000000..8a5a37f
--- /dev/null
+++ b/docs/admin-messages.md
@@ -0,0 +1,30 @@
+# Messages administrateur
+
+Dans Cmdr (F2), les commandes suivent les permissions existantes du projet.
+
+```text
+announce "Hello World!"
+ann "Server event starts soon."
+pm me "First line\nSecond line"
+pm Infu "A message using a shortened local username."
+pm all "A message for players in this server."
+pm others "A message for everyone here except me."
+pm ?2 "A message for two random local players."
+pm @FullUsername "A message for a player in any live server."
+pm #123456789 "A message addressed by UserId."
+pm @FirstUsername,SecondUsername "A message for multiple recipients."
+```
+
+Les commandes sont insensibles à la casse. `m` et `ann` sont des alias de `announce`. Les sélecteurs Cmdr (`.`, `*`, `**`, `?`, `?N`, `me`, `all`, `others`, `random`, noms abrégés et listes) concernent les joueurs du serveur courant. Les préfixes `@` et `#` permettent de cibler par nom complet ou UserId dans les autres serveurs de la même expérience. Les joueurs hors ligne ne reçoivent rien.
+
+Le texte est limité à 400 octets, PM à 20 destinataires et les envois sont espacés de 3 secondes par administrateur. Chaque carte reste 10 secondes, avec une file limitée à 8 messages en attente. La carte est centrée horizontalement en haut, avec une marge de 24 pixels sous la zone réservée à l'interface Roblox. Le fond s'ajuste au texte, avec une largeur maximale de 560 pixels, une limite adaptée au viewport et un défilement pour les messages longs. Les vrais retours à la ligne et `\n` sont pris en charge ; les balises RichText saisies sont affichées comme du texte.
+
+## Architecture et références
+
+- Cmdr `@rbxts/cmdr` 1.12.1 fournit les commandes, les sélecteurs et les unions préfixées : [commandes et shorthand](https://eryn.io/Cmdr/docs/commands/). Les types et le parseur installés ont été vérifiés localement.
+- Flamework Networking 1.3.2 fournit l'événement serveur → client typé, via `createServer` et `createClient`, conformément aux déclarations installées : [documentation](https://flamework.fireboltofdeath.dev/docs/additional-modules/networking/introduction/).
+- [MessagingService](https://create.roblox.com/docs/reference/engine/classes/MessagingService) fournit la diffusion interserveurs sur un topic versionné. Ce choix permet de contrôler les échecs, la reconnexion et la déduplication, sans utiliser la file globale de `@rbxts/net` 3.0.10. Aucun paquet ajouté.
+- [TextService](https://create.roblox.com/docs/reference/engine/classes/TextService) filtre le texte avant toute diffusion. Le résultat utilise [GetNonChatStringForBroadcastAsync](https://create.roblox.com/docs/reference/engine/classes/TextFilterResult) et PM contrôle les permissions avec `TextChatService.CanUsersChatAsync`.
+- React et ReactRoblox 17.3.13-ts.1 rendent la carte ; `TextService.GetTextSize` mesure le texte pour ajuster ses dimensions.
+
+Les messages interserveurs sont éphémères, sans garantie de livraison ou de présence du destinataire. L'échec de publication est signalé dans Cmdr, même si le message a pu être livré localement. Une expérience non publiée fonctionne uniquement localement ; la diffusion réelle doit être vérifiée avec deux serveurs d'une expérience publiée et les accès aux services Roblox disponibles.
diff --git a/flamework.build b/flamework.build
index 75e970c..aa011d5 100644
--- a/flamework.build
+++ b/flamework.build
@@ -5,7 +5,10 @@
"crownstudio-roblox-ts-template:out/server/services/startup-service@StartupService": "server/services/startup-service@StartupService",
"crownstudio-roblox-ts-template:out/client/controllers/cmdr-controller@CmdrController": "client/controllers/cmdr-controller@CmdrController",
"crownstudio-roblox-ts-template:out/server/services/cmdr-service@CmdrService": "server/services/cmdr-service@CmdrService",
- "crownstudio-roblox-ts-template:out/client/controllers/welcome-chat-controller@WelcomeChatController": "client/controllers/welcome-chat-controller@WelcomeChatController"
+ "crownstudio-roblox-ts-template:out/client/controllers/welcome-chat-controller@WelcomeChatController": "client/controllers/welcome-chat-controller@WelcomeChatController",
+ "crownstudio-roblox-ts-template:out/server/services/admin-message-service@AdminMessageService": "server/services/admin-message-service@AdminMessageService",
+ "crownstudio-roblox-ts-template:out/shared/admin-message@AdminMessageEvents": "shared/admin-message@AdminMessageEvents",
+ "crownstudio-roblox-ts-template:out/client/controllers/admin-message-controller@AdminMessageController": "client/controllers/admin-message-controller@AdminMessageController"
},
"metadata": {}
}
\ No newline at end of file
diff --git a/src/client/controllers/admin-message-controller.tsx b/src/client/controllers/admin-message-controller.tsx
new file mode 100644
index 0000000..d052d11
--- /dev/null
+++ b/src/client/controllers/admin-message-controller.tsx
@@ -0,0 +1,46 @@
+import { Controller, OnStart } from "@flamework/core";
+import React from "@rbxts/react";
+import { createRoot } from "@rbxts/react-roblox";
+import { Players } from "@rbxts/services";
+import { Trove } from "@rbxts/trove";
+import { AdminMessageCard } from "client/ui/admin-message";
+import { AdminMessage, AdminMessageEvents } from "shared/admin-message";
+
+@Controller()
+export class AdminMessageController implements OnStart {
+ private readonly trove = new Trove();
+ private readonly events = AdminMessageEvents.createClient({});
+
+ public onStart(): void {
+ const folder = new Instance("Folder");
+ folder.Name = "AdminMessageRoot";
+ folder.Parent = Players.LocalPlayer.WaitForChild("PlayerGui");
+ const root = createRoot(folder);
+ this.trove.add(folder);
+ this.trove.add(() => root.unmount());
+ const queue: AdminMessage[] = [];
+ let running = false;
+ let alive = true;
+ this.trove.add(() => {
+ alive = false;
+ });
+ this.trove.add(
+ this.events.adminMessage.connect((message) => {
+ if (queue.size() >= 8) queue.shift();
+ queue.push(message);
+ if (running) return;
+ running = true;
+ task.spawn(() => {
+ while (alive && queue.size() > 0) {
+ const current = queue.shift()!;
+ root.render();
+ task.wait(10);
+ }
+ if (alive) root.render(undefined);
+ running = false;
+ });
+ }),
+ );
+ this.trove.attachToInstance(script);
+ }
+}
diff --git a/src/client/ui/admin-message.tsx b/src/client/ui/admin-message.tsx
new file mode 100644
index 0000000..a3125d2
--- /dev/null
+++ b/src/client/ui/admin-message.tsx
@@ -0,0 +1,144 @@
+import React, { useEffect, useState } from "@rbxts/react";
+import { Players, TextService, Workspace } from "@rbxts/services";
+import { AdminMessage } from "shared/admin-message";
+
+function escapeRichText(text: string): string {
+ return text
+ .gsub("&", "&")[0]
+ .gsub("<", "<")[0]
+ .gsub(">", ">")[0]
+ .gsub('"', """)[0];
+}
+
+export function AdminMessageCard({ message }: { message: AdminMessage }): React.Element {
+ const [image, setImage] = useState("");
+ const [viewport, setViewport] = useState(
+ Workspace.CurrentCamera?.ViewportSize ?? new Vector2(800, 600),
+ );
+ useEffect(() => {
+ let active = true;
+ task.spawn(() => {
+ const [ok, thumbnail] = pcall(
+ () =>
+ Players.GetUserThumbnailAsync(
+ message.senderId,
+ Enum.ThumbnailType.HeadShot,
+ Enum.ThumbnailSize.Size150x150,
+ )[0],
+ );
+ if (ok && active) setImage(thumbnail);
+ });
+ return () => {
+ active = false;
+ };
+ }, [message.senderId]);
+ useEffect(() => {
+ let connection: RBXScriptConnection | undefined;
+ const bindCamera = () => {
+ connection?.Disconnect();
+ const camera = Workspace.CurrentCamera;
+ if (!camera) return;
+ setViewport(camera.ViewportSize);
+ connection = camera
+ .GetPropertyChangedSignal("ViewportSize")
+ .Connect(() => setViewport(camera.ViewportSize));
+ };
+ bindCamera();
+ const cameraChanged = Workspace.GetPropertyChangedSignal("CurrentCamera").Connect(bindCamera);
+ return () => {
+ connection?.Disconnect();
+ cameraChanged.Disconnect();
+ };
+ }, []);
+ const textSize = viewport.X < 480 ? 16 : 18;
+ const prefixWidth = message.private ? 42 : 0;
+ const fixedWidth = 24 + prefixWidth + 30 + 8;
+ const maximumWidth = math.max(80, math.min(560, viewport.X - 32) - fixedWidth);
+ const plainText = `${message.displayName}: ${message.text}`;
+ const bounds = TextService.GetTextSize(
+ plainText,
+ textSize,
+ Enum.Font.GothamMedium,
+ new Vector2(maximumWidth, 10000),
+ );
+ const textWidth = math.min(maximumWidth, math.ceil(bounds.X) + 4);
+ const measured = TextService.GetTextSize(
+ plainText,
+ textSize,
+ Enum.Font.GothamMedium,
+ new Vector2(textWidth, 10000),
+ );
+ const fullHeight = math.ceil(measured.Y) + 4;
+ const bodyHeight = math.min(fullHeight, math.max(36, math.min(120, viewport.Y * 0.22)));
+ const contentHeight = math.max(30, bodyHeight);
+ const richText = `${escapeRichText(message.displayName)}: ${escapeRichText(message.text)}`;
+ return (
+
+
+
+ {message.private && (
+
+ )}
+
+
+
+ bodyHeight ? 3 : 0}
+ BackgroundTransparency={1}
+ BorderSizePixel={0}
+ >
+
+
+
+
+ );
+}
diff --git a/src/server/commands/announce.ts b/src/server/commands/announce.ts
new file mode 100644
index 0000000..6d53deb
--- /dev/null
+++ b/src/server/commands/announce.ts
@@ -0,0 +1,10 @@
+const command = {
+ Name: "announce",
+ Aliases: ["m", "ann"],
+ Description: "Display an announcement across all live servers. Use \\n for a new line.",
+ Group: "DefaultAdmin",
+ Args: [
+ { Type: "string", Name: "message", Description: "The announcement text (maximum 400 bytes)." },
+ ],
+};
+export = command;
diff --git a/src/server/commands/announceServer.ts b/src/server/commands/announceServer.ts
new file mode 100644
index 0000000..b206d05
--- /dev/null
+++ b/src/server/commands/announceServer.ts
@@ -0,0 +1,8 @@
+import { Dependency } from "@flamework/core";
+import { CommandContext } from "@rbxts/cmdr";
+import { AdminMessageService } from "server/services/admin-message-service";
+
+function run(context: CommandContext, text: string): string {
+ return Dependency().send(context.Executor, text);
+}
+export = run;
diff --git a/src/server/commands/pm.ts b/src/server/commands/pm.ts
new file mode 100644
index 0000000..6d73b65
--- /dev/null
+++ b/src/server/commands/pm.ts
@@ -0,0 +1,21 @@
+const command = {
+ Name: "pm",
+ Aliases: ["privatemessage"],
+ Description:
+ "Send a private message, including to players in another server. Use \\n for a new line.",
+ Group: "DefaultAdmin",
+ Args: [
+ {
+ Type: "players @ playerIds # integers",
+ Name: "recipients",
+ Description:
+ "Local names/shorthand, @FullUsername, or #UserId; comma-separated lists supported.",
+ },
+ {
+ Type: "string",
+ Name: "message",
+ Description: "The private message text (maximum 400 bytes).",
+ },
+ ],
+};
+export = command;
diff --git a/src/server/commands/pmServer.ts b/src/server/commands/pmServer.ts
new file mode 100644
index 0000000..7922526
--- /dev/null
+++ b/src/server/commands/pmServer.ts
@@ -0,0 +1,9 @@
+import { Dependency } from "@flamework/core";
+import { CommandContext } from "@rbxts/cmdr";
+import { AdminMessageService } from "server/services/admin-message-service";
+
+function run(context: CommandContext, targets: (Player | number)[], text: string): string {
+ const recipients = targets.map((target) => (typeIs(target, "number") ? target : target.UserId));
+ return Dependency().send(context.Executor, text, recipients);
+}
+export = run;
diff --git a/src/server/services/admin-message-service.ts b/src/server/services/admin-message-service.ts
new file mode 100644
index 0000000..ae35057
--- /dev/null
+++ b/src/server/services/admin-message-service.ts
@@ -0,0 +1,136 @@
+import { OnStart, Service } from "@flamework/core";
+import Log from "@rbxts/log";
+import {
+ HttpService,
+ MessagingService,
+ Players,
+ RunService,
+ TextChatService,
+ TextService,
+} from "@rbxts/services";
+import { Trove } from "@rbxts/trove";
+import { isCmdrAllowed } from "server/config/cmdr-permissions";
+import {
+ ADMIN_MESSAGE_TOPIC,
+ AdminMessage,
+ AdminMessageEvents,
+ isAdminMessage,
+} from "shared/admin-message";
+
+@Service()
+export class AdminMessageService implements OnStart {
+ private readonly trove = new Trove();
+ private readonly seen = new Set();
+ private readonly lastSent = new Map();
+ private closed = false;
+ private readonly events = AdminMessageEvents.createServer({});
+
+ public onStart(): void {
+ this.trove.connect(Players.PlayerRemoving, (player) => this.lastSent.delete(player.UserId));
+ game.BindToClose(() => {
+ this.closed = true;
+ this.trove.destroy();
+ });
+ if (game.GameId === 0) {
+ Log.Info("Admin messages: local mode in an unpublished experience.");
+ return;
+ }
+ task.spawn(() => {
+ while (!this.closed) {
+ const [ok, connection] = pcall(() =>
+ MessagingService.SubscribeAsync(ADMIN_MESSAGE_TOPIC, (message) => {
+ if (isAdminMessage(message.Data)) this.receive(message.Data);
+ }),
+ );
+ if (ok) {
+ this.trove.add(connection);
+ return;
+ }
+ Log.Warn("Admin message subscription failed: {Reason}; retrying in 30 seconds", connection);
+ task.wait(30);
+ }
+ });
+ }
+
+ public send(sender: Player, rawText: string, targets?: number[]): string {
+ if (
+ !isCmdrAllowed(
+ sender.UserId,
+ RunService.IsStudio(),
+ game.CreatorType === Enum.CreatorType.User ? game.CreatorId : undefined,
+ )
+ ) {
+ return "You do not have permission to run this command.";
+ }
+ const text = rawText.gsub("\\n", "\n")[0];
+ if (text.size() > 400 || text.gsub("%s", "")[0].size() === 0)
+ return "Enter a message between 1 and 400 bytes.";
+ if (targets && (targets.size() === 0 || targets.size() > 20))
+ return "Select between 1 and 20 recipients.";
+ const now = os.clock();
+ if (now - (this.lastSent.get(sender.UserId) ?? -10) < 3)
+ return "Please wait 3 seconds between messages.";
+ this.lastSent.set(sender.UserId, now);
+ const [ok, result] = pcall(() => {
+ const recipients = targets ? [...new Set(targets)] : [];
+ if (targets) {
+ const otherIds = recipients.filter((id) => id !== sender.UserId);
+ if (otherIds.size() > 0) {
+ if (
+ otherIds.some(
+ (id) =>
+ !TextChatService.CanUsersChatAsync(
+ sender.UserId as unknown as User,
+ id as unknown as User,
+ ),
+ )
+ )
+ return "Private messaging is unavailable for one or more recipients.";
+ }
+ }
+ const filtered = TextService.FilterStringAsync(
+ text,
+ sender.UserId,
+ Enum.TextFilterContext.PublicChat,
+ ).GetNonChatStringForBroadcastAsync();
+ const message: AdminMessage = {
+ id: HttpService.GenerateGUID(false),
+ sentAt: os.time(),
+ senderId: sender.UserId,
+ displayName: sender.DisplayName,
+ text: filtered,
+ private: targets !== undefined,
+ recipients,
+ };
+ if (HttpService.JSONEncode(message).size() > 1000)
+ return "The encoded message is too large. Shorten it or select fewer recipients.";
+ this.receive(message);
+ if (game.GameId === 0)
+ return "Message delivered locally. Publish the experience to enable cross-server delivery.";
+ const [published, reason] = pcall(() =>
+ MessagingService.PublishAsync(ADMIN_MESSAGE_TOPIC, message),
+ );
+ if (!published) {
+ Log.Warn("Admin message publish failed: {Reason}", reason);
+ return "Message delivered locally, but cross-server delivery failed. Try again later.";
+ }
+ return "Message sent to live servers. Offline players do not receive messages.";
+ });
+ if (!ok) {
+ Log.Warn("Admin message could not be sent: {Reason}", result);
+ return "Message could not be sent. Text filtering or messaging is unavailable.";
+ }
+ return result;
+ }
+
+ private receive(message: AdminMessage): void {
+ if (this.seen.has(message.id) || math.abs(os.time() - message.sentAt) > 60) return;
+ if (message.text.size() > 400 || message.recipients.size() > 20) return;
+ this.seen.add(message.id);
+ task.delay(65, () => this.seen.delete(message.id));
+ for (const player of Players.GetPlayers()) {
+ if (!message.private || message.recipients.includes(player.UserId))
+ this.events.adminMessage.fire(player, message);
+ }
+ }
+}
diff --git a/src/server/services/cmdr-service.ts b/src/server/services/cmdr-service.ts
index 16177d6..daead66 100644
--- a/src/server/services/cmdr-service.ts
+++ b/src/server/services/cmdr-service.ts
@@ -17,7 +17,8 @@ export class CmdrService implements OnStart {
return "You do not have permission to run this command.";
}
});
- Cmdr.Registry.RegisterDefaultCommands();
+ Cmdr.Registry.RegisterDefaultCommands((command) => command.Name !== "announce");
+ Cmdr.Registry.RegisterCommandsIn(script.Parent!.Parent!.WaitForChild("commands"));
this.trove.connect(Players.PlayerAdded, (player) => {
this.updateAccess(player);
diff --git a/src/shared/admin-message.ts b/src/shared/admin-message.ts
new file mode 100644
index 0000000..87b3f31
--- /dev/null
+++ b/src/shared/admin-message.ts
@@ -0,0 +1,29 @@
+import { Networking } from "@flamework/networking";
+import { t } from "@rbxts/t";
+
+export interface AdminMessage {
+ id: string;
+ sentAt: number;
+ senderId: number;
+ displayName: string;
+ text: string;
+ private: boolean;
+ recipients: number[];
+}
+
+export const isAdminMessage = t.interface({
+ id: t.string,
+ sentAt: t.number,
+ senderId: t.number,
+ displayName: t.string,
+ text: t.string,
+ private: t.boolean,
+ recipients: t.array(t.number),
+});
+
+interface ServerEvents {
+ adminMessage(message: AdminMessage): void;
+}
+
+export const AdminMessageEvents = Networking.createEvent, ServerEvents>();
+export const ADMIN_MESSAGE_TOPIC = "AdminMessage.v1";