From 76d200f0688341864fbf0fd14fdb842f04dcedff Mon Sep 17 00:00:00 2001 From: Infuseting Date: Wed, 14 Oct 2026 09:16:43 +0200 Subject: [PATCH] feat(template): initialize libraries and release v1.0.0 --- flamework.build | 4 +- package.json | 5 +- readme.md | 29 ++++----- scripts/cmdr-permissions.test.mjs | 61 +++++++++++++++++++ scripts/setup.ps1 | 73 +++++++++++++++++++++++ src/client/controllers/cmdr-controller.ts | 30 ++++++++++ src/client/main.client.ts | 7 ++- src/server/config/cmdr-permissions.ts | 27 +++++++++ src/server/main.server.ts | 6 +- src/server/services/cmdr-service.ts | 43 +++++++++++++ src/server/services/startup-service.ts | 3 +- src/shared/cmdr.ts | 2 + src/shared/logging.ts | 12 ++++ 13 files changed, 278 insertions(+), 24 deletions(-) create mode 100644 scripts/cmdr-permissions.test.mjs create mode 100644 scripts/setup.ps1 create mode 100644 src/client/controllers/cmdr-controller.ts create mode 100644 src/server/config/cmdr-permissions.ts create mode 100644 src/server/services/cmdr-service.ts create mode 100644 src/shared/cmdr.ts create mode 100644 src/shared/logging.ts 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(), + ); +}