diff --git a/evals/RESULTS.md b/evals/RESULTS.md index 0902492..55eaf42 100644 --- a/evals/RESULTS.md +++ b/evals/RESULTS.md @@ -198,3 +198,37 @@ Not changed: whether a server toast sent at `onPlayerReady` is queued or lost wh WebUI is still coming up (the agent waited 3 s by analogy with the identity guide); whether `tostring(integer id)` equals the event's string id is asserted in the seat card from the host's `ToString()` and not measured with a real vehicle. + +## RP round, 2026-09-17 03:50–04:40: five agents write a roleplay server's basics on 0.1.2 + +Five subagents, `@open2077/mcp@0.1.2` only, one resource each, with a shared contract for the +cross-resource exports (`rp-round/PROMPT-COMMON.md`): `rp_economy` (wallet, payday, exports), +`rp_jobs` (five jobs, courier mission with a spawned car, GPS waypoints and paid deliveries), +`rp_shop` (consumables, weapons through the `open77_weapons` relay, vehicles, resale), `rp_chat` +(`/me` `/do` `/ooc` `/w` `/dice` `/showid` with proximity audiences), `rp_medic` (paid heal / +revive gated by the job, `/911`, `/medic`). All five validated OK first pass, compile with the +server's own Lua (`--lint`), start on the eval server (op77 3ca1ca66, client 76) and answer every +command from the console; `rp-round/rp_selftest` exercises the exports across VMs (8/8). The +player-side paths wait for a client (the owner's was up all night). 40–49 MCP calls per agent. + +What the round found: + +- **Command names collide silently**: `/heal` belongs to `open77_admin` and `/revive` to + freeroam; a name registered by two resources is served by the first, with no error. The medic + verbs became `/soin` / `/reanimer`. Nothing in the MCP lists the names the platform already + owns — `open77_validate` should (from the resources the server dir loads). +- `open77_events` said `chat:ready` had no documented payload (events.json predated the chat.md + row; regenerated, base #28). `Open77.time.monotonic` / `runtime.luaVersion` / `state.clear` + carried ~50 unrelated reasons (one-line table members read with the indentation reader; + fixed). The server-exports guide contradicted the `TriggerEvent` card (VM-local vs host bus; + fixed). Six examples passed a host-event string id to `chat.send` (fixed). +- `open77_data` never named its `catalogue` argument (two agents lost a call); it does now, and + says which vehicle records are player-spawnable. +- No server card for the `exports` registration global (guide prose only); `players.get().ready` + and `players.all()` element type undocumented (integers, measured); the `parameters` shape of + chat suggestions was a FiveM guess (now documented); `players.stats.read` / + `players.life.read` say "enforced by: client" while gating server natives. +- `since` moved on thirty client cards when the surface history was rebuilt after a reader + change: main had been fast-forwarded onto a branch that merged main, and the `--first-parent` + walk no longer resolved release dates to the commits the builds were cut from. Release commits + are pinned in the history file now (base #28). diff --git a/evals/rp-round/PROMPT-COMMON.md b/evals/rp-round/PROMPT-COMMON.md new file mode 100644 index 0000000..40d7576 --- /dev/null +++ b/evals/rp-round/PROMPT-COMMON.md @@ -0,0 +1,77 @@ +# Common rules for the RP resource round (read fully before starting) + +You are writing ONE Lua resource for the Open77 platform (a Cyberpunk 2077 multiplayer +framework, FiveM-like: manifest `open77.lua`, `server_script` / `client_script`, natives under +`Open77.*` plus FiveM-shaped globals). Your ONLY source of truth is the Open77 Devkit MCP, +driven through this harness (one call per invocation, run from any directory): + + node "C:/Users/Wodman/AppData/Local/Temp/claude/C--Games-cyberm/47b3b407-f916-4dcf-a69e-303889c762ee/scratchpad/devkit-eval2/mcp-call-012-76.mjs" "C:/Games/cyberm/rp-scripts" '' + node ... "C:/Games/cyberm/rp-scripts" list # the tool list + node ... "C:/Games/cyberm/rp-scripts" open77_search '{"query":"give money to a player"}' + node ... "C:/Games/cyberm/rp-scripts" open77_api '{"name":"Open77.players.name"}' + node ... "C:/Games/cyberm/rp-scripts" open77_guide '{"slug":"server-exports"}' + node ... "C:/Games/cyberm/rp-scripts" open77_validate '{"resource":"C:/Games/cyberm/rp-scripts/"}' + +Pass JSON args on the command line in single quotes; for arguments containing quotes, write +the JSON to a file and pass `@C:/path/args.json` instead. Each call takes 5-15 s (npx start). +The MCP answers for server build **2.31.13+op77.76**; that is the build the resource runs on. + +## Hard rules + +1. Do NOT read any file under `C:\Games\cyberm` other than your own resource directory. No + web search. Do not start, stop or talk to any server or game process. Do not run git. +2. Start with `list`, then `open77_skill` if present (or `open77_guide README`), then search. + Read the card (`open77_api`) of EVERY native you call: permission, reasons, since, runtime + side. Declare in the manifest exactly the permissions the cards require. A native the MCP + says is unavailable on op77.76 must not be used. +3. Server-authoritative design: the server decides money, jobs, spawns; a client only renders + and requests. Every native that returns `nil, reason` / `false, reason` is checked and the + player is told why in chat (French, short). Player-facing text in **French**, code comments + in English, no BOM, LF line endings, 4-space indent, Lua 5.4. +4. Persistence: use the KVP API the MCP documents (`Open77.kvp` or the server equivalent), + keyed by the player's stable identifier (find the native that gives a stable identity, not + the session id). Nothing may crash on a missing key or a departed player. +5. Host lifecycle events (`onPlayerReady`, `onPlayerDisconnected`, ...) deliver every argument + as a STRING; convert with `tonumber` before handing an id to a native that wants a number + (`Open77.chat.send` refuses strings). Inside `RegisterCommand` / `RegisterNetEvent` + handlers, `source` is already a number (0 = server console: refuse politely). +6. `Open77.chat.*` and `TriggerEvent` are refused with `resource_preparing` at the top level of + a script: put them in `AddEventHandler("onResourceStart", ...)` (check the name is your + own), commands or event handlers. Publish chat command suggestions on `chat:ready` + (`RegisterNetEvent`, `source` is the player, no arguments) and once from `onResourceStart` + with `-1`. +7. Sandbox: no `os`, `io`, `require`, `load`, `debug`. Wall clock: `Open77.time.unix()`; + elapsed: `Open77.time.monotonic()`; `math.random` is available. +8. Cross-resource calls: server exports, called synchronously as + `exports.:(...)` (read `open77_guide server-exports` for the exact form, + the promise/await rules and `GetInvokingResource`). Declare `dependency ""` in + the manifest for every resource whose exports you call. If the export is missing at run + time, degrade gracefully (message in chat), never crash. +9. Run `open77_validate` on your resource until it reports OK with 0 errors; keep warnings + only when you can explain them. Then break it once on purpose (wrong-side native, unknown + native, missing permission) to confirm the validator catches it, and restore. +10. Final deliverable, written under `C:\Games\cyberm\rp-scripts\\`: + - `open77.lua`, `server/main.lua`, optionally `client/main.lua`, optionally `README.md` + (French: what each command does, how to test it in 2 minutes). + Then hand back a report with: (a) the command list and what each does, (b) every MCP + call in order (tool + args + one word on usefulness), (c) where the MCP was wrong, + ambiguous, missing or made you guess, (d) the final `open77_validate` output verbatim, + (e) the final file contents. Report only; do not test on a server. + +## Shared contracts between the resources of this round (respect the names exactly) + +- `rp_economy` publishes server exports: + - `getBalance(playerId) -> integer` (0 when unknown) + - `add(playerId, amount, reason) -> newBalance | nil, reason` + - `remove(playerId, amount, reason) -> newBalance | nil, "insufficient_funds" | nil, reason` + and raises the host-wide event `rp_economy:changed` (playerId, newBalance, delta, reason) + through `TriggerEvent` after every change. +- `rp_jobs` publishes server exports: + - `getJob(playerId) -> jobName | nil` + - `hasJob(playerId, jobName) -> boolean` + and raises `rp_jobs:changed` (playerId, jobName | nil) on every change. Job names are + lower-case ASCII: `livreur`, `taxi`, `mecano`, `medecin`, `police`. +- Every command name is lower-case, without a prefix, and must not collide with these, + which other resources of the round own: `money`, `pay`, `givemoney`, `payday` (economy); + `jobs`, `job`, `mission`, `stopmission` (jobs); `shop`, `buy`, `sell` (shop); `me`, `do`, + `ooc`, `w`, `showid`, `dice` (chat); `heal`, `revive`, `911`, `medic` (medic). diff --git a/evals/rp-round/rp_chat/README.md b/evals/rp-round/rp_chat/README.md new file mode 100644 index 0000000..227b47c --- /dev/null +++ b/evals/rp-round/rp_chat/README.md @@ -0,0 +1,71 @@ +# rp_chat — commandes de chat roleplay + +Ressource **serveur uniquement** pour Open77 (build `2.31.13+op77.76`). Elle ajoute les +commandes de chat classiques d'un serveur RP : le serveur décide de l'audience (proximité), +lit les noms, les emplois et les soldes, et pousse les lignes par `Open77.chat`. + +## Commandes + +| Commande | Effet | Audience | +|---|---|---| +| `/me ` | `* ` en violet | tous les joueurs à **30 m** (vous compris) | +| `/do ` | `** (())` en violet clair | 30 m | +| `/ooc ` | `(( OOC ) : ` en gris | **tout le serveur** | +| `/w ` | murmure : la cible voit `(murmure) : `, vous voyez `(murmure à ) : `, en gris sombre | vous et la cible, qui doit être à **10 m** | +| `/dice [faces]` | ` lance un dé (6) : 4` en doré ; 6 faces par défaut, 2 à 1000 | 30 m | +| `/showid [playerId]` | carte d'identité : nom, emploi, et **votre solde uniquement sur votre propre carte** | vous seul ; la cible doit être à **5 m** | + +Les `playerId` sont les identifiants de session (`/id` dans le chat). Le nom vient de +`Open77.players.name`. Toute commande lancée depuis la console serveur est refusée +poliment (journal seulement) : elles exigent un personnage dans le monde. + +Chaque refus est expliqué au joueur en français : joueur introuvable, trop loin +(distance affichée), position inconnue, message vide ou trop long (512 octets), nombre +de faces invalide, etc. + +## Sources des données + +- **Audience de proximité** : `Open77.players.nearby(joueur, rayon, { includeSelf = true })` + (documenté par le MCP, disponible depuis op77.67). Il respecte le bucket de routage du + joueur. Si la position du joueur est inconnue (pas encore dans le monde), le message + n'est pas envoyé et le joueur en est informé — il n'y a pas de repli vers « tout le + monde », qui serait un faux positif en RP. +- **Distance** (`/w`, `/showid`) : `Open77.players.distance(a, b)`, en 3D. +- **Emploi** : `exports.rp_jobs:getJob(playerId)` ; `sans emploi` quand l'export manque, + échoue ou rend `nil`. +- **Solde** : `exports.rp_economy:getBalance(playerId)` ; `indisponible` quand l'export + manque ou échoue. Les exports synchrones lèvent une erreur en cas d'échec : chaque + appel est enveloppé dans `pcall`, rien ne plante. +- **Carte d'identité** : envoyée en **notification** (`Open77.notifications.send`) quand + la native existe **et** que la ressource `open77_notifications` est `running` + (`Open77.resource.state`) ; sinon, ou si l'envoi échoue, en lignes de chat (une par + tick pour garder l'ordre). Le manifeste ne déclare pas `open77_notifications` en + dépendance dure : sans le paquet, les commandes de chat restent disponibles et la + carte s'affiche dans le chat. + +## Manifeste + +- `permissions { "network.events" }` : exigé par `RegisterNetEvent("chat:ready")` et + `Open77.notifications.send`. +- `dependency "rp_jobs"`, `dependency "rp_economy"` : les deux ressources dont les + exports sont appelés (ordre de démarrage ; l'absence d'un export reste gérée). +- Aucune persistance : cette ressource ne stocke rien, le KVP n'est pas nécessaire. + +## Tester en deux minutes + +1. Déposer `rp_chat/` dans la racine des ressources, avec `rp_jobs` et `rp_economy`. + Démarrer le serveur ; le journal affiche + `[rp_chat] started: /me /do /ooc /w /dice /showid (notifications: toast|chat fallback)`. +2. Se connecter avec **deux clients** (A et B), noter leurs `/id`. +3. A et B côte à côte : `/me regarde autour de lui` → les deux voient + `* A regarde autour de lui`. `/do Il pleut.` → `** Il pleut. ((A))`. +4. B s'éloigne à plus de 30 m : `/me tousse` chez A n'atteint plus B (A le voit toujours). +5. `/ooc salut` → tout le serveur voit `(( OOC ) A: salut` en gris. +6. À moins de 10 m : `/w psst` → B voit `(murmure) A : psst`, A voit + `(murmure à B) A : psst`. À plus de 10 m : `Trop loin : B est à 14 m (10 m maximum).` +7. `/dice` → `A lance un dé (6) : n` ; `/dice 20` → `(20)` ; `/dice 1` → refus. +8. `/showid` → carte avec nom, emploi et solde. `/showid ` à moins de 5 m → carte de + B **sans** solde ; à plus de 5 m → refus avec la distance. +9. Depuis la console serveur, `me test` → rien dans le chat, une ligne de refus dans le + journal. +10. Ouvrir le chat et taper `/` : les six commandes apparaissent dans la complétion. diff --git a/evals/rp-round/rp_chat/open77.lua b/evals/rp-round/rp_chat/open77.lua new file mode 100644 index 0000000..1c55c9d --- /dev/null +++ b/evals/rp-round/rp_chat/open77.lua @@ -0,0 +1,12 @@ +resource "rp_chat" +version "1.0.0" +auto_start true + +-- network.events: RegisterNetEvent("chat:ready") and Open77.notifications.send +permissions { "network.events" } + +-- Server exports this resource calls (exports.rp_jobs:getJob, exports.rp_economy:getBalance) +dependency "rp_jobs" +dependency "rp_economy" + +server_script "server/main.lua" diff --git a/evals/rp-round/rp_chat/server/main.lua b/evals/rp-round/rp_chat/server/main.lua new file mode 100644 index 0000000..486ffd8 --- /dev/null +++ b/evals/rp-round/rp_chat/server/main.lua @@ -0,0 +1,413 @@ +-- rp_chat: roleplay chat commands (/me, /do, /ooc, /w, /dice, /showid). +-- Server-only. The server decides the audience (proximity), reads names, jobs +-- and balances, and pushes chat lines through the Open77.chat facade. + +local RESOURCE = GetCurrentResourceName() + +-- Radii in metres. +local RADIUS_ME = 30.0 +local RADIUS_DO = 30.0 +local RADIUS_DICE = 30.0 +local RADIUS_WHISPER = 10.0 +local RADIUS_SHOWID = 5.0 + +-- Text limit (bytes). Chat accepts up to 4,096; RP lines are kept short. +local MAX_TEXT_BYTES = 512 + +-- Dice bounds. +local DICE_DEFAULT = 6 +local DICE_MIN = 2 +local DICE_MAX = 1000 + +-- Colours: positional { r, g, b } arrays, never keyed tables (the chat UI +-- silently ignores a keyed { r = ..., g = ..., b = ... } table). +local COLOR_ME = { 194, 122, 255 } +local COLOR_DO = { 170, 140, 255 } +local COLOR_OOC = { 160, 160, 160 } +local COLOR_WHISPER = { 120, 120, 140 } +local COLOR_DICE = { 255, 200, 80 } +local COLOR_INFO = { 0, 229, 255 } +local COLOR_ERROR = { 255, 96, 96 } + +-- Message formats (player-facing, French). +local FORMAT_ME = "* %s %s" +local FORMAT_DO = "** %s ((%s))" +local FORMAT_OOC = "(( OOC ) %s: %s" +local FORMAT_WHISPER_TO_TARGET = "(murmure) %s : %s" +local FORMAT_WHISPER_TO_SENDER = "(murmure à %s) %s : %s" +local FORMAT_DICE = "%s lance un dé (%d) : %d" + +-- Slash-command suggestions published on chat:ready and on start. +local SUGGESTIONS = { + { command = "/me", help = "Décrit une action de votre personnage (30 m)", + parameters = { { name = "action", help = "L'action jouée" } } }, + { command = "/do", help = "Décrit une situation ou l'environnement (30 m)", + parameters = { { name = "description", help = "Ce que les autres perçoivent" } } }, + { command = "/ooc", help = "Message hors-jeu, visible par tout le serveur", + parameters = { { name = "texte", help = "Votre message" } } }, + { command = "/w", help = "Murmure à un joueur proche (10 m)", + parameters = { { name = "playerId", help = "Identifiant du joueur (/id)" }, + { name = "texte", help = "Votre message" } } }, + { command = "/dice", help = "Lance un dé (6 faces par défaut, 30 m)", + parameters = { { name = "faces", help = "Nombre de faces, optionnel (2 à 1000)" } } }, + { command = "/showid", help = "Montre votre carte d'identité, ou celle d'un joueur proche (5 m)", + parameters = { { name = "playerId", help = "Joueur ciblé, optionnel" } } }, +} + +-- French wording for the reasons the natives return. +local REASON_TEXT = { + player_not_found = "Joueur introuvable.", + invalid_player_id = "Identifiant de joueur invalide.", + invalid_argument = "Identifiant de joueur invalide.", + position_unknown = "Position inconnue : le joueur n'est pas encore dans le monde.", + sessions_unavailable = "Sessions joueurs indisponibles pour le moment.", + invalid_chat_target = "Destinataire de chat invalide.", + invalid_chat_message = "Message de chat invalide.", + event_queue_limit = "File de messages saturée, réessayez.", + resource_preparing = "La ressource n'est pas encore prête.", + resource_stopping = "La ressource s'arrête.", +} + +local function reasonText(reason) + return REASON_TEXT[reason] or ("Erreur : " .. tostring(reason)) +end + +-- Log helper: every line carries the resource name. +local function log(fmt, ...) + print(("[%s] " .. fmt):format(RESOURCE, ...)) +end + +-- One chat line to one player (or -1 for everyone), in the documented table shape. +local function sendLine(target, author, text, color) + local ok, reason = Open77.chat.send(target, { + type = "system", + author = author, + text = text, + color = color, + }) + if not ok then + log("chat.send to %s failed: %s", tostring(target), tostring(reason)) + end + return ok, reason +end + +-- A short error or notice to the caller only. +local function tell(source, text, color) + return sendLine(source, "RP", text, color or COLOR_ERROR) +end + +-- Refuses the console politely; commands here need a body in the world. +local function requirePlayer(source, command) + if type(source) ~= "number" or source <= 0 then + log("/%s refused: run it from the game, not the console", command) + return false + end + return true +end + +-- The caller's display name, or a fallback that never crashes. +local function nameOf(playerId) + local name = Open77.players.name(playerId) + if type(name) ~= "string" or name == "" then + return ("Joueur %s"):format(tostring(playerId)) + end + return name +end + +-- Joins the command arguments from index `from`, trimmed; nil when empty. +local function joinArgs(args, from) + local parts = {} + for i = from, (args.n or #args) do + local value = args[i] + if value ~= nil then + parts[#parts + 1] = tostring(value) + end + end + local text = table.concat(parts, " ") + text = text:gsub("^%s+", ""):gsub("%s+$", "") + if text == "" then + return nil + end + return text +end + +-- Validates a free-text argument; tells the caller why when it is refused. +local function requireText(source, text, usage) + if not text then + tell(source, "Usage : " .. usage) + return false + end + if #text > MAX_TEXT_BYTES then + tell(source, ("Message trop long (%d octets maximum)."):format(MAX_TEXT_BYTES)) + return false + end + return true +end + +-- Parses a player id argument (a string from chat) into a positive integer. +local function parsePlayerId(raw) + if raw == nil then + return nil + end + local id = tonumber(raw) + if not id or id <= 0 or id % 1 ~= 0 then + return nil + end + return math.tointeger(id) or id +end + +-- Sends one line to every player within `radius` of the caller, caller included. +-- Returns the number of recipients, or nil, reason when the audience is unknown. +local function sendNearby(source, radius, author, text, color) + local entries, reason = Open77.players.nearby(source, radius, { includeSelf = true }) + if not entries then + return nil, reason + end + local delivered = 0 + for _, entry in ipairs(entries) do + local playerId = tonumber(entry.playerId) or entry.playerId + if sendLine(playerId, author, text, color) then + delivered = delivered + 1 + end + end + return delivered +end + +-- A proximity command's shared tail: send, or explain why nobody heard it. +local function announceNearby(source, command, radius, author, text, color) + local delivered, reason = sendNearby(source, radius, author, text, color) + if not delivered then + tell(source, "Message non envoyé. " .. reasonText(reason)) + log("/%s by %d not delivered: %s", command, source, tostring(reason)) + return + end + log("/%s by %d -> %d recipient(s): %s", command, source, delivered, text) +end + +-- Distance check between two players; tells the caller why when it fails. +local function withinRange(source, target, radius) + local metres, reason = Open77.players.distance(source, target) + if not metres then + tell(source, reasonText(reason)) + return false + end + if metres > radius then + tell(source, ("Trop loin : %s est à %.0f m (%.0f m maximum)."):format( + nameOf(target), metres, radius)) + return false + end + return true +end + +-- Cross-resource reads. Synchronous exports raise on failure (missing +-- resource, missing export, callee error), so every call is wrapped in pcall +-- and a failure degrades to "unknown" instead of crashing the command. +local function getJob(playerId) + local ok, job = pcall(function() + return exports.rp_jobs:getJob(playerId) + end) + if not ok then + log("rp_jobs:getJob unavailable: %s", tostring(job)) + return nil + end + if type(job) ~= "string" or job == "" then + return nil + end + return job +end + +local function getBalance(playerId) + local ok, balance = pcall(function() + return exports.rp_economy:getBalance(playerId) + end) + if not ok then + log("rp_economy:getBalance unavailable: %s", tostring(balance)) + return nil + end + if type(balance) ~= "number" or balance ~= balance or math.abs(balance) == math.huge then + return nil + end + return math.floor(balance) +end + +-- Whether the server notifications API can actually reach a client: the +-- native must exist and the official package must be running, otherwise a +-- toast is dropped without a word and the card falls back to chat. +local function notificationsAvailable() + if type(Open77.notifications) ~= "table" or type(Open77.notifications.send) ~= "function" then + return false + end + if type(Open77.resource) == "table" and type(Open77.resource.state) == "function" then + return Open77.resource.state("open77_notifications") == "running" + end + return true +end + +-- Builds the identity card lines. The balance is only shown on the owner's card. +local function buildCard(viewer, target) + local lines = { + "Nom : " .. nameOf(target), + "Emploi : " .. (getJob(target) or "sans emploi"), + } + if viewer == target then + local balance = getBalance(target) + if balance then + lines[#lines + 1] = ("Solde : %d €$"):format(balance) + else + lines[#lines + 1] = "Solde : indisponible" + end + end + return lines +end + +-- Delivers a card: a toast when the notifications API exists, else chat lines. +local function deliverCard(viewer, target, lines) + local title = "Carte d'identité" + if viewer ~= target then + title = title .. " de " .. nameOf(target) + end + if notificationsAvailable() then + local id, reason = Open77.notifications.send(viewer, { + type = "info", + title = title, + message = table.concat(lines, " | "), + icon = "ID", + durationMs = 8000, + }) + if id then + return "notification" + end + log("notifications.send to %d failed (%s), falling back to chat", viewer, tostring(reason)) + end + -- Chat: one line per tick so they read in order (same-tick sends arrive reversed). + sendLine(viewer, "ID", "--- " .. title .. " ---", COLOR_INFO) + for _, line in ipairs(lines) do + Wait(0) + sendLine(viewer, "ID", line, COLOR_INFO) + end + return "chat" +end + +-- /me +RegisterCommand("me", function(source, args) + if not requirePlayer(source, "me") then return end + local action = joinArgs(args, 1) + if not requireText(source, action, "/me ") then return end + local text = FORMAT_ME:format(nameOf(source), action) + announceNearby(source, "me", RADIUS_ME, "RP", text, COLOR_ME) +end, false) + +-- /do +RegisterCommand("do", function(source, args) + if not requirePlayer(source, "do") then return end + local description = joinArgs(args, 1) + if not requireText(source, description, "/do ") then return end + local text = FORMAT_DO:format(description, nameOf(source)) + announceNearby(source, "do", RADIUS_DO, "RP", text, COLOR_DO) +end, false) + +-- /ooc +RegisterCommand("ooc", function(source, args) + if not requirePlayer(source, "ooc") then return end + local message = joinArgs(args, 1) + if not requireText(source, message, "/ooc ") then return end + local text = FORMAT_OOC:format(nameOf(source), message) + local ok, reason = sendLine(-1, "OOC", text, COLOR_OOC) + if not ok then + tell(source, "Message non envoyé. " .. reasonText(reason)) + return + end + log("/ooc by %d: %s", source, message) +end, false) + +-- /w +RegisterCommand("w", function(source, args) + if not requirePlayer(source, "w") then return end + local target = parsePlayerId(args[1]) + if not target then + tell(source, "Usage : /w ") + return + end + local message = joinArgs(args, 2) + if not requireText(source, message, "/w ") then return end + if target == source then + tell(source, "Vous ne pouvez pas vous murmurer à vous-même.") + return + end + if not Open77.players.name(target) then + tell(source, reasonText("player_not_found")) + return + end + if not withinRange(source, target, RADIUS_WHISPER) then return end + + local senderName, targetName = nameOf(source), nameOf(target) + local ok, reason = sendLine(target, "Murmure", + FORMAT_WHISPER_TO_TARGET:format(senderName, message), COLOR_WHISPER) + if not ok then + tell(source, "Murmure non envoyé. " .. reasonText(reason)) + return + end + sendLine(source, "Murmure", + FORMAT_WHISPER_TO_SENDER:format(targetName, senderName, message), COLOR_WHISPER) + log("/w %d -> %d: %s", source, target, message) +end, false) + +-- /dice [faces] +RegisterCommand("dice", function(source, args) + if not requirePlayer(source, "dice") then return end + local faces = DICE_DEFAULT + if args.n and args.n >= 1 and args[1] ~= nil then + local wanted = tonumber(args[1]) + if not wanted or wanted % 1 ~= 0 or wanted < DICE_MIN or wanted > DICE_MAX then + tell(source, ("Nombre de faces invalide (%d à %d)."):format(DICE_MIN, DICE_MAX)) + return + end + faces = math.tointeger(wanted) or DICE_DEFAULT + end + local roll = math.random(1, faces) + local text = FORMAT_DICE:format(nameOf(source), faces, roll) + announceNearby(source, "dice", RADIUS_DICE, "Dé", text, COLOR_DICE) +end, false) + +-- /showid [playerId] +RegisterCommand("showid", function(source, args) + if not requirePlayer(source, "showid") then return end + local target = source + if args.n and args.n >= 1 and args[1] ~= nil then + target = parsePlayerId(args[1]) + if not target then + tell(source, "Usage : /showid [playerId]") + return + end + if target ~= source then + if not Open77.players.name(target) then + tell(source, reasonText("player_not_found")) + return + end + if not withinRange(source, target, RADIUS_SHOWID) then return end + end + end + local lines = buildCard(source, target) + local channel = deliverCard(source, target, lines) + log("/showid by %d for %d via %s", source, target, channel) +end, false) + +-- Suggestions: per player when their chat comes up, and once for everyone on start. +RegisterNetEvent("chat:ready", function() + -- Bus events can reach this handler too: only answer an authenticated player. + if type(source) ~= "number" or source <= 0 then return end + local ok, reason = Open77.chat.addSuggestions(source, SUGGESTIONS) + if not ok then + log("addSuggestions for %d failed: %s", source, tostring(reason)) + end +end) + +AddEventHandler("onResourceStart", function(name) + if name ~= RESOURCE then return end + local ok, reason = Open77.chat.addSuggestions(-1, SUGGESTIONS) + if not ok then + log("addSuggestions for everyone failed: %s", tostring(reason)) + end + log("started: /me /do /ooc /w /dice /showid (notifications: %s)", + notificationsAvailable() and "toast" or "chat fallback") +end) diff --git a/evals/rp-round/rp_economy/README.md b/evals/rp-round/rp_economy/README.md new file mode 100644 index 0000000..c49e847 --- /dev/null +++ b/evals/rp-round/rp_economy/README.md @@ -0,0 +1,68 @@ +# rp_economy — portefeuille serveur (eurodollars) + +Ressource **serveur uniquement** pour Open77 (build `2.31.13+op77.76`). Chaque joueur possède un +solde entier en €$, rattaché à son identifiant durable (`Open77.players.identifier`), chargé +quand le joueur est prêt (`onPlayerReady`) et sauvegardé dans le KVP de la ressource à chaque +changement. Un nouveau joueur démarre avec **500 €$**. + +## Commandes + +| Commande | Qui | Effet | +|---|---|---| +| `/money` | tout joueur | Affiche le solde dans le chat (et une notification si `open77_notifications` tourne). | +| `/pay ` | tout joueur | Envoie `montant` €$ à un joueur connecté et prêt. Refusé si : montant non entier positif, fonds insuffisants, cible hors ligne, ou soi-même. Les deux joueurs sont prévenus. | +| `/givemoney ` | admin (ACL `command.givemoney`) ou console | Crédite un joueur. Depuis la console seul le joueur crédité est prévenu. | +| `/payday` | admin (ACL `command.payday`) ou console | Déclenche une paie immédiate pour tous les joueurs prêts. | + +**Paie automatique** : toutes les 10 minutes, chaque joueur connecté et prêt reçoit 200 €$ avec la +ligne de chat `Paie : +200 €$`. + +Les commandes restreintes utilisent `RegisterCommand(..., true)` : un joueur sans le droit ACL est +refusé par le serveur avant le handler (la réponse arrive dans le terminal Open77, pas dans le chat). +Pour autoriser un admin, ajouter `command.givemoney` et `command.payday` (ou `command.*`) à son +principal dans `acl.jsonc`, puis `acl.reload`. + +## Exports serveur (contrat partagé) + +```lua +exports.rp_economy:getBalance(playerId) -- integer, 0 si inconnu +exports.rp_economy:add(playerId, amount, reason) -- newBalance | nil, reason +exports.rp_economy:remove(playerId, amount, reason) -- newBalance | nil, "insufficient_funds" | nil, reason +``` + +Validation : `playerId` entier positif d'un joueur **chargé** (sinon `invalid_player_id` / +`player_not_found`), `amount` entier positif (`invalid_amount`), `reason` chaîne de 1 à 64 octets +sans caractère de contrôle, ou `nil` (`invalid_reason`). `balance_limit` si le solde dépasserait +10^12. Après chaque changement : + +```lua +TriggerEvent("rp_economy:changed", playerId, newBalance, delta, reason) +``` + +Les exports sont synchrones et ne yieldent jamais : l'appel `exports.rp_economy:add(...)` est sûr. +Déclarer `dependency "rp_economy"` dans le manifeste de la ressource appelante. + +## Journal + +Chaque changement est imprimé sous une forme grep-able : + +``` +[rp_economy] +200 player 3 payday balance=700 +[rp_economy] -50 player 3 pay:to:4 balance=650 +[rp_economy] +50 player 4 pay:from:3 balance=550 +[rp_economy] +1000 player 4 givemoney:by:0 balance=1550 +``` + +## Tester en 2 minutes + +1. Placer le dossier dans la racine des ressources du serveur (`auto_start true`), démarrer. +2. Se connecter avec un client : à l'arrivée en jeu le chat affiche `Solde : 500 €$`, et le log + serveur `[rp_economy] new wallet player balance=500`. +3. `/money` → `Solde : 500 €$` (+ toast si les notifications tournent). +4. Console serveur : `givemoney 1000` → le joueur voit `Un administrateur t'a crédité de + 1000 €$. Solde : 1500 €$`. +5. Console serveur : `payday` → le joueur voit `Paie : +200 €$`, log `+200 player payday`. +6. Avec un second client (`id2`) : `/pay id2 300` → les deux joueurs voient la ligne de transfert ; + `/pay id2 999999` → `Fonds insuffisants` ; `/pay 10` → refusé. +7. Se déconnecter puis revenir : le solde est conservé (`[rp_economy] loaded player ...`). +8. Depuis une autre ressource : `print(exports.rp_economy:getBalance(id))`. diff --git a/evals/rp-round/rp_economy/open77.lua b/evals/rp-round/rp_economy/open77.lua new file mode 100644 index 0000000..9c0e8a1 --- /dev/null +++ b/evals/rp-round/rp_economy/open77.lua @@ -0,0 +1,12 @@ +-- rp_economy: server-authoritative wallet for an RP server. +-- Server only: no client script, every decision and every message comes from the server. +resource "rp_economy" +version "1.0.0" +open77_version "*" +auto_start true + +-- network.events: required by RegisterNetEvent (the chat:ready suggestion hook) +-- and by Open77.notifications.send (the toast that accompanies /money). +permissions { "network.events" } + +server_script "server/main.lua" diff --git a/evals/rp-round/rp_economy/server/main.lua b/evals/rp-round/rp_economy/server/main.lua new file mode 100644 index 0000000..6de7825 --- /dev/null +++ b/evals/rp-round/rp_economy/server/main.lua @@ -0,0 +1,518 @@ +-- rp_economy — server-authoritative wallet (eurodollars) for an RP server. +-- +-- One integer balance per player, keyed by the durable identifier +-- (Open77.players.identifier), persisted in this resource's KVP store on every +-- change. Loaded when the player is ready, dropped from memory when they leave. +-- +-- Exports (contract shared with the other resources of the round): +-- getBalance(playerId) -> integer (0 when unknown) +-- add(playerId, amount, reason) -> newBalance | nil, reason +-- remove(playerId, amount, reason) -> newBalance | nil, "insufficient_funds" | nil, reason +-- Every change publishes TriggerEvent("rp_economy:changed", playerId, newBalance, delta, reason). + +local RESOURCE = GetCurrentResourceName() + +local START_BALANCE = 500 +local PAYDAY_AMOUNT = 200 +local PAYDAY_INTERVAL_MS = 10 * 60 * 1000 +local MAX_BALANCE = 1000000000000 -- 1e12: keeps every balance an exact JSON integer +local MAX_REASON_BYTES = 64 +local KEY_PREFIX = "balance:" +local CURRENCY = "€$" + +-- Live state. Both tables are keyed by the numeric session id and only hold +-- players whose wallet was actually loaded (identifier known, store readable). +local wallets = {} -- [playerId] = integer balance +local identifiers = {} -- [playerId] = durable userId, cached at load time + +local paydayThreadStarted = false + +------------------------------------------------------------------------------ +-- Helpers +------------------------------------------------------------------------------ + +local function log(text) + print("[" .. RESOURCE .. "] " .. text) +end + +-- Reason as it appears in the log: one grep-able token, never empty. +local function logToken(reason) + if reason == nil then + return "unspecified" + end + return (tostring(reason):gsub("%s+", "_")) +end + +local function formatMoney(amount) + return ("%d %s"):format(amount, CURRENCY) +end + +-- tonumber that tolerates nil (tonumber(nil) raises in Lua 5.4). +local function asNumber(value) + if value == nil then + return nil + end + return tonumber(value) +end + +-- A positive integer (integer-valued floats such as 5.0 are accepted), bounded. +local function toPositiveInteger(value, maximum) + if type(value) ~= "number" then + return nil + end + if value ~= value or value <= 0 or value % 1 ~= 0 then + return nil + end + local integer = math.tointeger(value) + if not integer or integer > maximum then + return nil + end + return integer +end + +-- Chat line to one player; player ids from host events are strings, so always +-- convert. Failures are logged, never raised: chat is a courtesy, not authority. +local function tell(playerId, text) + local id = asNumber(playerId) + if not id or id <= 0 then + return + end + local ok, reason = Open77.chat.send(id, text) + if not ok then + log(("chat.send refused for player %d: %s"):format(id, tostring(reason))) + end +end + +-- Toast, only when the notifications API exists on this server build. +local function notify(playerId, definition) + local id = asNumber(playerId) + if not id or id <= 0 then + return + end + if type(Open77.notifications) ~= "table" or type(Open77.notifications.send) ~= "function" then + return + end + local notificationId, reason = Open77.notifications.send(id, definition) + if not notificationId then + log(("notifications.send refused for player %d: %s"):format(id, tostring(reason))) + end +end + +-- Connected AND ready according to the host roster. +local function isPlayerReady(playerId) + local read = Open77.players.get(playerId) + return read ~= nil and read.ready == true +end + +------------------------------------------------------------------------------ +-- Persistence +------------------------------------------------------------------------------ + +local function storageKey(userId) + return KEY_PREFIX .. userId +end + +local function persist(playerId) + local userId = identifiers[playerId] + local balance = wallets[playerId] + if not userId or balance == nil then + return false + end + local ok, reason = Open77.kvp.set(storageKey(userId), balance) + if not ok then + log(("kvp.set failed for player %d (%s): %s"):format(playerId, userId, tostring(reason))) + return false + end + return true +end + +-- Loads (or creates) the wallet of a connected player. Returns the balance, +-- or nil, reason when the player cannot take part in the economy. +local function loadWallet(playerId) + if wallets[playerId] ~= nil then + return wallets[playerId] + end + local userId = Open77.players.identifier(playerId) + if type(userId) ~= "string" or userId == "" then + log(("no durable identifier for player %d, wallet not loaded"):format(playerId)) + return nil, "identifier_unavailable" + end + + local key = storageKey(userId) + local stored, reason = Open77.kvp.get(key) + if stored == nil and reason ~= nil then + -- A storage failure, not a missing key: never invent a balance on top of it. + log(("kvp.get failed for player %d (%s): %s"):format(playerId, userId, tostring(reason))) + return nil, "storage_unavailable" + end + + local balance + if stored == nil then + balance = START_BALANCE + else + balance = math.tointeger(stored) + if balance == nil or balance < 0 then + log(("corrupt balance for player %d (%s): %s, reset to 0"):format(playerId, userId, tostring(stored))) + balance = 0 + end + end + + identifiers[playerId] = userId + wallets[playerId] = balance + if stored == nil then + persist(playerId) + log(("new wallet player %d %s balance=%d"):format(playerId, userId, balance)) + else + log(("loaded player %d %s balance=%d"):format(playerId, userId, balance)) + end + return balance +end + +local function unloadWallet(playerId) + if wallets[playerId] ~= nil then + persist(playerId) + end + wallets[playerId] = nil + identifiers[playerId] = nil +end + +------------------------------------------------------------------------------ +-- Core ledger operation +------------------------------------------------------------------------------ + +-- Applies a signed delta to a loaded wallet. Validation of playerId/amount/reason +-- is the caller's job; this only enforces the balance bounds, persists, logs +-- and publishes the change. +local function applyDelta(playerId, delta, reason) + local balance = wallets[playerId] + if balance == nil then + return nil, "player_not_found" + end + local newBalance = balance + delta + if newBalance < 0 then + return nil, "insufficient_funds" + end + if newBalance > MAX_BALANCE then + return nil, "balance_limit" + end + + wallets[playerId] = newBalance + persist(playerId) + + log(("%+d player %d %s balance=%d"):format(delta, playerId, logToken(reason), newBalance)) + + local ok, publishReason = TriggerEvent("rp_economy:changed", playerId, newBalance, delta, reason) + if not ok then + log(("rp_economy:changed not published: %s"):format(tostring(publishReason))) + end + return newBalance +end + +------------------------------------------------------------------------------ +-- Exports (validate everything: an export is never proof of anything) +------------------------------------------------------------------------------ + +local function validatePlayerId(playerId) + local id = toPositiveInteger(playerId, 2147483647) + if not id then + return nil, "invalid_player_id" + end + if wallets[id] == nil then + return nil, "player_not_found" + end + return id +end + +local function validateAmount(amount) + local value = toPositiveInteger(amount, MAX_BALANCE) + if not value then + return nil, "invalid_amount" + end + return value +end + +local function validateReason(reason) + if reason == nil then + return true + end + if type(reason) ~= "string" or reason == "" or #reason > MAX_REASON_BYTES then + return nil, "invalid_reason" + end + if reason:find("[%c]") then + return nil, "invalid_reason" + end + return true +end + +exports("getBalance", function(playerId) + local id = toPositiveInteger(playerId, 2147483647) + if not id then + return 0 + end + return wallets[id] or 0 +end) + +exports("add", function(playerId, amount, reason) + local id, idReason = validatePlayerId(playerId) + if not id then + return nil, idReason + end + local value, amountReason = validateAmount(amount) + if not value then + return nil, amountReason + end + local reasonOk, reasonReason = validateReason(reason) + if not reasonOk then + return nil, reasonReason + end + return applyDelta(id, value, reason) +end) + +exports("remove", function(playerId, amount, reason) + local id, idReason = validatePlayerId(playerId) + if not id then + return nil, idReason + end + local value, amountReason = validateAmount(amount) + if not value then + return nil, amountReason + end + local reasonOk, reasonReason = validateReason(reason) + if not reasonOk then + return nil, reasonReason + end + return applyDelta(id, -value, reason) +end) + +------------------------------------------------------------------------------ +-- Payday +------------------------------------------------------------------------------ + +local function runPayday(trigger) + local paid = 0 + for _, rawId in ipairs(Open77.players.all() or {}) do + local playerId = asNumber(rawId) + if playerId and wallets[playerId] ~= nil and isPlayerReady(playerId) then + local newBalance = applyDelta(playerId, PAYDAY_AMOUNT, "payday") + if newBalance then + paid = paid + 1 + tell(playerId, ("Paie : +%d %s"):format(PAYDAY_AMOUNT, CURRENCY)) + end + end + end + log(("payday (%s) paid %d player(s)"):format(trigger, paid)) + return paid +end + +local function startPaydayThread() + if paydayThreadStarted then + return + end + paydayThreadStarted = true + CreateThread(function() + while true do + Wait(PAYDAY_INTERVAL_MS) + runPayday("timer") + end + end) +end + +------------------------------------------------------------------------------ +-- Chat suggestions +------------------------------------------------------------------------------ + +local SUGGESTIONS = { + { command = "/money", help = "Affiche votre solde" }, + { command = "/pay", help = "Envoie de l'argent à un joueur connecté", parameters = { + { name = "playerId", help = "Identifiant du joueur" }, + { name = "amount", help = "Montant en €$" }, + } }, + { command = "/givemoney", help = "[Admin] Crédite un joueur", parameters = { + { name = "playerId", help = "Identifiant du joueur" }, + { name = "amount", help = "Montant en €$" }, + } }, + { command = "/payday", help = "[Admin] Déclenche une paie immédiate" }, +} + +local function publishSuggestions(target) + local ok, reason = Open77.chat.addSuggestions(target, SUGGESTIONS) + if not ok then + log(("addSuggestions refused for target %s: %s"):format(tostring(target), tostring(reason))) + end +end + +------------------------------------------------------------------------------ +-- Lifecycle +------------------------------------------------------------------------------ + +AddEventHandler("onResourceStart", function(name) + if name ~= RESOURCE then + return + end + -- Players already in the world had their onPlayerReady before this + -- generation existed (hot reload): load them now. + for _, rawId in ipairs(Open77.players.all() or {}) do + local playerId = asNumber(rawId) + if playerId and isPlayerReady(playerId) then + loadWallet(playerId) + end + end + publishSuggestions(-1) + startPaydayThread() + log("started") +end) + +AddEventHandler("onResourceStop", function(name) + if name ~= RESOURCE then + return + end + -- Every change is already persisted; this is belt and braces. + for playerId in pairs(wallets) do + persist(playerId) + end +end) + +AddEventHandler("onPlayerReady", function(rawPlayerId) + local playerId = asNumber(rawPlayerId) + if not playerId then + return + end + local balance, reason = loadWallet(playerId) + if not balance then + tell(playerId, ("Portefeuille indisponible (%s) : préviens un admin."):format(tostring(reason))) + return + end + tell(playerId, ("Solde : %s"):format(formatMoney(balance))) +end) + +AddEventHandler("onPlayerDisconnected", function(rawPlayerId) + local playerId = asNumber(rawPlayerId) + if playerId then + unloadWallet(playerId) + end +end) + +RegisterNetEvent("chat:ready", function() + local playerId = asNumber(source) + if playerId and playerId > 0 then + publishSuggestions(playerId) + end +end) + +------------------------------------------------------------------------------ +-- Commands +------------------------------------------------------------------------------ + +-- Parses " " from a command; answers French errors to `reply`. +local function parseTargetAndAmount(args, reply, usage) + local target = toPositiveInteger(asNumber(args[1]), 2147483647) + local amount = toPositiveInteger(asNumber(args[2]), MAX_BALANCE) + if not target or not amount then + reply("Usage : " .. usage) + return nil + end + if wallets[target] == nil or not isPlayerReady(target) then + reply("Joueur introuvable ou pas encore en jeu.") + return nil + end + return target, amount +end + +RegisterCommand("money", function(source, args, raw) + if source == 0 then + print("[" .. RESOURCE .. "] /money : à utiliser depuis le jeu, pas depuis la console.") + return + end + local balance = wallets[source] + if balance == nil then + balance = loadWallet(source) + end + if balance == nil then + tell(source, "Portefeuille indisponible : préviens un admin.") + return + end + tell(source, ("Solde : %s"):format(formatMoney(balance))) + notify(source, { + type = "info", + title = "Portefeuille", + message = ("Solde : %s"):format(formatMoney(balance)), + icon = "E$", + durationMs = 5000, + }) +end, false) + +RegisterCommand("pay", function(source, args, raw) + if source == 0 then + print("[" .. RESOURCE .. "] /pay : à utiliser depuis le jeu, pas depuis la console.") + return + end + local reply = function(text) tell(source, text) end + if wallets[source] == nil then + reply("Portefeuille indisponible : préviens un admin.") + return + end + local target, amount = parseTargetAndAmount(args, reply, "/pay ") + if not target then + return + end + if target == source then + reply("Tu ne peux pas te payer toi-même.") + return + end + if wallets[source] < amount then + reply(("Fonds insuffisants : il te manque %s."):format(formatMoney(amount - wallets[source]))) + return + end + + local senderBalance, removeReason = applyDelta(source, -amount, "pay:to:" .. target) + if not senderBalance then + reply(("Paiement refusé (%s)."):format(tostring(removeReason))) + return + end + local targetBalance, addReason = applyDelta(target, amount, "pay:from:" .. source) + if not targetBalance then + -- Refund: the target could not receive (balance cap, vanished between checks). + applyDelta(source, amount, "pay:refund:" .. target) + reply(("Paiement refusé (%s), montant remboursé."):format(tostring(addReason))) + return + end + + local senderName = Open77.players.name(source) or ("#" .. source) + local targetName = Open77.players.name(target) or ("#" .. target) + reply(("Tu as envoyé %s à %s. Solde : %s"):format(formatMoney(amount), targetName, formatMoney(senderBalance))) + tell(target, ("%s t'a envoyé %s. Solde : %s"):format(senderName, formatMoney(amount), formatMoney(targetBalance))) +end, false) + +-- Restricted: ACL command.givemoney; the server console is always allowed. +RegisterCommand("givemoney", function(source, args, raw) + local reply + if source == 0 then + reply = function(text) print("[" .. RESOURCE .. "] givemoney : " .. text) end + else + reply = function(text) tell(source, text) end + end + local target, amount = parseTargetAndAmount(args, reply, "/givemoney ") + if not target then + return + end + local newBalance, reason = applyDelta(target, amount, "givemoney:by:" .. source) + if not newBalance then + reply(("Crédit refusé (%s)."):format(tostring(reason))) + return + end + tell(target, ("Un administrateur t'a crédité de %s. Solde : %s"):format(formatMoney(amount), formatMoney(newBalance))) + if source ~= 0 then + local targetName = Open77.players.name(target) or ("#" .. target) + reply(("%s crédité de %s (solde : %s)."):format(targetName, formatMoney(amount), formatMoney(newBalance))) + end +end, true) + +-- Restricted: ACL command.payday; the server console is always allowed. +RegisterCommand("payday", function(source, args, raw) + local paid = runPayday(source == 0 and "console" or ("player:" .. source)) + if source ~= 0 then + tell(source, ("Paie déclenchée : %d joueur(s) payé(s)."):format(paid)) + else + print("[" .. RESOURCE .. "] payday : " .. paid .. " joueur(s) payé(s).") + end +end, true) diff --git a/evals/rp-round/rp_jobs/README.md b/evals/rp-round/rp_jobs/README.md new file mode 100644 index 0000000..95dcbd2 --- /dev/null +++ b/evals/rp-round/rp_jobs/README.md @@ -0,0 +1,69 @@ +# rp_jobs — métiers d'un serveur RP + +Ressource serveur (plus un mini script client) pour Open77, build `2.31.13+op77.76`. +Un joueur occupe au plus un métier ; il est sauvegardé dans le KVP de la ressource +sous l'identifiant durable du joueur et restauré quand il revient. + +Métiers : `livreur`, `taxi`, `mecano`, `medecin`, `police`. + +## Commandes + +| Commande | Effet | +|---|---| +| `/jobs` | Liste les métiers avec une ligne de description ; le métier actuel est marqué `[actuel]`. | +| `/job ` | Prend le métier ``. Annoncé au joueur, sauvegardé, événement `rp_jobs:changed` levé. | +| `/job quit` | Démissionne (annule la tournée en cours s'il y en a une). | +| `/mission` | Livreur uniquement : lance une tournée de 3 livraisons. | +| `/stopmission` | Annule la tournée en cours et rend le véhicule. | + +Toutes les commandes refusent poliment la console serveur (`source == 0`). + +## La tournée du livreur + +1. Le serveur lit la position du joueur (`Open77.players.get`) et fait apparaître une + Makigai MaiMai (`Vehicle.v_standard2_makigai_maimai_player`) 4 m à côté. Si le spawn est + refusé, le joueur est prévenu et la tournée se fait à pied. +2. Trois points sont tirés à distance croissante (150–220 m, 230–310 m, 320–400 m) sur le + plan du joueur : le `z` est celui du joueur, jamais deviné. +3. Pour chaque point : coordonnées et distance dans le chat, et un waypoint GPS placé sur + la carte du joueur (relais `rp_jobs:waypoint` → `Open77.blips.setWaypoint` côté client). +4. Arrivée détectée côté serveur, un sondage par seconde, à 12 m (distance au sol). +5. Chaque livraison paie `exports.rp_economy:add(playerId, 150, "livraison")`. Si + `rp_economy` est absent, le joueur est prévenu que l'argent est hors ligne et la tournée + continue. +6. Le véhicule est retiré à la fin, sur `/stopmission`, sur `/job quit`, à la déconnexion + et à l'arrêt de la ressource (un `ttlMs` de 30 min sert de filet de sécurité). + +## Exports serveur + +```lua +exports.rp_jobs:getJob(playerId) -- "livreur" | nil +exports.rp_jobs:hasJob(playerId, "police") -- true | false +``` + +Événement hôte : `rp_jobs:changed (playerId, jobName | nil)` à chaque changement. + +## Journal + +```text +[rp_jobs] player 3 job=livreur +[rp_jobs] player 3 job=none +[rp_jobs] mission player 3 started vehicle=12 +[rp_jobs] mission player 3 point 2/3 reached +[rp_jobs] mission player 3 finished +[rp_jobs] mission player 3 cancelled reason=player_request +``` + +## Tester en 2 minutes + +1. Déposer `rp_jobs` (et `rp_economy`, déclaré en dépendance) dans le dossier des ressources ; + démarrer le serveur, se connecter. +2. `/jobs` → la liste des cinq métiers, aucun marqué. +3. `/job livreur` → « Vous etes maintenant Livreur… » ; le journal affiche + `[rp_jobs] player N job=livreur`. +4. `/mission` → une voiture apparaît à côté, le chat donne « Livraison 1/3 : rendez-vous en + X=… Y=… (a … m) » et un waypoint apparaît sur la carte. +5. Rouler jusqu'au point : « Colis 1/3 livre ! » puis « Livraison payee : +150 $… », et le + point suivant est annoncé. Après le 3e point la voiture disparaît. +6. Se déconnecter puis revenir : « Bon retour : vous reprenez votre poste de Livreur. » +7. `/stopmission` en cours de tournée rend le véhicule ; `/job quit` démissionne. diff --git a/evals/rp-round/rp_jobs/client/main.lua b/evals/rp-round/rp_jobs/client/main.lua new file mode 100644 index 0000000..bc5cc78 --- /dev/null +++ b/evals/rp-round/rp_jobs/client/main.lua @@ -0,0 +1,24 @@ +-- rp_jobs client -- renders the delivery waypoint the server asks for. +-- +-- Open77.blips is a client-only API, so the server relays the next delivery +-- point through a net event and this script only draws it. Nothing here is +-- authoritative: arrival, payment and the vehicle are decided on the server. + +RegisterNetEvent("rp_jobs:waypoint", function(position) + if type(position) ~= "table" or type(position.x) ~= "number" + or type(position.y) ~= "number" or type(position.z) ~= "number" then + return + end + local ok, reason = Open77.blips.setWaypoint({ x = position.x, y = position.y, z = position.z }) + if not ok then + print("[rp_jobs] client waypoint refused: " .. tostring(reason)) + end +end) + +RegisterNetEvent("rp_jobs:waypointClear", function() + -- Success is `true, wasSet`; the card does not say where the reason sits on failure. + local ok, second, third = Open77.blips.clearWaypoint() + if not ok then + print("[rp_jobs] client waypoint clear refused: " .. tostring(third or second)) + end +end) diff --git a/evals/rp-round/rp_jobs/open77.lua b/evals/rp-round/rp_jobs/open77.lua new file mode 100644 index 0000000..f95d8ac --- /dev/null +++ b/evals/rp-round/rp_jobs/open77.lua @@ -0,0 +1,14 @@ +resource "rp_jobs" +version "1.0.0" +auto_start true + +-- network.events : RegisterNetEvent (chat:ready) and Open77.net.emitClient (waypoint relay) +-- world.vehicles : Open77.vehicles.create / remove (delivery van) +-- ui.vanilla.map : Open77.blips.setWaypoint / clearWaypoint (client side) +permissions { "network.events", "world.vehicles", "ui.vanilla.map" } + +-- Delivery payouts go through exports.rp_economy:add(...) +dependency "rp_economy" + +server_script "server/main.lua" +client_script "client/main.lua" diff --git a/evals/rp-round/rp_jobs/server/main.lua b/evals/rp-round/rp_jobs/server/main.lua new file mode 100644 index 0000000..63790ee --- /dev/null +++ b/evals/rp-round/rp_jobs/server/main.lua @@ -0,0 +1,506 @@ +-- rp_jobs -- server-authoritative jobs for an RP server. +-- +-- A player holds at most one job, persisted in this resource's KVP store under +-- the player's durable identifier and restored on onPlayerReady. The only +-- scripted mission in this version is the courier run (job "livreur"): +-- a cheap car is spawned next to the player, three delivery points are drawn +-- at increasing distance, arrival is detected by polling the server-side +-- position once per second, and each delivery is paid through rp_economy. +-- +-- Exports (synchronous, never yield): getJob(playerId), hasJob(playerId, jobName) +-- Host-wide event raised on every change: rp_jobs:changed (playerId, jobName | nil) + +local RESOURCE = GetCurrentResourceName() + +--------------------------------------------------------------------------- +-- Configuration +--------------------------------------------------------------------------- + +local JOBS = { + { name = "livreur", label = "Livreur", description = "Livre des colis aux quatre coins de Night City (/mission)." }, + { name = "taxi", label = "Taxi", description = "Transporte les habitants d'un point a l'autre de la ville." }, + { name = "mecano", label = "Mecano", description = "Repare et entretient les vehicules des habitants." }, + { name = "medecin", label = "Medecin", description = "Soigne et reanime les blesses de Night City." }, + { name = "police", label = "Police", description = "Fait respecter la loi au sein du NCPD." }, +} + +local JOB_BY_NAME = {} +for _, job in ipairs(JOBS) do + JOB_BY_NAME[job.name] = job +end + +local MISSION = { + job = "livreur", + -- Cheapest player car in the catalogue; *_player records are the ones the docs say to prefer. + vehicleRecord = "Vehicle.v_standard2_makigai_maimai_player", + vehicleTtlMs = 30 * 60 * 1000, -- safety net: a crash should not leave a car forever + pointCount = 3, + -- Distance bands in metres, one per delivery point; each band starts after the previous one ends. + bands = { { 150, 220 }, { 230, 310 }, { 320, 400 } }, + arriveRadius = 12.0, + pollMs = 1000, + positionMaxAgeMs = 5000, + payout = 150, + payoutReason = "livraison", +} + +local SUGGESTIONS = { + { command = "/jobs", help = "Liste des metiers et celui que vous occupez" }, + { command = "/job", help = "Prendre un metier, ou /job quit pour demissionner", + parameters = { { name = "metier", help = "livreur, taxi, mecano, medecin, police ou quit" } } }, + { command = "/mission", help = "Livreur : demarrer une tournee de livraison" }, + { command = "/stopmission", help = "Annuler la tournee de livraison en cours" }, +} + +--------------------------------------------------------------------------- +-- State (keyed by numeric player id, valid for one connection only) +--------------------------------------------------------------------------- + +local jobs = {} -- [playerId] = jobName +local identifiers = {} -- [playerId] = durable identifier, cached at ready time +local missions = {} -- [playerId] = mission table (see startMission) + +--------------------------------------------------------------------------- +-- Helpers +--------------------------------------------------------------------------- + +local function log(fmt, ...) + print(("[rp_jobs] " .. fmt):format(...)) +end + +-- Chat to one player. playerId must already be a number. +local function tell(playerId, text) + local ok, reason = Open77.chat.send(playerId, text) + if not ok then + log("chat to player %s refused: %s", tostring(playerId), tostring(reason)) + end + return ok +end + +local function toPlayerId(value) + local id = tonumber(value) + if id == nil or id < 1 or id % 1 ~= 0 then + return nil + end + return id +end + +local function identifierOf(playerId) + local cached = identifiers[playerId] + if cached then + return cached + end + local identifier = Open77.players.identifier(playerId) + if identifier ~= nil and identifier ~= "" then + identifiers[playerId] = identifier + return identifier + end + return nil +end + +local function kvpKey(identifier) + return "job:" .. identifier +end + +local function distance2D(ax, ay, bx, by) + local dx, dy = ax - bx, ay - by + return math.sqrt(dx * dx + dy * dy) +end + +-- Fresh server-side position (and heading) of a player, or nil. +local function freshPosition(playerId) + local read = Open77.players.get(playerId) + if not read or not read.position then + return nil + end + if (read.ageMs or 0) > MISSION.positionMaxAgeMs then + return nil + end + return read.position, read.heading +end + +--------------------------------------------------------------------------- +-- Jobs +--------------------------------------------------------------------------- + +local function announceChange(playerId, jobName) + local ok, reason = TriggerEvent("rp_jobs:changed", playerId, jobName) + if not ok then + log("rp_jobs:changed for player %d not published: %s", playerId, tostring(reason)) + end +end + +-- Persist the job (or its absence) for a player, in the KVP store. +local function persistJob(playerId, jobName) + local identifier = identifierOf(playerId) + if not identifier then + log("player %d has no durable identifier, job not persisted", playerId) + return false + end + local ok, reason + if jobName then + ok, reason = Open77.kvp.set(kvpKey(identifier), jobName) + else + ok, reason = Open77.kvp.delete(kvpKey(identifier)) + if not ok and reason == nil then + ok = true -- absent key: nothing to delete, that is fine + end + end + if not ok then + log("kvp write for player %d failed: %s", playerId, tostring(reason)) + end + return ok +end + +local function setJob(playerId, jobName) + jobs[playerId] = jobName + persistJob(playerId, jobName) + log("player %d job=%s", playerId, jobName or "none") + announceChange(playerId, jobName) +end + +-- Restore a job from the KVP store; returns the job name or nil. +local function restoreJob(playerId) + local identifier = identifierOf(playerId) + if not identifier then + return nil + end + local stored = Open77.kvp.get(kvpKey(identifier)) + if type(stored) == "string" and JOB_BY_NAME[stored] then + jobs[playerId] = stored + log("player %d job=%s (restored)", playerId, stored) + return stored + end + if stored ~= nil then + -- An unknown job name in the store: drop it rather than carry garbage. + Open77.kvp.delete(kvpKey(identifier)) + end + return nil +end + +--------------------------------------------------------------------------- +-- Courier mission +--------------------------------------------------------------------------- + +local cancelMission -- forward declaration + +local function sendWaypoint(playerId, point) + local ok, reason = Open77.net.emitClient("rp_jobs:waypoint", playerId, { x = point.x, y = point.y, z = point.z }) + if not ok then + log("waypoint for player %d not sent: %s", playerId, tostring(reason)) + end +end + +local function clearWaypoint(playerId) + Open77.net.emitClient("rp_jobs:waypointClear", playerId) +end + +local function describePoint(playerId, mission) + local point = mission.points[mission.index] + local here = freshPosition(playerId) + local distanceText = "" + if here then + distanceText = (" (a %d m)"):format(math.floor(distance2D(here.x, here.y, point.x, point.y) + 0.5)) + end + tell(playerId, ("Livraison %d/%d : rendez-vous en X=%.1f Y=%.1f%s."):format( + mission.index, #mission.points, point.x, point.y, distanceText)) + sendWaypoint(playerId, point) +end + +-- Three points at increasing distance on the player's own ground plane (z reused). +local function drawPoints(origin) + local points = {} + for i = 1, MISSION.pointCount do + local band = MISSION.bands[i] or MISSION.bands[#MISSION.bands] + local distance = band[1] + math.random() * (band[2] - band[1]) + local angle = math.random() * 2 * math.pi + points[i] = { + x = math.floor((origin.x + math.cos(angle) * distance) * 10 + 0.5) / 10, + y = math.floor((origin.y + math.sin(angle) * distance) * 10 + 0.5) / 10, + z = origin.z, + } + end + return points +end + +-- Pay through rp_economy; the synchronous export raises when missing, hence pcall. +local function payDelivery(playerId) + local ok, balance, reason = pcall(function() + return exports.rp_economy:add(playerId, MISSION.payout, MISSION.payoutReason) + end) + if not ok then + log("mission player %d payout failed: %s", playerId, tostring(balance)) + tell(playerId, "Le systeme d'argent est hors ligne : cette livraison ne sera pas payee.") + return + end + if balance == nil then + log("mission player %d payout refused: %s", playerId, tostring(reason)) + tell(playerId, ("Paiement refuse (%s)."):format(tostring(reason or "inconnu"))) + return + end + tell(playerId, ("Livraison payee : +%d $. Solde : %s $."):format(MISSION.payout, tostring(balance))) +end + +local function removeMissionVehicle(mission) + if mission.vehicleId == nil then + return + end + local removed = Open77.vehicles.remove(mission.vehicleId) + if not removed then + log("mission player %d vehicle %s already gone", mission.playerId, tostring(mission.vehicleId)) + end + mission.vehicleId = nil +end + +local function finishMission(playerId, mission) + missions[playerId] = nil + removeMissionVehicle(mission) + clearWaypoint(playerId) + log("mission player %d finished", playerId) + tell(playerId, "Tournee terminee, beau travail ! Le vehicule de service est rendu.") +end + +cancelMission = function(playerId, reason, silent) + local mission = missions[playerId] + if not mission then + return false + end + missions[playerId] = nil + removeMissionVehicle(mission) + if not silent then + clearWaypoint(playerId) + tell(playerId, "Tournee annulee.") + end + log("mission player %d cancelled reason=%s", playerId, reason) + return true +end + +-- Runs inside the /mission command task; ends when the mission is over or replaced. +local function pollMission(playerId, mission) + while missions[playerId] == mission do + Wait(MISSION.pollMs) + if missions[playerId] ~= mission then + return + end + local here = freshPosition(playerId) + if here then + local point = mission.points[mission.index] + if distance2D(here.x, here.y, point.x, point.y) <= MISSION.arriveRadius then + log("mission player %d point %d/%d reached", playerId, mission.index, #mission.points) + tell(playerId, ("Colis %d/%d livre !"):format(mission.index, #mission.points)) + payDelivery(playerId) + if mission.index >= #mission.points then + finishMission(playerId, mission) + return + end + mission.index = mission.index + 1 + describePoint(playerId, mission) + end + end + end +end + +local function startMission(playerId) + local origin, heading = freshPosition(playerId) + if not origin then + tell(playerId, "Position inconnue pour le moment, reessayez dans un instant.") + return + end + + local mission = { + playerId = playerId, + points = drawPoints(origin), + index = 1, + vehicleId = nil, + } + missions[playerId] = mission + + local vehicleId, reason = Open77.vehicles.create({ + record = MISSION.vehicleRecord, + position = { x = origin.x + 4.0, y = origin.y, z = origin.z }, + yaw = heading or 0.0, + ttlMs = MISSION.vehicleTtlMs, + }) + if vehicleId then + mission.vehicleId = vehicleId + tell(playerId, "Tournee lancee : un vehicule de service vous attend juste a cote.") + else + tell(playerId, ("Pas de vehicule disponible (%s) : la tournee se fera a pied."):format(tostring(reason))) + end + log("mission player %d started vehicle=%s", playerId, tostring(vehicleId or "none")) + + describePoint(playerId, mission) + pollMission(playerId, mission) +end + +--------------------------------------------------------------------------- +-- Commands +--------------------------------------------------------------------------- + +RegisterCommand("jobs", function(source) + if source == 0 then + print("[rp_jobs] /jobs : a lancer depuis le jeu, pas depuis la console") + return + end + local current = jobs[source] + tell(source, "Metiers disponibles :") + for _, job in ipairs(JOBS) do + Wait(0) -- two sends in one tick arrive reversed; one line per tick keeps the order + local marker = (job.name == current) and " [actuel]" or "" + tell(source, ("- %s%s : %s"):format(job.name, marker, job.description)) + end + Wait(0) + if current then + tell(source, ("Vous etes %s. /job quit pour demissionner."):format(JOB_BY_NAME[current].label)) + else + tell(source, "Vous n'avez pas de metier. /job pour en prendre un.") + end +end, false) + +RegisterCommand("job", function(source, args) + if source == 0 then + print("[rp_jobs] /job : a lancer depuis le jeu, pas depuis la console") + return + end + local wanted = args[1] and string.lower(args[1]) or nil + if not wanted then + tell(source, "Usage : /job ou /job quit") + return + end + + local current = jobs[source] + if wanted == "quit" then + if not current then + tell(source, "Vous n'avez pas de metier a quitter.") + return + end + if missions[source] then + cancelMission(source, "job_quit") + end + setJob(source, nil) + tell(source, ("Vous avez quitte votre poste de %s."):format(JOB_BY_NAME[current].label)) + return + end + + local job = JOB_BY_NAME[wanted] + if not job then + tell(source, ("Metier inconnu : %s. /jobs pour la liste."):format(wanted)) + return + end + if current == job.name then + tell(source, ("Vous etes deja %s."):format(job.label)) + return + end + if current and missions[source] then + cancelMission(source, "job_changed") + end + setJob(source, job.name) + tell(source, ("Vous etes maintenant %s. %s"):format(job.label, job.description)) +end, false) + +RegisterCommand("mission", function(source) + if source == 0 then + print("[rp_jobs] /mission : a lancer depuis le jeu, pas depuis la console") + return + end + if jobs[source] ~= MISSION.job then + tell(source, "Seuls les livreurs ont une mission pour l'instant. /job livreur pour le devenir.") + return + end + if missions[source] then + tell(source, "Une tournee est deja en cours. /stopmission pour l'annuler.") + return + end + startMission(source) +end, false) + +RegisterCommand("stopmission", function(source) + if source == 0 then + print("[rp_jobs] /stopmission : a lancer depuis le jeu, pas depuis la console") + return + end + if not cancelMission(source, "player_request") then + tell(source, "Aucune tournee en cours.") + end +end, false) + +--------------------------------------------------------------------------- +-- Exports (synchronous: they never yield) +--------------------------------------------------------------------------- + +exports("getJob", function(playerId) + local id = toPlayerId(playerId) + if not id then + return nil + end + return jobs[id] +end) + +exports("hasJob", function(playerId, jobName) + local id = toPlayerId(playerId) + if not id or type(jobName) ~= "string" then + return false + end + return jobs[id] == string.lower(jobName) +end) + +--------------------------------------------------------------------------- +-- Lifecycle +--------------------------------------------------------------------------- + +AddEventHandler("onResourceStart", function(name) + if name ~= RESOURCE then + return + end + Open77.chat.addSuggestions(-1, SUGGESTIONS) + -- Players who were already in the world when the resource (re)started. + for _, playerId in ipairs(Open77.players.all()) do + local id = toPlayerId(playerId) + if id then + local read = Open77.players.get(id) + if read and read.ready then + restoreJob(id) + end + end + end + log("started") +end) + +AddEventHandler("onResourceStop", function(name) + if name ~= RESOURCE then + return + end + for playerId in pairs(missions) do + cancelMission(playerId, "resource_stop", true) + end +end) + +RegisterNetEvent("chat:ready", function() + local id = toPlayerId(source) + if id then + Open77.chat.addSuggestions(id, SUGGESTIONS) + end +end) + +AddEventHandler("onPlayerReady", function(playerId) + local id = toPlayerId(playerId) + if not id then + return + end + local restored = restoreJob(id) + if restored then + tell(id, ("Bon retour : vous reprenez votre poste de %s."):format(JOB_BY_NAME[restored].label)) + end +end) + +AddEventHandler("onPlayerDisconnected", function(playerId) + local id = toPlayerId(playerId) + if not id then + return + end + if missions[id] then + cancelMission(id, "disconnected", true) + end + jobs[id] = nil + identifiers[id] = nil +end) diff --git a/evals/rp-round/rp_medic/README.md b/evals/rp-round/rp_medic/README.md new file mode 100644 index 0000000..a6fd3f4 --- /dev/null +++ b/evals/rp-round/rp_medic/README.md @@ -0,0 +1,75 @@ +# rp_medic — service médical (serveur uniquement) + +> Les verbes sont `/soin` et `/reanimer` : `/heal` appartient déjà au menu admin (`open77_admin`) et `/revive` au freeroam, et un nom de commande enregistré deux fois est servi par la première ressource. + +Ressource Lua Open77 (build `2.31.13+op77.76`) qui donne aux joueurs dont le métier est +`medecin` les moyens de soigner et de réanimer, et à tout le monde un appel d'urgence. +Tout est décidé côté serveur : aucun script client. + +## Commandes + +| Commande | Qui | Effet | +|---|---|---| +| `/soin ` | médecin | Soigne entièrement un patient **vivant** à moins de 5 m (`Open77.stats.restoreHealth`). Le patient paie **100 €$** au médecin ; s'il ne peut pas payer, le soin est fait quand même et annoncé comme gratuit. | +| `/reanimer ` | médecin | Réanime un patient **mort** à moins de 5 m, là où il est tombé, à pleine santé avec 5 s d'invulnérabilité (`Open77.players.revive`). **300 €$**, mêmes règles de paiement. | +| `/911 ` | tout le monde | Envoie le message et la position arrondie de l'appelant à chaque médecin et policier connecté. L'appelant apprend combien d'intervenants ont été prévenus. | +| `/medic` | tout le monde | Liste les médecins connectés, du plus proche au plus lointain, avec leur distance. Un médecin voit en plus ses compteurs (soins, réanimations, €$ gagnés). | + +Règles communes à `/soin` et `/reanimer` : + +- un seul **temps de recharge de 30 s** par médecin, partagé entre les deux commandes ; +- impossible d'intervenir sur soi-même, sur un joueur pas encore entré en jeu, ou hors des 5 m ; +- `/soin` sur un mort renvoie vers `/reanimer`, `/reanimer` sur un vivant renvoie vers `/soin` ; +- `/soin` sur un patient déjà à pleine santé ne prélève rien ; +- depuis la console serveur (`source = 0`) les quatre commandes refusent poliment. + +L'identifiant d'un joueur s'obtient avec `/id` (commande intégrée du chat). + +## Métier, ACL et argent + +- Le métier vient de `rp_jobs` : `exports.rp_jobs:hasJob(id, "medecin")` pour `/soin` et + `/reanimer`, `exports.rp_jobs:getJob(id)` pour dresser la liste des médecins et policiers. +- **Repli ACL** : si l'export de `rp_jobs` est absent (ressource arrêtée, en rechargement, + export non publié), la ressource applique la sémantique d'une commande restreinte : seul un + joueur qui détient le droit ACL `command.soin` peut soigner, `command.reanimer` réanimer + (`Open77.acl.isAllowed`). Dans ce mode, `/medic` et `/911` considèrent comme médecin + quiconque détient `command.soin`, et la police n'est pas joignable (aucun droit ne la + désigne) — le message de `/911` le dit. +- L'argent vient de `rp_economy` : `remove` sur le patient puis `add` sur le médecin. Si + `remove` répond `nil` (`insufficient_funds` ou autre), l'acte est gratuit ; si `add` échoue + après un `remove` réussi, le patient est remboursé. Sans `rp_economy`, l'acte est gratuit et + annoncé comme tel. +- Les deux ressources sont déclarées en `dependencies` : le serveur refuse de démarrer + `rp_medic` si elles ne sont pas dans sa liste `load` (voir le guide *server-resources*). Le + repli ci-dessus couvre un export absent ou une dépendance arrêtée à chaud, pas une + dépendance jamais chargée. + +## Persistance + +Les compteurs par médecin (`heal:`, `revive:`, `earned:`) sont stockés avec +`Open77.kvp.increment`, clé = identifiant durable `Open77.players.identifier`, jamais l'id de +session. Un identifiant introuvable (joueur parti) saute simplement l'écriture. + +## Permissions du manifeste + +`players.stats.read` (lecture de la santé), `players.stats.apply` (`restoreHealth`), +`players.life.read` (`isDead`), `players.life.revive` (`revive`), `network.events` +(`RegisterNetEvent("chat:ready")` pour les suggestions), `acl.read` (`acl.isAllowed`). + +## Tester en 2 minutes + +1. Charger `rp_economy`, `rp_jobs` et `rp_medic` dans `server.jsonc` (`resources.load`) et + démarrer le serveur. Le journal affiche `[rp_medic] started: fees heal=100 revive=300 ...`. +2. Connecter deux clients A et B. Donner le métier à A via `rp_jobs` (par exemple `/job medecin` + selon cette ressource) et de l'argent à B via `rp_economy` (`/givemoney`). Noter les ids avec `/id`. +3. B tape `/911 on m'a tiré dessus` : A reçoit l'alerte « 911 » avec la position arrondie, B lit + « Appel transmis à 1 intervenant(s) ». +4. B tape `/medic` : la liste montre A et sa distance. +5. A se place à moins de 5 m de B, B perd de la vie, A tape `/soin ` : B est à pleine + santé, B a 100 €$ de moins, A 100 €$ de plus, journal `[rp_medic] player A healed player B fee=100`. + Retaper aussitôt : « Patientez encore N s ». Depuis plus de 5 m : « Trop loin : x m ». +6. Tuer B (`/kill` de test, chute…), A tape `/reanimer ` après 30 s : B se relève sur place, + 300 €$ transférés, journal `... revived ... fee=300`. Vider le compte de B puis recommencer : + l'acte passe et s'annonce gratuit. +7. B (non médecin) tape `/soin ` : « Réservé aux médecins. ». Arrêter `rp_jobs` puis retaper : + le message cite le droit `command.soin` (mode ACL). diff --git a/evals/rp-round/rp_medic/open77.lua b/evals/rp-round/rp_medic/open77.lua new file mode 100644 index 0000000..d7bacc9 --- /dev/null +++ b/evals/rp-round/rp_medic/open77.lua @@ -0,0 +1,26 @@ +resource "rp_medic" +version "1.0.0" +open77_version "*" +auto_start true + +-- Server-only medic service: /heal, /revive, /911, /medic. +-- Every permission below is the one the card of a native this resource calls requires: +-- players.stats.read Open77.stats.get (is the patient already at full health?) +-- players.stats.apply Open77.stats.restoreHealth (/heal) +-- players.life.read Open77.players.isDead (alive / dead gate of both commands) +-- players.life.revive Open77.players.revive (/revive) +-- network.events RegisterNetEvent("chat:ready") (chat suggestions) +-- acl.read Open77.acl.isAllowed (fallback when rp_jobs has no export) +permissions { + "players.stats.read", + "players.stats.apply", + "players.life.read", + "players.life.revive", + "network.events", + "acl.read", +} + +-- Exports called synchronously: rp_jobs (hasJob / getJob), rp_economy (remove / add). +dependencies { "rp_economy", "rp_jobs" } + +server_script "server/main.lua" diff --git a/evals/rp-round/rp_medic/server/main.lua b/evals/rp-round/rp_medic/server/main.lua new file mode 100644 index 0000000..764df2f --- /dev/null +++ b/evals/rp-round/rp_medic/server/main.lua @@ -0,0 +1,500 @@ +-- rp_medic - server-only medic service for an RP server. +-- +-- /soin medic only, target alive within 5 m, full health, 100 eddies +-- /reanimer medic only, target dead within 5 m, revived where it fell, 300 eddies +-- /911 anyone: message + rounded position sent to every medic and cop +-- /medic connected medics with their distance to the caller +-- +-- Authority: everything is decided here. Money goes through rp_economy, jobs through +-- rp_jobs; when rp_jobs publishes no export the ACL right of a restricted command +-- (command.soin / command.reanimer) decides instead. Persistence (per-medic counters) +-- goes through Open77.kvp keyed by the durable identifier, never the session id. + +-- The platform already owns /heal (open77_admin) and /revive (freeroam), and a +-- command name registered twice across resources is served by the first one: +-- the medic verbs are /soin and /reanimer. + +local RESOURCE = GetCurrentResourceName() + +local HEAL_FEE = 100 +local REVIVE_FEE = 300 +local RANGE_M = 5.0 +local COOLDOWN_S = 30 +local MEDIC_JOB = "medecin" +local POLICE_JOB = "police" +local REVIVE_HEALTH = 1.0 -- fraction of the maximum after a paid revive +local REVIVE_GRACE_MS = 5000 + +-- [medicId] = Open77.time.monotonic() of the medic's last paid act (heal or revive). +local cooldowns = {} + +local COLOR_MEDIC = { 80, 220, 160 } +local COLOR_ALERT = { 255, 90, 90 } + +local function log(fmt, ...) + print(("[rp_medic] " .. fmt):format(...)) +end + +-- Chat to one player. `playerId` is always a number here (command / net-event source). +local function say(playerId, text) + local ok, reason = Open77.chat.send(playerId, { author = "MEDIC", text = text, color = COLOR_MEDIC }) + if not ok then + log("chat.send to %s failed: %s", tostring(playerId), tostring(reason)) + end +end + +local function round(value) + return math.floor(value + 0.5) +end + +-- --------------------------------------------------------------------------- +-- rp_jobs (synchronous exports raise when the export or the resource is missing) +-- --------------------------------------------------------------------------- + +-- Returns `job, true` when rp_jobs answered, `nil, false` when its export is unavailable. +local function jobOf(playerId) + local ok, job = pcall(function() + return exports.rp_jobs:getJob(playerId) + end) + if not ok then + log("rp_jobs getJob unavailable: %s", tostring(job)) + return nil, false + end + return job, true +end + +-- Returns true / false when rp_jobs answered, nil when its export is unavailable. +local function hasJob(playerId, jobName) + local ok, result = pcall(function() + return exports.rp_jobs:hasJob(playerId, jobName) + end) + if not ok then + log("rp_jobs hasJob unavailable: %s", tostring(result)) + return nil + end + return result == true +end + +-- ACL fallback: the same right a restricted command of that name would require. +local function aclAllows(playerId, right) + local allowed, reason = Open77.acl.isAllowed(playerId, right) + if allowed ~= true and reason then + log("acl.isAllowed(%s, %s) refused: %s", tostring(playerId), right, tostring(reason)) + end + return allowed == true +end + +-- May this player use the medic command `commandName`? Returns allowed, mode. +local function isMedic(playerId, commandName) + local viaJobs = hasJob(playerId, MEDIC_JOB) + if viaJobs ~= nil then + return viaJobs, "jobs" + end + return aclAllows(playerId, "command." .. commandName), "acl" +end + +-- Roles of a connected player for the roster commands. Without rp_jobs a medic is +-- whoever holds command.heal; the police cannot be identified and is left out. +local function rolesOf(playerId) + local job, available = jobOf(playerId) + if available then + return { medic = job == MEDIC_JOB, police = job == POLICE_JOB }, "jobs" + end + return { medic = aclAllows(playerId, "command.soin"), police = false }, "acl" +end + +-- --------------------------------------------------------------------------- +-- rp_economy +-- --------------------------------------------------------------------------- + +-- Charges `amount` to the patient and credits the medic. +-- Returns "paid", "free" (patient cannot pay) or "unavailable" (no economy service). +local function charge(patient, medic, amount, reason) + local ok, newBalance, why = pcall(function() + return exports.rp_economy:remove(patient, amount, reason) + end) + if not ok then + log("rp_economy remove unavailable: %s", tostring(newBalance)) + return "unavailable" + end + if newBalance == nil then + log("player %d cannot pay %d (%s): free", patient, amount, tostring(why)) + return "free" + end + + local okAdd, credited, addWhy = pcall(function() + return exports.rp_economy:add(medic, amount, reason) + end) + if not okAdd or credited == nil then + -- The patient paid but the medic could not be credited: give the money back. + log("crediting medic %d failed (%s), refunding player %d", medic, + tostring(okAdd and addWhy or credited), patient) + pcall(function() + return exports.rp_economy:add(patient, amount, reason .. "_refund") + end) + return "free" + end + return "paid" +end + +-- --------------------------------------------------------------------------- +-- Persistence: per-medic counters keyed by the durable identifier +-- --------------------------------------------------------------------------- + +local function recordAct(medic, kind, fee) + local identifier = Open77.players.identifier(medic) + if not identifier then + return + end + local _, reason = Open77.kvp.increment(kind .. ":" .. identifier, 1) + if reason then + log("kvp increment %s failed: %s", kind, tostring(reason)) + end + if fee > 0 then + Open77.kvp.increment("earned:" .. identifier, fee) + end +end + +local function statsLine(medic) + local identifier = Open77.players.identifier(medic) + if not identifier then + return nil + end + local heals = Open77.kvp.get("heal:" .. identifier, 0) or 0 + local revives = Open77.kvp.get("revive:" .. identifier, 0) or 0 + local earned = Open77.kvp.get("earned:" .. identifier, 0) or 0 + return ("Vos interventions : %d soin(s), %d réanimation(s), %d €$ gagnés."):format(heals, revives, earned) +end + +-- --------------------------------------------------------------------------- +-- Shared checks +-- --------------------------------------------------------------------------- + +local function cooldownLeft(medic) + local last = cooldowns[medic] + if not last then + return 0 + end + local left = COOLDOWN_S - (Open77.time.monotonic() - last) + if left > 0 then + return left + end + return 0 +end + +-- Common gate of /heal and /revive: a player, a medic, no cooldown, a valid patient +-- within range. Returns the patient id, or nil after telling the caller why. +local function medicGate(source, args, commandName, usage) + if source == 0 then + print(("[rp_medic] /%s must be used by a player in game, not from the console"):format(commandName)) + return nil + end + + local allowed, mode = isMedic(source, commandName) + if not allowed then + if mode == "acl" then + say(source, "Réservé aux médecins (service rp_jobs indisponible : droit command." .. commandName .. " requis).") + else + say(source, "Réservé aux médecins.") + end + return nil + end + + local left = cooldownLeft(source) + if left > 0 then + say(source, ("Patientez encore %d s avant une nouvelle intervention."):format(math.ceil(left))) + return nil + end + + local target = tonumber(args[1]) + if not target or target < 1 or target % 1 ~= 0 then + say(source, usage) + return nil + end + target = math.tointeger(target) + if target == source then + say(source, "Vous ne pouvez pas intervenir sur vous-même.") + return nil + end + + local read, reason = Open77.players.get(target) + if not read then + say(source, ("Joueur %d introuvable (%s)."):format(target, tostring(reason))) + return nil + end + if not read.ready then + say(source, "Ce joueur n'est pas encore en jeu.") + return nil + end + + local metres, dreason = Open77.players.distance(source, target) + if not metres then + say(source, "Position du patient inconnue (" .. tostring(dreason) .. ").") + return nil + end + if metres > RANGE_M then + say(source, ("Trop loin : %.1f m (5 m maximum)."):format(metres)) + return nil + end + return target +end + +local function feeSentence(outcome, fee) + if outcome == "paid" then + return ("%d €$ encaissés."):format(fee) + elseif outcome == "free" then + return "Le patient ne peut pas payer : intervention gratuite." + end + return "Service économique indisponible : intervention gratuite." +end + +-- --------------------------------------------------------------------------- +-- /soin +-- --------------------------------------------------------------------------- + +RegisterCommand("soin", function(source, args) + local patient = medicGate(source, args, "soin", "Usage : /soin ") + if not patient then + return + end + + local dead = Open77.players.isDead(patient) + if dead == nil then + return say(source, "État du patient inconnu.") + end + if dead then + return say(source, "Ce patient est mort : utilisez /reanimer.") + end + + local stats = Open77.stats.get(patient) + if stats and stats.health and stats.health.value >= stats.health.maximum then + return say(source, "Ce patient est déjà en pleine santé.") + end + + local ok, reason = Open77.stats.restoreHealth(patient) + if not ok then + return say(source, "Soin impossible : " .. tostring(reason) .. ".") + end + + cooldowns[source] = Open77.time.monotonic() + local outcome = charge(patient, source, HEAL_FEE, "medic_heal") + local fee = outcome == "paid" and HEAL_FEE or 0 + recordAct(source, "heal", fee) + + local medicName = Open77.players.name(source) or ("joueur " .. source) + local patientName = Open77.players.name(patient) or ("joueur " .. patient) + say(source, ("Patient %s soigné. %s"):format(patientName, feeSentence(outcome, HEAL_FEE))) + if outcome == "paid" then + say(patient, ("Le médecin %s vous a soigné : %d €$ prélevés."):format(medicName, HEAL_FEE)) + else + say(patient, ("Le médecin %s vous a soigné gratuitement."):format(medicName)) + end + log("player %d healed player %d fee=%d", source, patient, fee) +end, false) + +-- --------------------------------------------------------------------------- +-- /reanimer +-- --------------------------------------------------------------------------- + +RegisterCommand("reanimer", function(source, args) + local patient = medicGate(source, args, "reanimer", "Usage : /reanimer ") + if not patient then + return + end + + local dead = Open77.players.isDead(patient) + if dead == nil then + return say(source, "État du patient inconnu.") + end + if not dead then + return say(source, "Ce patient est vivant : utilisez /soin.") + end + + -- Documented safe path for a dead player: the server's life authority revives + -- the body where it fell; the call refuses (false, reason) during a transition. + local ok, reason = Open77.players.revive(patient, { health = REVIVE_HEALTH, graceMs = REVIVE_GRACE_MS }) + if not ok then + return say(source, "Réanimation impossible : " .. tostring(reason) .. ".") + end + + cooldowns[source] = Open77.time.monotonic() + local outcome = charge(patient, source, REVIVE_FEE, "medic_revive") + local fee = outcome == "paid" and REVIVE_FEE or 0 + recordAct(source, "revive", fee) + + local medicName = Open77.players.name(source) or ("joueur " .. source) + local patientName = Open77.players.name(patient) or ("joueur " .. patient) + say(source, ("Patient %s réanimé. %s"):format(patientName, feeSentence(outcome, REVIVE_FEE))) + if outcome == "paid" then + say(patient, ("Le médecin %s vous a réanimé : %d €$ prélevés."):format(medicName, REVIVE_FEE)) + else + say(patient, ("Le médecin %s vous a réanimé gratuitement."):format(medicName)) + end + log("player %d revived player %d fee=%d", source, patient, fee) +end, false) + +-- --------------------------------------------------------------------------- +-- /911 +-- --------------------------------------------------------------------------- + +RegisterCommand("911", function(source, args) + if source == 0 then + return print("[rp_medic] /911 must be used by a player in game, not from the console") + end + + local message = table.concat(args, " ", 1, args.n or #args) + message = message:match("^%s*(.-)%s*$") + if message == "" then + return say(source, "Usage : /911 ") + end + + local pos = Open77.players.position(source) + local where = "position inconnue" + if pos then + where = ("%d, %d, %d"):format(round(pos.x), round(pos.y), round(pos.z)) + end + local callerName = Open77.players.name(source) or ("joueur " .. source) + local alert = { + author = "911", + text = ("%s (id %d) en %s : %s"):format(callerName, source, where, message), + color = COLOR_ALERT, + } + + local notified = 0 + local mode = "jobs" + for _, id in ipairs(Open77.players.all()) do + if id ~= source then + local roles, m = rolesOf(id) + mode = m + if roles.medic or roles.police then + local ok, reason = Open77.chat.send(id, alert) + if ok then + notified = notified + 1 + else + log("911 alert to player %d failed: %s", id, tostring(reason)) + end + end + end + end + + if mode == "acl" then + say(source, ("Appel transmis à %d intervenant(s) (service rp_jobs indisponible : police non joignable)."):format(notified)) + else + say(source, ("Appel transmis à %d intervenant(s) (médecins et police)."):format(notified)) + end + log("player %d called 911 at %s notified=%d", source, where, notified) +end, false) + +-- --------------------------------------------------------------------------- +-- /medic +-- --------------------------------------------------------------------------- + +RegisterCommand("medic", function(source) + if source == 0 then + return print("[rp_medic] /medic must be used by a player in game, not from the console") + end + + local medics = {} + local callerIsMedic = false + for _, id in ipairs(Open77.players.all()) do + local roles = rolesOf(id) + if roles.medic then + if id == source then + callerIsMedic = true + else + local metres = Open77.players.distance(source, id) + medics[#medics + 1] = { + id = id, + name = Open77.players.name(id) or ("joueur " .. id), + metres = metres, + } + end + end + end + + table.sort(medics, function(a, b) + if a.metres and b.metres then + return a.metres < b.metres + end + return a.metres ~= nil and b.metres == nil + end) + + if #medics == 0 then + if callerIsMedic then + say(source, "Aucun autre médecin connecté.") + else + say(source, "Aucun médecin connecté.") + end + else + say(source, ("%d médecin(s) en service :"):format(#medics)) + for _, medic in ipairs(medics) do + Wait(0) -- two sends in the same tick arrive in reverse order + local dist = medic.metres and ("%.0f m"):format(medic.metres) or "distance inconnue" + say(source, ("- %s (id %d) : %s"):format(medic.name, medic.id, dist)) + end + end + + if callerIsMedic then + local line = statsLine(source) + if line then + Wait(0) + say(source, line) + end + end +end, false) + +-- --------------------------------------------------------------------------- +-- Lifecycle and chat suggestions +-- --------------------------------------------------------------------------- + +local SUGGESTIONS = { + { + command = "/soin", + help = "Soigner un patient vivant à moins de 5 m (médecin, 100 €$)", + parameters = { { name = "idJoueur", help = "Identifiant du patient (/id)" } }, + }, + { + command = "/reanimer", + help = "Réanimer un patient mort à moins de 5 m (médecin, 300 €$)", + parameters = { { name = "idJoueur", help = "Identifiant du patient (/id)" } }, + }, + { + command = "/911", + help = "Alerter les médecins et la police avec votre position", + parameters = { { name = "message", help = "Ce qui se passe" } }, + }, + { + command = "/medic", + help = "Médecins connectés et leur distance", + }, +} + +local function publishSuggestions(target) + local ok, reason = Open77.chat.addSuggestions(target, SUGGESTIONS) + if not ok then + log("addSuggestions(%s) failed: %s", tostring(target), tostring(reason)) + end +end + +AddEventHandler("onResourceStart", function(name) + if name ~= RESOURCE then + return + end + publishSuggestions(-1) + log("started: fees heal=%d revive=%d, range=%.0f m, cooldown=%d s", HEAL_FEE, REVIVE_FEE, RANGE_M, COOLDOWN_S) +end) + +RegisterNetEvent("chat:ready", function() + if type(source) ~= "number" or source < 1 then + return + end + publishSuggestions(source) +end) + +-- Host lifecycle arguments are strings: convert before touching the cooldown table. +AddEventHandler("onPlayerDisconnected", function(playerId) + local id = tonumber(playerId) + if id then + cooldowns[id] = nil + end +end) diff --git a/evals/rp-round/rp_selftest/open77.lua b/evals/rp-round/rp_selftest/open77.lua new file mode 100644 index 0000000..4c0aded --- /dev/null +++ b/evals/rp-round/rp_selftest/open77.lua @@ -0,0 +1,11 @@ +resource "rp_selftest" +version "0.1.0" +auto_start true + +-- Exercises the cross-resource contracts of the RP round from the server +-- alone (no player): the economy and jobs exports, their events, and the +-- refusals each must give. Prints one PASS/FAIL line per check. +dependency "rp_economy" +dependency "rp_jobs" + +server_script "server/main.lua" diff --git a/evals/rp-round/rp_selftest/server/main.lua b/evals/rp-round/rp_selftest/server/main.lua new file mode 100644 index 0000000..df76d58 --- /dev/null +++ b/evals/rp-round/rp_selftest/server/main.lua @@ -0,0 +1,55 @@ +-- rp_selftest: server-only checks of the RP round's cross-resource contracts. +-- Runs once at start; every line is "[rp_selftest] PASS|FAIL -- detail". + +local passed, failed = 0, 0 + +local function check(name, ok, detail) + if ok then + passed = passed + 1 + print(("[rp_selftest] PASS %s -- %s"):format(name, tostring(detail or ""))) + else + failed = failed + 1 + print(("[rp_selftest] FAIL %s -- %s"):format(name, tostring(detail or ""))) + end +end + +local function call(resource, name, ...) + local ok, a, b = pcall(function(...) + return exports[resource][name](exports[resource], ...) + end, ...) + if not ok then return nil, "error: " .. tostring(a) end + return a, b +end + +local changed = {} +AddEventHandler("rp_economy:changed", function(playerId, balance, delta, reason) + changed[#changed + 1] = { playerId = playerId, balance = balance, delta = delta, reason = reason } +end) + +AddEventHandler("onResourceStart", function(name) + if name ~= GetCurrentResourceName() then return end + CreateThread(function() + Wait(500) + -- economy: argument validation must refuse without touching anything + local v, r = call("rp_economy", "add", "x", 10, "test") + check("economy.add refuses a non-numeric id", v == nil and type(r) == "string", r) + v, r = call("rp_economy", "add", 999999, -5, "test") + check("economy.add refuses a negative amount", v == nil and type(r) == "string", r) + v, r = call("rp_economy", "getBalance", 999999) + check("economy.getBalance answers a number for an unknown player", type(v) == "number", tostring(v) .. " " .. tostring(r)) + v, r = call("rp_economy", "remove", 999999, 1, "test") + check("economy.remove refuses when the player is unknown or has no funds", v == nil and type(r) == "string", r) + + -- jobs + v, r = call("rp_jobs", "getJob", 999999) + check("jobs.getJob answers nil for an unknown player", v == nil, tostring(v) .. " " .. tostring(r)) + v, r = call("rp_jobs", "hasJob", 999999, "livreur") + check("jobs.hasJob answers false for an unknown player", v == false, tostring(v) .. " " .. tostring(r)) + v, r = call("rp_jobs", "hasJob", "abc", "livreur") + check("jobs.hasJob survives a bad id", v == false or v == nil, tostring(v) .. " " .. tostring(r)) + + Wait(200) + check("economy.changed was not raised by refused calls", #changed == 0, #changed .. " event(s)") + print(("[rp_selftest] done: %d passed, %d failed"):format(passed, failed)) + end) +end) diff --git a/evals/rp-round/rp_shop/README.md b/evals/rp-round/rp_shop/README.md new file mode 100644 index 0000000..312e551 --- /dev/null +++ b/evals/rp-round/rp_shop/README.md @@ -0,0 +1,63 @@ +# rp_shop — boutique serveur + +Ressource **serveur uniquement** : une boutique pour serveur RP. L'argent passe +toujours par les exports de `rp_economy`, jamais par cette ressource. + +## Commandes + +| Commande | Effet | +|---|---| +| `/shop` | Affiche le catalogue (une ligne par objet, groupé par famille) et ton solde. | +| `/buy ` | Débite le prix via `rp_economy`, puis livre l'objet. Fonds insuffisants ou système monétaire hors ligne : rien n'est livré. Si la livraison échoue (natif refusé), tu es remboursé et la raison est affichée. | +| `/sell ` | Uniquement pour les véhicules achetés ici : retire ton dernier véhicule de ce modèle encore en circulation et rembourse 50 % du prix. Sans argument : ton dernier véhicule acheté ici, quel que soit le modèle. | + +Un joueur mort ne peut ni acheter ni vendre. La console serveur est refusée +(`/shop` depuis la console imprime seulement le catalogue dans le log). + +## Catalogue + +| Objet | Famille | Prix | Livraison | +|---|---|---|---| +| `soin` | consommable | 50 $ | vie au maximum (`Open77.stats.restore` health) | +| `stim` | consommable | 30 $ | endurance au maximum (`Open77.stats.restore` stamina) | +| `armure` | consommable | 150 $ | armure 100 (`Open77.players.setArmor`) | +| `pistolet` | arme | 400 $ | `Items.Preset_Lexington_Default`, emplacement 1 | +| `fusil` | arme | 1 200 $ | `Items.Preset_Carnage_Default`, emplacement 2 | +| `katana` | arme | 900 $ | `Items.Preset_Katana_Default`, emplacement 3 | +| `hella` | véhicule | 15 000 $ | `Vehicle.v_standard2_archer_hella_player`, à 3,5 m à ta droite, même orientation | +| `quadra` | véhicule | 60 000 $ | `Vehicle.v_sport1_quadra_turbo_r_player`, idem | + +Les armes sont livrées par le relais client `open77_weapons` : la commande +répond « livraison en cours », puis « Livré » (ou remboursement) quand le client +a confirmé. Les véhicules sont créés `persistent = true` : seul `/sell` (ou un +admin) les retire. + +## Persistance + +Les véhicules achetés sont mémorisés dans le KVP de la ressource sous la clé +`garage:` (JSON), donc `/sell` survit à un rechargement de +la ressource. Une entrée dont le véhicule n'existe plus (ou dont l'id a été +recyclé pour un autre modèle) est ignorée puis purgée. + +## Dépendances et permissions + +- `dependency "rp_economy"` — exports `getBalance`, `add`, `remove`. +- `dependency "open77_weapons >=0.1.0"` — relais des armes. +- `network.events` (armes, `chat:ready`), `world.vehicles`, `players.stats.apply`, + `players.life.read`. + +Si `rp_economy` n'est pas chargée, la boutique reste utilisable en lecture +(`/shop`) et refuse les achats avec un message clair. + +## Tester en 2 minutes + +1. Démarrer le serveur avec `rp_economy`, `open77_weapons` et `rp_shop` ; se + connecter et être vivant. +2. `/shop` — le catalogue s'affiche en trois familles, puis ton solde. +3. `/buy soin` — le log serveur affiche `[rp_shop] player bought soin for 50` + et la vie est au maximum. Sans argent : « Fonds insuffisants ». +4. `/buy katana` — « livraison en cours… » puis « Livré : Katana (emplacement 3) ». +5. `/buy hella` — une Archer Hella apparaît à ta droite, orientée comme toi. +6. `/sell hella` — la voiture disparaît, 7 500 $ sont recrédités. +7. `/sell hella` à nouveau — « Tu n'as aucun Archer Hella (berline) acheté ici + encore en circulation. » diff --git a/evals/rp-round/rp_shop/open77.lua b/evals/rp-round/rp_shop/open77.lua new file mode 100644 index 0000000..60aa9db --- /dev/null +++ b/evals/rp-round/rp_shop/open77.lua @@ -0,0 +1,17 @@ +resource "rp_shop" +version "1.0.0" +auto_start true + +-- Money goes through rp_economy's server exports; weapons through the +-- official open77_weapons client relay (Open77.weapons.assign). +dependency "rp_economy" +dependency "open77_weapons >=0.1.0" + +permissions { + "network.events", -- Open77.weapons.assign, RegisterNetEvent (chat:ready) + "world.vehicles", -- Open77.vehicles.create / get / remove + "players.stats.apply", -- Open77.players.restoreHealth / setArmor, Open77.stats.restore + "players.life.read", -- Open77.players.isDead +} + +server_script "server/main.lua" diff --git a/evals/rp-round/rp_shop/server/main.lua b/evals/rp-round/rp_shop/server/main.lua new file mode 100644 index 0000000..afbe6ce --- /dev/null +++ b/evals/rp-round/rp_shop/server/main.lua @@ -0,0 +1,553 @@ +-- rp_shop: server-authoritative shop for an RP server. +-- +-- /shop lists the catalogue, grouped by family +-- /buy charges the price through rp_economy, then delivers +-- /sell sells back the last shop-bought vehicle of that model (50 %) +-- +-- Money is never touched here: every charge and refund goes through the +-- rp_economy server exports. Vehicles bought here are remembered per durable +-- player identifier in this resource's KVP store, so /sell survives a reload. + +local RESOURCE = GetCurrentResourceName() +local LOG = "[" .. RESOURCE .. "]" + +-- Catalogue: `id` is what the player types. Families are listed in this +-- order by /shop. Prices are eurodollars. +local CATALOGUE = { + { id = "soin", family = "consommable", label = "Kit de soin (vie au maximum)", price = 50, kind = "restore", pool = "health" }, + { id = "stim", family = "consommable", label = "Stimulant (endurance au maximum)", price = 30, kind = "restore", pool = "stamina" }, + { id = "armure", family = "consommable", label = "Gilet pare-balles (armure 100)", price = 150, kind = "armor", amount = 100 }, + { id = "pistolet", family = "arme", label = "Pistolet M-10AF Lexington", price = 400, kind = "weapon", record = "Items.Preset_Lexington_Default", slot = 1 }, + { id = "fusil", family = "arme", label = "Fusil à pompe Carnage", price = 1200, kind = "weapon", record = "Items.Preset_Carnage_Default", slot = 2 }, + { id = "katana", family = "arme", label = "Katana", price = 900, kind = "weapon", record = "Items.Preset_Katana_Default", slot = 3 }, + { id = "hella", family = "vehicule", label = "Archer Hella (berline)", price = 15000, kind = "vehicle", record = "Vehicle.v_standard2_archer_hella_player" }, + { id = "quadra", family = "vehicule", label = "Quadra Turbo-R (hypercar)", price = 60000, kind = "vehicle", record = "Vehicle.v_sport1_quadra_turbo_r_player" }, +} + +local FAMILIES = { + { key = "consommable", title = "Consommables" }, + { key = "arme", title = "Armes" }, + { key = "vehicule", title = "Véhicules" }, +} + +local SELL_RATIO = 0.5 -- refund on /sell +local VEHICLE_SIDE_OFFSET = 3.5 -- metres to the player's right +local POSITION_MAX_AGE_MS = 5000 -- reject a stale player snapshot +local WEAPON_FALLBACK_MS = 15000 -- refund if the weapon relay never answers + +local byId = {} +for _, item in ipairs(CATALOGUE) do + byId[item.id] = item +end + +-- Vehicles bought and not yet sold, keyed by durable identifier: +-- garages[identifier] = { { id, record, item, price, at }, ... } (oldest first). +local garages = {} + +-- Weapon deliveries waiting for open77:weapons:completed, keyed by request id. +local pendingWeapons = {} + +local SUGGESTIONS = { + { command = "/shop", help = "Affiche le catalogue de la boutique" }, + { command = "/buy", help = "Achète un objet de la boutique", + parameters = { { name = "objet", help = "soin, stim, armure, pistolet, fusil, katana, hella, quadra" } } }, + { command = "/sell", help = "Revend un véhicule acheté ici (50 % du prix)", + parameters = { { name = "objet", help = "hella ou quadra (vide = dernier véhicule)" } } }, +} + +-- --------------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------------- + +local function say(playerId, text) + local ok, reason = Open77.chat.send(playerId, text) + if not ok then + print(LOG .. " chat.send refused for player " .. tostring(playerId) .. ": " .. tostring(reason)) + end +end + +local function priceLabel(amount) + return tostring(math.floor(tonumber(amount) or 0)) .. " $" +end + +local function identifierOf(playerId) + local identifier = Open77.players.identifier(playerId) + if identifier == nil or identifier == "" then + return nil + end + return tostring(identifier) +end + +-- Every economy call is synchronous and RAISES when rp_economy is missing or +-- refuses the call, so it is wrapped. Returns: status, value, reason +-- status = "offline" the export could not be reached (value = error text) +-- status = "refused" the export answered nil, reason +-- status = "ok" value is the new balance +local function ecoRemove(playerId, amount, reason) + local ok, balance, why = pcall(function() + return exports.rp_economy:remove(playerId, amount, reason) + end) + if not ok then + return "offline", tostring(balance) + end + if balance == nil then + return "refused", nil, tostring(why or "unknown") + end + return "ok", balance +end + +local function ecoAdd(playerId, amount, reason) + local ok, balance, why = pcall(function() + return exports.rp_economy:add(playerId, amount, reason) + end) + if not ok then + return "offline", tostring(balance) + end + if balance == nil then + return "refused", nil, tostring(why or "unknown") + end + return "ok", balance +end + +local function ecoBalance(playerId) + local ok, balance = pcall(function() + return exports.rp_economy:getBalance(playerId) + end) + if ok and type(balance) == "number" then + return balance + end + return nil +end + +-- Refund after a failed delivery; tells the player either way. +local function refund(playerId, item, why) + local status, value, reason = ecoAdd(playerId, item.price, "remboursement " .. item.id) + if status == "ok" then + say(playerId, ("Livraison impossible (%s) : %s remboursé."):format(tostring(why), priceLabel(item.price))) + print(LOG .. " player " .. tostring(playerId) .. " refunded " .. item.price .. " for " .. item.id .. " (" .. tostring(why) .. ")") + else + say(playerId, ("Livraison impossible (%s) et remboursement impossible (%s) : contacte un admin."):format(tostring(why), tostring(reason or value))) + print(LOG .. " REFUND FAILED for player " .. tostring(playerId) .. " item " .. item.id .. " amount " .. item.price .. ": " .. tostring(reason or value)) + end +end + +-- Refuses the console and dead players; returns true when the caller may trade. +local function canTrade(source) + if source == 0 then + print(LOG .. " cette commande se lance depuis le jeu, pas depuis la console") + return false + end + local dead, reason = Open77.players.isDead(source) + if dead == nil then + say(source, "Impossible de vérifier ton état (" .. tostring(reason or "inconnu") .. "), réessaie dans un instant.") + return false + end + if dead then + say(source, "Tu es mort : la boutique ne sert pas les cadavres.") + return false + end + return true +end + +-- --------------------------------------------------------------------------- +-- Garage ledger (KVP, keyed by durable identifier) +-- --------------------------------------------------------------------------- + +local function garageKey(identifier) + return "garage:" .. identifier +end + +local function loadGarage(identifier) + if garages[identifier] then + return garages[identifier] + end + local list = {} + local raw = Open77.kvp.get(garageKey(identifier), nil) + if type(raw) == "string" and raw ~= "" then + local decoded = json.decode(raw) + if type(decoded) == "table" then + for _, entry in ipairs(decoded) do + if type(entry) == "table" and entry.id ~= nil and type(entry.item) == "string" then + list[#list + 1] = entry + end + end + else + print(LOG .. " garage ledger of " .. identifier .. " is corrupt, starting empty") + end + end + garages[identifier] = list + return list +end + +local function saveGarage(identifier) + local list = garages[identifier] or {} + local ok, reason + if #list == 0 then + -- false with no reason just means the key was already absent + ok, reason = Open77.kvp.delete(garageKey(identifier)) + ok = ok or reason == nil + else + local encoded = json.encode(list) + if not encoded then + print(LOG .. " could not encode the garage ledger of " .. identifier) + return + end + ok, reason = Open77.kvp.set(garageKey(identifier), encoded) + end + if not ok then + print(LOG .. " kvp write failed for " .. identifier .. ": " .. tostring(reason)) + end +end + +-- The ledger entry's vehicle, if it still exists and is still ours. +local function ledgerVehicle(entry) + local vehicle = Open77.vehicles.get(entry.id) + if not vehicle then + return nil + end + -- Guard against a recycled id pointing at somebody else's car. + if vehicle.record ~= entry.record then + return nil + end + if vehicle.resource ~= nil and vehicle.resource ~= RESOURCE then + return nil + end + return vehicle +end + +-- Drops entries whose vehicle is gone; returns true when something changed. +local function pruneGarage(identifier) + local list = garages[identifier] + if not list then + return false + end + local kept, changed = {}, false + for _, entry in ipairs(list) do + if ledgerVehicle(entry) then + kept[#kept + 1] = entry + else + changed = true + end + end + if changed then + garages[identifier] = kept + saveGarage(identifier) + end + return changed +end + +-- --------------------------------------------------------------------------- +-- Delivery +-- --------------------------------------------------------------------------- + +local function deliverRestore(playerId, item) + local ok, reason = Open77.stats.restore(playerId, item.pool) + if not ok then + return false, reason or "refus" + end + return true +end + +local function deliverArmor(playerId, item) + local ok, reason = Open77.players.setArmor(playerId, item.amount) + if not ok then + return false, reason or "refus" + end + return true +end + +-- Asynchronous: the money is already taken, the answer comes back on +-- open77:weapons:completed (or never, hence the fallback thread). +local function deliverWeapon(playerId, item) + local requestId, reason = Open77.weapons.assign(playerId, item.record, item.slot, { active = true }) + if not requestId then + return false, reason or "refus" + end + local key = tostring(requestId) + pendingWeapons[key] = { playerId = playerId, item = item } + CreateThread(function() + Wait(WEAPON_FALLBACK_MS) + local pending = pendingWeapons[key] + if pending then + pendingWeapons[key] = nil + print(LOG .. " weapon request " .. key .. " never completed for player " .. tostring(playerId)) + refund(playerId, item, "pas de réponse du client") + end + end) + return true, nil, "pending" +end + +AddEventHandler("open77:weapons:completed", function(playerId, requestId, operation, accepted, reason, result) + local pending = pendingWeapons[tostring(requestId)] + if not pending then + return + end + pendingWeapons[tostring(requestId)] = nil + local target = tonumber(playerId) or pending.playerId + local okAccepted = (accepted == true) or (accepted == "true") + if okAccepted then + local slot = (type(result) == "table" and result.slot) or pending.item.slot + say(target, ("Livré : %s (emplacement %s)."):format(pending.item.label, tostring(slot))) + print(LOG .. " player " .. tostring(target) .. " received " .. pending.item.id) + else + refund(target, pending.item, tostring(reason or operation or "refus")) + end +end) + +-- Spawns the car beside the player, facing the same way. `me` is a fresh +-- Open77.players.get snapshot validated by the caller. +local function deliverVehicle(playerId, item, me, identifier) + local yaw = tonumber(me.heading) or tonumber(me.yaw) or 0.0 + -- REDengine: yaw 0 faces +y, +x is the entity's right. A positive yaw is + -- taken as a counter-clockwise turn seen from above, so the right-hand + -- unit vector is (cos, sin); a wrong sign only swaps left and right. + local rad = math.rad(yaw) + local rx, ry = math.cos(rad), math.sin(rad) + local vehicleId, reason = Open77.vehicles.create({ + record = item.record, + position = { + x = me.position.x + rx * VEHICLE_SIDE_OFFSET, + y = me.position.y + ry * VEHICLE_SIDE_OFFSET, + z = me.position.z, + }, + yaw = yaw, + bucket = me.bucket, + persistent = true, -- the player's property: only /sell removes it + }) + if not vehicleId then + return false, reason or "refus" + end + local list = loadGarage(identifier) + list[#list + 1] = { + id = vehicleId, + record = item.record, + item = item.id, + price = item.price, + at = math.floor(Open77.time.unix()), + } + saveGarage(identifier) + return true, nil, vehicleId +end + +-- --------------------------------------------------------------------------- +-- Commands +-- --------------------------------------------------------------------------- + +RegisterCommand("shop", function(source, args, raw) + if source == 0 then + for _, item in ipairs(CATALOGUE) do + print(LOG .. " " .. item.id .. " - " .. item.label .. " - " .. priceLabel(item.price)) + end + return + end + local lines = { "=== Boutique === (/buy , /sell )" } + for _, family in ipairs(FAMILIES) do + lines[#lines + 1] = "-- " .. family.title .. " --" + for _, item in ipairs(CATALOGUE) do + if item.family == family.key then + lines[#lines + 1] = (" %s : %s - %s"):format(item.id, item.label, priceLabel(item.price)) + end + end + end + local balance = ecoBalance(source) + if balance then + lines[#lines + 1] = "Ton solde : " .. priceLabel(balance) + else + lines[#lines + 1] = "Système monétaire hors ligne : les achats sont impossibles pour le moment." + end + -- Two sends in the same tick arrive in reverse order: one line per tick. + for _, line in ipairs(lines) do + say(source, line) + Wait(0) + end +end, false) + +RegisterCommand("buy", function(source, args, raw) + if not canTrade(source) then + return + end + local wanted = args[1] and string.lower(args[1]) or nil + local item = wanted and byId[wanted] or nil + if not item then + say(source, "Usage : /buy . Objets : soin, stim, armure, pistolet, fusil, katana, hella, quadra (/shop pour les prix).") + return + end + + -- Everything that can refuse for free is checked BEFORE the money moves. + local me, identifier + if item.kind == "vehicle" then + identifier = identifierOf(source) + if not identifier then + say(source, "Identité introuvable : impossible d'enregistrer le véhicule à ton nom.") + return + end + local reason + me, reason = Open77.players.get(source) + if not me then + say(source, "Position inconnue (" .. tostring(reason or "inconnu") .. ") : réessaie dans un instant.") + return + end + if not me.position or (me.ageMs or 0) > POSITION_MAX_AGE_MS then + say(source, "Position trop ancienne : bouge un peu puis réessaie.") + return + end + end + + local status, balance, reason = ecoRemove(source, item.price, "achat " .. item.id) + if status == "offline" then + say(source, "Système monétaire hors ligne : achat impossible pour le moment.") + print(LOG .. " rp_economy unreachable for player " .. tostring(source) .. ": " .. tostring(balance)) + return + end + if status == "refused" then + if reason == "insufficient_funds" then + local have = ecoBalance(source) + say(source, ("Fonds insuffisants : %s coûte %s%s."):format(item.label, priceLabel(item.price), + have and (", tu as " .. priceLabel(have)) or "")) + else + say(source, "Paiement refusé (" .. tostring(reason) .. ").") + end + return + end + print(LOG .. " player " .. tostring(source) .. " bought " .. item.id .. " for " .. item.price) + + local ok, why, extra + if item.kind == "restore" then + ok, why = deliverRestore(source, item) + elseif item.kind == "armor" then + ok, why = deliverArmor(source, item) + elseif item.kind == "weapon" then + ok, why, extra = deliverWeapon(source, item) + elseif item.kind == "vehicle" then + ok, why, extra = deliverVehicle(source, item, me, identifier) + else + ok, why = false, "objet mal configuré" + end + + if not ok then + refund(source, item, why) + return + end + if item.kind == "weapon" then + say(source, ("Achat de %s pour %s, livraison en cours... (solde : %s)"):format(item.label, priceLabel(item.price), priceLabel(balance))) + elseif item.kind == "vehicle" then + say(source, ("Achat de %s pour %s : il t'attend à côté de toi (solde : %s)."):format(item.label, priceLabel(item.price), priceLabel(balance))) + print(LOG .. " vehicle " .. tostring(extra) .. " created for player " .. tostring(source)) + else + say(source, ("Achat de %s pour %s (solde : %s)."):format(item.label, priceLabel(item.price), priceLabel(balance))) + end +end, false) + +RegisterCommand("sell", function(source, args, raw) + if not canTrade(source) then + return + end + local wanted = args[1] and string.lower(args[1]) or nil + local item = wanted and byId[wanted] or nil + if wanted and not item then + say(source, "Objet inconnu. Usage : /sell (vide = ton dernier véhicule).") + return + end + if item and item.kind ~= "vehicle" then + say(source, "Seuls les véhicules achetés ici se revendent (hella, quadra).") + return + end + + local identifier = identifierOf(source) + if not identifier then + say(source, "Identité introuvable : impossible de retrouver tes véhicules.") + return + end + local list = loadGarage(identifier) + pruneGarage(identifier) + list = garages[identifier] + + -- The last still-existing shop-bought vehicle (of that model, if given). + local index + for i = #list, 1, -1 do + if (not item or list[i].item == item.id) and ledgerVehicle(list[i]) then + index = i + break + end + end + if not index then + if item then + say(source, "Tu n'as aucun " .. item.label .. " acheté ici encore en circulation.") + else + say(source, "Tu n'as aucun véhicule acheté ici encore en circulation.") + end + return + end + local entry = list[index] + local sold = byId[entry.item] + local refundAmount = math.floor((tonumber(entry.price) or (sold and sold.price) or 0) * SELL_RATIO) + local label = sold and sold.label or entry.item + + -- Money first: a refund that fails leaves the car with its owner. + local status, balance, reason = ecoAdd(source, refundAmount, "vente " .. entry.item) + if status == "offline" then + say(source, "Système monétaire hors ligne : vente impossible pour le moment.") + print(LOG .. " rp_economy unreachable for player " .. tostring(source) .. ": " .. tostring(balance)) + return + end + if status == "refused" then + say(source, "Remboursement refusé (" .. tostring(reason) .. ") : vente annulée.") + return + end + + local removed = Open77.vehicles.remove(entry.id) + if not removed then + -- Take the refund back; the player keeps the car. + local back = ecoRemove(source, refundAmount, "annulation vente " .. entry.item) + if back ~= "ok" then + print(LOG .. " could not take back the refund of " .. refundAmount .. " from player " .. tostring(source)) + end + say(source, "Le véhicule n'a pas pu être retiré : vente annulée.") + return + end + table.remove(list, index) + saveGarage(identifier) + say(source, ("%s vendu pour %s (solde : %s)."):format(label, priceLabel(refundAmount), priceLabel(balance))) + print(LOG .. " player " .. tostring(source) .. " sold " .. entry.item .. " (vehicle " .. tostring(entry.id) .. ") for " .. refundAmount) +end, false) + +-- --------------------------------------------------------------------------- +-- Lifecycle +-- --------------------------------------------------------------------------- + +-- Host event: every argument is a string. Keep the ledgers honest when a car +-- disappears for any reason (explosion cleanup, admin removal...). +AddEventHandler("onVehicleRemoved", function(vehicleId, reason) + local gone = tostring(vehicleId) + for identifier, list in pairs(garages) do + for i = #list, 1, -1 do + if tostring(list[i].id) == gone then + table.remove(list, i) + saveGarage(identifier) + end + end + end +end) + +-- Forget the in-memory ledger of a departed player; the KVP copy stays. +AddEventHandler("onPlayerDisconnected", function(playerId, reason) + local identifier = Open77.players.identifier(playerId) + if identifier ~= nil and identifier ~= "" then + garages[tostring(identifier)] = nil + end +end) + +RegisterNetEvent("chat:ready", function() + if type(source) ~= "number" or source == 0 then + return + end + Open77.chat.addSuggestions(source, SUGGESTIONS) +end) + +AddEventHandler("onResourceStart", function(name) + if name ~= RESOURCE then + return + end + Open77.chat.addSuggestions(-1, SUGGESTIONS) + print(LOG .. " boutique ouverte : " .. #CATALOGUE .. " objets, /shop pour le catalogue") +end) diff --git a/src/server.ts b/src/server.ts index a6009f5..94feb48 100644 --- a/src/server.ts +++ b/src/server.ts @@ -313,8 +313,9 @@ export function createMcpServer(context: ServerContext): McpServer { { title: "Game data catalogues", description: - "Look up spawnable names: vehicles, weapons, items (clothing), npc-templates, props, vfx, sfx, animations, animsets. " + - "Query matches the record/path/name; returns the fields the server itself answers Open77.data.* from.", + "Look up spawnable names. Arguments: `catalogue` (one of vehicles, weapons, items (clothing), npc-templates, props, vfx, sfx, " + + "animations, animsets) and an optional `query`. Query matches the record/path/name; returns the fields the server itself " + + "answers Open77.data.* from. Vehicle records a player may spawn end in `_player` (the others are quest/scene vehicles).", inputSchema: { catalogue: z.string().describe("vehicles | weapons | items | npc-templates | props | vfx | sfx | animations | animsets"), query: z.string().optional().describe("Substring or words to match; omit for the catalogue summary"),