diff --git a/.agent/rules/game-content-language.md b/.agent/rules/game-content-language.md
new file mode 100644
index 0000000..3e233e7
--- /dev/null
+++ b/.agent/rules/game-content-language.md
@@ -0,0 +1,9 @@
+---
+trigger: always_on
+---
+
+# English-only game content
+
+Read and follow the repository-root AGENTS.md.
+
+Write all game content strictly in English, regardless of the language of the user's request. This covers all player-facing text, names, descriptions, dialogue, UI, notifications, publishing text, text and speech in generated assets, and new game code identifiers, comments, logs, and commands. Translate affected non-English game content when editing it, and review all added or modified content before finishing. Only add localization when explicitly requested by the user. Conversation and repository documentation may remain in the user's language.
diff --git a/.cursor/rules/game-content-language.mdc b/.cursor/rules/game-content-language.mdc
new file mode 100644
index 0000000..62f252c
--- /dev/null
+++ b/.cursor/rules/game-content-language.mdc
@@ -0,0 +1,8 @@
+---
+description: Require English for all game content
+alwaysApply: true
+---
+
+Read and follow the repository-root AGENTS.md.
+
+Write all game content strictly in English, regardless of the conversation language. This includes player-facing text, text and speech in generated assets, and new game code identifiers, comments, logs, and commands. Translate affected non-English game content when editing it and review modified content before finishing. Only add localization when explicitly requested by the user. Conversation and repository documentation may remain in the user's language.
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..08697d6
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,36 @@
+# Game content language
+
+All AI assistants working in this repository must write all game content strictly in English, regardless of the language used in the user's request or conversation.
+
+- Use English for all player-facing text: UI, menus, buttons, tooltips, notifications, errors, tutorials, dialogue, quests, item and ability names, descriptions, achievements, shops, and game publishing text.
+- Use English for text embedded in generated images, textures, models, audio, and other game assets. Request English text and speech when using generation tools.
+- Use English for new code identifiers, comments, logs, and developer commands associated with the game.
+- When editing existing game content in another language, translate the affected content into natural English. Do not introduce French or other language variants unless the user explicitly requests localization.
+- Before finishing a change, review the added or modified game content and asset-generation prompts for language consistency.
+
+Conversation with the user and repository documentation may remain in the user's language. This does not change the English-only requirement for game content.
+
+This policy applies to Codex, ChatGPT, Claude, Antigravity, Cursor, and any other AI assistant contributing to the project.
+
+# Feature verification through Roblox Studio MCP
+
+After implementing or modifying any feature, the agent must verify that it works in Roblox Studio through the Roblox Studio MCP before declaring it complete.
+
+- Run the experience and exercise the affected feature through the MCP, checking its expected behavior and relevant client/server interactions.
+- Inspect Roblox Studio Output and available runtime logs through the MCP for errors, warnings, and other signs of bugs during the test.
+- Fix issues introduced by the change, then repeat the runtime test and Output inspection until the feature behaves as expected without related errors.
+- Compilation, static checks, and automated tests complement this verification; they do not replace the MCP runtime check.
+- In the completion report, briefly state what was tested and the result of the Output inspection. If the MCP is unavailable or verification cannot be completed, explicitly report the blocker and the unverified behavior instead of claiming the feature is verified.
+
+# Commit changes before completing a discussion
+
+All AI assistants working in this repository must create a Git commit for their changes before ending a discussion or declaring an added or modified feature complete.
+
+- Complete the applicable verification before committing, including Roblox Studio MCP runtime verification for feature changes.
+- Stage and commit only changes belonging to the current task. Do not include unrelated user changes or changes from other agents.
+- Use a clear commit message describing the completed changes.
+- If the discussion produced no repository changes, do not create an empty commit.
+- If a commit cannot be created, explicitly report the blocker and the changes left uncommitted.
+- Include the commit hash in the completion report. Creating a commit does not authorize pushing it to a remote repository.
+
+This policy applies to every AI assistant contributing to the project.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..1673a43
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,5 @@
+# Project instructions
+
+Read and follow [AGENTS.md](AGENTS.md) before making changes.
+
+All game content must be written strictly in English, regardless of the conversation language. This includes player-facing text, text or speech in assets, and new game code identifiers, comments, logs, and commands. Review changed content for English consistency before finishing. User conversation and repository documentation may remain in the user's language. Only add localization when explicitly requested by the user.
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 4e97ac4..aa011d5 100644
--- a/flamework.build
+++ b/flamework.build
@@ -4,7 +4,11 @@
"identifiers": {
"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/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/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/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 595dfc7..4255e75 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,6 @@
+nodeLinker: hoisted
+shamefullyHoist: true
+
minimumReleaseAgeExclude:
- "@typescript-eslint/eslint-plugin@8.71.2"
- "@typescript-eslint/parser@8.71.2"
diff --git a/readme.md b/readme.md
index afa2184..a00d72b 100644
--- a/readme.md
+++ b/readme.md
@@ -10,6 +10,21 @@ Sous Windows 10/11, lancer depuis le dépôt :
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\setup.ps1
```
+```powershell
+$paths = @(
+ "$env:LOCALAPPDATA\mise\installs\pnpm\11.21.0"
+ "$env:USERPROFILE\.rokit\bin"
+)
+
+$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
+$newPath = (($userPath -split ";" ) + $paths | Where-Object { $_ } | Select-Object -Unique) -join ";"
+
+[Environment]::SetEnvironmentVariable("Path", $newPath, "User")
+$env:Path = "$($paths -join ';');$env:Path"
+
+pnpm --version
+rokit --version
+```
Le [script d'installation](scripts/setup.ps1) nécessite WinGet (App Installer du Microsoft Store). Il installe Git, [mise](https://mise.jdx.dev/installing-mise.html), Rokit, Roblox Studio et Blender via WinGet, puis les versions définies dans `mise.toml` et `rokit.toml`, et les dépendances pnpm verrouillées. Il termine par `pnpm check` et `pnpm build` et s'arrête dès qu'une commande échoue. Windows peut demander une élévation pour certains installateurs. Une relance conserve les applications déjà installées ; elle ne les met pas à jour automatiquement. Les applications WinGet utilisent la version disponible dans son catalogue, les outils du projet restent épinglés.
## Bibliothèques installées
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/controllers/welcome-chat-controller.ts b/src/client/controllers/welcome-chat-controller.ts
new file mode 100644
index 0000000..3238f1b
--- /dev/null
+++ b/src/client/controllers/welcome-chat-controller.ts
@@ -0,0 +1,49 @@
+import { Controller, OnStart } from "@flamework/core";
+import Log from "@rbxts/log";
+import { TextChatService } from "@rbxts/services";
+import { WELCOME_CHAT, WELCOME_CHAT_MESSAGE } from "shared/welcome-chat";
+
+@Controller()
+export class WelcomeChatController implements OnStart {
+ public onStart(): void {
+ task.spawn(() => this.displayWelcome());
+ }
+
+ private displayWelcome(): void {
+ if (!game.IsLoaded()) {
+ game.Loaded.Wait();
+ }
+
+ const deadline = os.clock() + WELCOME_CHAT.channelTimeoutSeconds;
+ const channels = TextChatService.WaitForChild(
+ "TextChannels",
+ WELCOME_CHAT.channelTimeoutSeconds,
+ );
+ const channel = channels?.WaitForChild("RBXGeneral", math.max(deadline - os.clock(), 0));
+
+ if (!channel?.IsA("TextChannel")) {
+ Log.Warn("Welcome chat unavailable: RBXGeneral did not become ready before the timeout");
+ return;
+ }
+
+ // Channels can exist before the chat UI subscribes to system messages.
+ const window = TextChatService.WaitForChild(
+ "ChatWindowConfiguration",
+ math.max(deadline - os.clock(), 0),
+ );
+ if (!window?.IsA("ChatWindowConfiguration")) {
+ Log.Warn("Welcome chat unavailable: the chat window configuration is missing");
+ return;
+ }
+ while (window.AbsoluteSize.X <= 0 || window.AbsoluteSize.Y <= 0) {
+ if (os.clock() >= deadline) {
+ Log.Warn(
+ "Welcome chat unavailable: the chat window did not become ready before the timeout",
+ );
+ return;
+ }
+ task.wait(0.1);
+ }
+ channel.DisplaySystemMessage(WELCOME_CHAT_MESSAGE);
+ }
+}
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";
diff --git a/src/shared/logging.ts b/src/shared/logging.ts
index 05c59ff..82bbfba 100644
--- a/src/shared/logging.ts
+++ b/src/shared/logging.ts
@@ -1,3 +1,4 @@
+
import Log, { LogLevel } from "@rbxts/log";
import { RunService } from "@rbxts/services";
diff --git a/src/shared/welcome-chat.ts b/src/shared/welcome-chat.ts
new file mode 100644
index 0000000..602dc7d
--- /dev/null
+++ b/src/shared/welcome-chat.ts
@@ -0,0 +1,20 @@
+export const WELCOME_CHAT = {
+ delaySeconds: 2,
+ channelTimeoutSeconds: 30,
+ colors: {
+ heading: "#FFD166",
+ text: "#F1F5FF",
+ liked: "#5DE4A8",
+ follow: "#74C7FF",
+ hint: "#BDC6DC",
+ },
+};
+
+const colors = WELCOME_CHAT.colors;
+
+export const WELCOME_CHAT_MESSAGE =
+ `✨ SUPPORT THE GAME ✨\n` +
+ `If you like the game ` +
+ `👍 LIKED` +
+ ` and ` +
+ `🔔 FOLLOW\n`;