From ed73e6e01ecffc511636c7e4dcbf762078a390e2 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 20 Sep 2026 02:11:32 +0200 Subject: [PATCH] camera and screen: the view is something you have to give back Both namespaces hand a resource something it must return, and both fail in a way that looks like a crash from the player's chair. The cameras guide opens its release section with "a stuck camera is indistinguishable from a crash", and the screen slot is single and shared with the server relay. The platform already releases the view on stop, reload, an uncaught coroutine error, death, an unreadable body, world exit, shutdown, destroy and supersede. Every one of those fires on a generation that has already failed or gone away. A LIVE caller is covered by none of them: a pcall-caught error, an early return down an untested branch, a panel closed by the one path that forgets. `Camera.Shot(id, options, body)` closes exactly that gap -- take, await the blend, run the body under pcall, hand the view back on every exit, and await the returning blend. When it returns the player has their own eyes back, on them rather than mid-blend. A raise becomes `shot_raised` rather than an unwind, because propagating it after the restore is the one ordering that loses the error and keeps the symptom. A rejected take means the shot never happened, so the body does not run -- it would draw over a view somebody else now owns. `Screen.Black` is the same scope for the image. The honest boundary is in the header: a body that never returns is never left, and there the platform's own rules are the only backstop. Seven natives answer a promise as a SECOND value, which `Native.Call` cannot carry -- it keeps the first. They are reached directly, and the promise rides on the Result under a name that says what it settles on: `.blend`, `.covered`, `.restored`, `.finished`. A cut carries none, and `Await(nil)` answers Ok so no call site needs the `if`. Refusals name what the platform leaves you asking: `camera_held_by:` is lifted onto `.holder` with no extra call, a bare `camera_held` spends one read to answer the same question, and `camera_budget_exhausted` names 16 and 64. One source disagreement is recorded rather than resolved: the reference and the devkit's own example read `cameras().held` two incompatible ways. `State` therefore decides on the holder NAME, which is correct under either. 547 checks, 0 failed. All fourteen new blocks mutation-tested. Co-Authored-By: Claude Opus 5 (1M context) --- boot/server.lua | 2 +- client/camera.lua | 1093 +++++++++++++++++++++++++++++++++++++++++++++ client/screen.lua | 700 +++++++++++++++++++++++++++++ init.lua | 4 +- open77.lua | 2 +- tests/run.lua | 825 +++++++++++++++++++++++++++++++++- 6 files changed, 2620 insertions(+), 6 deletions(-) create mode 100644 client/camera.lua create mode 100644 client/screen.lua diff --git a/boot/server.lua b/boot/server.lua index a0b8fac..66c7cfe 100644 --- a/boot/server.lua +++ b/boot/server.lua @@ -22,7 +22,7 @@ -- `init.lua` and the manifest, because it was already stale once -- 0.2.0 under -- a 0.3.0 library -- and the only symptom was a wrong number in a journal, -- which costs somebody an hour and never gets reported. -local VERSION = '0.3.0' +local VERSION = '0.4.0' Open77.log.info(('opx_lib %s present. The library is client-side: the server ' .. 'sandbox has no module loader, so nothing is published here.'):format(VERSION)) diff --git a/client/camera.lua b/client/camera.lua new file mode 100644 index 0000000..2e84405 --- /dev/null +++ b/client/camera.lua @@ -0,0 +1,1093 @@ +--- Taking the player's view, and being certain they get it back. +-- @author dop42 +-- +-- CreateThread(function() +-- local cam = Lib.Camera.Create({ position = corner, lookAt = 0, fov = 32 }) +-- if not cam.ok then return end +-- +-- -- The view is taken, the body runs, and the view is GIVEN BACK, whether +-- -- the body returns, raises, or the platform pulls the shot out from +-- -- under it. +-- local shot = Lib.Camera.Shot(cam.value, { blendMs = 600 }, function() +-- openCreatorUi() +-- Wait(8000) +-- end) +-- if not shot.ok then print('no shot: ' .. shot.error) end +-- +-- Lib.Camera.Destroy(cam.value) +-- end) +-- +-- Wraps `Open77.camera.*`. The consumer must declare `camera.script`. +-- +-- A STUCK CAMERA IS INDISTINGUISHABLE FROM A CRASH, which is the sentence the +-- platform's own release rules open with and the reason this module exists. +-- Every other refusal here costs a caller a shot; this one costs a player their +-- session. +-- +-- WHAT THE PLATFORM ALREADY GUARANTEES, and it is most of it. The view comes +-- back by itself on the next frame when the resource stops or reloads, when an +-- uncaught coroutine error kills the generation, when the player dies or their +-- body goes unreadable, when the world unloads, when the client shuts down, +-- when the held camera is destroyed, and when a different camera of your own is +-- activated. None of that is opt-in and none of it is this library's to +-- reimplement. `Camera.RELEASES` writes the list down so a rejected promise can +-- be turned into a sentence instead of a token. +-- +-- WHAT IT DOES NOT COVER, AND WHAT `Shot` ADDS. Every rule above fires on a +-- generation that has ALREADY failed or gone away. A caller that is still very +-- much alive is not covered at all: a `pcall`-caught error, an early `return` +-- down a branch nobody tested, a UI that closes by a path that forgets the +-- hand-back. Those leave a live resource holding the view and a player looking +-- at a wall with nothing in the log. `Shot` is the scope that closes them: it +-- takes the view, runs a body under `pcall`, and hands the view back on every +-- exit, then waits for the hand-back blend to land before it returns. When +-- `Shot` returns, the player has their own view back. +-- +-- It is not magic and it is not a substitute for the platform's rules: a body +-- that suspends forever never returns, so `Shot` never returns either, and the +-- release then comes from the platform when the resource stops. That is the +-- honest boundary. A caller who will not use `Shot` should at least treat every +-- early return as a `Release`. +-- +-- THE PROMISE IS A SECOND RETURN VALUE, so `activate`, `deactivate`, `follow` +-- and `unfollow` cannot go through `Native.Call` -- it keeps the first return +-- and drops the rest, which is the edge `client/native.lua` documents and +-- `client/character.lua` already meets from the other side. They are reached +-- directly with `Native.Reach`. ONE RULE covers every one of them: the Result's +-- `value` is the native's FIRST return, exactly as the platform gives it, and +-- the promise rides on the Result under `blend`. A cut carries no promise +-- because a cut is already finished -- `blend` is then nil, and `Camera.Await` +-- answers Ok for a nil promise so a caller need not branch on which they got. +-- +-- ONE VIEW, AND A REFUSAL THAT NAMES WHO HAS IT. A second resource asking for +-- the view is refused rather than served, because two resources each believing +-- they are driving is worse than one of them not getting the shot. The refusal +-- carries `camera_held_by:`, and where it does not -- the bare +-- `camera_held` -- this module asks `cameras()` and names the holder anyway. +-- `Camera.Holder()` is the same question asked before the attempt. +-- +-- THE CAMERA IS A CHILD OF THE BODY. 2.31 has no creatable camera object, so +-- what a world position really does is drive the player's own camera component +-- through a per-frame conversion. If the body goes, the camera goes -- which is +-- why a death is a release and not a pause -- and the world streams around the +-- BODY, not the lens, which is why `activate` refuses a camera more than +-- `Camera.RANGE` metres away rather than handing back a grey frame. +-- +-- ONE DISAGREEMENT IN THE SOURCES, LEFT VISIBLE. The reference for `cameras()` +-- says `holder` alone is not owner-scoped, which reads as "`held` is true only +-- for you"; the devkit's own example for the same native tests +-- `cams.held and cams.holder ~= `, which reads as "`held` is true for +-- anybody". They cannot both be right and no game was available to settle it, +-- so `Camera.State` decides on `holder` -- a NAME, unambiguous under either +-- reading -- and falls back to `held` only when there is no name to compare. + +local Native = require('@opx_lib/client.native') +local Validate = require('@opx_lib/pure.validate') +local Result = require('@opx_lib/pure.result') +local Async = require('@opx_lib/client.async') + +local Camera = {} + +--- The manifest permission a consumer must declare to use this module. +--- +--- THE MAXIMUM AND NOT THE MINIMUM, for the reason `client/marker.lua` sets +--- out: `NEEDS` is one string, `Permission.Of` reads exactly that field, and a +--- consumer who declares a permission they never exercise loses nothing while +--- one who is never told loses the shot. `Ray` and `Owner` are gated on nothing +--- and are listed in `UNGATED`; they pass `nil` to `Native.Call` so a refusal +--- from one is never rewritten into "add camera.script", which would fix +--- nothing. +Camera.NEEDS = 'camera.script' + +--- The functions in this module that need no permission. +Camera.UNGATED = { 'Ray', 'Owner' } + +--- Camera definitions one resource may hold at once. The platform's number. +--- +--- Not pre-checked. Counting would cost a `cameras()` call on every create and +--- the count would still be a guess by the time the create landed; the engine +--- enforces it exactly and `Create` only rewrites the refusal into one that +--- says what the numbers are. +Camera.LIMIT = 16 + +--- Camera definitions the WHOLE CLIENT may hold, shared with every other +--- resource. The limit a consumer cannot reason about locally: a resource well +--- inside its own 16 can still be refused because of somebody else's cameras. +Camera.CLIENT_LIMIT = 64 + +--- How far from the player a camera may be activated, in metres. +--- +--- The platform's number, and the reason for it is not politeness: the world +--- streams around the player's body, so a camera parked further away renders +--- whatever is streamed for the BODY -- missing geometry, missing NPCs, +--- unloaded interiors. `activate` refuses past this so the failure is a named +--- refusal rather than a grey frame. Not pre-checked here, because the check +--- needs the camera's live position and the player's, and an attached camera +--- has neither until the engine resolves it; the refusal is rewritten instead. +Camera.RANGE = 250 + +--- The field of view the platform accepts, in degrees. `0` is also accepted and +--- means "leave the engine's own field of view alone", which is not the same as +--- 5 degrees and is why the bound is not simply 0..170. +Camera.MIN_FOV = 5 +Camera.MAX_FOV = 170 + +--- The longest blend the platform accepts, in milliseconds. Sixty seconds. +Camera.MAX_BLEND = 60000 + +--- The four shake presets, in the platform's own order. An unknown name is +--- refused by the engine rather than defaulted, so it is refused here too -- +--- one native call earlier, and naming the four that would have worked. +Camera.SHAKES = { 'hand', 'drunk', 'explosion', 'earthquake' } + +--- The platform's cap on shake amplitude, and the duration it uses when none is +--- given. Documented, never filled in: a default this module sent would be a +--- library value masquerading as the engine's, and the two would drift. +Camera.MAX_AMPLITUDE = 4.0 +Camera.SHAKE_MS = 500 + +--- The phases `cameras()` reports, in the order a shot passes through them. +Camera.PHASES = { 'idle', 'blending_in', 'holding', 'blending_out' } + +--- Follow-camera offsets, in the TARGET's own frame. The platform's ranges. +Camera.FOLLOW = { + distance = { 0, 100 }, + height = { -50, 50 }, + side = { -50, 50 }, +} + +--- Every documented reason a held view is released, and what it means. +--- +--- This is the release-rules table, written down. It is data rather than prose +--- because it has a job: a rejected blend promise carries one of these tokens +--- and nothing else, and a caller reading `resource_error` in a log has no way +--- to know that their camera DEFINITIONS went with it and must be re-created. +--- Where the platform lists stop and reload separately they share a token, so +--- they share an entry. +--- +--- A token that is not here is not an error: a newer build may add one, and +--- `Camera.Why` passes an unknown token through rather than claiming to know. +Camera.RELEASES = { + resource_stopped = 'your resource stopped or was reloaded; the new generation starts with nothing', + resource_error = 'a coroutine in your resource errored -- your camera definitions went with it, ' + .. 'so re-create them before putting the shot back up', + player_died = 'the player died; the definition survives, so one activate puts the shot back', + player_unavailable = 'the player\'s body was unreadable for about half a second', + world_exit = 'the world unloaded, the player disconnected, or the session ended', + client_shutdown = 'the client shut down', + camera_destroyed = 'the camera being looked through was destroyed', + camera_superseded = 'a different camera of yours was activated', + camera_deactivated = 'the view was given back', +} + +-- Every bound above is the platform's, from the scripted-camera reference: 16 +-- cameras a resource and 64 a client, 250 m of streaming range, a field of view +-- of 5..170 degrees, a blend of 0..60000 ms, an amplitude capped at 4.0, and +-- the three follow offsets. They are enforced at this door so that a caller +-- learns which of THEIR numbers was out of range, rather than reading a token +-- back from the engine -- which answers `invalid_argument` for the streaming +-- range and for a non-finite field of view alike and so cannot tell them apart. + +--- A finite { x, y, z }, or nil. Rebuilt rather than passed through, so a +--- caller's table cannot be mutated behind them and no extra key rides along. +local function point(value) + if Validate.Table(value, 8) == nil then return nil end + local x = Validate.Number(value.x, -100000, 100000) + local y = Validate.Number(value.y, -100000, 100000) + local z = Validate.Number(value.z, -100000, 100000) + if x == nil or y == nil or z == nil then return nil end + return { x = x, y = y, z = z } +end + +--- A camera id, or nil. +--- +--- A NUMBER AND NOT A STRING, unlike a marker or a blip handle: these are small +--- per-resource ids -- sixteen of them at most -- and the platform types the +--- argument as a number. `tostring(camId)` is therefore a caller error and is +--- refused here rather than at the engine, where it reads as `invalid_camera_id` +--- with no hint that the value merely had the wrong type. +local function camOf(value) + if type(value) ~= 'number' then return nil end + return Validate.Integer(value, 1, nil) +end + +--- An entity id, or nil. `0` is the local player's own body and is valid. +local function entityOf(value) + if type(value) ~= 'number' then return nil end + return Validate.Integer(value, 0, nil) +end + +--- Integer milliseconds within a range, or nil. +--- +--- `type(value) ~= 'number'` FIRST, and that line is the point of the helper. +--- `Validate.Number` runs `tonumber`, so `'600'` off a config file or a WebUI +--- payload would pass a bare range check -- and the platform's own word for a +--- blend is "not a number in 0..60000". A numeric string is not a number. NaN, +--- infinity and a fractional millisecond are refused by `Validate.Integer` +--- underneath, which is the whole reason to go through it. +local function millis(value, high) + if type(value) ~= 'number' then return nil end + return Validate.Integer(value, 0, high) +end + +--- A rotation the native will take: a quaternion or Euler degrees, or nil. +--- +--- WHICH ONE IS DECIDED BY `w`, because that is the only field the two forms do +--- not share. A quaternion needs all four components -- three of them is not a +--- rotation, it is a typo. Euler takes any subset of pitch, yaw and roll, which +--- is what the reference says, and no range: they are DEGREES and the engine +--- wraps them, so refusing 450 would be this module inventing a rule. +--- Finiteness is still checked, because a NaN angle is a shot that silently +--- points nowhere. +local function rotation(value) + if Validate.Table(value, 8) == nil then return nil end + + if value.w ~= nil then + local out = {} + for _, key in ipairs({ 'x', 'y', 'z', 'w' }) do + local number = Validate.Number(value[key], -100000, 100000) + if number == nil then return nil end + out[key] = number + end + return out + end + + local out, named = {}, false + for _, key in ipairs({ 'pitch', 'yaw', 'roll' }) do + if value[key] ~= nil then + local number = Validate.Number(value[key], -100000, 100000) + if number == nil then return nil end + out[key] = number + named = true + end + end + if not named then return nil end + return out +end + +--- A field of view the native will take, or nil. `0` passes through untouched: +--- it is the documented way to say "leave the engine's own alone". +local function fov(value) + if type(value) ~= 'number' then return nil end + if value == 0 then return 0 end + return Validate.Number(value, Camera.MIN_FOV, Camera.MAX_FOV) +end + +--- Every field `create` accepts. The set is closed on purpose: a misspelled +--- one -- `lookat`, `attach`, `offsets` -- is otherwise accepted by the engine, +--- ignored, and produces a shot that is subtly not the one that was asked for, +--- with no refusal anywhere. +local CREATE_FIELDS = { + position = true, attachTo = true, offset = true, + lookAt = true, rotation = true, fov = true, +} + +--- Every field the table form of `setTransform` accepts. +local MOVE_FIELDS = { position = true, rotation = true, fov = true } + +--- Validates a camera definition and answers the spec to send, or a refusal. +--- +--- One function behind `Create` and `Move`, because the platform uses one field +--- vocabulary for both and two copies of the same six bounds would disagree by +--- the third release. `creating` decides two things: which fields are allowed, +--- and whether the definition must say where the camera is at all. +--- +--- THE CODES REUSE THE PLATFORM'S OWN WORDS -- `invalid_camera_options`, +--- `invalid_camera_position`, `invalid_camera_offset`, `invalid_camera_look_at`, +--- `invalid_camera_rotation`, `invalid_camera_fov`, `invalid_entity_id`, +--- `empty_camera_transform` -- so a caller branching on a code writes one branch +--- whether the refusal came from this door or from the engine behind it. +-- @return table|nil spec, string|nil code, string|nil detail +local function definition(given, creating) + local allowed = creating and CREATE_FIELDS or MOVE_FIELDS + + if Validate.Table(given, 16) == nil then + return nil, 'invalid_camera_options', 'a camera definition is a plain table' + end + + for key in pairs(given) do + if not allowed[key] then + return nil, 'invalid_camera_options', + ('camera field %q is not one this call takes'):format(tostring(key)) + end + end + + local spec = {} + + if given.position ~= nil then + spec.position = point(given.position) + if spec.position == nil then + return nil, 'invalid_camera_position', 'position is not a finite { x, y, z }' + end + end + + if creating then + if given.attachTo ~= nil then + spec.attachTo = entityOf(given.attachTo) + if spec.attachTo == nil then + return nil, 'invalid_entity_id', + 'attachTo is an entity id; 0 is the local player\'s own body' + end + end + + if given.offset ~= nil then + spec.offset = point(given.offset) + if spec.offset == nil then + return nil, 'invalid_camera_offset', + 'offset is a finite { x, y, z } in the parent\'s frame (X right, Y forward, Z up)' + end + end + + -- A camera with neither is a camera with no pose at all. The platform + -- states the rule as "position is required unless attachTo is given", + -- and a definition that satisfies neither half never reaches it. + if spec.position == nil and spec.attachTo == nil then + return nil, 'invalid_camera_position', + 'a camera needs a world position, or an attachTo to hang off' + end + + if given.lookAt ~= nil then + if type(given.lookAt) == 'number' then + -- A number is an ENTITY, and 0 is the local player. This is the + -- one field where the two readings are both legal, so the type + -- decides and a bad number is named as the entity it meant to be. + spec.lookAt = entityOf(given.lookAt) + if spec.lookAt == nil then + return nil, 'invalid_entity_id', + 'a numeric lookAt is an entity id; 0 is the local player' + end + else + spec.lookAt = point(given.lookAt) + if spec.lookAt == nil then + return nil, 'invalid_camera_look_at', + 'lookAt is a finite { x, y, z } world point, or an entity id' + end + end + end + end + + if given.rotation ~= nil then + spec.rotation = rotation(given.rotation) + if spec.rotation == nil then + return nil, 'invalid_camera_rotation', + 'rotation is a quaternion { x, y, z, w } or Euler degrees { pitch, yaw, roll }' + end + end + + if given.fov ~= nil then + spec.fov = fov(given.fov) + if spec.fov == nil then + return nil, 'invalid_camera_fov', ('fov is %d to %d degrees, or 0 to leave the ' + .. 'engine\'s own alone'):format(Camera.MIN_FOV, Camera.MAX_FOV) + end + end + + -- A rotation and a lookAt in one definition are two aims, and the engine + -- resolves it by having the rotation clear the look-at. Accepting both here + -- and letting one win silently is exactly the trap the platform avoided by + -- documenting that behaviour, so the pair is refused and the caller picks. + if spec.rotation ~= nil and spec.lookAt ~= nil then + return nil, 'invalid_camera_rotation', + 'a rotation and a lookAt are two different aims; setting a rotation clears a ' + .. 'look-at, so name one' + end + + return spec +end + +--- The blend options a call takes, or a refusal. +local function blend(options, what) + if options == nil then return {} end + if Validate.Table(options, 8) == nil then + return nil, 'invalid_camera_options', what .. ' options is not a plain table' + end + for key in pairs(options) do + if key ~= 'blendMs' then + return nil, 'invalid_camera_options', + ('%s takes blendMs and nothing else; %q is not a field it has') + :format(what, tostring(key)) + end + end + if options.blendMs == nil then return {} end + + local ms = millis(options.blendMs, Camera.MAX_BLEND) + if ms == nil then + return nil, 'invalid_blend_ms', ('blendMs is a whole number of milliseconds, 0 to %d; ' + .. 'NaN, infinity and a numeric string are not numbers') + :format(Camera.MAX_BLEND) + end + return { blendMs = ms } +end + +--- Calls a native that answers a value AND a promise, and packs both. +--- +--- The shape every promise-carrying call in this module goes through. It cannot +--- be `Native.Call`: that keeps the first return and drops the rest, so the +--- promise -- the only thing that says when the shot is actually up -- would +--- vanish between the engine and the caller. +-- @return table a Result whose `value` is the first return and whose `blend` +-- is the promise, or nil for a cut +local function reachWithPromise(path, ...) + local native, missing = Native.Reach(path) + if native == nil then + return Result.Err(missing, ('Open77.%s is not available on this build'):format(path)) + end + + local held = table.pack(pcall(native, ...)) + if not held[1] then + return Result.Err('native_raised', ('Open77.%s raised: %s'):format(path, tostring(held[2]))) + end + + local answer, second = held[2], held[3] + if answer == nil or answer == false then + -- `second` is the reason on a refusal and the promise on an answer. The + -- two never overlap, because a refusal has no promise to give. + return Native.Refusal(path, Camera.NEEDS, second) + end + + local made = Result.Ok(answer) + if type(second) == 'table' then made.blend = second end + return made +end + +--- Names the resource that holds the view, in a refusal that only hinted at it. +--- +--- `camera_held_by:` already carries the answer and is passed through +--- with the name lifted into the detail. The bare `camera_held` does not, and +--- that is the refusal worth spending a native call on: "somebody has the view" +--- is not actionable and "open77_creator has the view" is. The extra call +--- happens only on the failure path, so a shot that works pays nothing. +local function named(failure) + if type(failure.error) ~= 'string' then return failure end + if failure.error:find('camera_held', 1, true) ~= 1 then return failure end + + local holder = failure.error:match('^camera_held_by:(.+)$') + if holder == nil then + local read = Camera.Holder() + if read.ok then holder = read.value end + end + + failure.holder = holder + failure.detail = holder ~= nil + and ('%s has the view; it is refused rather than stolen, because two resources each ' + .. 'believing they are driving is worse than one of them not getting the shot') + :format(holder) + or 'another resource has the view, and this build would not say which' + return failure +end + +-- ── Who has the view ───────────────────────────────────────────────────────── + +--- This resource's own name, or nil on a build that will not say. +--- +--- Ungated and immutable for the lifetime of the resource definition, which is +--- what makes it safe to ask on a read path. A plain value and not a Result: +--- the only question a caller has is "is this string the holder", and nil +--- answers it honestly. +-- @author dop42 +-- @return string|nil +function Camera.Owner() + local native = Native.Reach('resource.name') + if native == nil then return nil end + + local read, name = pcall(native) + if not read or type(name) ~= 'string' or name == '' then return nil end + return name +end + +--- Who owns the view, as one word, from a snapshot the caller already holds. +--- +--- 'mine' this resource has the view +--- 'theirs' another resource has it; `snap.holder` names them +--- 'held' somebody has it and this build would not say who +--- 'free' nobody has it +--- 'unknown' not a snapshot +--- +--- A plain string and not a Result: it reads a table the caller already has, +--- cannot fail, and is asked once per decision. The same argument +--- `client/input.lua` and `client/marker.lua` make for their readers. +--- +--- IT DECIDES ON `holder`, NOT `held`, and the header says why: the two sources +--- disagree about whether `held` is owner-scoped, and a name compared against +--- our own name is right under either reading. `held` is consulted only when +--- there is no name -- and then the answer is the deliberately vague 'held', +--- because that is genuinely all that is known. +-- @author dop42 +-- @param snap table a snapshot from `Cameras` +-- @return string +function Camera.State(snap) + if type(snap) ~= 'table' then return 'unknown' end + + local holder = Validate.Text(snap.holder, 128) + if holder == nil then + -- No name. `held` is the only evidence left, and it does not say whose. + if snap.held == true then return 'held' end + return 'free' + end + + local me = Camera.Owner() + if me == nil then return 'held' end + if holder == me then return 'mine' end + return 'theirs' +end + +--- This resource's cameras and whether it holds the view, with `state` added. +--- +--- The snapshot is ANNOTATED, not rebuilt: rebuilding would silently drop every +--- field a newer build added, which is backwards for a value whose whole job is +--- diagnosis. The camera LIST is owner-scoped; `holder` is not, and that +--- asymmetry is the point of the call. +-- @author dop42 +-- @return table a Result carrying the snapshot +function Camera.Cameras() + local read = Native.Call('camera.cameras', Camera.NEEDS) + if not read.ok then return read end + if type(read.value) == 'table' then read.value.state = Camera.State(read.value) end + return read +end + +--- The resource holding the view, or Ok(nil) when nobody is. +--- +--- THE ONE QUESTION A `camera_held_by:` REFUSAL LEAVES YOU ASKING, asked before +--- the attempt instead of after it. `Ok(nil)` is an answer -- the view is free +--- -- which is exactly the case `Result` exists for. +-- @author dop42 +-- @return table a Result carrying the holder's name, or nil +function Camera.Holder() + local read = Native.Call('camera.cameras', Camera.NEEDS) + if not read.ok then return read end + if type(read.value) ~= 'table' then + return Result.Err('invalid_camera_snapshot', 'camera.cameras did not answer a table') + end + return Result.Ok(Validate.Text(read.value.holder, 128)) +end + +-- ── Definitions ────────────────────────────────────────────────────────────── + +--- Creates a camera and answers its id. Nothing is rendered until `Take`. +--- +--- A CAMERA IS A DEFINITION, NOT A VIEW. Sixteen of these may exist at once and +--- exactly one of them can be live, so creating one costs a player nothing and +--- is safe to do up front -- the character creator in the platform's own example +--- builds both shots before it takes the view. +-- @author dop42 +-- @param options table position, attachTo, offset, lookAt, rotation, fov +-- @return table a Result carrying the camera id +function Camera.Create(options) + local spec, code, detail = definition(options, true) + if spec == nil then return Result.Err(code, detail) end + + local made = Native.Call('camera.create', Camera.NEEDS, spec) + if not made.ok and made.error == 'camera_budget_exhausted' then + return Result.Err('camera_budget_exhausted', ('this resource may hold %d camera ' + .. 'definitions at once and the client shares %d; destroy one you are finished ' + .. 'with -- a definition costs nothing to re-create'):format( + Camera.LIMIT, Camera.CLIENT_LIMIT)) + end + return made +end + +--- Moves and/or turns a camera. Cheap enough to call every frame: that is how a +--- dolly is written. +--- +--- The TABLE form of `setTransform` only, because the positional form cannot +--- carry a field of view and two doors onto one native is two places for a +--- bound to go stale. Setting a rotation clears a previous look-at, which is +--- why the door refuses a patch carrying both. +-- @author dop42 +-- @param camId number +-- @param patch table position, rotation, fov +-- @return table a Result +function Camera.Move(camId, patch) + local id = camOf(camId) + if id == nil then + return Result.Err('invalid_camera_id', 'a camera id is a whole number') + end + + local spec, code, detail = definition(patch, false) + if spec == nil then return Result.Err(code, detail) end + if next(spec) == nil then + -- The platform's own word for it, so a caller writes one branch whether + -- the empty patch was caught here or one native call later. + return Result.Err('empty_camera_transform', 'a patch with no fields changes nothing') + end + + return Native.Call('camera.setTransform', Camera.NEEDS, id, spec) +end + +--- Points a camera at a world point or at an entity. Entity `0` is the player. +--- +--- The aim is re-solved EVERY frame, so a tracked entity that moves stays +--- centred and an entity that streams out holds the last aim rather than +--- snapping the shot to the world origin. +-- @author dop42 +-- @param camId number +-- @param target table|number { x, y, z } or an entity id +-- @return table a Result +function Camera.LookAt(camId, target) + local id = camOf(camId) + if id == nil then + return Result.Err('invalid_camera_id', 'a camera id is a whole number') + end + + if type(target) == 'number' then + local entity = entityOf(target) + if entity == nil then + return Result.Err('invalid_entity_id', + 'a numeric target is an entity id; 0 is the local player') + end + return Native.Call('camera.lookAt', Camera.NEEDS, id, entity) + end + + local at = point(target) + if at == nil then + return Result.Err('invalid_camera_look_at', + 'a target is a finite { x, y, z } world point, or an entity id') + end + return Native.Call('camera.lookAt', Camera.NEEDS, id, at) +end + +--- Parents a camera to an entity: it follows the parent's position AND +--- rotation, so this plus a fixed rotation is a dealership turntable. +--- +--- BOTH ARGUMENTS ARE REQUIRED HERE, though the native's are optional, and that +--- is deliberate. `Open77.camera.attach()` with no arguments is an older call +--- that means something else entirely -- restore the player's own camera -- and +--- a caller who forgot an argument would reach it by accident. A door that +--- cannot be fallen through is worth one refused call. +-- @author dop42 +-- @param camId number +-- @param entity number an entity id; 0 is the local player's own body +-- @param offset table|nil { x, y, z } in the parent's frame +-- @return table a Result +function Camera.Attach(camId, entity, offset) + local id = camOf(camId) + if id == nil then + return Result.Err('invalid_camera_id', 'a camera id is a whole number') + end + + local parent = entityOf(entity) + if parent == nil then + return Result.Err('invalid_entity_id', + 'attach needs an entity id; 0 is the local player\'s own body') + end + + if offset == nil then + return Native.Call('camera.attach', Camera.NEEDS, id, parent) + end + + local at = point(offset) + if at == nil then + return Result.Err('invalid_camera_offset', + 'offset is a finite { x, y, z } in the parent\'s frame (X right, Y forward, Z up)') + end + return Native.Call('camera.attach', Camera.NEEDS, id, parent, at) +end + +--- Un-parents a camera. It keeps the pose it had; the next `Move` places it. +--- +--- Not spelled `Detach`, for the platform's own reason: `Open77.camera.detach()` +--- already means "move the player's own camera off their eyes". +-- @author dop42 +-- @param camId number +-- @return table a Result +function Camera.DetachFrom(camId) + local id = camOf(camId) + if id == nil then + return Result.Err('invalid_camera_id', 'a camera id is a whole number') + end + return Native.Call('camera.detachFrom', Camera.NEEDS, id) +end + +--- Destroys a camera. If it is the live one, the view comes back. +--- +--- Destroying the camera being looked through is legal and IS a release: the +--- pending blend promise rejects with `camera_destroyed` rather than hanging. +-- @author dop42 +-- @param camId number +-- @return table a Result +function Camera.Destroy(camId) + local id = camOf(camId) + if id == nil then + return Result.Err('invalid_camera_id', 'a camera id is a whole number') + end + return Native.Call('camera.destroy', Camera.NEEDS, id) +end + +-- ── Holding the view ───────────────────────────────────────────────────────── + +--- Takes the view. `blendMs` interpolates from wherever the view is now. +--- +--- The Result's `value` is the platform's own `true`; the blend promise is +--- `.blend`, and it is nil for a cut because a cut has already settled by the +--- time the call returns. `Camera.Await` takes either. +--- +--- A REFUSAL NAMES THE HOLDER. `camera_held_by:` carries the name and +--- it is lifted into `.holder` and into the detail; a bare `camera_held` costs +--- one extra read of `cameras()` to answer the same question. +--- +--- `camera_unavailable` means the player is dead or unreadable, or a perspective +--- transition is in flight. Retry a frame later -- taking the camera mid +--- transition would leave the body and the view disagreeing for as long as the +--- transition runs. +-- @author dop42 +-- @param camId number +-- @param options table|nil { blendMs = 0..60000 } +-- @return table a Result; `.blend` is the promise, or nil for a cut +function Camera.Take(camId, options) + local id = camOf(camId) + if id == nil then + return Result.Err('invalid_camera_id', 'a camera id is a whole number') + end + + local settings, code, detail = blend(options, 'Take') + if settings == nil then return Result.Err(code, detail) end + + local took = reachWithPromise('camera.activate', id, settings) + if took.ok then return took end + + if took.error == 'invalid_argument' then + -- The one refusal the engine cannot name: `invalid_blend_ms` covers the + -- blend and this door covers every other argument, so what is left is + -- the streaming ceiling. + return Result.Err('invalid_argument', ('the camera is further than %d m from the ' + .. 'player; the world streams around the BODY and not the lens, so a shot that ' + .. 'far away would render unstreamed space -- move the player, not just the ' + .. 'camera'):format(Camera.RANGE)) + end + return named(took) +end + +--- Gives the view back, blending to the player's own eyes. Only the holder may. +--- +--- A blended hand-back finishes exactly on the player's eyes, so the restore +--- writes the value already on screen and there is no jump at the end. +-- @author dop42 +-- @param options table|nil { blendMs = 0..60000 } +-- @return table a Result; `.blend` is the promise, or nil for an instant one +function Camera.Release(options) + local settings, code, detail = blend(options, 'Release') + if settings == nil then return Result.Err(code, detail) end + return reachWithPromise('camera.deactivate', settings) +end + +--- Waits for a blend to land, and says which release rule ended it if it did. +--- +--- MUST RUN INSIDE A `CreateThread`: it suspends. A nil promise answers Ok +--- immediately, because that is what a cut is -- already finished, nothing left +--- to wait for -- and making the caller branch on which they got would put the +--- same `if` at every call site. +--- +--- A REJECTION IS NOT AN ERROR IN THE CALLER'S CODE. It is one of the release +--- rules: the player died, the resource was reloaded, a different camera of +--- theirs was activated. `Camera.RELEASES` turns the token into the sentence, +--- and the token itself stays as the Result's code so a caller can still branch +--- on it. Never `await` one of these without a failure branch -- every release +--- rejects the promise rather than dropping it, precisely so the coroutine that +--- was going to tidy up wakes up and does. +-- @author dop42 +-- @param promise table|nil the `.blend` off a Take, Release, Follow or Unfollow +-- @return table a Result +function Camera.Await(promise) + if promise == nil then return Result.Ok(nil) end + + local settled = Async.All({ promise }) + if settled.ok then return Result.Ok(settled.value[1]) end + + return Result.Err(settled.error, Camera.Why(settled.error)) +end + +--- What a release token means, as a sentence. +--- +--- An unknown token is passed through rather than guessed at: a newer build may +--- release for a reason this library has never heard of, and inventing a +--- sentence for it would be worse than repeating the token. +-- @author dop42 +-- @param reason any +-- @return string +function Camera.Why(reason) + local token = type(reason) == 'string' and reason or 'unknown' + return Camera.RELEASES[token] or ('the shot ended: ' .. token) +end + +--- Takes the view, runs `body`, and gives the view back whatever happens. +--- +--- THE GUARANTEE THIS MODULE EXISTS FOR, and it is worth stating exactly. +--- When `Shot` returns, the player has their own view back: the hand-back is +--- issued on every exit from `body` -- a return, a raise, or a release the +--- platform performed underneath it -- and the returning blend is awaited, so +--- the view is back on the eyes and not halfway there. +--- +--- WHAT IT DOES NOT COVER, said plainly. A `body` that never returns -- an +--- infinite loop, a `Wait` that is never satisfied -- is never left by any +--- path, so nothing here runs and the release comes from the platform when the +--- resource stops. And a caller who does not use `Shot` gets exactly the +--- platform's own rules and no more. +--- +--- MUST RUN INSIDE A `CreateThread`: it awaits both blends. +--- +--- A RAISE IN `body` BECOMES A RESULT, not an unwind: `shot_raised`, carrying +--- the message. Letting it propagate would take down the consumer's handler +--- AFTER the view was restored, which is the one ordering that loses the error +--- and keeps the symptom. `.released` says whether the hand-back was accepted; +--- it is false only when the platform refused it for a reason other than "you +--- do not hold the view", which is itself a success -- something released the +--- shot first, and the view is back either way. +-- @author dop42 +-- @param camId number +-- @param options table|nil { blendMs, releaseMs } +-- @param body function run while the view is held +-- @return table a Result carrying whatever `body` answered +function Camera.Shot(camId, options, body) + -- `Shot(cam, fn)` is the common case and reads better than `Shot(cam, nil, fn)`. + if type(options) == 'function' and body == nil then + options, body = nil, options + end + if type(body) ~= 'function' then + return Result.Err('invalid_body', 'Shot runs a function while it holds the view') + end + + local takeMs, releaseMs + if options ~= nil then + if Validate.Table(options, 8) == nil then + return Result.Err('invalid_camera_options', 'Shot options is not a plain table') + end + for key in pairs(options) do + if key ~= 'blendMs' and key ~= 'releaseMs' then + return Result.Err('invalid_camera_options', + ('Shot takes blendMs and releaseMs; %q is not a field it has') + :format(tostring(key))) + end + end + takeMs, releaseMs = options.blendMs, options.releaseMs + -- The hand-back defaults to the same blend as the take. A shot that + -- eases in over 600 ms and cuts out is the one thing a player notices. + if releaseMs == nil then releaseMs = takeMs end + end + + local took = Camera.Take(camId, takeMs ~= nil and { blendMs = takeMs } or nil) + if not took.ok then return took end + + -- Hands the view back and waits for it to land. Tolerant on purpose. + local function handBack() + local gave = Camera.Release(releaseMs ~= nil and { blendMs = releaseMs } or nil) + if not gave.ok then + -- We no longer hold it, which is the outcome we wanted anyway: a + -- release rule got there first and the view is already back. + return gave.error == 'camera_not_active' + end + Camera.Await(gave.blend) + return true + end + + local reached = Camera.Await(took.blend) + if not reached.ok then + -- The shot never happened. `body` is NOT run: it would draw a UI over a + -- view somebody else now owns. + local failed = Result.Err(reached.error, reached.detail) + failed.released = handBack() + return failed + end + + local ran, answered = pcall(body) + local released = handBack() + + local out = ran and Result.Ok(answered) + or Result.Err('shot_raised', tostring(answered)) + out.released = released + return out +end + +-- ── Following ──────────────────────────────────────────────────────────────── + +--- Rides an entity: the camera sits behind it and aims at it, both re-solved +--- every frame. Entity `0` is the local player's own body. +--- +--- ONE CAMERA PER RESOURCE, REUSED. Calling this again with a different entity +--- retargets the same camera and answers the same id rather than creating a +--- second one -- a resource that follows the nearest player once a second would +--- otherwise exhaust its sixteen definitions in sixteen seconds and start +--- failing for a reason that has nothing to do with what it asked for. Each +--- call supersedes the previous call's blend promise, which settles as +--- `camera_superseded`. +--- +--- `distance`, `height` and `side` are in the TARGET's own frame, which is what +--- makes `distance` mean "behind him" rather than "north of him". +--- +--- NOT A NEW WAY TO HOLD THE VIEW: this is create plus lookAt plus activate, so +--- every release rule applies to it unchanged, and so does the streaming +--- ceiling -- following a target three hundred metres away gives a correctly +--- aimed camera looking at unstreamed space, which is an empty frame and not an +--- error. Move the body first. +-- @author dop42 +-- @param entity number an entity id; 0 is the local player +-- @param options table|nil distance, height, side, fov, blendMs +-- @return table a Result carrying the camera id; `.blend` is the promise +function Camera.Follow(entity, options) + local target = entityOf(entity) + if target == nil then + -- `invalid_entity`, not `invalid_entity_id`: `follow` is the one native + -- in this namespace that spells it the short way, and reusing the + -- platform's own word per call beats one tidy word of our own. + return Result.Err('invalid_entity', + 'follow needs an entity id; 0 is the local player\'s own body') + end + + local spec = {} + if options ~= nil then + if Validate.Table(options, 8) == nil then + return Result.Err('invalid_camera_options', 'follow options is not a plain table') + end + + for key in pairs(options) do + if key ~= 'distance' and key ~= 'height' and key ~= 'side' + and key ~= 'fov' and key ~= 'blendMs' then + return Result.Err('invalid_camera_options', + ('follow field %q is not one this call takes'):format(tostring(key))) + end + end + + for _, key in ipairs({ 'distance', 'height', 'side' }) do + if options[key] ~= nil then + local range = Camera.FOLLOW[key] + local number = Validate.Number(options[key], range[1], range[2]) + if number == nil then + return Result.Err('invalid_follow_' .. key, + ('%s is a number of metres, %g to %g'):format(key, range[1], range[2])) + end + spec[key] = number + end + end + + if options.fov ~= nil then + spec.fov = fov(options.fov) + if spec.fov == nil then + return Result.Err('invalid_camera_fov', ('fov is %d to %d degrees, or 0 to ' + .. 'leave the engine\'s own alone'):format(Camera.MIN_FOV, Camera.MAX_FOV)) + end + end + + if options.blendMs ~= nil then + spec.blendMs = millis(options.blendMs, Camera.MAX_BLEND) + if spec.blendMs == nil then + return Result.Err('invalid_blend_ms', ('blendMs is a whole number of ' + .. 'milliseconds, 0 to %d'):format(Camera.MAX_BLEND)) + end + end + end + + local followed = reachWithPromise('camera.follow', target, spec) + if followed.ok then return followed end + return named(followed) +end + +--- Gives the view back AND destroys the follow camera. +--- +--- The camera is destroyed even when the view had already been released by +--- something else, so a follow can never be left behind. A second call answers +--- `not_following`, which is the honest word for it rather than a silent Ok. +-- @author dop42 +-- @param options table|nil { blendMs = 0..60000 } +-- @return table a Result; `.blend` is the promise, or nil for an instant one +function Camera.Unfollow(options) + local settings, code, detail = blend(options, 'Unfollow') + if settings == nil then return Result.Err(code, detail) end + return reachWithPromise('camera.unfollow', settings) +end + +-- ── Shake ──────────────────────────────────────────────────────────────────── + +--- Shakes a camera with one of the four presets. +--- +--- Additive on top of the resolved pose, so it composes with a blend and with a +--- look-at instead of fighting either, and it decays exactly to rest. Pass nil +--- for `camId` to shake whichever camera this resource currently holds. +-- @author dop42 +-- @param camId number|nil +-- @param preset string hand, drunk, explosion or earthquake +-- @param amplitude number|nil 0 to 4.0, the platform's own default is 1.0 +-- @param ms number|nil the platform's own default is 500 +-- @return table a Result +function Camera.Shake(camId, preset, amplitude, ms) + local id + if camId ~= nil then + id = camOf(camId) + if id == nil then + return Result.Err('invalid_camera_id', + 'a camera id is a whole number, or nil for the camera you hold') + end + end + + local shake = Validate.OneOf(preset, Camera.SHAKES) + if shake == nil then + return Result.Err('invalid_shake_preset', ('shake preset %q is not one of: %s') + :format(tostring(preset), table.concat(Camera.SHAKES, ', '))) + end + + local force + if amplitude ~= nil then + if type(amplitude) ~= 'number' then + return Result.Err('invalid_shake_argument', 'amplitude is a number') + end + force = Validate.Number(amplitude, 0, Camera.MAX_AMPLITUDE) + if force == nil then + return Result.Err('invalid_shake_argument', + ('amplitude is 0 to %g'):format(Camera.MAX_AMPLITUDE)) + end + end + + local duration + if ms ~= nil then + duration = millis(ms, Camera.MAX_BLEND) + if duration == nil then + return Result.Err('invalid_shake_argument', + ('ms is a whole number of milliseconds, 0 to %d'):format(Camera.MAX_BLEND)) + end + end + + return Native.Call('camera.shake', Camera.NEEDS, id, shake, force, duration) +end + +--- Stops a shake. The camera stays exactly where it is: this is not a release. +--- +--- A camera with no shake running accepts it without complaint, so a tidy-up +--- path may call it unconditionally. +-- @author dop42 +-- @param camId number +-- @return table a Result +function Camera.StopShake(camId) + local id = camOf(camId) + if id == nil then + return Result.Err('invalid_camera_id', 'a camera id is a whole number') + end + return Native.Call('camera.stopShake', Camera.NEEDS, id) +end + +-- ── Reading the view ───────────────────────────────────────────────────────── + +--- The world ray behind a screen point, `0..1` from the TOP LEFT. +--- +--- The second of the two functions here that need no permission: it reads the +--- camera, it does not move it. It passes `nil` to `Native.Call` so a refusal +--- from it is never rewritten into "add camera.script", which would be advice +--- that fixes nothing. +--- +--- The frame is exactly the one `Open77.camera.project` answers, so the two +--- round-trip: project a world point, unproject the screen point, and the +--- direction points from the origin back at the point you started with. +-- @author dop42 +-- @param x number 0..1 +-- @param y number 0..1 +-- @return table a Result carrying { origin, direction } +function Camera.Ray(x, y) + -- The platform's own word, and the bound is the documented frame. A + -- coordinate outside it is not a point on the screen. + local across, down = Validate.Number(x, 0, 1), Validate.Number(y, 0, 1) + if across == nil or down == nil then + return Result.Err('invalid_screen_point', + 'x and y are finite numbers 0..1, with the origin at the top left') + end + return Native.Call('camera.unproject', nil, across, down) +end + +return Camera diff --git a/client/screen.lua b/client/screen.lua new file mode 100644 index 0000000..4356f00 --- /dev/null +++ b/client/screen.lua @@ -0,0 +1,700 @@ +--- Covering the screen, and being certain the image comes back. +-- @author dop42 +-- +-- CreateThread(function() +-- local hidden = Lib.Screen.Black({ durationMs = 400, fadeInMs = 400 }, function() +-- moveThePlayerSomewhereElse() +-- end) +-- if not hidden.ok then print('no fade: ' .. hidden.error) end +-- end) +-- +-- Wraps `Open77.screen.*`. The consumer must declare `screen.effects`. +-- +-- THIS IS CYBERPUNK'S OWN QUEST FADE MANAGER, not an overlay. Nothing here +-- creates a WebUI layer, loads a quest, applies a status effect or touches +-- routing: it drives the engine's fade and reports what the engine says about +-- it. Completion comes from the native manager's own direction and remaining +-- duration rather than an estimated `Wait`, which is why the promises below +-- mean something. +-- +-- A FADE NOBODY RETURNS IS INDISTINGUISHABLE FROM A CRASH to the player looking +-- at it, and the platform took that seriously enough to build a safety deadline +-- into every transition: `timeoutMs` is real time, the client enforces it, and +-- there is deliberately no "fade out forever" spelling of any of these calls. +-- So unlike the camera, the floor here is the platform's and not ours. What +-- `Screen.Black` adds on top is the same scope the camera's `Shot` adds: the +-- image is handed back on EVERY exit from the body -- a return, a raise, a +-- transition that ended underneath it -- rather than only when the deadline +-- finally expires, because fifteen seconds of black is a bug report either way. +-- +-- THE SLOT IS SINGLE AND IT IS SHARED. Only one Open77 transition may be +-- active; a second request answers `screen_busy`. That one slot is shared with +-- the SERVER RELAY -- `Open77.players.fade` drives the same native through the +-- same arbitration -- so `screen_busy` is not necessarily another client +-- resource, and a server that faded this player is a perfectly ordinary cause. +-- A fade the GAME itself owns is a different answer again, `native_screen_busy`, +-- and Open77 will not clear it. Both refusals are rewritten here to say which. +-- +-- WHICH IS WHY `IsFaded` EXISTS, and why it is the first call in a spawn or a +-- teleport. The screen may already be black because another resource faded it, +-- because the server did, or because a quest or loading fade took it, and +-- stacking a second fade on that is how a player ends up watching two fades in +-- a row. The platform is explicit that a wrong `false` is exactly what causes +-- that, so this reader fails to `true` -- see its own note. +-- +-- OWNED AND UNOWNED READS ARE DIFFERENT QUESTIONS. `State` is about YOUR +-- transition and is scoped to this resource generation; `IsFaded` and +-- `NativeState` are about THE SCREEN and report a fade anybody put up. Reaching +-- for the first when you meant the second is how the two-fade bug gets written. +-- +-- THE PROMISE IS A SECOND RETURN VALUE on `fadeOut`, `fadeIn` and `transition`, +-- so none of the three can go through `Native.Call` -- it keeps the first return +-- and drops the rest, the edge `client/native.lua` documents. They are reached +-- directly. ONE RULE, the same one `client/camera.lua` follows: the Result's +-- `value` is the native's FIRST return, untouched, and the promise rides on the +-- Result under a name that says what it settles on -- `covered`, `restored`, +-- `finished`. `IsFaded` is reached directly too, for the OTHER reason in +-- `native.lua`: its answer is legitimately `false`, and the falsy-means-refused +-- rule would turn "the screen is clear" into an error. +-- +-- IDS ARE OPAQUE STRINGS. Not player ids, not native pointers, and not numbers: +-- another resource, another host or a reloaded generation cannot act on an old +-- one. Nothing here parses one, and a caller must not either. + +local Native = require('@opx_lib/client.native') +local Validate = require('@opx_lib/pure.validate') +local Result = require('@opx_lib/pure.result') +local Colour = require('@opx_lib/pure.colour') +local Async = require('@opx_lib/client.async') + +local Screen = {} + +--- The manifest permission a consumer must declare to use this module. +--- +--- There is no `UNGATED` list here and its absence is a claim, not an +--- oversight: all eight of the natives this module reaches are gated on +--- `screen.effects`, the unowned reads included. Reading whether the screen is +--- black is as privileged as making it black, which is a defensible line -- the +--- answer describes the player's whole view and not just this resource's corner +--- of it. +Screen.NEEDS = 'screen.effects' + +--- The presets the catalogue currently contains. Exactly one. +--- +--- Held statically so an unknown preset is refused WITHOUT a native call, and +--- so the refusal names the one that would have worked. `glitch` is the name +--- people try: it answers `unsupported_screen_preset` because the loading-screen +--- glitch has not been isolated safely, which is a different thing from a typo +--- and is why the door forwards it rather than pretending the name is unknown. +--- `Catalog()` is what THIS BUILD supports; this is what the door will accept. +Screen.PRESETS = { 'fade' } + +--- The preset the platform documents as unsupported, and why the door lets it +--- through to the engine instead of rejecting it locally: the answer a caller +--- wants for `glitch` is the platform's own `unsupported_screen_preset`, which +--- says "not yet", and not "misspelled". +Screen.KNOWN = { 'fade', 'glitch' } + +--- The platform's defaults, used for CHECKING and never sent. +--- +--- The timeout rule below is cross-field -- a deadline has to exceed the +--- sequence it bounds -- so checking a `transition` that named only `holdMs` +--- needs to know what the other two will be. These are that knowledge. They are +--- not merged into what is sent: forwarding them would turn the engine's +--- default into this library's copy of it, and the copy is what goes stale. +Screen.DEFAULTS = { durationMs = 500, holdMs = 250, fadeInMs = 500 } + +--- The bounds the reference gives, in integer milliseconds throughout. +Screen.MAX_FADE_MS = 10000 +Screen.MAX_HOLD_MS = 30000 +Screen.MIN_TIMEOUT_MS = 1000 +Screen.MAX_TIMEOUT_MS = 60000 + +--- How far a safety deadline must exceed the sequence it bounds, in ms. +--- +--- The platform's number, and the reason it is not zero: a deadline that +--- expires on the same tick the return fade completes is a coin toss between +--- `finished` and `timeout`, and a caller cannot write a branch against a coin. +Screen.TIMEOUT_MARGIN_MS = 500 + +--- The alpha the platform demands. Not a default -- the only accepted value. +--- A colour is a TINT on the engine's own fade, and a translucent one would be +--- a fade that does not fade. +Screen.ALPHA = 255 + +--- The phases a transition passes through, in order. +Screen.PHASES = { + 'fading_out', 'covered', 'fading_in', 'finished', 'cancelled', 'failed', +} + +--- The three phases from which nothing more happens. +--- +--- Read by `Screen.Over`, and the set is deliberately the TERMINAL ones rather +--- than the live ones -- see that function for why the difference decides what +--- an unrecognised phase from a newer build means. +Screen.TERMINAL = { finished = true, cancelled = true, failed = true } + +--- Every documented reason a promise rejects, and what it means. +--- +--- Data rather than prose because a rejection carries the token and nothing +--- else, and `never_covered` in particular is unreadable without this table: +--- it is OURS rather than the engine's, raised when a `fadeOut` returned the +--- image without ever reaching black. The engine calls that case `completed`, +--- which would read as success to a caller who was waiting to do something +--- invisible. +Screen.REASONS = { + requested = 'something asked for it: a cancel, or a fadeIn that reversed the outgoing fade', + timeout = 'the client\'s real-time safety deadline expired and gave the image back', + native_interrupted = 'a vanilla game fade took priority; Open77 dropped ownership rather than fight it', + world_unavailable = 'the world went away under the transition', + restore_failed = 'a native restoration failed', + resource_error = 'a coroutine in your resource errored, which releases its own fade', + resource_stopped = 'your resource stopped or was reloaded', + never_covered = 'the transition returned the image without ever reaching black -- the engine ' + .. 'reports that as completed, which would read as success', +} + +-- Every bound above is the platform's, from the screen-transition options +-- table: durations 0..10000 ms, a hold 0..30000, a deadline 1000..60000 that +-- must clear the planned sequence by 500 ms, and integer colour channels whose +-- alpha must be 255. They are enforced at this door so a caller learns which of +-- THEIR numbers was wrong, rather than reading `invalid_screen_option:` +-- back from the engine -- which names the key but not the rule it broke. + +--- Integer milliseconds within a range, or nil. +--- +--- `type(value) ~= 'number'` FIRST, and that line is the whole helper. +--- `Validate.Number` runs `tonumber`, so `'500'` off a config file or a WebUI +--- payload would pass a bare range check -- and the platform is explicit that +--- numeric strings are rejected, alongside NaN and infinity, which +--- `Validate.Integer` refuses underneath. Durations here are integer +--- milliseconds, never seconds and never fractions of one. +local function millis(value, low, high) + if type(value) ~= 'number' then return nil end + return Validate.Integer(value, low, high) +end + +--- An RGBA colour the native will take, or nil. +--- +--- ALPHA MUST BE 255 AND IS CHECKED AS AN EQUALITY, not a range: the platform +--- says so, and a caller who passed 128 meant a translucent fade, which this +--- API does not have. Refusing it by name beats sending it and watching the +--- engine pick something. +--- +--- A `#RRGGBB` STRING IS ACCEPTED AND CONVERTED, exactly as `client/marker.lua` +--- accepts one and for the same reason: hex is how a colour arrives from a +--- config file or a WebUI, the conversion is `Lib.Colour.Parse` and therefore +--- strict, and the alternative is every consumer writing the same three +--- `tonumber(_, 16)` calls. What reaches the engine is integers either way, and +--- six hex digits carry no alpha, so the engine's own 255 stands. +--- +--- OMITTED CHANNELS KEEP THEIR DEFAULTS, which is the platform's rule and the +--- reason a partial colour is not completed here. Note the engine controls how +--- a non-black colour renders: a tinted fade can keep a blurred scene rather +--- than produce a flat rectangle, so black is the only one that conceals the +--- 3D world. Nothing here can enforce that; it is said instead. +local function colour(value) + if type(value) == 'string' then + local parsed = Colour.Parse(value) + if parsed == nil then return nil end + return { r = parsed.r, g = parsed.g, b = parsed.b } + end + + if Validate.Table(value, 8) == nil then return nil end + + local out = {} + for _, key in ipairs({ 'r', 'g', 'b' }) do + if value[key] ~= nil then + local channel = Validate.Integer(value[key], 0, 255) + if channel == nil then return nil end + out[key] = channel + end + end + + if value.a ~= nil then + if value.a ~= Screen.ALPHA then return nil end + out.a = Screen.ALPHA + end + + if next(out) == nil then return nil end + return out +end + +--- Validates one call's options and answers what to send, or a refusal. +--- +--- One function behind all three calls, because they draw from one option +--- vocabulary and three copies of the same five bounds would disagree by the +--- third release. `allowed` is what THIS call takes, and it is the whole +--- difference between them: `fadeIn` takes a duration and nothing else, and +--- accepting `holdMs` there would be a hold nobody ever performs. +--- +--- THE CODE IS THE PLATFORM'S OWN, `invalid_screen_option:`, parameterised +--- exactly as the engine parameterises it, so a caller branching on a prefix +--- writes one branch whether the refusal came from this door or from behind it. +-- @return table|nil spec, string|nil code, string|nil detail +local function options(given, allowed, what) + if given == nil then return {} end + if Validate.Table(given, 8) == nil then + return nil, 'invalid_screen_options', what .. ' options is not a plain table' + end + + for key in pairs(given) do + if not allowed[key] then + local names = {} + for name in pairs(allowed) do names[#names + 1] = name end + table.sort(names) + return nil, 'invalid_screen_option:' .. tostring(key), + ('%s takes %s; %q is not one of them'):format( + what, table.concat(names, ', '), tostring(key)) + end + end + + local spec = {} + + for _, key in ipairs({ 'durationMs', 'fadeInMs' }) do + if allowed[key] and given[key] ~= nil then + spec[key] = millis(given[key], 0, Screen.MAX_FADE_MS) + if spec[key] == nil then + return nil, 'invalid_screen_option:' .. key, + ('%s is a whole number of milliseconds, 0 to %d; zero is an instant cut, ' + .. 'and NaN, infinity and a numeric string are not numbers') + :format(key, Screen.MAX_FADE_MS) + end + end + end + + if allowed.holdMs and given.holdMs ~= nil then + spec.holdMs = millis(given.holdMs, 0, Screen.MAX_HOLD_MS) + if spec.holdMs == nil then + return nil, 'invalid_screen_option:holdMs', + ('holdMs is a whole number of milliseconds, 0 to %d, and it starts after the ' + .. 'outgoing fade completes'):format(Screen.MAX_HOLD_MS) + end + end + + if allowed.timeoutMs and given.timeoutMs ~= nil then + spec.timeoutMs = millis(given.timeoutMs, Screen.MIN_TIMEOUT_MS, Screen.MAX_TIMEOUT_MS) + if spec.timeoutMs == nil then + return nil, 'invalid_screen_option:timeoutMs', + ('timeoutMs is a whole number of milliseconds, %d to %d') + :format(Screen.MIN_TIMEOUT_MS, Screen.MAX_TIMEOUT_MS) + end + end + + if allowed.color and given.color ~= nil then + spec.color = colour(given.color) + if spec.color == nil then + return nil, 'invalid_screen_color', 'color is { r, g, b } bytes 0..255 or a ' + .. '"#RRGGBB" string, and an alpha, if given, must be exactly 255' + end + end + + -- ── cross-field: the deadline must clear the sequence ──────────────────── + -- The rule is the platform's and the arithmetic has to happen somewhere, + -- because the engine answers `screen_timeout_too_short` without saying what + -- the sequence added up to -- which is the only number a caller needs to + -- fix it. Omitted durations take their documented defaults, so the sum is + -- always knowable at this door. + if spec.timeoutMs ~= nil then + local planned = 0 + for _, key in ipairs({ 'durationMs', 'holdMs', 'fadeInMs' }) do + if allowed[key] then + planned = planned + (spec[key] or Screen.DEFAULTS[key]) + end + end + + if spec.timeoutMs < planned + Screen.TIMEOUT_MARGIN_MS then + return nil, 'screen_timeout_too_short', + ('timeoutMs %d must exceed the %d ms this call plans by at least %d ms') + :format(spec.timeoutMs, planned, Screen.TIMEOUT_MARGIN_MS) + end + end + + return spec +end + +--- An opaque transition id, checked without ever being interpreted. +--- +--- The platform says these are opaque strings, so the only check worth making +--- is that it IS a string: a caller who ran the id through `tonumber` is +--- stopped here rather than reading `transition_not_found` back and concluding +--- their transition had ended. +local function idOf(value) + return Validate.Text(value, 64) +end + +--- Rewrites the two busy refusals into ones that say WHO is busy. +--- +--- `screen_busy` and `native_screen_busy` are one word apart and mean entirely +--- different things to the caller: the first is an Open77 transition that may +--- well be the server's relay and will end, the second is the game's own fade +--- or loading screen, which Open77 deliberately does not clear. Telling them +--- apart decides whether retrying is sensible. +local function busy(failure) + if failure.error == 'screen_busy' then + failure.detail = 'the single Open77 transition slot is taken -- by another resource, or ' + .. 'by the server relay, which shares it. Open77.screen.isFaded() says whether the ' + .. 'screen is already black; if it is, you may not need a fade of your own' + elseif failure.error == 'native_screen_busy' then + failure.detail = 'the game itself owns a fade or a loading operation. Open77 does not ' + .. 'take that over and will not clear it afterwards; wait for it' + end + return failure +end + +--- Calls a native that answers a value AND a promise, and packs both. +--- +--- It cannot be `Native.Call`: that keeps the first return and drops the rest, +--- so the promise -- the only thing that says when the screen is actually +--- covered -- would vanish between the engine and the caller. +-- @return table a Result whose `value` is the first return, plus the promise +-- under `field` +local function reachWithPromise(path, field, ...) + local native, missing = Native.Reach(path) + if native == nil then + return Result.Err(missing, ('Open77.%s is not available on this build'):format(path)) + end + + local held = table.pack(pcall(native, ...)) + if not held[1] then + return Result.Err('native_raised', ('Open77.%s raised: %s'):format(path, tostring(held[2]))) + end + + local answer, second = held[2], held[3] + if answer == nil or answer == false then + return busy(Native.Refusal(path, Screen.NEEDS, second)) + end + + local made = Result.Ok(answer) + if type(second) == 'table' then made[field] = second end + return made +end + +-- ── Covering ───────────────────────────────────────────────────────────────── + +--- Fades the screen out and HOLDS it until this resource gives it back. +--- +--- The Result's `value` is the opaque id, which `FadeIn` and `Cancel` need; +--- `.covered` is the promise, and it settles when the screen is actually black +--- on the engine's own timing. AN ID IS NOT A BLACK SCREEN: the call returns as +--- soon as the request is accepted, so anything that must happen unseen happens +--- after the promise, not after the call. +--- +--- THE SAFETY DEADLINE IS NOT OPTIONAL. Whatever `timeoutMs` says, the client +--- gives the image back when it expires, and a `FadeIn` does not extend it. +--- That is the floor under every path through a caller's code, including the +--- ones nobody wrote. +-- @author dop42 +-- @param spec table|nil durationMs, color, timeoutMs +-- @return table a Result carrying the id; `.covered` is the promise +function Screen.FadeOut(spec) + local sent, code, detail = options(spec, + { durationMs = true, color = true, timeoutMs = true }, 'fadeOut') + if sent == nil then return Result.Err(code, detail) end + + return reachWithPromise('screen.fadeOut', 'covered', sent) +end + +--- Gives the image back for this resource's active transition. +--- +--- Can reverse an outgoing fade: a caller who changed their mind mid-fade does +--- not have to wait for black first. `.restored` settles when the image is back. +--- `durationMs` and nothing else -- a hold or a colour here would be a value +--- that never gets used, which is worse than a refusal. +-- @author dop42 +-- @param id string the id `FadeOut` or `Transition` answered +-- @param spec table|nil { durationMs } +-- @return table a Result; `.restored` is the promise +function Screen.FadeIn(id, spec) + local transition = idOf(id) + if transition == nil then + return Result.Err('invalid_transition_id', 'a transition id is the opaque string the ' + .. 'fade answered; do not convert it') + end + + local sent, code, detail = options(spec, { durationMs = true }, 'fadeIn') + if sent == nil then return Result.Err(code, detail) end + + return reachWithPromise('screen.fadeIn', 'restored', transition, sent) +end + +--- Runs the whole fade, hold and return sequence in one call. +--- +--- For a scene change with nothing to do in the dark. When there IS something +--- to do in the dark, `Screen.Black` is the call: a hold is a fixed number of +--- milliseconds and a server round trip is not. +--- +--- `.finished` settles when the sequence has finished, not when the screen +--- turns black. +-- @author dop42 +-- @param preset string currently `fade` +-- @param spec table|nil durationMs, holdMs, fadeInMs, timeoutMs, color +-- @return table a Result carrying the id; `.finished` is the promise +function Screen.Transition(preset, spec) + local named = Validate.OneOf(preset, Screen.KNOWN) + if named == nil then + return Result.Err('unsupported_screen_preset', ('screen preset %q is not one this ' + .. 'platform has; the catalogue currently contains: %s') + :format(tostring(preset), table.concat(Screen.PRESETS, ', '))) + end + + local sent, code, detail = options(spec, { + durationMs = true, holdMs = true, fadeInMs = true, + timeoutMs = true, color = true, + }, 'transition') + if sent == nil then return Result.Err(code, detail) end + + return reachWithPromise('screen.transition', 'finished', named, sent) +end + +--- Restores the image immediately, including during the return fade. +--- +--- Terminal the moment it returns, so it carries no promise: there is nothing +--- left to wait for. +-- @author dop42 +-- @param id string +-- @return table a Result +function Screen.Cancel(id) + local transition = idOf(id) + if transition == nil then + return Result.Err('invalid_transition_id', 'a transition id is an opaque string') + end + return Native.Call('screen.cancel', Screen.NEEDS, transition) +end + +-- ── Waiting ────────────────────────────────────────────────────────────────── + +--- Waits for a fade to settle, and says why if it ended some other way. +--- +--- MUST RUN INSIDE A `CreateThread`: it suspends. A nil promise answers Ok, so +--- a caller need not branch on whether the call they made hands one back. +--- +--- A REJECTION IS NOT A BUG IN THE CALLER'S CODE. It is one of the documented +--- endings -- cancelled, timed out, a vanilla fade taking over, the resource +--- stopping -- and `Screen.REASONS` turns the token into the sentence while the +--- token stays as the Result's code so a caller can still branch on it. The +--- platform rejects rather than leaving a promise pending precisely so the +--- coroutine that was going to call `FadeIn` wakes up instead of parking +--- forever. +-- @author dop42 +-- @param promise table|nil `.covered`, `.restored` or `.finished` +-- @return table a Result carrying the state table +function Screen.Await(promise) + if promise == nil then return Result.Ok(nil) end + + local settled = Async.All({ promise }) + if settled.ok then return Result.Ok(settled.value[1]) end + + return Result.Err(settled.error, Screen.Why(settled.error)) +end + +--- What a rejection token means, as a sentence. +--- +--- An unknown token is passed through rather than guessed at: a newer build may +--- end a transition for a reason this library has never heard of. +-- @author dop42 +-- @param reason any +-- @return string +function Screen.Why(reason) + local token = type(reason) == 'string' and reason or 'unknown' + return Screen.REASONS[token] or ('the transition ended: ' .. token) +end + +--- Covers the screen, runs `body`, and gives the image back whatever happens. +--- +--- THE GUARANTEE. When `Black` returns, the image is coming back or is already +--- back: the return fade is issued on every exit from `body` -- a return, a +--- raise, or a transition that ended underneath it -- and awaited. The +--- platform's safety deadline remains the floor beneath that, which is what +--- makes the pair sound: this call removes the fifteen-second stare, and the +--- deadline covers the case where this call never gets to run at all. +--- +--- IT DOES NOT CHECK `IsFaded` FOR YOU, deliberately. Whether an already-black +--- screen means "skip my fade" or "fade anyway" is the caller's decision and +--- depends on what put it there; guessing would be this library writing policy. +--- If the slot is taken the refusal is `screen_busy` and says so. +--- +--- MUST RUN INSIDE A `CreateThread`: it awaits both halves. +--- +--- A RAISE IN `body` BECOMES A RESULT, `screen_body_raised`, rather than an +--- unwind -- for the same reason the camera's `Shot` catches one: letting it +--- propagate after the image was restored is the ordering that loses the error +--- and keeps the symptom. `.restored` says whether the return was accepted. +-- @author dop42 +-- @param spec table|nil durationMs, color, timeoutMs, fadeInMs +-- @param body function run while the screen is covered +-- @return table a Result carrying whatever `body` answered +function Screen.Black(spec, body) + if type(spec) == 'function' and body == nil then + spec, body = nil, spec + end + if type(body) ~= 'function' then + return Result.Err('invalid_body', 'Black runs a function while the screen is covered') + end + + local back + if spec ~= nil then + if Validate.Table(spec, 8) == nil then + return Result.Err('invalid_screen_options', 'Black options is not a plain table') + end + -- `fadeInMs` belongs to the RETURN and is not a `fadeOut` option, so it + -- is lifted out here rather than forwarded and refused. + back = spec.fadeInMs + if back ~= nil then + local copy = {} + for key, value in pairs(spec) do + if key ~= 'fadeInMs' then copy[key] = value end + end + spec = copy + end + end + + local out = Screen.FadeOut(spec) + if not out.ok then return out end + + -- Hands the image back and waits for it to land. Tolerant on purpose: the + -- transition may already be over, which is the outcome we wanted anyway. + local function restore() + local given = Screen.FadeIn(out.value, back ~= nil and { durationMs = back } or nil) + if not given.ok then + return given.error == 'transition_not_active' + or given.error == 'transition_not_found' + or given.error == 'already_fading_in' + end + Screen.Await(given.restored) + return true + end + + local covered = Screen.Await(out.covered) + if not covered.ok then + -- The screen never went black. `body` is NOT run: it was written to + -- happen unseen, and running it now would run it in plain view. + local failed = Result.Err(covered.error, covered.detail) + failed.restored = restore() + return failed + end + + local ran, answered = pcall(body) + local restored = restore() + + local done = ran and Result.Ok(answered) + or Result.Err('screen_body_raised', tostring(answered)) + done.restored = restored + return done +end + +-- ── Reading ────────────────────────────────────────────────────────────────── + +--- Is the screen covered right now, whoever covered it? +--- +--- UNOWNED: true for a fade this resource put up, one another resource put up, +--- one the server relay put up, and a vanilla quest or loading fade. That is +--- the whole point of it -- the question before a spawn or a teleport is about +--- the screen, not about your transition. +--- +--- A PLAIN BOOLEAN AND NOT A RESULT, on the argument `client/input.lua` makes +--- for its readers: this is asked on a decision path, once per spawn, and the +--- caller's next line is an `if`. +--- +--- IT FAILS TO `true`, AND THAT IS THE ENTIRE DESIGN OF THE FUNCTION. The +--- native answers `nil, reason` rather than a confident `false` when the +--- guarded backend is unavailable, precisely because the platform's own note +--- says a wrong `false` here is what stacks two fades on one player. Collapsing +--- that `nil` to `false` would reintroduce the bug the native was shaped to +--- avoid, so it collapses to `true`: the cost of a wrong `true` is one fade +--- skipped on a host where fades do not work anyway, and the cost of a wrong +--- `false` is the player watching two. A caller who must tell "black" from +--- "could not tell" asks `NativeState`, which keeps the distinction. +-- @author dop42 +-- @return boolean +function Screen.IsFaded() + -- Reached directly: `false` is a real answer here -- the screen is clear -- + -- and `Native.Call`'s falsy-means-refused rule would turn it into an error. + -- The same edge `Open77.zones.contains` meets in `client/zone.lua`. + local native = Native.Reach('screen.isFaded') + if native == nil then return true end + + local read, faded = pcall(native) + if not read then return true end + if faded == nil then return true end + return faded == true +end + +--- The engine's own fade state: faded, fading, out, busy, ready, remainingMs. +--- +--- The detailed form of `IsFaded`, and a Result rather than a plain value +--- because the distinction `IsFaded` gives up -- "could not tell" against "not +--- black" -- is exactly what a caller comes here for. `fading` separates *on +--- its way to black* from *already black*, so a caller that must not interrupt +--- an incoming fade can wait for it instead of racing it. +--- +--- Engine truth, not an estimate. It is still not a GPU presentation fence and +--- not proof that streaming has finished. +-- @author dop42 +-- @return table a Result carrying the state +function Screen.NativeState() + return Native.Call('screen.nativeState', Screen.NEEDS) +end + +--- This resource generation's own transition, with `over` added. +--- +--- OWNER-SCOPED, and that is the difference from the two readers above: it +--- answers for your transition and nothing else. A stale id from a previous +--- generation is not yours and does not resolve. Terminal history is bounded to +--- 64 entries across the whole client and is dropped when a resource stops, so +--- a read of a long-finished transition can legitimately answer nothing. +--- +--- The snapshot is ANNOTATED, not rebuilt, so a newer build's extra fields +--- survive. +-- @author dop42 +-- @param id string +-- @return table a Result carrying the snapshot +function Screen.State(id) + local transition = idOf(id) + if transition == nil then + return Result.Err('invalid_transition_id', 'a transition id is an opaque string') + end + + local read = Native.Call('screen.state', Screen.NEEDS, transition) + if not read.ok then return read end + if type(read.value) == 'table' then read.value.over = Screen.Over(read.value) end + return read +end + +--- Is this transition finished with, so the id can be dropped? +--- +--- The question a caller holding an id actually has: the platform's own worked +--- example clears its stored id on `finished`, `cancelled` and `failed` and +--- keeps it on everything else, and this is that rule in one place instead of +--- three event handlers. +--- +--- A plain boolean, over a snapshot the caller already holds: it cannot fail. +--- +--- IT TESTS THE TERMINAL SET, NOT THE LIVE ONE, and the direction matters. A +--- phase this library has never heard of -- a newer build's -- reads as NOT +--- over, so a caller keeps the id and still calls `FadeIn`. The other way round, +--- an unrecognised phase would look terminal, the caller would drop the id, and +--- nobody would ever hand the image back. One of those errors is a redundant +--- call and the other is a black screen. +-- @author dop42 +-- @param snap table a snapshot from `State`, or an event's state table +-- @return boolean +function Screen.Over(snap) + if type(snap) ~= 'table' then return false end + return Screen.TERMINAL[snap.phase] == true +end + +--- The presets this BUILD supports, and whether each backend is available. +--- +--- Answers the platform and not the static `Screen.PRESETS`, deliberately: the +--- native is newer than some builds this library runs on, and falling back to +--- the static list would claim `fade` works on a build that has none of this. +--- `available` verifies the guarded native entry on this executable -- not that +--- a world is ready, and not that the slot is free. +-- @author dop42 +-- @return table a Result carrying the catalogue +function Screen.Catalog() + return Native.Call('screen.catalog', Screen.NEEDS) +end + +return Screen diff --git a/init.lua b/init.lua index cf79c8e..ee1bf68 100644 --- a/init.lua +++ b/init.lua @@ -55,7 +55,7 @@ local Lib = {} --- Semantic: a new module or function is a minor, a changed answer is a major. --- A consumer pinning behaviour compares this and not the resource's manifest --- version, which an operator can edit. -Lib.VERSION = '0.3.0' +Lib.VERSION = '0.4.0' -- ── Pure: no natives, no permissions, no host ──────────────────────────────── Lib.Result = require('@opx_lib/pure.result') @@ -86,6 +86,8 @@ Lib.World = require('@opx_lib/client.world') Lib.Players = require('@opx_lib/client.players') Lib.Blip = require('@opx_lib/client.blip') Lib.Store = require('@opx_lib/client.store') +Lib.Camera = require('@opx_lib/client.camera') +Lib.Screen = require('@opx_lib/client.screen') --- Module name -> the manifest permission it needs, read off each module's own --- `NEEDS`. Derived, never written out by hand: a list kept in two places is a diff --git a/open77.lua b/open77.lua index 64f243f..03e013d 100644 --- a/open77.lua +++ b/open77.lua @@ -34,7 +34,7 @@ -- an export; `modules/class.lua` explains what an export does to a metatable. resource "opx_lib" -version "0.3.0" +version "0.4.0" -- `>=0.0.1`, which is what every resource that has ever installed on this -- platform declares. A range carrying build metadata is accepted by the server diff --git a/tests/run.lua b/tests/run.lua index 5ead90f..4f5de2c 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -147,7 +147,8 @@ do check('every client module is on the table', Lib.Native and Lib.Timer and Lib.Character and Lib.Notify and Lib.Anim and Lib.Input and Lib.Marker and Lib.Callback and Lib.Zone and Lib.Rpc - and Lib.Async and Lib.World and Lib.Players and Lib.Blip and Lib.Store and true) + and Lib.Async and Lib.World and Lib.Players and Lib.Blip and Lib.Store + and Lib.Camera and Lib.Screen and true) check('it carries its own version', type(Lib.VERSION) == 'string') check('loading it touched no native', next(recorded) == nil) end @@ -590,8 +591,12 @@ do check('a pure module contributes nothing to the manifest line', Lib.NEEDS.Table == nil and Lib.NEEDS.Math == nil) check('Manifest answers the whole line', - Lib.Manifest() == 'permissions { "input.actions", "network.events", ' - .. '"ui.vanilla.hud", "ui.vanilla.map", "world.markers", "world.query" }') + Lib.Manifest() == 'permissions { "camera.script", "input.actions", ' + .. '"network.events", "screen.effects", "ui.vanilla.hud", ' + .. '"ui.vanilla.map", "world.markers", "world.query" }') + check('and a module added this release appears in it without anyone ' + .. 'editing a list by hand', + Lib.NEEDS.Camera == 'camera.script' and Lib.NEEDS.Screen == 'screen.effects') end -- ── Notify ─────────────────────────────────────────────────────────────────── @@ -1441,6 +1446,820 @@ do check('async needs no permission', Lib.Async.NEEDS == nil) end +-- ── Camera ─────────────────────────────────────────────────────────────────── +-- A promise the fake host hands back. `awaited` is what proves a blend was +-- actually waited on rather than merely returned, which is the difference +-- between "the view was released" and "the view is back". +local function promise(...) + local held = table.pack(...) + local it = { awaited = false } + it.await = function(self) + local me = self or it + me.awaited = true + return table.unpack(held, 1, held.n) + end + return it +end + +section('camera: what the platform said') +do + local C = Lib.Camera + + check('the four shake presets are the ones the door accepts', + #C.SHAKES == 4 and C.SHAKES[1] == 'hand' and C.SHAKES[4] == 'earthquake') + check('and the four phases a shot passes through', + #C.PHASES == 4 and C.PHASES[1] == 'idle' and C.PHASES[3] == 'holding') + check('the platform quotas and bounds are written down', + C.LIMIT == 16 and C.CLIENT_LIMIT == 64 and C.RANGE == 250 + and C.MIN_FOV == 5 and C.MAX_FOV == 170 and C.MAX_BLEND == 60000 + and C.MAX_AMPLITUDE == 4.0 and C.SHAKE_MS == 500) + check('and the follow offsets, in the target\'s own frame', + C.FOLLOW.distance[2] == 100 and C.FOLLOW.height[1] == -50 + and C.FOLLOW.side[2] == 50) + + -- The release rules, which are the reason the module exists. + local rules = 0 + for _ in pairs(C.RELEASES) do rules = rules + 1 end + check('every documented release rule has a sentence', rules == 9 + and C.RELEASES.resource_stopped ~= nil and C.RELEASES.player_died ~= nil + and C.RELEASES.camera_superseded ~= nil and C.RELEASES.world_exit ~= nil) + check('and the one that costs a caller their definitions says so', + C.RELEASES.resource_error:find('re-create', 1, true) ~= nil) + check('Why turns a token into that sentence', + C.Why('player_died') == C.RELEASES.player_died) + check('and passes a token it has never heard of through rather than ' + .. 'inventing a meaning for it', + C.Why('some_newer_build_reason'):find('some_newer_build_reason', 1, true) ~= nil) + + check('the module states its permission for Lib.Manifest()', + C.NEEDS == 'camera.script') + check('while recording which of its functions need none', + C.UNGATED[1] == 'Ray' and C.UNGATED[2] == 'Owner') + + -- The same asymmetry marker has: a refusal from the ungated call must not + -- be rewritten into "add camera.script", which would fix nothing. + install({ + ['camera.unproject'] = function() return nil, 'permission_denied:camera.script' end, + ['camera.create'] = function() return nil, 'permission_denied:camera.script' end, + }) + local ray = C.Ray(0.5, 0.5) + check('a refusal from the ungated call is passed through unrewritten', + ray.error == 'permission_denied:camera.script' + and ray.detail:find('add permission', 1, true) == nil) + local gated = C.Create({ position = { x = 0, y = 0, z = 0 } }) + check('while a gated one names the manifest line to add', + gated.error == 'permission_denied' + and gated.detail:find('permission "camera.script"', 1, true) ~= nil) + + _G.Open77 = {} + check('on a build without the namespace, the module says so rather than ' + .. 'raising inside a consumer\'s handler', + C.Create({ position = { x = 0, y = 0, z = 0 } }).error == 'native_not_found' + and C.Take(1).error == 'native_not_found' + and C.Release().error == 'native_not_found') +end + +section('camera: the door') +do + local C = Lib.Camera + + install({ + ['camera.create'] = function() return 3 end, + ['camera.setTransform'] = function() return true end, + ['camera.lookAt'] = function() return true end, + ['camera.attach'] = function() return true end, + ['camera.detachFrom'] = function() return true end, + ['camera.destroy'] = function() return true end, + }) + local function made(spec) return C.Create(spec) end + local function sent() return lastCall().args[1] end + + local cam = C.Create({ position = { x = 1.5, y = 2.5, z = 3.5 }, fov = 45 }) + check('Create answers the id the platform gave', cam.ok and cam.value == 3) + check('and rebuilds the position rather than passing the caller\'s table', + sent().position.x == 1.5 and sent().fov == 45) + + local before = #recorded + check('a camera with neither a position nor an attachTo is refused, ' + .. 'because it has no pose at all', + made({ fov = 45 }).error == 'invalid_camera_position') + check('but an attachTo alone is a camera', made({ attachTo = 0 }).ok) + check('a position that is not finite is refused by the platform\'s word', + made({ position = { x = 0 / 0, y = 0, z = 0 } }).error == 'invalid_camera_position') + check('and the two refusals never reached the platform', #recorded == before + 1) + + -- The field of view, at both ends, plus the documented escape hatch. + check('fov holds at 5 and 170', made({ position = { x = 0, y = 0, z = 0 }, fov = 5 }).ok + and made({ position = { x = 0, y = 0, z = 0 }, fov = 170 }).ok) + check('and is refused just outside either end, by name', + made({ position = { x = 0, y = 0, z = 0 }, fov = 4.99 }).error == 'invalid_camera_fov' + and made({ position = { x = 0, y = 0, z = 0 }, fov = 170.01 }).error == 'invalid_camera_fov') + C.Create({ position = { x = 0, y = 0, z = 0 }, fov = 0 }) + check('zero is not five degrees: it means leave the engine\'s own alone, ' + .. 'and it reaches the engine as zero', sent().fov == 0) + check('a fov that is not a number at all is refused', + made({ position = { x = 0, y = 0, z = 0 }, fov = '45' }).error == 'invalid_camera_fov') + + -- lookAt is the one field where a number and a table are both legal. + C.Create({ position = { x = 0, y = 0, z = 0 }, lookAt = 0 }) + check('entity 0 is the local player and survives the door as the number it is', + sent().lookAt == 0) + check('a negative entity is refused as the entity it meant to be', + made({ position = { x = 0, y = 0, z = 0 }, lookAt = -1 }).error == 'invalid_entity_id') + check('a look-at point is refused by the look-at\'s own word', + made({ position = { x = 0, y = 0, z = 0 }, lookAt = { x = 0, y = 0 } }).error + == 'invalid_camera_look_at' + and made({ position = { x = 0, y = 0, z = 0 }, lookAt = 'up' }).error + == 'invalid_camera_look_at') + + -- Rotation: two forms, told apart by w. + check('a quaternion is accepted whole', + made({ position = { x = 0, y = 0, z = 0 }, + rotation = { x = 0, y = 0, z = 0, w = 1 } }).ok) + check('but three quarters of one is a typo, not a rotation', + made({ position = { x = 0, y = 0, z = 0 }, + rotation = { x = 0, y = 0, w = 1 } }).error == 'invalid_camera_rotation') + check('Euler degrees take any subset and are not range-checked, because ' + .. 'the engine wraps them', + made({ position = { x = 0, y = 0, z = 0 }, rotation = { yaw = 450 } }).ok) + check('but a NaN angle is refused rather than pointed nowhere', + made({ position = { x = 0, y = 0, z = 0 }, rotation = { yaw = 0 / 0 } }).error + == 'invalid_camera_rotation') + check('an empty rotation table is not a rotation', + made({ position = { x = 0, y = 0, z = 0 }, rotation = {} }).error + == 'invalid_camera_rotation') + + local both = made({ position = { x = 0, y = 0, z = 0 }, lookAt = 0, + rotation = { yaw = 90 } }) + check('a rotation and a lookAt in one definition are two aims, so the ' + .. 'caller is made to pick rather than one winning silently', + both.error == 'invalid_camera_rotation' + and both.detail:find('two different aims', 1, true) ~= nil) + + local typo = made({ position = { x = 0, y = 0, z = 0 }, lookat = 0 }) + check('a misspelled field is named rather than silently ignored', + typo.error == 'invalid_camera_options' and typo.detail:find('lookat', 1, true) ~= nil) + + -- Move: the table form, and the narrower field set. + check('Move patches a live camera', C.Move(3, { position = { x = 1, y = 2, z = 3 } }).ok) + check('and takes a fov with it, which the positional form cannot', + C.Move(3, { fov = 60 }).ok and lastCall().args[2].fov == 60) + check('an empty patch is refused by the platform\'s own word rather than ' + .. 'spent on a native call', + C.Move(3, {}).error == 'empty_camera_transform') + check('and a field only a create has is refused there', + C.Move(3, { attachTo = 0 }).error == 'invalid_camera_options') + + -- The id is a number here, unlike a marker's decimal string. + check('a camera id that was run through tostring is refused at the door', + C.Destroy('3').error == 'invalid_camera_id' + and C.Move(3.5, { fov = 60 }).error == 'invalid_camera_id' + and C.Destroy(0).error == 'invalid_camera_id') + + check('Attach needs both arguments, so nobody reaches the no-argument ' + .. 'call that restores the player\'s own camera by accident', + C.Attach(3).error == 'invalid_entity_id' and C.Attach(3, 0).ok) + check('and an offset, when given, is a finite point in the parent frame', + C.Attach(3, 0, { x = 0, y = -1, z = 1 }).ok + and C.Attach(3, 0, { x = 0 / 0, y = 0, z = 0 }).error == 'invalid_camera_offset') + check('DetachFrom takes an id', C.DetachFrom(3).ok) + check('Destroy takes an id', C.Destroy(3).ok) + + install({ ['camera.create'] = function() return nil, 'camera_budget_exhausted' end }) + local full = C.Create({ position = { x = 0, y = 0, z = 0 } }) + check('the budget refusal is rewritten to say what the numbers are', + full.error == 'camera_budget_exhausted' + and full.detail:find('16', 1, true) ~= nil and full.detail:find('64', 1, true) ~= nil) +end + +section('camera: the blend, at both ends') +do + local C = Lib.Camera + install({ + ['camera.activate'] = function() return true end, + ['camera.deactivate'] = function() return true end, + }) + + check('blendMs holds at 0 and 60000', + C.Take(1, { blendMs = 0 }).ok and C.Take(1, { blendMs = 60000 }).ok) + check('and is refused just outside either end, by the platform\'s word', + C.Take(1, { blendMs = -1 }).error == 'invalid_blend_ms' + and C.Take(1, { blendMs = 60001 }).error == 'invalid_blend_ms') + check('a numeric string is not a number, and neither is NaN or infinity', + C.Take(1, { blendMs = '600' }).error == 'invalid_blend_ms' + and C.Take(1, { blendMs = 0 / 0 }).error == 'invalid_blend_ms' + and C.Take(1, { blendMs = math.huge }).error == 'invalid_blend_ms') + check('and half a millisecond is not a whole number of them', + C.Take(1, { blendMs = 1.5 }).error == 'invalid_blend_ms') + check('the same bound guards the hand-back', + C.Release({ blendMs = 60000 }).ok + and C.Release({ blendMs = 60001 }).error == 'invalid_blend_ms') + check('and an option neither call has is refused by name', + C.Take(1, { blendms = 400 }).error == 'invalid_camera_options' + and C.Release({ holdMs = 400 }).error == 'invalid_camera_options') +end + +section('camera: who has the view') +do + local C = Lib.Camera + + install({ ['resource.name'] = function() return 'open77_shop' end }) + check('State answers free when nobody holds the view', + C.State({ held = false }) == 'free' and C.State({ holder = '' }) == 'free') + check('mine when the holder is us', C.State({ held = true, holder = 'open77_shop' }) == 'mine') + check('and theirs when it is somebody else', + C.State({ held = true, holder = 'open77_creator' }) == 'theirs') + check('and it decides on the NAME, so it is right under either reading of ' + .. '`held` -- the two sources disagree about that field', + C.State({ held = false, holder = 'open77_creator' }) == 'theirs') + check('anything that is not a snapshot is not guessed at', + C.State(nil) == 'unknown' and C.State('open77_shop') == 'unknown') + + install({ ['camera.cameras'] = function() + return { held = true, holder = 'open77_creator', phase = 'holding', cameras = {} } + end }) + check('a build that will not say its own name answers the honestly vague ' + .. '"held" rather than guessing whose it is', + C.State({ held = true, holder = 'open77_creator' }) == 'held') + + local snap = C.Cameras() + check('Cameras annotates the snapshot with that state', + snap.ok and snap.value.state == 'held') + check('and leaves the platform\'s own fields alone, so a newer build\'s ' + .. 'extra ones survive', snap.value.phase == 'holding') + + local holder = C.Holder() + check('Holder answers the one question a camera_held_by refusal leaves you ' + .. 'asking', holder.ok and holder.value == 'open77_creator') + + install({ ['camera.cameras'] = function() return { held = false, cameras = {} } end }) + local free = C.Holder() + check('and Ok(nil) when the view is free, which is an answer and not a ' + .. 'failure', free.ok and free.value == nil) +end + +section('camera: a refusal that names the holder') +do + local C = Lib.Camera + + install({ + ['camera.activate'] = function() return false, 'camera_held_by:open77_creator' end, + ['camera.cameras'] = function() return { held = true, holder = 'open77_creator' } end, + }) + local refused = C.Take(1) + check('the platform\'s own parameterised code survives as the code', + refused.error == 'camera_held_by:open77_creator') + check('and the holder is lifted out of it, onto the Result and into the detail', + refused.holder == 'open77_creator' + and refused.detail:find('open77_creator has the view', 1, true) ~= nil) + check('without spending a native call to learn what the code already said', + lastCall().path == 'camera.activate') + + install({ + ['camera.activate'] = function() return false, 'camera_held' end, + ['camera.cameras'] = function() return { held = true, holder = 'open77_creator' } end, + }) + local bare = C.Take(1) + check('the bare refusal is worth one extra read, because "somebody has the ' + .. 'view" is not actionable and a name is', + bare.error == 'camera_held' and bare.holder == 'open77_creator' + and lastCall().path == 'camera.cameras') + + install({ + ['camera.activate'] = function() return false, 'camera_held' end, + ['camera.cameras'] = function() return nil, 'camera_unavailable_on_this_host' end, + }) + local quiet = C.Take(1) + check('and when the build will not say either, it says that rather than ' + .. 'inventing a holder', + quiet.holder == nil and quiet.detail:find('would not say which', 1, true) ~= nil) + + install({ ['camera.follow'] = function() return nil, 'camera_held_by:open77_admin' end }) + check('follow is refused the same way, because it is not a new way to hold ' + .. 'the view', C.Follow(0).holder == 'open77_admin') + + -- The one refusal the engine cannot name for itself. + install({ ['camera.activate'] = function() return false, 'invalid_argument' end }) + local far = C.Take(1) + check('activate\'s bare invalid_argument is rewritten into the streaming ' + .. 'ceiling, which is the only thing left it can mean', + far.error == 'invalid_argument' and far.detail:find('250 m', 1, true) ~= nil + and far.detail:find('move the player', 1, true) ~= nil) +end + +section('camera: the promise that comes second') +do + local C = Lib.Camera + + local blend = promise(true) + install({ + ['camera.activate'] = function() return true, blend end, + ['camera.deactivate'] = function() return true, blend end, + ['camera.follow'] = function() return 7, blend end, + ['camera.unfollow'] = function() return true, blend end, + }) + + local took = C.Take(1, { blendMs = 600 }) + check('the Result carries the native\'s FIRST return, untouched', + took.ok and took.value == true) + check('and the promise -- which Native.Call would have dropped -- rides ' + .. 'along under blend', took.blend == blend) + check('Release carries one too', C.Release({ blendMs = 400 }).blend == blend) + local followed = C.Follow(0, { distance = 5.0 }) + check('and Follow answers the camera id with the blend beside it', + followed.ok and followed.value == 7 and followed.blend == blend) + check('as does Unfollow', C.Unfollow().blend == blend) + + install({ ['camera.activate'] = function() return true end }) + local cut = C.Take(1) + check('a cut settles before the call returns and hands back no promise', + cut.ok and cut.blend == nil) + check('and awaiting what a cut gave you is Ok, so no call site needs an if', + C.Await(cut.blend).ok) + + local landed = promise(true) + check('Await resolves when the blend lands', C.Await(landed).ok and landed.awaited) + + local died = C.Await(promise(nil, 'player_died')) + check('a release rejects the promise rather than dropping it, so the ' + .. 'coroutine that was going to tidy up wakes up', + not died.ok and died.error == 'player_died') + check('and the reason arrives as the sentence, not just the token', + died.detail == Lib.Camera.RELEASES.player_died) +end + +section('camera: the release guarantee') +do + local C = Lib.Camera + local handBack + + local function host(activate, deactivate) + install({ + ['camera.activate'] = activate, + ['camera.deactivate'] = deactivate or function() + handBack = promise(true) + return true, handBack + end, + }) + end + + -- 1. The ordinary path. + handBack = nil + host(function() return true, promise(true) end) + local ran = false + local shot = C.Shot(1, { blendMs = 600 }, function() + ran = true + return 'done' + end) + check('Shot runs the body while it holds the view and answers what the ' + .. 'body answered', ran and shot.ok and shot.value == 'done') + check('and the view is handed back', lastCall().path == 'camera.deactivate') + check('and the hand-back blend is AWAITED, so when Shot returns the view ' + .. 'is on the player\'s eyes and not halfway there', + handBack ~= nil and handBack.awaited and shot.released == true) + check('the hand-back defaults to the blend the take used, because a shot ' + .. 'that eases in and cuts out is the thing a player notices', + lastCall().args[1].blendMs == 600) + + -- 2. The path the platform's own rules do not cover. + handBack = nil + host(function() return true, promise(true) end) + local raised = C.Shot(1, { blendMs = 0 }, function() error('the UI blew up') end) + check('a body that RAISES still gives the view back -- the case a live ' + .. 'resource is in, which no release rule covers', + lastCall().path == 'camera.deactivate' and raised.released == true) + check('and the raise is reported rather than swallowed or unwound', + not raised.ok and raised.error == 'shot_raised' + and raised.detail:find('the UI blew up', 1, true) ~= nil) + + -- 3. Released underneath us before the shot was ever up. + handBack = nil + host(function() return true, promise(nil, 'camera_superseded') end) + local never = C.Shot(1, { blendMs = 600 }, function() + ran = 'body ran anyway' + return true + end) + check('a blend that never lands means the shot never happened, so the ' + .. 'body does not run over a view somebody else now owns', + not never.ok and never.error == 'camera_superseded' and ran ~= 'body ran anyway') + check('and the view is still handed back on the way out', + lastCall().path == 'camera.deactivate') + + -- 4. Something released it first. That is success, not failure. + host(function() return true, promise(true) end, + function() return false, 'camera_not_active' end) + local already = C.Shot(1, nil, function() return 1 end) + check('a hand-back refused with camera_not_active is the outcome we wanted: ' + .. 'a release rule got there first and the view is already back', + already.ok and already.released == true) + + -- 5. A hand-back refused for any other reason is NOT quietly a success. + host(function() return true, promise(true) end, + function() return false, 'camera_unavailable_on_this_host' end) + local stuck = C.Shot(1, nil, function() return 1 end) + check('but any other refusal is reported, because a view that did not come ' + .. 'back is the whole thing this module is about', + stuck.ok and stuck.released == false) + + -- 6. A take that was refused never claims to have released anything. + install({ ['camera.activate'] = function() return false, 'camera_unavailable' end }) + local before = #recorded + local no = C.Shot(1, nil, function() return 1 end) + check('a refused take is passed straight back and nothing is handed back, ' + .. 'because nothing was taken', + no.error == 'camera_unavailable' and no.released == nil and #recorded == before + 1) + + -- The door on Shot itself. + host(function() return true, promise(true) end) + check('Shot(cam, fn) is the same call as Shot(cam, nil, fn)', + C.Shot(1, function() return 'short' end).value == 'short') + check('and something that is not a function is refused before the view is ' + .. 'ever taken', C.Shot(1, nil, 'not a function').error == 'invalid_body') + check('as is an option Shot does not have', + C.Shot(1, { holdMs = 10 }, function() end).error == 'invalid_camera_options') + check('releaseMs overrides the take\'s blend when the caller wants a ' + .. 'different one', (function() + C.Shot(1, { blendMs = 600, releaseMs = 120 }, function() end) + return lastCall().args[1].blendMs == 120 + end)()) +end + +section('camera: shake and the ungated ray') +do + local C = Lib.Camera + install({ + ['camera.shake'] = function() return true end, + ['camera.stopShake'] = function() return true end, + ['camera.unproject'] = function() return { origin = {}, direction = {} } end, + }) + + check('every documented preset is accepted', (function() + for _, preset in ipairs(C.SHAKES) do + if not C.Shake(1, preset).ok then return false end + end + return true + end)()) + local unknown = C.Shake(1, 'wobble') + check('an unknown preset is refused here, one native call early, and the ' + .. 'refusal lists the four that would have worked', + unknown.error == 'invalid_shake_preset' + and unknown.detail:find('earthquake', 1, true) ~= nil) + check('amplitude holds at 0 and 4', C.Shake(1, 'hand', 0).ok and C.Shake(1, 'hand', 4).ok) + check('and is refused just outside either end', + C.Shake(1, 'hand', -0.01).error == 'invalid_shake_argument' + and C.Shake(1, 'hand', 4.01).error == 'invalid_shake_argument') + check('a duration is whole milliseconds, not a numeric string', + C.Shake(1, 'hand', 1.0, '500').error == 'invalid_shake_argument') + C.Shake(nil, 'explosion', 2.0, 1000) + check('and omitting the id shakes whichever camera you hold', + lastCall().args[1] == nil and lastCall().args[2] == 'explosion') + + check('StopShake takes an id', C.StopShake(1).ok) + + check('the screen point holds at 0 and 1', C.Ray(0, 0).ok and C.Ray(1, 1).ok) + check('and is refused just outside either end, by the platform\'s word', + C.Ray(-0.01, 0.5).error == 'invalid_screen_point' + and C.Ray(0.5, 1.01).error == 'invalid_screen_point' + and C.Ray(0.5, 0 / 0).error == 'invalid_screen_point') +end + +-- ── Screen ─────────────────────────────────────────────────────────────────── +section('screen: what the platform said') +do + local S = Lib.Screen + + check('the catalogue currently contains exactly one preset', + #S.PRESETS == 1 and S.PRESETS[1] == 'fade') + check('the documented bounds are written down', + S.MAX_FADE_MS == 10000 and S.MAX_HOLD_MS == 30000 + and S.MIN_TIMEOUT_MS == 1000 and S.MAX_TIMEOUT_MS == 60000 + and S.TIMEOUT_MARGIN_MS == 500 and S.ALPHA == 255) + check('and the defaults, which are for checking and never sent', + S.DEFAULTS.durationMs == 500 and S.DEFAULTS.holdMs == 250 + and S.DEFAULTS.fadeInMs == 500) + check('the three phases nothing follows are the terminal set', + S.TERMINAL.finished and S.TERMINAL.cancelled and S.TERMINAL.failed + and S.TERMINAL.covered == nil) + check('and the reason that is ours rather than the engine\'s is explained, ' + .. 'because the engine calls it completed', + S.REASONS.never_covered:find('without ever reaching black', 1, true) ~= nil) + check('Why turns a token into that sentence, and passes an unknown through', + S.Why('timeout') == S.REASONS.timeout + and S.Why('newer_reason'):find('newer_reason', 1, true) ~= nil) + check('the module states its permission for Lib.Manifest()', + S.NEEDS == 'screen.effects') + + _G.Open77 = {} + check('on a build without the namespace the module says so', + S.FadeOut().error == 'native_not_found' + and S.Catalog().error == 'native_not_found') + check('and IsFaded fails to true, because a wrong false is what stacks two ' + .. 'fades on one player', S.IsFaded() == true) +end + +section('screen: the door') +do + local S = Lib.Screen + install({ + ['screen.fadeOut'] = function() return '12' end, + ['screen.fadeIn'] = function() return true end, + ['screen.transition'] = function() return '13' end, + }) + local function sent() return lastCall().args[#lastCall().args] end + + check('durationMs holds at 0 and 10000', + S.FadeOut({ durationMs = 0 }).ok and S.FadeOut({ durationMs = 10000 }).ok) + check('and is refused just outside either end, under the platform\'s own ' + .. 'parameterised code', + S.FadeOut({ durationMs = -1 }).error == 'invalid_screen_option:durationMs' + and S.FadeOut({ durationMs = 10001 }).error == 'invalid_screen_option:durationMs') + check('a numeric string is not a number, and neither is NaN or infinity', + S.FadeOut({ durationMs = '500' }).error == 'invalid_screen_option:durationMs' + and S.FadeOut({ durationMs = 0 / 0 }).error == 'invalid_screen_option:durationMs' + and S.FadeOut({ durationMs = math.huge }).error == 'invalid_screen_option:durationMs') + check('and durations are integer milliseconds, not fractions of one', + S.FadeOut({ durationMs = 500.5 }).error == 'invalid_screen_option:durationMs') + + check('holdMs holds at 0 and 30000', + S.Transition('fade', { holdMs = 0, timeoutMs = 2000 }).ok + and S.Transition('fade', { holdMs = 30000, timeoutMs = 31600 }).ok) + check('and is refused just past its own end, which is not the fade bound', + S.Transition('fade', { holdMs = 30001 }).error == 'invalid_screen_option:holdMs') + check('fadeInMs holds at 0 and 10000 and is refused past it', + S.Transition('fade', { fadeInMs = 10000 }).ok + and S.Transition('fade', { fadeInMs = 10001 }).error + == 'invalid_screen_option:fadeInMs') + + check('timeoutMs holds at 1000 and 60000', + S.FadeOut({ durationMs = 400, timeoutMs = 1000 }).ok + and S.FadeOut({ timeoutMs = 60000 }).ok) + check('and is refused just outside either end', + S.FadeOut({ timeoutMs = 999 }).error == 'invalid_screen_option:timeoutMs' + and S.FadeOut({ timeoutMs = 60001 }).error == 'invalid_screen_option:timeoutMs') + + -- The cross-field rule, using the documented defaults for what was omitted. + local short = S.Transition('fade', { timeoutMs = 1249 }) + check('a deadline that does not clear the sequence it bounds is refused ' + .. 'with the platform\'s own word', + short.error == 'screen_timeout_too_short') + check('and the refusal states the sum, which is the one number the engine ' + .. 'does not give you', short.detail:find('1250 ms', 1, true) ~= nil) + check('the margin is exactly 500 ms, at both sides of the line', + S.Transition('fade', { timeoutMs = 1750 }).ok + and S.Transition('fade', { timeoutMs = 1749 }).error == 'screen_timeout_too_short') + check('a fadeOut is checked against the outgoing fade alone, because the ' + .. 'return it will get is not this call\'s to know', + S.FadeOut({ durationMs = 2000, timeoutMs = 2500 }).ok + and S.FadeOut({ durationMs = 2000, timeoutMs = 2499 }).error + == 'screen_timeout_too_short') + + -- The option sets differ per call, and that is the point. + check('fadeIn takes a duration and nothing else, because a hold there is a ' + .. 'hold nobody ever performs', + S.FadeIn('12', { durationMs = 400 }).ok + and S.FadeIn('12', { holdMs = 400 }).error == 'invalid_screen_option:holdMs') + local unknown = S.FadeOut({ duration = 400 }) + check('an unknown option is named, not ignored', + unknown.error == 'invalid_screen_option:duration' + and unknown.detail:find('durationMs', 1, true) ~= nil) + + -- Colour. + check('colour channels hold at 0 and 255', + S.FadeOut({ color = { r = 0, g = 0, b = 0 } }).ok + and S.FadeOut({ color = { r = 255, g = 255, b = 255 } }).ok) + check('and are refused just outside either end', + S.FadeOut({ color = { r = -1 } }).error == 'invalid_screen_color' + and S.FadeOut({ color = { g = 256 } }).error == 'invalid_screen_color') + check('alpha is not a range: 255 is the only value this API has', + S.FadeOut({ color = { r = 0, a = 255 } }).ok + and S.FadeOut({ color = { r = 0, a = 254 } }).error == 'invalid_screen_color') + S.FadeOut({ color = '#ff3b47' }) + check('a hex string is converted to the bytes the native wants, as it is ' + .. 'everywhere else in this library', + sent().color.r == 255 and sent().color.g == 59 and sent().color.b == 71) + check('and a malformed one is refused', + S.FadeOut({ color = '#f00' }).error == 'invalid_screen_color') + S.FadeOut({ color = { r = 12 } }) + check('an omitted channel is left to the engine rather than filled in here', + sent().color.g == nil and sent().color.a == nil) + + -- Presets. + check('the one preset in the catalogue is accepted', S.Transition('fade').ok) + check('a preset the platform knows but has not landed is forwarded, so the ' + .. 'caller reads "not yet" and not "misspelled"', + S.Transition('glitch').ok and lastCall().args[1] == 'glitch') + local nonsense = S.Transition('sparkle') + check('while a name nobody has is refused here, naming the one that works', + nonsense.error == 'unsupported_screen_preset' + and nonsense.detail:find('fade', 1, true) ~= nil) + + -- Ids are opaque strings and are never interpreted. + check('an id that was run through tonumber is refused at the door', + S.FadeIn(12).error == 'invalid_transition_id' + and S.Cancel(nil).error == 'invalid_transition_id' + and S.State('').error == 'invalid_transition_id') +end + +section('screen: the slot is single and shared') +do + local S = Lib.Screen + + install({ + ['screen.fadeOut'] = function() return nil, 'screen_busy' end, + ['screen.transition'] = function() return nil, 'screen_busy' end, + }) + local taken = S.FadeOut({ durationMs = 400 }) + check('a second request answers the platform\'s own screen_busy', + taken.error == 'screen_busy') + check('and the refusal says the slot is shared with the server relay, ' + .. 'which is the cause a client resource cannot see', + taken.detail:find('server relay', 1, true) ~= nil + and taken.detail:find('isFaded', 1, true) ~= nil) + check('transition is refused the same way, from the same slot', + S.Transition('fade').error == 'screen_busy') + + install({ ['screen.fadeOut'] = function() return nil, 'native_screen_busy' end }) + local vanilla = S.FadeOut() + check('a fade the GAME owns is a different answer and a different remedy', + vanilla.error == 'native_screen_busy' + and vanilla.detail:find('will not clear it', 1, true) ~= nil) +end + +section('screen: the promise that comes second') +do + local S = Lib.Screen + + local covered, restored, finished = promise({ black = true }), promise(true), promise(true) + install({ + ['screen.fadeOut'] = function() return '12', covered end, + ['screen.fadeIn'] = function() return true, restored end, + ['screen.transition'] = function() return '13', finished end, + }) + + local out = S.FadeOut({ durationMs = 400 }) + check('the Result carries the opaque id, exactly as the native gave it', + out.ok and out.value == '12') + check('and the promise -- which Native.Call would have dropped -- rides ' + .. 'along, named for what it settles on', out.covered == covered) + check('fadeIn keeps its own true and names its promise for the image ' + .. 'coming back', + S.FadeIn('12').value == true and S.FadeIn('12').restored == restored) + check('and a transition names its promise for the end of the sequence, ' + .. 'which is not when the screen turns black', + S.Transition('fade').finished == finished) + + local black = S.Await(out.covered) + check('Await resolves with the state table the event carries', + black.ok and black.value.black == true and covered.awaited) + check('and a nil promise is Ok, so no call site needs an if', + S.Await(nil).ok) + + local ended = S.Await(promise(nil, 'never_covered')) + check('a transition that ended some other way rejects rather than parking ' + .. 'the coroutine that was going to call fadeIn', + not ended.ok and ended.error == 'never_covered') + check('and the reason arrives as the sentence, which for this one is the ' + .. 'only way to read it', ended.detail == Lib.Screen.REASONS.never_covered) +end + +section('screen: the reads, owned and unowned') +do + local S = Lib.Screen + + install({ ['screen.isFaded'] = function() return true end }) + check('IsFaded answers the screen, whoever covered it', S.IsFaded() == true) + install({ ['screen.isFaded'] = function() return false end }) + check('and false is an ANSWER -- the screen is clear -- which is why it ' + .. 'cannot go through Native.Call', S.IsFaded() == false) + install({ ['screen.isFaded'] = function() return nil, 'screen_unavailable' end }) + check('a guarded backend that will not say answers true, not false: a ' + .. 'wrong false is exactly what stacks two fades', S.IsFaded() == true) + install({ ['screen.isFaded'] = function() error('boom') end }) + check('and so does a read that raises', S.IsFaded() == true) + + install({ ['screen.nativeState'] = function() + return { faded = false, fading = true, remainingMs = 120 } + end }) + local native = S.NativeState() + check('NativeState keeps the distinction IsFaded gives up, so a caller can ' + .. 'wait for an incoming fade instead of racing it', + native.ok and native.value.fading == true and native.value.faded == false) + + check('Over reads the terminal phases', + S.Over({ phase = 'finished' }) and S.Over({ phase = 'cancelled' }) + and S.Over({ phase = 'failed' })) + check('and the live ones are not over', + not S.Over({ phase = 'fading_out' }) and not S.Over({ phase = 'covered' }) + and not S.Over({ phase = 'fading_in' })) + check('a phase from a newer build reads as NOT over, which keeps the id ' + .. 'and risks one redundant fadeIn -- the other direction risks a ' + .. 'screen nobody ever restores', + not S.Over({ phase = 'something_new' }) and not S.Over(nil)) + + install({ ['screen.state'] = function() + return { id = '12', phase = 'covered', black = true, elapsedMs = 650 } + end }) + local mine = S.State('12') + check('State annotates the owner-scoped snapshot with that answer', + mine.ok and mine.value.over == false) + check('and leaves the platform\'s own fields alone', + mine.value.elapsedMs == 650 and mine.value.black == true) + + install({ ['screen.catalog'] = function() + return { fade = { available = true, backend = 'native_quest_fade' } } + end }) + local catalogue = S.Catalog() + check('Catalog asks the build rather than answering the static list', + catalogue.ok and catalogue.value.fade.available == true) +end + +section('screen: the image comes back') +do + local S = Lib.Screen + local restored + + local function host(fadeOut, fadeIn) + install({ + ['screen.fadeOut'] = fadeOut, + ['screen.fadeIn'] = fadeIn or function() + restored = promise(true) + return true, restored + end, + }) + end + + -- 1. The ordinary path. + restored = nil + host(function() return '12', promise({ black = true }) end) + local ran = false + local hidden = S.Black({ durationMs = 400, fadeInMs = 250 }, function() + ran = true + return 'moved' + end) + check('Black runs the body once the screen is actually black and answers ' + .. 'what the body answered', ran and hidden.ok and hidden.value == 'moved') + check('and the image is handed back', lastCall().path == 'screen.fadeIn') + check('with fadeInMs lifted out of the fadeOut options, where it is not a ' + .. 'field, and spent on the return', lastCall().args[2].durationMs == 250) + check('and the return is AWAITED, so when Black returns the image is back', + restored ~= nil and restored.awaited and hidden.restored == true) + check('the fadeIn is given the id the fadeOut answered', + lastCall().args[1] == '12') + + -- 2. The path the safety deadline would otherwise have to cover. + restored = nil + host(function() return '12', promise({ black = true }) end) + local raised = S.Black(nil, function() error('the teleport threw') end) + check('a body that RAISES still gives the image back, rather than leaving ' + .. 'the player staring at black until the deadline expires', + lastCall().path == 'screen.fadeIn' and raised.restored == true) + check('and the raise is reported rather than swallowed', + not raised.ok and raised.error == 'screen_body_raised' + and raised.detail:find('the teleport threw', 1, true) ~= nil) + + -- 3. It never went black, so the body must not run in plain view. + host(function() return '12', promise(nil, 'native_interrupted') end) + local never = S.Black(nil, function() + ran = 'body ran in plain view' + return true + end) + check('a fade that never reaches black does not run a body written to ' + .. 'happen unseen', + not never.ok and never.error == 'native_interrupted' + and ran ~= 'body ran in plain view') + check('and it still tries to restore on the way out', + lastCall().path == 'screen.fadeIn') + + -- 4. Already over. That is the outcome we wanted. + host(function() return '12', promise({ black = true }) end, + function() return false, 'transition_not_active' end) + local over = S.Black(nil, function() return 1 end) + check('a return refused because the transition is already over is a ' + .. 'success: the image is back either way', + over.ok and over.restored == true) + + -- 5. Any other refusal is not quietly a success. + host(function() return '12', promise({ black = true }) end, + function() return false, 'not_owner' end) + local wrong = S.Black(nil, function() return 1 end) + check('but a return refused for any other reason is reported', + wrong.ok and wrong.restored == false) + + -- 6. Nothing was covered, so nothing is restored. + install({ ['screen.fadeOut'] = function() return nil, 'screen_busy' end }) + local before = #recorded + local busy = S.Black(nil, function() return 1 end) + check('a refused fade is passed straight back and nothing is restored, ' + .. 'because nothing was covered', + busy.error == 'screen_busy' and busy.restored == nil and #recorded == before + 1) + + host(function() return '12', promise({ black = true }) end) + check('Black(fn) is the same call as Black(nil, fn)', + S.Black(function() return 'short' end).value == 'short') + check('and something that is not a function is refused before the screen ' + .. 'is ever covered', S.Black(nil, 'not a function').error == 'invalid_body') +end + -- ── Timer ──────────────────────────────────────────────────────────────────── section('timer') do