From dea9bd52ee4b81916aac40812187e6bf98db74e7 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Tue, 9 Jun 2026 09:15:07 +0200 Subject: [PATCH] feat(sandbox): add carre program --- PLAN.md | 177 ++++++++++++++++++++++++++++++ apis/libcarre.lua | 171 +++++++++++++++++++++++++++++ packages/index.json | 1 + packages/trapos-sandbox/ccpm.json | 11 ++ programs/carre.lua | 62 +++++++++++ tests/carre.lua | 122 ++++++++++++++++++++ 6 files changed, 544 insertions(+) create mode 100644 PLAN.md create mode 100644 apis/libcarre.lua create mode 100644 packages/trapos-sandbox/ccpm.json create mode 100644 programs/carre.lua create mode 100644 tests/carre.lua diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..296a4bc --- /dev/null +++ b/PLAN.md @@ -0,0 +1,177 @@ +# Programme `carre` Plan + +## Objectif + +Ajouter un nouveau programme ComputerCraft nommé `carre` qui dessine des carres dans le terminal. + +Le programme doit etre utile pour un test simple, mais assez amusant pour explorer plusieurs rendus: carre fixe, carre plein, carre aleatoire, et repetition de carres. + +Le code doit aussi pouvoir servir d'exemple pedagogique pour debutants Lua. L'implementation pourra donc contenir quelques commentaires en francais, courts et utiles, pour expliquer les etapes importantes sans noyer le programme. + +## Emplacement + +- Programme: `programs/carre.lua` +- Tests: `tests/programs/carre_test.lua` ou `tests/carre_test.lua`, selon les conventions existantes au moment de l'implementation +- Package: nouveau package separe `trapos-sandbox`, afin de tester `ccpm` sans inclure `carre` directement dans `trapos` + +## Interface Proposee + +Commande de base: + +```text +carre [options] +``` + +Options: + +- `-size `: taille du carre, par defaut `8` +- `-x `: colonne de depart, par defaut centree si possible +- `-y `: ligne de depart, par defaut centree si possible +- `-char `: caractere utilise pour dessiner, par defaut `#` +- `-fill`: dessine un carre plein au lieu d'un contour +- `-random`: choisit une taille, une position et un caractere aleatoires simples compatibles avec le terminal +- `-count `: dessine plusieurs carres, utile avec `-random`, par defaut `1` +- `-delay `: pause entre deux carres quand `-count` est superieur a `1`, par defaut `0` +- `-clear`: efface l'ecran avant de dessiner +- `-help` / `--help`: affiche l'aide +- `-version` / `--version`: affiche la version du package via `/apis/libversion` + +Exemples: + +```text +carre +carre -size 12 -char * -clear +carre -size 6 -fill -x 4 -y 3 +carre -random +carre -random -count 5 -delay 0.2 -clear +``` + +## Comportement Attendu + +- Le programme dessine dans le terminal courant avec `term.setCursorPos()` et `write()`. +- Si `-clear` est donne, il appelle `term.clear()` avant le dessin. +- Sans `-x` ou `-y`, le carre est centre dans la zone disponible quand la taille du terminal le permet. +- Les coordonnees et tailles sont bornees pour eviter les erreurs ou les dessins hors ecran. +- `-size 1` dessine un seul caractere. +- `-size 2` dessine un carre minimal valide. +- Si `-fill` est absent, seul le contour est dessine. +- Si `-fill` est present, toute la surface est remplie. +- Si `-random` est present, les options explicites doivent rester prioritaires quand c'est raisonnable: par exemple `carre -random -char @` garde `@` mais randomise taille/position. +- Si `-count` est superieur a `1`, chaque carre est dessine successivement sur le meme ecran par defaut. Avec `-clear`, l'ecran est efface avant chaque carre pour produire une petite animation. +- Les couleurs ne sont pas prioritaires pour la premiere version: l'objectif est de garder un programme lisible pour debutants. + +## Regles De Validation Des Arguments + +- `-size` doit etre un entier positif. +- `-x` et `-y` doivent etre des entiers positifs. +- `-count` doit etre un entier positif. +- `-delay` doit etre un nombre positif ou nul. +- `-char` prend le premier caractere non vide fourni. +- Option inconnue: afficher une erreur courte puis suggerer `carre -help`. +- Argument invalide: afficher une erreur courte puis quitter sans stack trace. + +## Idee D'Implementation + +Structure minimale dans `programs/carre.lua`: + +1. Charger `/apis/libversion` pour `--version`. +2. Definir `printUsage()`. +3. Parser `...` avec une boucle simple sur `args`. +4. Recuperer `term.getSize()`. +5. Calculer une configuration finale de dessin. +6. Dessiner avec une fonction locale `drawSquare(x, y, size, char, fill)`. +7. Repeter selon `count`, avec `sleep(delay)` si necessaire. + +Commentaires a privilegier dans le code: + +- Expliquer pourquoi on lit `...` pour recuperer les arguments du programme. +- Expliquer le calcul de centrage. +- Expliquer la difference entre contour et remplissage. +- Expliquer que le mode aleatoire borne les valeurs pour rester visible dans le terminal. + +Commentaires a eviter: + +- Commentaires qui repetent exactement le code, par exemple `-- ajoute 1 a x`. +- Gros blocs de theorie Lua qui rendraient le programme moins lisible. + +Pseudo-rendu contour: + +```text +######## +# # +# # +# # +# # +# # +# # +######## +``` + +Pseudo-rendu plein: + +```text +######## +######## +######## +######## +``` + +## Tests Proposes + +Ajouter des tests CraftOS-PC avec `/apis/libtest.lua` pour les parties deterministes: + +- Parse des options de base. +- Rejet des valeurs invalides. +- Calcul de centrage avec une taille de terminal simulee. +- Calcul de bornage quand le carre depasse la zone disponible. +- Rendu contour dans une surface terminal simulee. +- Rendu plein dans une surface terminal simulee. +- Mode random teste avec une source aleatoire injectable ou avec des invariants seulement: taille positive, position visible, pas d'erreur. + +Pour rendre le programme testable sans gros refactor, l'implementation peut garder les fonctions pures dans le fichier et n'executer `main(...)` qu'a la fin. Si les conventions du depot preferent tester seulement les programmes via execution CraftOS-PC, adapter les tests en consequence. + +## Verification + +Apres implementation: + +```text +just check +just trapos --headless --exec 'shell.run("/programs/carre", "-size", "5", "-clear"); os.shutdown()' +just trapos --headless --exec 'shell.run("/programs/carre", "-random", "-count", "3", "-delay", "0"); os.shutdown()' +``` + +Si des tests sont ajoutes: + +```text +just trapos --headless --exec 'shell.run("/programs/runtest", "/tests/carre_test.lua"); os.shutdown()' +``` + +## Packaging + +- Creer le nouveau package `packages/trapos-sandbox/ccpm.json`. +- Ne pas ajouter `carre` au package principal `trapos`. +- Ajouter `programs/carre.lua` dans les fichiers du package `trapos-sandbox`. +- Bumper la version du package `trapos-sandbox` quand le comportement change. +- Reporter la meme version dans `packages/index.json`. +- Verifier que `carre --version` retourne bien la version attendue. + +## Decisions Validees + +- `carre` doit etre dans un nouveau package separe, pas dans `trapos`, pour faciliter les tests de `ccpm`. +- Le package s'appelle `trapos-sandbox`. +- Le programme s'appelle `carre.lua`, sans accent. +- Les commentaires en francais sont souhaites pour rendre le code utile a des debutants. +- Les couleurs sont repoussees: le programme doit rester simple et pedagogique pour la premiere version. + +## Questions Restantes + +- `carre -random -count N` doit-il garder tous les carres visibles par defaut, ou faut-il finalement effacer entre chaque dessin meme sans `-clear`? + +## Hors Scope Pour La Premiere Version + +- Dessin de rectangles non carres. +- Animation avancee avec rebonds. +- Interface interactive au clavier. +- Sauvegarde du dessin dans un fichier. +- Support multi-caracteres pour les bordures. +- Couleurs de texte ou de fond. diff --git a/apis/libcarre.lua b/apis/libcarre.lua new file mode 100644 index 0000000..ede26a8 --- /dev/null +++ b/apis/libcarre.lua @@ -0,0 +1,171 @@ +local DEFAULT_SIZE = 8; +local DEFAULT_CHAR = '#'; +local RANDOM_CHARS = { '#', '*', '+', 'x', '@', '=' }; + +local function firstChar(value) + value = tostring(value or ''); + if value == '' then return nil; end + return string.sub(value, 1, 1); +end + +local function parsePositiveInteger(value, name) + local number = tonumber(value); + if not number or number < 1 or number ~= math.floor(number) then + return nil, name .. ' doit etre un entier positif'; + end + return number; +end + +local function parseNonNegativeNumber(value, name) + local number = tonumber(value); + if not number or number < 0 then + return nil, name .. ' doit etre un nombre positif ou nul'; + end + return number; +end + +local function clamp(value, minValue, maxValue) + if value < minValue then return minValue; end + if value > maxValue then return maxValue; end + return value; +end + +local function createCarre(opts) + opts = opts or {}; + local random = opts.random or math.random; + + local api = {}; + + function api.parseArgs(args) + args = args or {}; + local config = { + size = DEFAULT_SIZE, + char = DEFAULT_CHAR, + fill = false, + random = false, + count = 1, + delay = 0, + clear = false, + explicit = {}, + }; + + local i = 1; + while i <= (args.n or #args) do + local arg = args[i]; + + if arg == '-size' then + local value, err = parsePositiveInteger(args[i + 1], '-size'); + if not value then return nil, err; end + config.size = value; + config.explicit.size = true; + i = i + 1; + elseif arg == '-x' then + local value, err = parsePositiveInteger(args[i + 1], '-x'); + if not value then return nil, err; end + config.x = value; + config.explicit.x = true; + i = i + 1; + elseif arg == '-y' then + local value, err = parsePositiveInteger(args[i + 1], '-y'); + if not value then return nil, err; end + config.y = value; + config.explicit.y = true; + i = i + 1; + elseif arg == '-char' then + local value = firstChar(args[i + 1]); + if not value then return nil, '-char doit recevoir un caractere'; end + config.char = value; + config.explicit.char = true; + i = i + 1; + elseif arg == '-fill' then + config.fill = true; + elseif arg == '-random' then + config.random = true; + elseif arg == '-count' then + local value, err = parsePositiveInteger(args[i + 1], '-count'); + if not value then return nil, err; end + config.count = value; + i = i + 1; + elseif arg == '-delay' then + local value, err = parseNonNegativeNumber(args[i + 1], '-delay'); + if not value then return nil, err; end + config.delay = value; + i = i + 1; + elseif arg == '-clear' then + config.clear = true; + else + return nil, 'option inconnue: ' .. tostring(arg); + end + + i = i + 1; + end + + return config; + end + + function api.computeSquare(config, width, height) + width = math.max(1, tonumber(width) or 1); + height = math.max(1, tonumber(height) or 1); + config = config or {}; + + local explicit = config.explicit or {}; + local maxSize = math.max(1, math.min(width, height)); + local size = config.size or DEFAULT_SIZE; + local char = config.char or DEFAULT_CHAR; + + if config.random then + if not explicit.size then + size = random(1, maxSize); + end + if not explicit.char then + char = RANDOM_CHARS[random(1, #RANDOM_CHARS)]; + end + end + + size = clamp(size, 1, maxSize); + + local maxX = math.max(1, width - size + 1); + local maxY = math.max(1, height - size + 1); + local x = config.x; + local y = config.y; + + if config.random and not explicit.x then + x = random(1, maxX); + elseif not x then + x = math.floor((width - size) / 2) + 1; + end + + if config.random and not explicit.y then + y = random(1, maxY); + elseif not y then + y = math.floor((height - size) / 2) + 1; + end + + return { + x = clamp(x, 1, maxX), + y = clamp(y, 1, maxY), + size = size, + char = firstChar(char) or DEFAULT_CHAR, + fill = config.fill == true, + }; + end + + function api.drawSquare(termLib, square) + for row = 1, square.size do + termLib.setCursorPos(square.x, square.y + row - 1); + + for column = 1, square.size do + local isBorder = row == 1 or row == square.size or column == 1 or column == square.size; + if square.fill or isBorder then + termLib.write(square.char); + else + termLib.write(' '); + end + end + end + end + + return api; +end + +return createCarre; diff --git a/packages/index.json b/packages/index.json index 709e2c9..d6acbbf 100644 --- a/packages/index.json +++ b/packages/index.json @@ -6,6 +6,7 @@ "trapos-net": "0.2.1", "trapos-ui": "0.2.2", "trapos-ai": "0.5.1", + "trapos-sandbox": "0.1.0", "trapos": "0.6.2" } } diff --git a/packages/trapos-sandbox/ccpm.json b/packages/trapos-sandbox/ccpm.json new file mode 100644 index 0000000..076d1c3 --- /dev/null +++ b/packages/trapos-sandbox/ccpm.json @@ -0,0 +1,11 @@ +{ + "name": "trapos-sandbox", + "version": "0.1.0", + "description": "TrapOS sandbox programs for ccpm experiments and Lua learning", + "dependencies": ["trapos-core"], + "files": [ + "apis/libcarre.lua", + "programs/carre.lua" + ], + "autostart": [] +} diff --git a/programs/carre.lua b/programs/carre.lua new file mode 100644 index 0000000..0a5c3ae --- /dev/null +++ b/programs/carre.lua @@ -0,0 +1,62 @@ +local createCarre = require('/apis/libcarre'); +local createVersion = require('/apis/libversion'); + +-- Les arguments du programme sont disponibles dans `...`. +-- `table.pack` permet de les garder dans une table facile a parcourir. +local args = table.pack(...); + +local function printUsage() + print('carre usage:'); + print(); + print(' carre'); + print(' carre -size [-x ] [-y ] [-char ] [-fill] [-clear]'); + print(' carre -random [-count ] [-delay ] [-clear]'); + print(' carre --version'); + print(' carre --help'); + print(); + print('exemples:'); + print(' carre -size 10 -char * -clear'); + print(' carre -size 6 -fill -x 4 -y 3'); + print(' carre -random -count 5 -delay 0.2'); +end + +local command = args[1]; + +if command == '-help' or command == '--help' or command == 'help' then + printUsage(); + return; +end + +if command == '-version' or command == '--version' or command == 'version' then + print('v' .. createVersion().forSelf()); + return; +end + +local carre = createCarre(); +local config, err = carre.parseArgs(args); +if not config then + print(err); + print('utilise: carre -help'); + return; +end + +local width, height = term.getSize(); + +for index = 1, config.count do + if config.clear then + term.clear(); + end + + -- Le calcul choisit une position visible. Si rien n'est donne, + -- le carre est centre dans le terminal. + local square = carre.computeSquare(config, width, height); + + -- Le mode contour ecrit seulement les bords. Le mode plein ecrit partout. + carre.drawSquare(term.current(), square); + + if config.delay > 0 and index < config.count then + sleep(config.delay); + end +end + +term.setCursorPos(1, height); diff --git a/tests/carre.lua b/tests/carre.lua new file mode 100644 index 0000000..d7d00ae --- /dev/null +++ b/tests/carre.lua @@ -0,0 +1,122 @@ +local createLibTest = require('/apis/libtest'); +local createCarre = require('/apis/libcarre'); + +local testlib = createLibTest({ ... }); + +local function packed(...) + return table.pack(...); +end + +local function fakeTerm(width, height) + local buffer = {}; + local cursorX = 1; + local cursorY = 1; + + for y = 1, height do + buffer[y] = {}; + for x = 1, width do + buffer[y][x] = '.'; + end + end + + return { + setCursorPos = function(x, y) + cursorX = x; + cursorY = y; + end, + write = function(text) + text = tostring(text); + for i = 1, #text do + if buffer[cursorY] and buffer[cursorY][cursorX] then + buffer[cursorY][cursorX] = string.sub(text, i, i); + end + cursorX = cursorX + 1; + end + end, + line = function(y) + return table.concat(buffer[y], ''); + end, + }; +end + +testlib.test('parseArgs accepts basic drawing options', function() + local carre = createCarre(); + local config = carre.parseArgs(packed('-size', '5', '-x', '2', '-y', '3', '-char', '@', '-fill', '-clear')); + + testlib.assertEquals(config.size, 5); + testlib.assertEquals(config.x, 2); + testlib.assertEquals(config.y, 3); + testlib.assertEquals(config.char, '@'); + testlib.assertEquals(config.fill, true); + testlib.assertEquals(config.clear, true); +end); + +testlib.test('parseArgs rejects invalid sizes', function() + local carre = createCarre(); + local config, err = carre.parseArgs(packed('-size', '0')); + + testlib.assertEquals(config, nil); + testlib.assertTrue(string.find(err, 'entier positif', 1, true)); +end); + +testlib.test('computeSquare centers and clamps the square', function() + local carre = createCarre(); + local square = carre.computeSquare({ size = 4, char = '#', fill = false }, 10, 6); + + testlib.assertEquals(square.x, 4); + testlib.assertEquals(square.y, 2); + testlib.assertEquals(square.size, 4); + + square = carre.computeSquare({ size = 20, x = 99, y = 99, char = '#' }, 7, 5); + testlib.assertEquals(square.x, 3); + testlib.assertEquals(square.y, 1); + testlib.assertEquals(square.size, 5); +end); + +testlib.test('computeSquare random keeps explicit character', function() + local values = { 3, 2, 4 }; + local index = 0; + local carre = createCarre({ + random = function(minValue, maxValue) + index = index + 1; + return math.max(minValue, math.min(maxValue, values[index])); + end, + }); + + local square = carre.computeSquare({ + random = true, + char = '@', + explicit = { char = true }, + }, 10, 8); + + testlib.assertEquals(square.size, 3); + testlib.assertEquals(square.x, 2); + testlib.assertEquals(square.y, 4); + testlib.assertEquals(square.char, '@'); +end); + +testlib.test('drawSquare renders an outline', function() + local carre = createCarre(); + local termLib = fakeTerm(8, 6); + + carre.drawSquare(termLib, { x = 2, y = 2, size = 4, char = '#', fill = false }); + + testlib.assertEquals(termLib.line(1), '........'); + testlib.assertEquals(termLib.line(2), '.####...'); + testlib.assertEquals(termLib.line(3), '.# #...'); + testlib.assertEquals(termLib.line(4), '.# #...'); + testlib.assertEquals(termLib.line(5), '.####...'); +end); + +testlib.test('drawSquare renders a filled square', function() + local carre = createCarre(); + local termLib = fakeTerm(6, 5); + + carre.drawSquare(termLib, { x = 2, y = 2, size = 3, char = '*', fill = true }); + + testlib.assertEquals(termLib.line(2), '.***..'); + testlib.assertEquals(termLib.line(3), '.***..'); + testlib.assertEquals(termLib.line(4), '.***..'); +end); + +testlib.run();