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