diff --git a/flamework.build b/flamework.build
index 3a63735..4e97ac4 100644
--- a/flamework.build
+++ b/flamework.build
@@ -2,7 +2,9 @@
"version": 1,
"flameworkVersion": "1.3.2",
"identifiers": {
- "crownstudio-roblox-ts-template:out/server/services/startup-service@StartupService": "server/services/startup-service@StartupService"
+ "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"
},
"metadata": {}
}
\ No newline at end of file
diff --git a/package.json b/package.json
index 53098e3..d6c8229 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "crownstudio-roblox-ts-template",
- "version": "0.1.0",
+ "version": "1.0.0",
"private": true,
"packageManager": "pnpm@11.21.0",
"engines": {
@@ -18,7 +18,8 @@
"lint": "eslint .",
"format:check": "prettier --check .",
"format": "prettier --write .",
- "check": "pnpm lint && pnpm compile",
+ "test:cmdr": "node --test scripts/cmdr-permissions.test.mjs",
+ "check": "pnpm lint && pnpm test:cmdr && pnpm compile",
"prepare": "husky"
},
"rbxts-build": {
diff --git a/readme.md b/readme.md
index 59d5d44..afa2184 100644
--- a/readme.md
+++ b/readme.md
@@ -4,24 +4,19 @@ Template Roblox TypeScript avec Flamework, Rojo et une sélection de bibliothèq
## Démarrage
-Prérequis : [Roblox Studio](https://create.roblox.com/docs/studio/setup), [mise](https://mise.jdx.dev/getting-started.html) et [Rokit](https://github.com/rojo-rbx/rokit#readme). Sous Windows, rouvrir le terminal après leur installation pour actualiser `PATH`.
+Sous Windows 10/11, lancer depuis le dépôt :
```powershell
-mise install
-rokit install
-pnpm install --frozen-lockfile
-pnpm check
-pnpm build
-pnpm serve
+powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\setup.ps1
```
-`mise` épingle Node.js 24.16.0, pnpm 11.21.0 et uv 0.13.0. `rokit` installe Rojo 7.7.1, Asphalt 2.0.2, darklua 0.19.0 et Mantle 0.11.18. `pnpm build` compile le TypeScript et crée `game.rbxl` avec Rojo. Ouvrir ce fichier dans Studio, ou utiliser `pnpm start` pour le flux [`rbxts-build`](https://github.com/osyrisrblx/rbxts-build#readme). `pnpm watch` lance la compilation et le serveur Rojo en continu. Le projet Rojo est [`default.project.json`](default.project.json) et les entrées client/serveur sont sous [`src`](src/).
-
-Autres commandes : `pnpm lint`, `pnpm format:check` et `pnpm format`. [Husky](https://typicode.github.io/husky/) installe un hook local `pre-commit` qui exécute `pnpm lint`. [Renovate](https://docs.renovatebot.com/) lit [`renovate.json`](renovate.json) une fois son application activée sur le dépôt distant.
+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
-Chaque paquet de cette section est une dépendance directe. Les liens mènent à la documentation du projet ou à son README actuel ; les versions exécutables sont épinglées dans le lockfile. Les paquets ne sont pas automatiquement utilisés par le code de démarrage.
+Chaque paquet de cette section est une dépendance directe. Les liens mènent à la documentation du projet ou à son README actuel ; les versions exécutables sont épinglées dans le lockfile. Les 22 bibliothèques ont été examinées : Flamework, son gestionnaire de composants, Cmdr et le logger sont branchés au démarrage ; les autres bibliothèques s'utilisent à l'import ou se configurent avec les données de leur fonctionnalité.
+
+**Console Cmdr** : en mode Play dans Studio, ouvrir avec **F2**. Les commandes intégrées sont enregistrées et protégées côté serveur. Hors Studio, seuls le propriétaire individuel de l'expérience et les UserIds renseignés dans [`cmdr-permissions.ts`](src/server/config/cmdr-permissions.ts) sont autorisés. Pour une expérience de groupe, renseigner les administrateurs explicitement. `pnpm test:cmdr` vérifie cette politique ; ce test fait aussi partie de `pnpm check`. La configuration client est dans [`cmdr-controller.ts`](src/client/controllers/cmdr-controller.ts).
- `@flamework/core` ([documentation](https://flamework.fireboltofdeath.dev/docs/introduction/)) — services, contrôleurs et injection ; le service de démarrage en donne un exemple.
- `@flamework/components` ([documentation](https://flamework.fireboltofdeath.dev/docs/additional-modules/components/creating-a-component/)) — composants d'instances.
@@ -34,17 +29,17 @@ Chaque paquet de cette section est une dépendance directe. Les liens mènent à
- `@rbxts/lapis` ([documentation](https://github.com/nezuo/lapis/tree/master/docs)) — persistance de données.
- `@rbxts/trove` ([README et API](https://github.com/Sleitnick/rbxts-trove#readme)) — nettoyage des ressources.
- `@rbxts/log` ([README](https://github.com/roblox-aurora/rbx-log#readme)) — journalisation.
-- `@rbxts/sift` ([documentation](https://cxmeel.github.io/sift/)) — collections immuables
+- `@rbxts/sift` ([documentation](https://cxmeel.github.io/sift/)) — collections immuables
- `@rbxts/t` ([README et API](https://github.com/osyrisrblx/t#readme)) — validation de valeurs à l'exécution.
- `@rbxts/cmdr` ([documentation](https://eryn.io/Cmdr/), [types roblox-ts](https://github.com/sasial-dev/Cmdr#readme)) — commandes de développement.
- `@rbxts/gamejoy` ([README](https://github.com/Rimuy/GameJoy#readme)) — contrôles de jeu.
- `@rbxts/tween` ([README et API](https://github.com/Validark/Roblox-TS-Libraries/blob/master/tween/README.md)) — interpolations.
- `@rbxts/timer` ([README](https://github.com/Bytebit-Org/roblox-Timer#readme)) — temporisation.
- `@rbxts/ui-labs` ([documentation](https://ui-labs.luau.page/docs/getstarted.html)) — prévisualisation d'interface.
-- `@rbxts/sound-manager` ([documentation du projet](https://github.com/xelnae-r/Sound-Manager#readme)) — gestion des sons
-- `@rbxts/object-cache` ([documentation amont](https://github.com/Pyseph/ObjectCache#readme)) — cache de pièces/objets
+- `@rbxts/sound-manager` ([documentation du projet](https://github.com/xelnae-r/Sound-Manager#readme)) — gestion des sons
+- `@rbxts/object-cache` ([documentation amont](https://github.com/Pyseph/ObjectCache#readme)) — cache de pièces/objets
- `@rbxts/net` ([README](https://github.com/roblox-aurora/rbx-net#readme)) — contient `ExperienceBroadcastEvent` pour les messages entre serveurs.
-- `@rbxts-js/roblox-lua-promise` ([README](https://github.com/evaera/roblox-lua-promise#readme)) — implémentation de promesses
+- `@rbxts-js/roblox-lua-promise` ([README](https://github.com/evaera/roblox-lua-promise#readme)) — implémentation de promesses
Pour choisir une approche lors d'une nouvelle fonctionnalité, le skill local [`roblox-feature-docs`](.agents/skills/roblox-feature-docs/SKILL.md) compare les bibliothèques pertinentes, leurs documentations et les API réellement disponibles dans les versions verrouillées.
@@ -79,6 +74,4 @@ Le serveur Blender peut exécuter du code Python dans Blender : utiliser uniquem
L'[API Cloud Creator Store](https://create.roblox.com/docs/cloud/reference/features/creator-store) est une API Roblox distincte des serveurs MCP ci-dessus. Elle n'est pas activée ici : son usage nécessite une clé et un cas d'utilisation précis.
-## Vérification du template
-
-Sur Windows, l'installation verrouillée, `pnpm check`, `pnpm format:check`, `pnpm build` et `pnpm start -- --no-watch` ont réussi. `pnpm build` produit `game.rbxl` et `pnpm start` a lancé Roblox Studio. `codex mcp list` reconnaît les deux serveurs. Un test de jeu dans Studio et un appel MCP complet restent à effectuer.
+Version du template : 1.0.0
diff --git a/scripts/cmdr-permissions.test.mjs b/scripts/cmdr-permissions.test.mjs
new file mode 100644
index 0000000..08a405f
--- /dev/null
+++ b/scripts/cmdr-permissions.test.mjs
@@ -0,0 +1,61 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import { URL } from "node:url";
+import { runInNewContext } from "node:vm";
+import ts from "typescript";
+
+// Run the actual, engine-independent permission policy, not a duplicate of it.
+const source = readFileSync(
+ new URL("../src/server/config/cmdr-permissions.ts", import.meta.url),
+ "utf8",
+);
+const compiled = ts.transpileModule(source, {
+ compilerOptions: { module: ts.ModuleKind.CommonJS },
+}).outputText;
+const policyModule = { exports: {} };
+runInNewContext(compiled, {
+ exports: policyModule.exports,
+ module: policyModule,
+});
+const { isCmdrAllowed, CMDR_PERMISSIONS } = policyModule.exports;
+
+test("published game denies ordinary players by default", () => {
+ assert.equal(isCmdrAllowed(42, false, 99), false);
+});
+
+test("Studio allows local testing without publishing or configuring an admin", () => {
+ assert.equal(isCmdrAllowed(-1, true, undefined), true);
+});
+
+test("experience owner is allowed in a user-owned published game", () => {
+ assert.equal(isCmdrAllowed(99, false, 99), true);
+});
+
+test("no user owner means no automatic permission in a group-owned game", () => {
+ assert.equal(isCmdrAllowed(99, false, undefined), false);
+});
+
+test("only explicitly listed users get allowlist access", () => {
+ const permissions = {
+ allowInStudio: false,
+ allowExperienceOwner: false,
+ adminUserIds: new Set([42]),
+ };
+ assert.equal(isCmdrAllowed(42, false, undefined, permissions), true);
+ assert.equal(isCmdrAllowed(43, false, undefined, permissions), false);
+});
+
+test("Studio and owner exceptions can both be disabled", () => {
+ const permissions = {
+ allowInStudio: false,
+ allowExperienceOwner: false,
+ adminUserIds: new Set(),
+ };
+ assert.equal(isCmdrAllowed(99, true, 99, permissions), false);
+ assert.equal(isCmdrAllowed(99, false, 99, permissions), false);
+});
+
+test("published default configuration contains no placeholder administrator", () => {
+ assert.equal(CMDR_PERMISSIONS.adminUserIds.size, 0);
+});
diff --git a/scripts/setup.ps1 b/scripts/setup.ps1
new file mode 100644
index 0000000..723392a
--- /dev/null
+++ b/scripts/setup.ps1
@@ -0,0 +1,73 @@
+#Requires -Version 5.1
+[CmdletBinding()]
+param(
+ [switch]$DryRun,
+ [switch]$SkipChecks
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+$projectRoot = Split-Path -Parent $PSScriptRoot
+
+function Invoke-Tool {
+ param([string]$Name, [string[]]$Arguments)
+ Write-Host "> $Name $($Arguments -join ' ')"
+ if ($DryRun) { return }
+ & $Name @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "$Name a echoue (code $LASTEXITCODE). Corriger l'erreur puis relancer le script."
+ }
+}
+
+function Update-SessionPath {
+ $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine')
+ $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
+ $env:Path = "$machinePath;$userPath;$env:Path;$env:USERPROFILE\.rokit\bin"
+}
+
+function Install-Package {
+ param([string]$Id)
+ if (-not $DryRun) {
+ & winget list --id $Id --exact --source winget --accept-source-agreements --disable-interactivity
+ if ($LASTEXITCODE -eq 0) {
+ Write-Host "$Id est deja installe."
+ return
+ }
+ # APP_NOT_INSTALLED: other errors must not be mistaken for a missing package.
+ if ($LASTEXITCODE -ne -1978335212) {
+ throw "Impossible de verifier $Id avec winget (code $LASTEXITCODE)."
+ }
+ }
+ Invoke-Tool 'winget' @('install', '--id', $Id, '--exact', '--source', 'winget', '--silent', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity')
+ if (-not $DryRun) { Update-SessionPath }
+}
+
+Push-Location $projectRoot
+try {
+ if (-not $DryRun -and -not (Get-Command winget -ErrorAction SilentlyContinue)) {
+ throw "Installer App Installer (winget) depuis le Microsoft Store, puis relancer ce script."
+ }
+
+ foreach ($package in @('Git.Git', 'jdx.mise', 'Rojo.Rokit', 'Roblox.RobloxStudio', 'BlenderFoundation.Blender')) {
+ Install-Package $package
+ }
+
+ Invoke-Tool 'mise' @('trust', 'mise.toml')
+ Invoke-Tool 'mise' @('install')
+ Invoke-Tool 'rokit' @('install')
+ Invoke-Tool 'mise' @('exec', '--', 'pnpm', 'install', '--frozen-lockfile')
+ if (-not $SkipChecks) {
+ Invoke-Tool 'mise' @('exec', '--', 'pnpm', 'check')
+ Invoke-Tool 'mise' @('exec', '--', 'pnpm', 'build')
+ }
+
+ if ($DryRun) {
+ Write-Host 'Apercu termine : aucune installation effectuee.'
+ } else {
+ Write-Host 'Installation terminee. Rouvrir le terminal et les clients MCP.'
+ Write-Host 'Pour les commandes pnpm/uvx, utiliser mise exec -- ou activer mise dans le shell.'
+ Write-Host 'Activer le MCP dans Studio et installer/activer l extension MCP officielle dans Blender (voir README).'
+ }
+} finally {
+ Pop-Location
+}
diff --git a/src/client/controllers/cmdr-controller.ts b/src/client/controllers/cmdr-controller.ts
new file mode 100644
index 0000000..885ee02
--- /dev/null
+++ b/src/client/controllers/cmdr-controller.ts
@@ -0,0 +1,30 @@
+import { Controller, OnStart } from "@flamework/core";
+import { CmdrClient } from "@rbxts/cmdr";
+import { Players } from "@rbxts/services";
+import { Trove } from "@rbxts/trove";
+import { CMDR_ACCESS_ATTRIBUTE } from "shared/cmdr";
+import { TEMPLATE_NAME } from "shared/template-name";
+
+@Controller()
+export class CmdrController implements OnStart {
+ private readonly trove = new Trove();
+
+ public onStart(): void {
+ CmdrClient.SetActivationKeys([Enum.KeyCode.F2]);
+ CmdrClient.SetPlaceName(TEMPLATE_NAME);
+ CmdrClient.SetActivationUnlocksMouse(true);
+ this.trove.connect(Players.LocalPlayer.GetAttributeChangedSignal(CMDR_ACCESS_ATTRIBUTE), () =>
+ this.updateAccess(),
+ );
+ this.trove.attachToInstance(script);
+ this.updateAccess();
+ }
+
+ private updateAccess(): void {
+ const enabled = Players.LocalPlayer.GetAttribute(CMDR_ACCESS_ATTRIBUTE) === true;
+ CmdrClient.SetEnabled(enabled);
+ if (!enabled) {
+ CmdrClient.Hide();
+ }
+ }
+}
diff --git a/src/client/main.client.ts b/src/client/main.client.ts
index 5017629..877d9e6 100644
--- a/src/client/main.client.ts
+++ b/src/client/main.client.ts
@@ -1,5 +1,10 @@
import { Flamework } from "@flamework/core";
+import "@flamework/components";
+import Log from "@rbxts/log";
+import { initializeLogging } from "shared/logging";
import { TEMPLATE_NAME } from "shared/template-name";
+initializeLogging();
+Flamework.addPaths("src/client/controllers");
Flamework.ignite();
-print(`${TEMPLATE_NAME} ready (client)`);
+Log.Info("{Template} ready (client)", TEMPLATE_NAME);
diff --git a/src/server/config/cmdr-permissions.ts b/src/server/config/cmdr-permissions.ts
new file mode 100644
index 0000000..08a6085
--- /dev/null
+++ b/src/server/config/cmdr-permissions.ts
@@ -0,0 +1,27 @@
+export interface CmdrPermissions {
+ readonly allowInStudio: boolean;
+ readonly allowExperienceOwner: boolean;
+ readonly adminUserIds: ReadonlySet;
+}
+
+export const CMDR_PERMISSIONS: CmdrPermissions = {
+ allowInStudio: true,
+ allowExperienceOwner: true,
+ // Add individual Roblox UserIds here, especially for group-owned experiences.
+ adminUserIds: new Set(),
+};
+
+export function isCmdrAllowed(
+ userId: number,
+ isStudio: boolean,
+ experienceOwnerUserId: number | undefined,
+ permissions: CmdrPermissions = CMDR_PERMISSIONS,
+): boolean {
+ return (
+ (isStudio && permissions.allowInStudio) ||
+ permissions.adminUserIds.has(userId) ||
+ (permissions.allowExperienceOwner &&
+ experienceOwnerUserId !== undefined &&
+ userId === experienceOwnerUserId)
+ );
+}
diff --git a/src/server/main.server.ts b/src/server/main.server.ts
index 6e21d2b..fcdb2b7 100644
--- a/src/server/main.server.ts
+++ b/src/server/main.server.ts
@@ -1,6 +1,10 @@
import { Flamework } from "@flamework/core";
+import "@flamework/components";
+import Log from "@rbxts/log";
+import { initializeLogging } from "shared/logging";
import { TEMPLATE_NAME } from "shared/template-name";
+initializeLogging();
Flamework.addPaths("src/server/services");
Flamework.ignite();
-print(`${TEMPLATE_NAME} ready (server)`);
+Log.Info("{Template} ready (server)", TEMPLATE_NAME);
diff --git a/src/server/services/cmdr-service.ts b/src/server/services/cmdr-service.ts
new file mode 100644
index 0000000..16177d6
--- /dev/null
+++ b/src/server/services/cmdr-service.ts
@@ -0,0 +1,43 @@
+import { OnStart, Service } from "@flamework/core";
+import { Cmdr } from "@rbxts/cmdr";
+import Log from "@rbxts/log";
+import { Players, RunService } from "@rbxts/services";
+import { Trove } from "@rbxts/trove";
+import { isCmdrAllowed } from "server/config/cmdr-permissions";
+import { CMDR_ACCESS_ATTRIBUTE } from "shared/cmdr";
+
+@Service()
+export class CmdrService implements OnStart {
+ private readonly trove = new Trove();
+
+ public onStart(): void {
+ // Register authorization before exposing the built-in commands.
+ Cmdr.Registry.RegisterHook("BeforeRun", (context) => {
+ if (!this.canUseConsole(context.Executor)) {
+ return "You do not have permission to run this command.";
+ }
+ });
+ Cmdr.Registry.RegisterDefaultCommands();
+
+ this.trove.connect(Players.PlayerAdded, (player) => {
+ this.updateAccess(player);
+ });
+ for (const player of Players.GetPlayers()) {
+ this.updateAccess(player);
+ }
+ game.BindToClose(() => this.trove.destroy());
+ Log.Info("Cmdr ready with {CommandCount} commands", Cmdr.Registry.GetCommands().size());
+ }
+
+ private canUseConsole(player: Player): boolean {
+ return isCmdrAllowed(
+ player.UserId,
+ RunService.IsStudio(),
+ game.CreatorType === Enum.CreatorType.User ? game.CreatorId : undefined,
+ );
+ }
+
+ private updateAccess(player: Player): void {
+ player.SetAttribute(CMDR_ACCESS_ATTRIBUTE, this.canUseConsole(player));
+ }
+}
diff --git a/src/server/services/startup-service.ts b/src/server/services/startup-service.ts
index 2cc86db..a7c7164 100644
--- a/src/server/services/startup-service.ts
+++ b/src/server/services/startup-service.ts
@@ -1,8 +1,9 @@
import { OnStart, Service } from "@flamework/core";
+import Log from "@rbxts/log";
@Service()
export class StartupService implements OnStart {
public onStart(): void {
- print("CrownStudio template ready (server)");
+ Log.Debug("StartupService started");
}
}
diff --git a/src/shared/cmdr.ts b/src/shared/cmdr.ts
new file mode 100644
index 0000000..6eb092a
--- /dev/null
+++ b/src/shared/cmdr.ts
@@ -0,0 +1,2 @@
+// This attribute controls visibility only. Authorization is checked on the server.
+export const CMDR_ACCESS_ATTRIBUTE = "CmdrEnabled";
diff --git a/src/shared/logging.ts b/src/shared/logging.ts
new file mode 100644
index 0000000..05c59ff
--- /dev/null
+++ b/src/shared/logging.ts
@@ -0,0 +1,12 @@
+import Log, { LogLevel } from "@rbxts/log";
+import { RunService } from "@rbxts/services";
+
+export function initializeLogging(): void {
+ Log.SetLogger(
+ Log.Configure()
+ .SetMinLogLevel(RunService.IsStudio() ? LogLevel.Debugging : LogLevel.Information)
+ .EnrichWithProperty("Side", RunService.IsServer() ? "server" : "client")
+ .WriteTo(Log.RobloxOutput())
+ .Create(),
+ );
+}