diff --git a/evals/RESULTS.md b/evals/RESULTS.md index dfc4063..0902492 100644 --- a/evals/RESULTS.md +++ b/evals/RESULTS.md @@ -128,3 +128,73 @@ native, the not-in-build native and the undeclared permission every time, with l Not changed: `open77_data` has no enum for weather presets (they live in the guide prose), and the reason lists of `setTime`/`setWeather` differ between card and guide section. + +## Round 3, 2026-09-16 20:00–21:40: two agents on 0.1.1, then a probe on the server + +Two subagents, `@open2077/mcp@0.1.1` only (`harness/mcp-call.mjs`), one task each; the +resources are in `round3/`. Both validated clean on the first pass and ran on the private stock +release-75 server through the MCP's live tools (`refresh`, `ensure`, `announce`, `motd`, `lock`, +`whosin`; console refusals correct, no runtime error). What they reported wrong or ambiguous was +then **measured** with `round3/probe-op77.75.lua` (a resource that prints what each call answers +at chunk top level, in `onResourceStart` and from a command) before anything was rewritten. + +| Resource | Task | MCP calls | `open77_validate` | Live | +|---|---|---|---|---| +| `eval_announce` | admin `/announce` toast + chat, `/motd`, suggestions on `chat:ready` | 44 | OK first try | started; `announce` reached toast + chat, `motd` printed | +| `eval_carlock` | `/lock` `/whosin` on the caller's vehicle, `onPlayerLeftVehicle` | 40 | OK first try | started; console refusals correct | + +**Measured on op77.75** (each line is the probe's output, not a reading of the source): + +- `Open77.chat.broadcast / addSuggestions / send` at chunk top level → `false, resource_preparing`; + `Open77.notifications.broadcast` and `TriggerClientEvent` at top level → **work**. The + 0.1.1 cards said the opposite for notifications. +- `Open77.chat.send("1", …)` → `false, invalid_chat_target`: a string id is refused, and every + host event (`onPlayerReady`, `onPlayerDisconnected`, …) delivers string ids. The identity + guide's own `onPlayerReady` example did exactly that. +- `os`, `io`, `debug`, `package`, `require`, `load`, `loadfile`, `dofile`, `collectgarbage` → + nil; `math`, `string`, `table`, `utf8`, `coroutine`, `json` present (`math.type` too). +- `Open77.vehicles.flags.paintApplied` = 512; `occupantInSeat(unknown, "driver")` → + `nil, "vehicle_not_found"`; `getPlayerSeat(unknown)` → a single `nil`. +- `RegisterCommand` handler: `source` is a number (0 = console), `args` is `table.pack` of + **string** tokens (`args.n`), `raw` is `name arg arg` without the `/`; the console splits on + spaces only (quotes stay literal), chat splits shell-style. + +**What changed because of it** (base #25, app #4, devkit 0.1.2): + +- The constant tables (`Open77.vehicles.seats/flags/…`) read "NOT AVAILABLE on op77.75 + (unreleased)" while every guide used them: the release surface never listed a table of + scalars. They carry `since op77.45` now, and `open77_validate` checks a dotted reference that + is not a call (`Open77.vehicles.flags.locked`, `local send = Open77.chat.send`) the same way + it checks a call — both agents had guarded the tables with `pcall`. +- `TriggerEvent`, `Open77.chat.*`, `Open77.events.emit*` listed none of the bus refusals + (`resource_preparing`, `event_queue_limit`, `invalid_event_name`, …); the readers follow the + `EventFailure(...)` helpers, tuple returns, the array-registered bus doors, the + `local eventTrigger = __open77_trigger_event` aliases, and a member that returns another + member. `Open77.chat.broadcast` (which "lists no reasons while claiming to be exactly + send(-1)") now carries `send`'s. +- `open77_events` showed payloads for 4 of 92 lifecycle events, one of them wrong + (`onResourceStart (resourceName, revision)` was the neighbouring bus row's). 76 of 92 carry a + payload; a table row feeds only the events in its first cell, prose signatures and handler + parameter lists fill the rest. `chat:ready` is documented (client net event, no arguments, + `source` = the player). +- `open77_validate` accepted `os.time()` and `require`; it names each absent library and + function with the replacement (`Open77.time.unix()`, more `server_script` lines), and on the + client side `setmetatable` / `coroutine.create`. It warned that the notifications guide's + `dependency "open77_notifications >=1.0.0"` "is not a resource slug": the runtime grammar + (`name`, then `>=`/`<=`/`>`/`<`/`=`/`==` constraints) is parsed, and a malformed constraint is + an error. +- `open77_search "register command admin only restricted"` ranked the client `RegisterCommand` + above the server one; a side named in the question is now a filter, and the tool says so. +- `open77_changes to=main` lists what exists on main and in no published build; a card prints + "reasons: none found in the handler" instead of omitting the line, and a description equal to + the summary is not printed twice (`GetCurrentResourceName`, now `shared`). +- `server-api#resource-lifecycle-events` returned 40 lines of cyberware/dash/reflex prose: the + guide filed five feature summaries and the namespaced-equivalents table under that heading. + They have their own headings, and the guide has a "What the sandbox provides" section. +- The manifest schema lacked `exports` / `server_exports` (A20), which made + `extract-schemas.py` refuse to run. + +Not changed: whether a server toast sent at `onPlayerReady` is queued or lost while the client +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. diff --git a/evals/round3/eval_announce/open77.lua b/evals/round3/eval_announce/open77.lua new file mode 100644 index 0000000..008effa --- /dev/null +++ b/evals/round3/eval_announce/open77.lua @@ -0,0 +1,14 @@ +resource "eval_announce" +version "1.0.0" +open77_version "*" +auto_start true + +-- Open77.notifications.send / broadcast and RegisterNetEvent all require network.events +-- (per their native cards). Open77.chat.* and RegisterCommand check no permission. +permissions { "network.events" } + +-- The server notification natives route to the official open77_notifications client +-- package; the notifications guide declares it as a dependency. +dependency "open77_notifications" + +server_script "server/main.lua" diff --git a/evals/round3/eval_announce/server/main.lua b/evals/round3/eval_announce/server/main.lua new file mode 100644 index 0000000..581dec8 --- /dev/null +++ b/evals/round3/eval_announce/server/main.lua @@ -0,0 +1,226 @@ +-- eval_announce / server/main.lua +-- +-- Server-only resource. +-- /announce admin-only (RegisterCommand restricted = true, so the caller must hold +-- the ACL right `command.announce`; the dedicated console is always allowed). +-- Shows the text to every player as a toast and as a chat line. +-- /motd any player: the current message of the day as a private toast. +-- +-- Anything that reaches clients (notifications, chat suggestions) is refused with +-- `resource_preparing` while the chunk is loading, so nothing below runs at top level: +-- everything is inside onResourceStart, a command, or an event handler. + +local RESOURCE_NAME = "eval_announce" + +local MAX_MESSAGE_BYTES = 384 -- notifications definition reference: message max 384 UTF-8 bytes +local MAX_TITLE_BYTES = 96 -- title max 96 UTF-8 bytes +local MOTD_DURATION_MS = 8000 -- timed values are 750-120000 +local ANNOUNCE_DURATION_MS = 10000 +local MOTD_DELAY_MS = 3000 -- onPlayerReady is a barrier lifting, not "world is up" + +local motd = nil + +-- Every reason the natives document, mapped to a readable sentence. Unknown reasons still +-- produce a message: nothing here may crash on an unexpected string or on nil. +local REASON_TEXT = { + -- Open77.notifications.send / Open77.notifications.broadcast + ["network_unavailable"] = "the network transport is unavailable", + ["permission_denied:network.events"] = "the manifest does not declare network.events", + ["duplicate_notification_id"] = "a notification with that id is already live for this resource", + ["resource_preparing"] = "the resource chunk is still loading, clients cannot be reached yet", + -- Open77.chat.send / broadcast / addSuggestion / addSuggestions + ["invalid_chat_message"] = "the chat message is malformed", + ["invalid_chat_target"] = "the chat target is not a connected player id or -1", + ["invalid_chat_command"] = "the suggested command text is invalid", + ["invalid_chat_suggestion"] = "the suggestion descriptor is malformed", +} + +local function describe(reason) + if reason == nil then + return "no reason given" + end + return REASON_TEXT[reason] or ("unexpected reason '" .. tostring(reason) .. "'") +end + +local function log(fmt, ...) + print(("[%s] " .. fmt):format(RESOURCE_NAME, ...)) +end + +local function truncateBytes(text, maxBytes) + text = tostring(text or "") + if #text <= maxBytes then + return text + end + return text:sub(1, maxBytes - 3) .. "..." +end + +-- source is the player id, or 0 for the dedicated console. +local function isConsole(source) + return source == nil or tonumber(source) == 0 +end + +-- Answer the caller: chat for a player, the log for the console. Never assumes the chat +-- facade succeeded. +local function reply(source, text) + if isConsole(source) then + log("%s", text) + return + end + local ok, reason = Open77.chat.send(source, { type = "system", author = RESOURCE_NAME, text = text }) + if not ok then + log("reply to player %s failed: %s", tostring(source), describe(reason)) + end +end + +-- Chat autocomplete entries for both commands. +local SUGGESTIONS = { + { + command = "/announce", + help = "Admin: show a message to every player (toast + chat)", + parameters = { { name = "text", help = "The announcement text" } }, + }, + { + command = "/motd", + help = "Show the message of the day", + }, +} + +local function publishSuggestions(target) + local ok, reason = Open77.chat.addSuggestions(target, SUGGESTIONS) + if not ok then + log("could not publish chat suggestions to %s: %s", tostring(target), describe(reason)) + return false + end + return true +end + +-- Private toast with the current message of the day. Returns true on success, or +-- false plus a sentence explaining why. +local function sendMotd(playerId) + if motd == nil then + return false, "no message of the day is set" + end + local id, reason = Open77.notifications.send(playerId, { + type = "info", + title = "Message of the day", + message = motd, + icon = "MOTD", + position = "top_center", + durationMs = MOTD_DURATION_MS, + }) + if id == nil then + return false, "motd toast failed: " .. describe(reason) + end + return true +end + +-- Resource start: set the message of the day, and advertise the commands to everyone +-- already connected (their chat:ready fired before this resource existed). +AddEventHandler("onResourceStart", function(name) + if name ~= GetCurrentResourceName() then + return + end + motd = truncateBytes("Welcome to the server. Be nice, have fun, and type /motd to read this again.", MAX_MESSAGE_BYTES) + log("message of the day set: %s", motd) + publishSuggestions(-1) +end) + +-- A player's chat UI came up: publish this resource's suggestions to that player. +-- `source` is the authenticated connection (RegisterNetEvent). A RegisterNetEvent handler also +-- receives host-wide bus events, where source is not set, so validate it before use. +RegisterNetEvent("chat:ready", function(...) + local target = source + if isConsole(target) then + local first = ... + if first ~= nil then + target = first + else + log("chat:ready without a usable source; suggestions not published") + return + end + end + publishSuggestions(target) +end) + +-- The join-time readiness gate lifted for a player: show them the message of the day. +-- playerId arrives as a string, which Open77.notifications.send accepts. +AddEventHandler("onPlayerReady", function(playerId, detail) + if playerId == nil then + log("onPlayerReady without a player id (detail=%s); motd skipped", tostring(detail)) + return + end + CreateThread(function() + Wait(MOTD_DELAY_MS) + local ok, why = sendMotd(playerId) + if not ok then + log("motd for player %s not shown: %s", tostring(playerId), tostring(why)) + end + end) +end) + +-- /announce -- restricted: ACL right command.announce (console always allowed). +RegisterCommand("announce", function(source, args, raw) + local words = {} + for _, value in ipairs(args or {}) do + words[#words + 1] = tostring(value) + end + local text = table.concat(words, " ") + text = text:match("^%s*(.-)%s*$") or "" + if text == "" then + reply(source, "Usage: /announce ") + return + end + + local body = truncateBytes(text, MAX_MESSAGE_BYTES) + local who = "SERVER" + if not isConsole(source) then + who = Open77.players.name(source) or ("player " .. tostring(source)) + end + + local problems = {} + + -- 1. toast to everyone + local id, reason = Open77.notifications.broadcast({ + type = "warning", + title = truncateBytes("Announcement from " .. who, MAX_TITLE_BYTES), + message = body, + icon = "!", + position = "top_center", + durationMs = ANNOUNCE_DURATION_MS, + }) + if id == nil then + problems[#problems + 1] = "toast: " .. describe(reason) + end + + -- 2. chat line to everyone (system message, positional RGB colour) + local ok, chatReason = Open77.chat.broadcast({ + type = "system", + author = "ANNOUNCE", + text = ("[%s] %s"):format(who, body), + color = { 255, 196, 0 }, + }) + if not ok then + problems[#problems + 1] = "chat: " .. describe(chatReason) + end + + if #problems == 0 then + log("announcement by %s: %s", who, body) + reply(source, "Announcement sent.") + else + local summary = table.concat(problems, "; ") + log("announcement by %s partially failed (%s): %s", who, summary, body) + reply(source, "Announcement problems: " .. summary) + end +end, true) + +-- /motd -- any player; the console gets the text in its log. +RegisterCommand("motd", function(source, args, raw) + if isConsole(source) then + log("message of the day: %s", motd or "(none set)") + return + end + local ok, why = sendMotd(source) + if not ok then + reply(source, "Could not show the message of the day: " .. tostring(why)) + end +end, false) diff --git a/evals/round3/eval_carlock/open77.lua b/evals/round3/eval_carlock/open77.lua new file mode 100644 index 0000000..b41e43b --- /dev/null +++ b/evals/round3/eval_carlock/open77.lua @@ -0,0 +1,11 @@ +resource "eval_carlock" +version "0.1.0" +auto_start true + +-- Every native this resource calls that is gated by a permission +-- (getPlayerSeat, get, setLocked, occupantInSeat) requires world.vehicles. +-- seatName, isLocked, players.name, chat.send, RegisterCommand and +-- AddEventHandler check no permission. +permissions { "world.vehicles" } + +server_script "server/main.lua" diff --git a/evals/round3/eval_carlock/server/main.lua b/evals/round3/eval_carlock/server/main.lua new file mode 100644 index 0000000..cdbadf7 --- /dev/null +++ b/evals/round3/eval_carlock/server/main.lua @@ -0,0 +1,260 @@ +-- eval_carlock -- server side only. +-- +-- /lock toggle the entry lock of the vehicle the caller sits in +-- /whosin list the occupants of the caller's vehicle, seat by seat +-- +-- Everything below is written from the Open77 devkit MCP alone +-- (answering for build 2.31.13+op77.75). + +local RESOURCE_TAG = "[eval_carlock]" + +-- Vehicles this resource locked, keyed by the vehicle id as a string. +-- Event arguments arrive as strings on the server, while getPlayerSeat +-- answers an integer, so both are normalised through vehicleKey(). +local lockedByUs = {} + +local function vehicleKey(id) + local n = tonumber(id) + if n == nil then return tostring(id) end + return tostring(n) +end + +local function log(fmt, ...) + print(("%s %s"):format(RESOURCE_TAG, fmt:format(...))) +end + +-- Chat delivery can itself fail (invalid_chat_message, invalid_chat_target); +-- a console caller (source 0) gets the line printed instead. +local function tell(source, text) + if source == nil or source == 0 then + log("%s", text) + return + end + local ok, reason = Open77.chat.send(source, text) + if not ok then + log("chat.send to %s failed: %s -- %s", tostring(source), tostring(reason), text) + end +end + +-- Display name, never nil: players.name answers nil for a departed session. +local function nameOf(playerId) + local name = Open77.players.name(playerId) + if name == nil or name == "" then + return ("player #%s"):format(tostring(playerId)) + end + return name +end + +-- -------------------------------------------------------------------------- +-- Vehicle flag bits. +-- +-- The MCP documents a public constant table `Open77.vehicles.flags` +-- (engineOn = 1, locked = 2, destroyed = 4, exploded = 8, invulnerable = 16, +-- immortal = 32, lightsOn = 64, highBeams = 128, sirenOn = 256, +-- paintApplied = 512) but marks it NOT AVAILABLE on build 2.31.13+op77.75. +-- The same values are documented in the guides +-- `vehicles#entry-lock-and-synchronized-horn` and +-- `data-reference#vehicle-seats-doors-windows-and-state-bits`, so the table +-- is used when the runtime has it and the documented values otherwise. +-- -------------------------------------------------------------------------- +local DOCUMENTED_FLAGS = { + engineOn = 1, locked = 2, destroyed = 4, exploded = 8, + invulnerable = 16, immortal = 32, lightsOn = 64, highBeams = 128, + sirenOn = 256, paintApplied = 512, +} + +local function vehicleFlagBits() + local ok, t = pcall(function() return Open77.vehicles.flags end) + if ok and type(t) == "table" and type(t.locked) == "number" then + return t, "Open77.vehicles.flags" + end + return DOCUMENTED_FLAGS, "documented values (constant table absent on this build)" +end + +local FLAGS, FLAGS_SOURCE = vehicleFlagBits() + +-- Reads the locked state from the snapshot's `flags` integer. Returns nil +-- when the snapshot carries no usable flags field. +local function lockedFromFlags(snapshot) + if snapshot == nil then return nil end + local flags = tonumber(snapshot.flags) + if flags == nil then return nil end + -- Lua 5.4 bitwise operators accept an integral float and raise on + -- anything else; the pcall turns that raise into "unknown". + local ok, locked = pcall(function() return (flags & FLAGS.locked) ~= 0 end) + if not ok then return nil end + return locked +end + +-- -------------------------------------------------------------------------- +-- Seats. Canonical names come from the MCP's data reference; the order is +-- the one freeSeats() documents (driver, frontPassenger, rearLeft, rearRight). +-- `Open77.vehicles.seats` (driver = -1 ...) is documented but NOT AVAILABLE +-- on this build, so the seats are named by their documented aliases and +-- resolved through Open77.vehicles.seatName, which needs no permission. +-- -------------------------------------------------------------------------- +local SEAT_ORDER = { "driver", "frontPassenger", "rearLeft", "rearRight" } +local SEAT_LABEL = { + driver = "Driver", + frontPassenger = "Front passenger", + rearLeft = "Rear left", + rearRight = "Rear right", +} + +-- Failure messages for the lock natives' documented reasons. +local LOCK_FAILURE = { + invalid_argument = "The vehicle id was refused as invalid.", + ["permission_denied:world.vehicles"] = "This resource lacks the world.vehicles permission.", + vehicle_not_found = "That vehicle no longer exists.", + vehicles_unavailable = "The vehicle service is unavailable right now.", +} + +local SEAT_FAILURE = { + invalid_seat = "the seat name was refused", + vehicle_not_found = "the vehicle no longer exists", +} + +-- Resolves the caller's vehicle. Returns vehicleId, assignment or nil, message. +local function callerVehicle(source) + if source == nil or source == 0 then + return nil, nil, "This command must be run by a player seated in a vehicle." + end + local seat = Open77.vehicles.getPlayerSeat(source) + if seat == nil then + return nil, nil, "You are not in a vehicle." + end + local vehicleId = seat.vehicleId + if vehicleId == nil then + return nil, nil, "Your seat assignment carries no vehicle id." + end + return vehicleId, seat, nil +end + +-- -------------------------------------------------------------------------- +-- /lock +-- -------------------------------------------------------------------------- +RegisterCommand("lock", function(source, args, raw) + local vehicleId, seat, why = callerVehicle(source) + if vehicleId == nil then + return tell(source, why) + end + + local car = Open77.vehicles.get(vehicleId) + if car == nil then + return tell(source, "Your vehicle could not be read (unknown id).") + end + + -- Current state, from the snapshot's flags bits first; the derived + -- `locked` boolean and isLocked() are fallbacks in case the snapshot + -- carries no flags field. + local currentlyLocked = lockedFromFlags(car) + if currentlyLocked == nil then + if type(car.locked) == "boolean" then + currentlyLocked = car.locked + else + currentlyLocked = Open77.vehicles.isLocked(vehicleId) + end + end + if currentlyLocked == nil then + return tell(source, "The lock state of your vehicle could not be determined.") + end + + local wantLocked = not currentlyLocked + local ok, reason = Open77.vehicles.setLocked(vehicleId, wantLocked) + if not ok then + local msg = LOCK_FAILURE[reason] or ("Lock change refused: " .. tostring(reason)) + log("setLocked(%s, %s) by %s failed: %s", vehicleKey(vehicleId), tostring(wantLocked), + nameOf(source), tostring(reason)) + return tell(source, msg) + end + + local key = vehicleKey(vehicleId) + if wantLocked then + lockedByUs[key] = { by = source, at = Open77.time.unix() } + else + lockedByUs[key] = nil + end + + -- Report the state the server now holds, not the one we asked for. + local after = Open77.vehicles.isLocked(vehicleId) + if after == nil then after = wantLocked end + tell(source, ("Vehicle %s is now %s."):format(key, after and "locked" or "unlocked")) + log("%s %s vehicle %s from seat %s (flags read via %s)", nameOf(source), + after and "locked" or "unlocked", key, tostring(seat.seat), FLAGS_SOURCE) +end, false) + +-- -------------------------------------------------------------------------- +-- /whosin +-- -------------------------------------------------------------------------- +RegisterCommand("whosin", function(source, args, raw) + local vehicleId, seat, why = callerVehicle(source) + if vehicleId == nil then + return tell(source, why) + end + + tell(source, ("Occupants of vehicle %s:"):format(vehicleKey(vehicleId))) + Wait(0) -- chat sends in one tick arrive in reverse order; keep the header first + + for _, alias in ipairs(SEAT_ORDER) do + local canonical = Open77.vehicles.seatName(alias) or alias + local label = SEAT_LABEL[alias] or alias + + -- occupantInSeat answers: playerId, occupantRecord + -- or: nil (seat free) + -- or: nil, reason (invalid_seat, vehicle_not_found) + local occupant, extra = Open77.vehicles.occupantInSeat(vehicleId, alias) + local line + if occupant == nil then + if type(extra) == "string" then + line = ("%s (%s): unreadable, %s"):format(label, canonical, + SEAT_FAILURE[extra] or tostring(extra)) + if extra == "vehicle_not_found" then + tell(source, line) + return + end + else + line = ("%s (%s): empty"):format(label, canonical) + end + else + local state = "" + if type(extra) == "table" then + if extra.entering then state = " (entering)" end + if extra.exiting then state = " (exiting)" end + end + local who = nameOf(occupant) + if tostring(occupant) == tostring(source) then who = who .. " (you)" end + line = ("%s (%s): %s%s"):format(label, canonical, who, state) + end + tell(source, line) + -- Two sends in the same tick can arrive out of order; keep the list readable. + Wait(0) + end +end, false) + +-- -------------------------------------------------------------------------- +-- A player left a vehicle. onPlayerLeftVehicle(playerId, vehicleId, seat) +-- is the derived, reserved platform event; arguments arrive as strings. +-- -------------------------------------------------------------------------- +AddEventHandler("onPlayerLeftVehicle", function(playerId, vehicleId, seat) + if vehicleId == nil then return end + local key = vehicleKey(vehicleId) + local entry = lockedByUs[key] + if entry == nil then return end + log("%s left vehicle %s (seat %s) -- that vehicle was locked by this resource (by %s)", + nameOf(playerId), key, tostring(seat), nameOf(entry.by)) +end) + +-- Forget vehicles that are gone so the table does not grow forever. +AddEventHandler("onVehicleRemoved", function(id, reason) + if id == nil then return end + local key = vehicleKey(id) + if lockedByUs[key] ~= nil then + lockedByUs[key] = nil + log("vehicle %s removed (%s); it was locked by this resource", key, tostring(reason)) + end +end) + +AddEventHandler("onResourceStart", function(name) + if name ~= Open77.resource.name() then return end + log("started; vehicle flag bits from %s (locked = %d)", FLAGS_SOURCE, FLAGS.locked) +end) diff --git a/evals/round3/probe-op77.75.lua b/evals/round3/probe-op77.75.lua new file mode 100644 index 0000000..7e7a72c --- /dev/null +++ b/evals/round3/probe-op77.75.lua @@ -0,0 +1,35 @@ +-- eval_probe: runtime truths for the devkit notes (top level = chunk load) +local function show(label, ...) + local parts = {} + for i = 1, select("#", ...) do parts[#parts + 1] = tostring((select(i, ...))) end + print(("[probe] %s -> %s"):format(label, table.concat(parts, ", "))) +end +show("top-level notifications.broadcast", Open77.notifications.broadcast({ type = "info", title = "probe", message = "top" })) +show("top-level chat.broadcast", Open77.chat.broadcast("probe top-level")) +show("top-level chat.addSuggestions(-1)", Open77.chat.addSuggestions(-1, { { command = "/probe", help = "x" } })) +show("top-level TriggerClientEvent", TriggerClientEvent("probe:x", -1, { a = 1 })) +show("chat.send('1')", Open77.chat.send("1", "string id")) +show("chat.send(1)", Open77.chat.send(1, "int id")) +show("chat.send(nil)", Open77.chat.send(nil, "nil id")) +show("os", os); show("io", io); show("debug", debug); show("package", package); show("require", require) +show("load", load); show("dofile", dofile); show("loadfile", loadfile); show("collectgarbage", collectgarbage) +show("math", math ~= nil, "string", string ~= nil, "table", table ~= nil, "utf8", utf8 ~= nil, "coroutine", coroutine ~= nil, "json", json ~= nil) +show("math.type", math.type, "math.tointeger", math.tointeger) +show("vehicles.flags.paintApplied", Open77.vehicles.flags.paintApplied) +show("vehicles.seats.driver", Open77.vehicles.seats.driver, "occupantFlags.entering", Open77.vehicles.occupantFlags.entering) +show("vehicles.occupantInSeat(999999,'driver')", Open77.vehicles.occupantInSeat(999999, "driver")) +show("vehicles.seatFree(999999,'driver')", Open77.vehicles.seatFree(999999, "driver")) +show("vehicles.getPlayerSeat(1)", Open77.vehicles.getPlayerSeat(1)) +show("vehicles.getPlayerSeat('1')", Open77.vehicles.getPlayerSeat("1")) +show("players.name('1')", Open77.players.name("1")) +show("GetCurrentResourceName", GetCurrentResourceName(), Open77.resource.name()) +AddEventHandler("onResourceStart", function(name) + if name ~= GetCurrentResourceName() then return end + show("onResourceStart notifications.broadcast", Open77.notifications.broadcast({ type = "info", title = "probe", message = "start" })) + show("onResourceStart chat.broadcast", Open77.chat.broadcast("probe onResourceStart")) + show("onResourceStart chat.addSuggestions(-1)", Open77.chat.addSuggestions(-1, { { command = "/probe", help = "x" } })) + show("onResourceStart TriggerClientEvent", TriggerClientEvent("probe:x", -1, { a = 1 })) +end) +RegisterCommand("probe", function(source, args, raw) + show("probe cmd source", source, type(source)); show("probe cmd args.n", args.n, type(args[1]), args[1], args[2]); show("probe cmd raw", raw) +end, false) diff --git a/package-lock.json b/package-lock.json index 18f2171..104438d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@open2077/mcp", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@open2077/mcp", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index c28b4c9..b852e70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open2077/mcp", - "version": "0.1.1", + "version": "0.1.2", "description": "Open77 Devkit MCP: the Open77 Lua API, permissions, events and game data for coding agents, pinned to the server build you run.", "license": "MIT", "type": "module", diff --git a/skill/SKILL.md b/skill/SKILL.md index 98e9f40..75c3b7a 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -46,11 +46,14 @@ shape: the guides were written from measurements in the real game. resource may not raise; use your own `myresource:*` names for your events. 7. **Server-side actions on a player who is not alive crash the client.** Check the player's state before teleporting, spawning into, or reviving; gamemode guides show the guarded pattern. -8. **Nothing reaches a client before the resource is running.** A `TriggerClientEvent`, a chat - suggestion or a notification at the top level of a server script answers - `nil, "resource_preparing"`: do those from `AddEventHandler("onResourceStart", ...)`, a - command, an event or a later tick. Lifecycle handlers (`onResourceStart`, `onPlayerReady`, - `onPlayerDisconnected`) are listed by `open77_events` under `prefix=lifecycle`. +8. **The host bus is closed until the resource is running.** `TriggerEvent` and every + `Open77.chat.*` facade at the top level of a server script answer `false, "resource_preparing"` + (measured on op77.75): send from `AddEventHandler("onResourceStart", ...)`, a command, an event + or a later tick. `TriggerClientEvent` and `Open77.notifications.*` work at top level. Lifecycle + handlers (`onResourceStart`, `onPlayerReady`, `onPlayerDisconnected`) are listed by + `open77_events` under `prefix=lifecycle`, with their payloads; **every argument of a host event + is a string**, and `Open77.chat.send` refuses a string id (`invalid_chat_target`), so + `tonumber(playerId)` before handing one to a native that wants a number. 9. **A new resource must be admitted by `resources.load`.** A server provisioned by the first-run wizard lists its resources by name; add yours to `server.jsonc`, then `refresh` + `ensure ` at the console. `refresh` rescans manifests but never re-reads `server.jsonc`, so a @@ -59,6 +62,12 @@ shape: the guides were written from measurements in the real game. 10. **Generation cleanup is automatic, ownership is not.** Handlers, timers and entities a resource creates are swept when it stops; exports that act on another resource's behalf must use `GetInvokingResource()`, never a name passed as an argument. +11. **The sandbox has `math`, `string`, `table`, `utf8`, `coroutine` and `json` -- nothing else.** + `os`, `io`, `debug`, `package`, `require`, `load`, `loadfile`, `dofile` and `collectgarbage` + are nil on both sides (the client also removes `setmetatable`/`getmetatable` and + `coroutine.create/resume/wrap`; use `CreateThread`). Wall-clock time is `Open77.time.unix()`, + elapsed time `Open77.time.monotonic()`; more files are more `server_script` lines, not + `require`. `open77_validate` flags each of these. ## Manifest diff --git a/src/index/refresh.ts b/src/index/refresh.ts index 2479102..cbed317 100644 --- a/src/index/refresh.ts +++ b/src/index/refresh.ts @@ -4,7 +4,7 @@ * 1. `OPEN77_INDEX_DIR`, an explicit directory (development, CI); * 2. the cache under `~/.open77/mcp/index//`, refreshed from the CDN * (`https://cdn.open2077.net/dev-index//`) when the manifest's - * ETag changed, at most once a day unless forced; + * ETag changed, checked at most once an hour unless forced (`--refresh`); * 3. the snapshot embedded in the package (`index/`), which is what a first * run and an offline run answer from. * @@ -25,7 +25,10 @@ import type { IndexManifest } from "./types.js"; export const CDN_BASE = process.env["OPEN77_CDN_BASE"] ?? "https://cdn.open2077.net"; export const INDEX_PREFIX = "dev-index"; export const CACHE_ROOT = process.env["OPEN77_MCP_CACHE"] ?? path.join(os.homedir(), ".open77", "mcp"); -const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; +// One manifest GET per build per hour: a corrected index published mid-day +// (eval round 3 fixed cards at 19:47) reached nobody for a day at 24 h, the +// hosted endpoint included, since its ten-minute re-resolve honours this too. +const REFRESH_INTERVAL_MS = 60 * 60 * 1000; const FETCH_TIMEOUT_MS = 8000; export interface LatestIndex { diff --git a/src/server.ts b/src/server.ts index 47a2e2d..a6009f5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -51,12 +51,14 @@ export function renderCard(card: ApiCard, context: ServerContext, guides: Guide[ lines.push(`# ${cardHeader(card)}`); lines.push(""); lines.push(card.summary); - if (card.description) lines.push("", card.description); + // A card whose description is its summary (a global documented by one table + // row) would print the sentence twice. + if (card.description && card.description.trim() !== card.summary.trim()) lines.push("", card.description); lines.push(""); lines.push(`- runtime: ${card.runtime} (${card.api_set})`); lines.push(`- permissions: ${card.permissions.length ? card.permissions.join(", ") : "none checked in the handler"}`); lines.push(`- since: ${card.since ?? "not in any published build (main only)"}`); - if (card.reasons.length) lines.push(`- reasons it can return: ${card.reasons.join(", ")}`); + lines.push(`- reasons it can return: ${card.reasons.length ? card.reasons.join(", ") : card.constant ? "none (a table, not a call)" : "none found in the handler; a refusal it forwards from a callee may still reach you as a second return value"}`); if (card.returns.length) lines.push(`- returns: ${card.returns.join("; ")}`); if (card.inferred) lines.push("- signature: inferred from the handler, not reviewed"); const available = availabilityNote(card, context); @@ -112,7 +114,9 @@ export function createMcpServer(context: ServerContext): McpServer { title: "Search the Open77 API and guides", description: "Lexical search over the Open77 Lua natives, guides, open77:* events, manifest permissions and FiveM equivalents. " + - "Use it first; then open77_api / open77_guide for the full text. Returns the build it answers for.", + "Use it first; then open77_api / open77_guide for the full text. Returns the build it answers for. " + + "Pass runtime=server (or client) when writing one side: several names (RegisterCommand, TriggerEvent, Open77.vehicles.get) " + + "exist on both with different contracts; a query containing the word server or client applies that filter itself.", inputSchema: { query: z.string().min(1).describe("Words, a native name (Open77.vehicles.spawn), a FiveM name, an event or a permission"), kind: z.enum(["card", "guide", "event", "permission", "fivem"]).optional().describe("Restrict to one kind"), @@ -122,7 +126,12 @@ export function createMcpServer(context: ServerContext): McpServer { annotations: { readOnlyHint: true, openWorldHint: false }, }, async ({ query, kind, runtime, limit }) => { - const hits = search.search(query, { kinds: kind ? [kind as SearchKind] : undefined, runtime, limit: limit ?? 12 }); + // "register command admin only" ranked the client RegisterCommand above the + // server one for a server resource (eval 3): a side named in the question + // is a filter, unless the caller chose one. + const named = /\b(server|client)(?:-side| side)?\b/i.exec(query)?.[1]?.toLowerCase() as "server" | "client" | undefined; + const side = runtime ?? named; + const hits = search.search(query, { kinds: kind ? [kind as SearchKind] : undefined, runtime: side, limit: limit ?? 12 }); if (!hits.length) { // A question the index cannot answer is a documentation gap. One JSON // line on stderr; the hosted container's log is what the digest reads. @@ -256,7 +265,7 @@ export function createMcpServer(context: ServerContext): McpServer { if (wanted === "lifecycle") return Boolean(e.lifecycle) || !e.name.startsWith("open77:"); return e.name.toLowerCase().startsWith(wanted) || e.name.toLowerCase().includes(wanted); }); - const lines = events.map((e) => `- ${e.name} [${e.sides.join(", ") || "guide only"}]${e.payload ? ` payload ${e.payload}` : ""}${e.guides.length ? ` — ${e.guides.map((g) => `${g.file.replace(/\.md$/, "")}#${g.anchor}`).slice(0, 2).join(", ")}` : ""}`); + const lines = events.map((e) => `- ${e.name} [${e.sides.join(", ") || "guide only"}]${e.payload ? ` payload ${e.payload}` : e.lifecycle ? " payload: not documented in any guide" : ""}${e.guides.length ? ` — ${e.guides.map((g) => `${g.file.replace(/\.md$/, "")}#${g.anchor}`).slice(0, 3).join(", ")}` : ""}`); const reserved = prefix ? [] : index.events.reservedPrefixes.map((p) => p.prefix); const hint = events.length === 0 && wanted ? [`Nothing named like "${prefix}". Lifecycle handlers (AddEventHandler("onPlayerReady", ...)) are listed under prefix=lifecycle; an index built before 2026-09-16 carries only open77:* names, in which case the guide is the source: open77_search "${prefix}".`] @@ -425,14 +434,25 @@ export function createMcpServer(context: ServerContext): McpServer { description: "Which natives a newer server build adds compared to an older one, from the since field. Useful to answer 'what do I gain by updating' or 'why does this work on my dev box and not on the owner's server'.", inputSchema: { from: z.string().describe("Older build, e.g. 2.31.13+op77.54 or just 54"), - to: z.string().optional().describe("Newer build; default the served build"), + to: z.string().optional().describe("Newer build; default the served build. `main` lists what exists on main and in no published build yet (since null)"), }, annotations: { readOnlyHint: true, openWorldHint: false }, }, async ({ from, to }) => { const fromN = /^\d+$/.test(from) ? Number(from) : opNumber(from); + if (to && /^(main|unreleased|next)$/i.test(to)) { + // What a server owner cannot run yet: cards no snapshot registers. + const pending = index.cards.filter((c) => !c.since); + const later = index.cards.filter((c) => c.since && opNumber(c.since) > fromN); + const lines = [ + `${pending.length} natives on main and in no published build (unusable on any server today), ${later.length} released after op77.${fromN}`, + ...pending.map((c) => `- ${cardHeader(c)} — ${c.summary}`), + ]; + if (later.length) lines.push("", `Released after op77.${fromN}: open77_changes from=${fromN} lists them by build.`); + return text(lines.join("\n")); + } const toN = to ? (/^\d+$/.test(to) ? Number(to) : opNumber(to)) : opNumber(context.resolved.build); - if (fromN < 0 || toN < 0) return text("Builds are written 2.31.13+op77.NN (or just NN)."); + if (fromN < 0 || toN < 0) return text("Builds are written 2.31.13+op77.NN (or just NN), or `main` for the unreleased surface."); const added = index.cards.filter((c) => c.since && opNumber(c.since) > fromN && opNumber(c.since) <= toN); const byBuild = new Map(); for (const c of added) byBuild.set(c.since!, [...(byBuild.get(c.since!) ?? []), c]); diff --git a/src/workspace/validate.ts b/src/workspace/validate.ts index 1e73d8e..2e9b616 100644 --- a/src/workspace/validate.ts +++ b/src/workspace/validate.ts @@ -122,6 +122,16 @@ async function expandGlob(root: string, pattern: string): Promise { const RE_NATIVE = /\b((?:Open77|WebUI|Citizen|MySQL)(?:\.[A-Za-z_]\w*)+)\s*\(/g; const RE_GLOBAL = /(? { const findings: Finding[] = []; @@ -153,7 +163,15 @@ export async function validateResource(dir: string, context: ServerContext): Pro if (!knownPermissions.has(permission)) findings.push({ severity: "error", file: "open77.lua", message: `permission ${permission} is not one the runtime enforces`, fix: "open77_permissions lists the real names" }); } for (const dependency of manifest.lists["dependencies"] ?? []) { - if (!/^[a-z][a-z0-9_]{2,63}$/.test(dependency)) findings.push({ severity: "warning", file: "open77.lua", message: `dependency ${dependency} is not a resource slug` }); + // The runtime grammar (ServerResourceHost.ValidateDependency): a name, then + // optional constraints separated by spaces -- `open77_notifications >=1.0.0`. + const [depName, ...constraints] = dependency.trim().split(/\s+/); + if (!depName || !/^[a-z][a-z0-9_]{2,63}$/.test(depName)) findings.push({ severity: "warning", file: "open77.lua", message: `dependency ${dependency}: ${depName ?? dependency} is not a resource slug` }); + for (const constraint of constraints) { + if (!/^(?:>=|<=|==|=|>|<)\d+(?:\.\d+){0,2}$/.test(constraint)) { + findings.push({ severity: "error", file: "open77.lua", message: `dependency ${dependency}: constraint ${constraint} is not with op in >=, <=, >, <, =, == and a 1-3 part version`, fix: `dependency "${depName} >=1.0.0"` }); + } + } } type Side = "client" | "server" | "shared"; @@ -183,6 +201,7 @@ export async function validateResource(dir: string, context: ServerContext): Pro } const globalsBySide = { client: new Set(), server: new Set() }; for (const card of index.cards) if (card.namespace === "_G") globalsBySide[card.runtime].add(card.name); + const namespaces = new Set(index.cards.map((c) => c.namespace)); const usedPermissions = new Map>(); for (const script of scripts) { @@ -198,6 +217,12 @@ export async function validateResource(dir: string, context: ServerContext): Pro } const stripped = code.replace(RE_COMMENT_BLOCK, (m) => m.replace(/[^\n]/g, " ")).replace(RE_COMMENT_LINE, ""); const lines = stripped.split("\n"); + // A script that defines its own `load` or `os` is not reaching for the + // absent library; the sandbox checks skip names it declares as locals. + const localNames = new Set(); + for (const m of stripped.matchAll(/\blocal\s+(?:function\s+)?([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)/g)) { + for (const n of m[1]!.split(",")) localNames.add(n.trim()); + } for (let i = 0; i < lines.length; i += 1) { const line = lines[i]!; const check = (qualified: string, isGlobal: boolean) => { @@ -228,6 +253,32 @@ export async function validateResource(dir: string, context: ServerContext): Pro const global = match[1]!; if (globalsBySide.client.has(global) || globalsBySide.server.has(global)) check(global, true); } + for (const match of line.matchAll(RE_NATIVE_REF)) { + const segments = match[1]!.split("."); + // Longest catalogued prefix with at least a namespace and a member. + let hit: string | null = null; + for (let n = segments.length; n >= 3; n -= 1) { + const candidate = segments.slice(0, n).join("."); + if (byQualified.has(candidate)) { hit = candidate; break; } + } + if (hit) check(hit, false); + else if (segments.length >= 3 && namespaces.has(segments.slice(0, 2).join("."))) { + // A member of a real namespace that no card describes: as unknown as a call would be. + findings.push({ severity: "error", file: script.file, line: i + 1, message: `${segments.slice(0, 3).join(".")} is not in the catalogue for ${resolved.build}: it does not exist on this build`, fix: `open77_namespace ${segments.slice(0, 2).join(".")} lists the members` }); + } + } + for (const match of line.matchAll(RE_SANDBOX_LIBRARY)) { + if (!localNames.has(match[1]!)) findings.push({ severity: "error", file: script.file, line: i + 1, message: `${match[1]} is nil in the ${script.side === "shared" ? "client and server" : script.side} sandbox (no os, io, debug or package library)`, fix: match[1] === "os" ? "time: Open77.time.unix() / GetUnixTime() (wall clock), Open77.time.monotonic() / GetGameTimer() (elapsed); persistence: Open77.kvp or the database API" : "the sandbox has math, string, table, utf8, coroutine and json only" }); + } + for (const match of line.matchAll(RE_SANDBOX_FUNCTION)) { + if (!localNames.has(match[1]!)) findings.push({ severity: "error", file: script.file, line: i + 1, message: `${match[1]} is nil in the sandbox (no require, load, loadfile, dofile or collectgarbage)`, fix: match[1] === "require" ? "list every file in the manifest (server_script / client_script / shared_script) instead; a later file sees the globals of an earlier one" : "there is no code loading at run time" }); + } + if (script.side !== "server") { + for (const match of line.matchAll(RE_CLIENT_SANDBOX)) { + const what = match[1] ?? match[2]!; + if (!localNames.has(what)) findings.push({ severity: "error", file: script.file, line: i + 1, message: `${what} is nil in the client sandbox`, fix: what.startsWith("coroutine") ? "CreateThread(fn) / Wait(ms) are the client's coroutines" : "metatables are not reachable from a resource" }); + } + } } } diff --git a/tests/index.test.ts b/tests/index.test.ts index 712926a..d16de3c 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -203,6 +203,40 @@ test("a scaffolded resource validates clean; a broken one does not", async () => assert.ok(messages.some((m) => /Open77.nope.thing is not in the catalogue/.test(m)), messages.join("\n")); assert.ok(messages.some((m) => /Open77.camera.attach is a client native used in a server script/.test(m)), messages.join("\n")); assert.ok(messages.some((m) => /network.events is required by TriggerClientEvent/.test(m)), messages.join("\n")); + + // Eval 3: the sandbox, the dependency grammar and references that are not calls. + const sand = path.join(root, "sand_res"); + await mkdir(path.join(sand, "server"), { recursive: true }); + await mkdir(path.join(sand, "client"), { recursive: true }); + await writeFile(path.join(sand, "open77.lua"), [ + 'resource "sand_res"', + 'dependency "open77_notifications >=1.0.0"', + 'dependency "open77_zones >=abc"', + 'server_script "server/main.lua"', + 'client_script "client/main.lua"', + "", + ].join("\n"), "utf8"); + await writeFile(path.join(sand, "server/main.lua"), [ + "local when = os.time()", + 'local helper = require("helper")', + "local ghost = Open77.vehicles.nothing.here", + "local send = Open77.chat.send", + "local ns = Open77.vehicles", + "local function load(x) return x end", + "print(load(1))", + "", + ].join("\n"), "utf8"); + await writeFile(path.join(sand, "client/main.lua"), "local t = setmetatable({}, {})\nlocal co = coroutine.create(function() end)\n", "utf8"); + const sandbox = await validateResource(sand, ctx); + const errors = sandbox.filter((f) => f.severity === "error").map((f) => `${f.file}:${f.line ?? 0} ${f.message}`); + assert.ok(errors.some((m) => /^server\/main.lua:1 os is nil/.test(m)), errors.join("\n")); + assert.ok(errors.some((m) => /^server\/main.lua:2 require is nil/.test(m)), errors.join("\n")); + assert.ok(errors.some((m) => /^server\/main.lua:3 Open77.vehicles.nothing is not in the catalogue/.test(m)), errors.join("\n")); + assert.ok(!errors.some((m) => /Open77.chat.send|Open77.vehicles is not|load is nil/.test(m)), errors.join("\n")); + assert.ok(errors.some((m) => /^client\/main.lua:1 setmetatable is nil in the client sandbox/.test(m)), errors.join("\n")); + assert.ok(errors.some((m) => /^client\/main.lua:2 coroutine.create is nil in the client sandbox/.test(m)), errors.join("\n")); + assert.ok(errors.some((m) => /constraint >=abc is not/.test(m)), errors.join("\n")); + assert.ok(!sandbox.some((f) => /open77_notifications >=1.0.0/.test(f.message)), "the runtime's dependency grammar is accepted"); } finally { await rm(root, { recursive: true, force: true }); }