diff --git a/config/pool.lua b/config/pool.lua index 817c82da..d5e76247 100644 --- a/config/pool.lua +++ b/config/pool.lua @@ -25,7 +25,17 @@ OPX.Config.MODULES.pool = { SOLO_SHOTS = 10, DUEL_TARGET = 5, AUDIO_VOLUME = 0.65, -- 0 mutes the recorded table contacts - AIM_GUIDE = true, -- straight line to the first ball/cushion; no bounce prediction + AIM_GUIDE = true, -- master switch for the optical trajectory overlay + TRAJECTORY = { + ENABLED = true, + BASE_LEVEL = 0, -- 0: optics required; 1..3: minimum assistance for everyone + MIN_CONDITION = 1, -- installed implants at 0 condition do not grant a preview + MAX_BOUNCES = 2, MAX_DISTANCE = 4.5, + ANGLE_STEP = 0.005, POWER_STEP = 0.05, DEFAULT_POWER = 0.5, + IMPLANTS = { + kiroshi_optics = { SLOT = 'eyes', TIERS = { [1] = 1, [2] = 2, [3] = 3 } }, + }, + }, -- opx_pool_assets reserves these three native recreation aliases globally. -- Do not use them as tents, sleeping bags or gym equipment on this world. MODELS = { diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 9a73ae92..ce8abde0 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -1037,9 +1037,39 @@ refusal, it retries once without that optional field and logs the legacy fallbac Destroying only the owned camera restores the native presentation. Actual local head/hair rendering and framing still require a live client check. -The straight aiming guide stops at the first ball or cushion and shows a contact -ring, without predicting rebounds. `AIM_GUIDE = false` hides it. `SENSITIVITY` -defaults to `0.0018`, `CHARGE_SECONDS` to `1.8`. Real CC0 cue, ball and pocket +The optical trajectory preview is configured under `TRAJECTORY` in +`config/pool.lua`. `AIM_GUIDE = false` or `TRAJECTORY.ENABLED = false` disables it. +By default `BASE_LEVEL = 0`: no implant means no overlay. Installed +`kiroshi_optics` in the `eyes` slot grants level 1/2/3 for implant tier 1/2/3: + +- Level 1: the incoming line and first-impact ring, up to 1.5 m. +- Level 2: incoming guidance plus separate estimated paths for the white ball + (cyan) and the first object ball (amber), up to 3 m per traced path. +- Level 3: outgoing paths and cushion rebounds, subject to `MAX_BOUNCES` (0–2) + and `MAX_DISTANCE` (0.5–6 m; default 4.5 m). + +`IMPLANTS` maps a ripperdoc chrome ID to `SLOT` and a `TIERS` map from installed +implant tier (1–5) to assistance level (0–3). Change it to support other optics. +`MIN_CONDITION` defaults to 1: broken or unknown-condition implants grant no bonus. +`BASE_LEVEL` can provide a minimum level without chrome if the operator wants one. +The server reads the same installed `cyberware` metadata as ripperdoc, not inventory +items or a level sent by the client. Only the current shooter receives their own +numeric grant; no implant records are added to pool packets. The server refreshes +it at least on each watch and once per second during a turn, so removal/tier changes +do not depend on a client's cooperation. Character/session changes still clear the +entire pool view. Missing or malformed grants disable preview without dropping +ball playback, and revocation immediately clears existing lines when received. + +The HUD identifies the preview as an estimate. Its path responds to direction and +charge power; before charging it uses `DEFAULT_POWER` (default 0.5). `ANGLE_STEP` +(default 0.005 radians, coarser at lower levels) and `POWER_STEP` (default 0.05) +quantize the estimate for a stable, deliberately approximate readout. It models +stationary balls, drag, the first transfer of momentum and cushion losses. It +stops at pockets or the next object collision, and does not promise multi-ball +chains, spin or an exact final resting position. No preview modifies the real +balls, scores, turns or server shot validation. + +`SENSITIVITY` defaults to `0.0018`, `CHARGE_SECONDS` to `1.8`. Real CC0 cue, ball and pocket recordings are delivered as PCM WAV by the asset resource; rails reuse the ball clip at lower gain. `AUDIO_VOLUME` (0–1, default 0.65) sets loudness, zero mutes. Server contacts carry bounded IDs/times; client audio follows the same 100 ms @@ -1049,7 +1079,7 @@ client exports; that resource owns at most eight spatial voices. See its NOTICE and audio/sources.json for original recordings, CC0 links, exact hashes and edits. The camera needs `camera.script`; the bounded aiming guide uses `world.debug`. -Its camera/prop work runs per frame; guide geometry is capped at 20 lines and +Its camera/prop work runs per frame; preview geometry is capped at 20 lines and refreshed every 100 ms with a 200 ms native expiry. The power overlay consumes no pointer or keyboard and expires stale readings after 500 ms. These are client presentation features, not new permissions to decide shots or outcomes. diff --git a/modules/pool/client/guide.lua b/modules/pool/client/guide.lua index c76cb68c..19f4b15d 100644 --- a/modules/pool/client/guide.lua +++ b/modules/pool/client/guide.lua @@ -1,4 +1,4 @@ ---- A bounded straight aiming aid, ending at the first ball or cushion. +--- Resource-owned optical preview; finite geometry with a short native expiry. -- @author dop42 local M = OPX.Modules.Get('pool') local Model = M.Model @@ -9,49 +9,30 @@ function G.Clear() if shown then Open77.debugDraw.clear('pool.aim'); shown = false end end function G.Reset() G.Clear(); refused, last = false, -math.huge end -function G.End(spec, points, angle) - local x, y, c, s = points[1][1], points[1][2], math.cos(angle), math.sin(angle) - local length = 6 - local r = Model.RADIUS - if math.abs(c) > 1e-6 then length = math.min(length, ((c > 0 and spec.halfLength-r or -spec.halfLength+r)-x)/c) end - if math.abs(s) > 1e-6 then length = math.min(length, ((s > 0 and spec.halfWidth-r or -spec.halfWidth+r)-y)/s) end - for index = 2, Model.BALLS do - local ball = points[index] - if ball[3] then - local dx, dy = ball[1]-x, ball[2]-y - local ahead, side = dx*c+dy*s, dx*s-dy*c - if ahead > 0 and math.abs(side) < r*2 then - local touch = ahead-math.sqrt(math.max(0, r*r*4-side*side)) - if touch >= 0 then length = math.min(length, touch) end - end - end - end - length = math.max(0, length) - return x+c*length, y+s*length, length -end -function G.Draw(spec, points, angle, now) - if refused or now-last < 50 then return end +function G.End(spec, points, angle) return M.Prediction.End(spec,points,angle) end +function G.Draw(spec, points, angle, now, profile, power) + profile = profile or M.Assistance.Profile(M.Assistance.Read(), 1) + if profile.level == 0 then G.Clear(); return end + if refused or now-last < 100 then return end last = now - local x, y, length = G.End(spec, points, angle) - local ball = points[1] + local paths, contact = M.Prediction.Trace(spec, points, angle, power, profile) local lines = {} - local function point(px, py) - local p = Model.World(spec, px, py); p.z = p.z-Model.RADIUS+0.003; return p + local c, s = math.cos(math.rad(spec.yaw)), math.sin(math.rad(spec.yaw)) + local function point(x, y) + return { x=spec.x+x*c-y*s, y=spec.y+x*s+y*c, z=spec.z-Model.RADIUS+0.003 } end - local count = math.min(12, math.max(1, math.ceil(length/0.08))) - for index = 0, count-1 do - local a, b = index/count, math.min(1, (index+0.55)/count) - lines[#lines+1] = { a = point(ball[1]+(x-ball[1])*a, ball[2]+(y-ball[2])*a), - b = point(ball[1]+(x-ball[1])*b, ball[2]+(y-ball[2])*b), color = '#ADEFFFC0' } + for _, path in ipairs(paths) do + lines[#lines+1] = { a=point(path[1],path[2]), b=point(path[3],path[4]), color=path[5] } end - for index = 0, 7 do - local a, b = index*math.pi/4, (index+1)*math.pi/4 - lines[#lines+1] = { a = point(x+math.cos(a)*Model.RADIUS, y+math.sin(a)*Model.RADIUS), - b = point(x+math.cos(b)*Model.RADIUS, y+math.sin(b)*Model.RADIUS), color = '#ADEFFFCC' } - end - local ok, why = Open77.debugDraw.set('pool.aim', { lines = lines, ttl = 0.2, maxDistance = 8, thickness = 1.5 }) - if not ok then - refused = true; G.Clear(); OPX.Note('pool', 'aim guide unavailable: ' .. tostring(why)); return + if contact then + for index = 0, 7 do + local a, b = index*math.pi/4, (index+1)*math.pi/4 + lines[#lines+1] = { a=point(contact[1]+math.cos(a)*Model.RADIUS,contact[2]+math.sin(a)*Model.RADIUS), + b=point(contact[1]+math.cos(b)*Model.RADIUS,contact[2]+math.sin(b)*Model.RADIUS), color='#ADEFFFCC' } + end end + if #lines == 0 then G.Clear(); return end + local ok, why = Open77.debugDraw.set('pool.aim', { lines=lines, ttl=0.2, maxDistance=8, thickness=1.5 }) + if not ok then refused=true; G.Clear(); OPX.Note('pool', 'aim guide unavailable: '..tostring(why)); return end shown = true end diff --git a/modules/pool/client/main.lua b/modules/pool/client/main.lua index c45b6af2..712de91a 100644 --- a/modules/pool/client/main.lua +++ b/modules/pool/client/main.lua @@ -4,7 +4,7 @@ local M = OPX.Modules.Get('pool') local Model, B, I, R, C, G, Match, TableMenu = M.Model, M.Playback, M.Input, M.Render, M.Camera, M.Guide, M.Match, M.TableMenu local spec, buffer, input, positions, target, prompts, jobs, lastPacket, subscribed, lastMouse, lastWheel, lastAim, waiting, owned, held, reported, lastControls local autoAim, following, shotSeen, hudVisible, member, memberSlot, watching -local openTable, reopenTable +local openTable, reopenTable, assistanceSettings local function request(action, extra, snapshot) extra = extra or {} extra.epoch = snapshot and snapshot.epoch or buffer.epoch @@ -56,8 +56,10 @@ local function receive(packet) if not spec or not subscribed then return end if type(packet) == 'table' and packet.closed == true then clear(true); return end local oldEpoch, wasOwned = buffer.epoch, owned + local oldAssistance = buffer.state and buffer.state.assistance or 0 if B.Push(buffer, packet, OPX.Now()) then lastPacket = OPX.Now() + if oldAssistance ~= buffer.state.assistance then G.Clear() end M.Audio.Queue(packet, buffer) local nextOwned = buffer.state.owner == Open77.players.localId() if oldEpoch ~= buffer.epoch or (wasOwned and not nextOwned) then @@ -189,7 +191,8 @@ end local function guide() if owned and input.aiming and positions and positions[1][3] and buffer.state and buffer.state.phase == 'ready' and Open77.input.isCaptured() == false and M.Settings.AIM_GUIDE ~= false then - G.Draw(spec, positions, input.angle, OPX.Now()) + G.Draw(spec, positions, input.angle, OPX.Now(), + M.Assistance.Profile(assistanceSettings, buffer.state.assistance), input.chargedAt and input.power or nil) else G.Clear() end end openTable = function() @@ -245,7 +248,8 @@ local function strip() turn = game and game.turn or 0, raceLabel = locale('pool.raceTo', { target = game and game.target or 5 }), soloStats = locale('pool.points', { score = game and game.scores[1] or 0, shots = game and game.shots or 0 }), duel = game and game.mode == 'duel' or false, target = game and game.target or 5, - hint = locale(following and 'pool.rolling' or not playing and 'pool.manageHint' or not owned and 'pool.waitTurn' or input.chargedAt and 'pool.release' or 'pool.charge'), + hint = locale(following and 'pool.rolling' or not playing and 'pool.manageHint' or not owned and 'pool.waitTurn' or input.chargedAt and 'pool.release' or 'pool.charge') + .. (owned and input.aiming and (buffer.state.assistance or 0) > 0 and (' · '..locale('pool.preview'..buffer.state.assistance)) or ''), view = locale(input.overview and 'pool.tableView' or 'pool.cueView') }) end local function frame() press(); draw(); if subscribed and spec then M.Audio.Frame(spec, OPX.Now()) end end @@ -258,6 +262,7 @@ function M.Init() end function M.Start() R.Reset(); G.Reset() + assistanceSettings = M.Assistance.Read(M.Settings.TRAJECTORY) spec = Model.Table() if not spec or not R.Register() then return end target, prompts = OPX.Api.Get('target'), OPX.Api.Get('prompts') diff --git a/modules/pool/client/prediction.lua b/modules/pool/client/prediction.lua new file mode 100644 index 00000000..b05376e4 --- /dev/null +++ b/modules/pool/client/prediction.lua @@ -0,0 +1,80 @@ +--- Bounded optical estimates: static balls, one transferred impact, no game mutations. +-- @author dop42 +local M = OPX.Modules.Get('pool') +local R, Number = M.Model.RADIUS, M.Model.Number +local DRAG, RAIL, TRANSFER = 0.7, 0.82, 0.985 +M.Prediction = {} +local P = M.Prediction +local function circle(x, y, dx, dy, cx, cy, radius) + local rx, ry = cx-x, cy-y + local ahead = rx*dx+ry*dy + if ahead <= 0 then return nil end + local side = rx*dy-ry*dx + local square = radius*radius-side*side + if square < 0 then return nil end + return math.max(0, ahead-math.sqrt(square)) +end +local function first(spec, points, x, y, dx, dy, limit, self, skip) + local distance, kind, index = limit, 'stop', nil + local function offer(t, what, which) + if t and t >= 0 and t < distance then distance, kind, index = t, what, which end + end + if math.abs(dx) > 1e-8 then offer(((dx > 0 and spec.halfLength-R or -spec.halfLength+R)-x)/dx, 'x') end + if math.abs(dy) > 1e-8 then offer(((dy > 0 and spec.halfWidth-R or -spec.halfWidth+R)-y)/dy, 'y') end + for slot = 1, M.Model.BALLS do + local ball = points[slot] + if slot ~= self and slot ~= skip and ball and ball[3] then + local t = circle(x,y,dx,dy,ball[1],ball[2],R*2) + if t and t < distance then distance,kind,index = t,'ball',slot end + end + end + for side = -1, 1, 2 do + for column = -1, 1 do offer(circle(x,y,dx,dy,column*spec.halfLength,side*spec.halfWidth,0.065), 'pocket') end + end + return distance, kind, index +end +function P.End(spec, points, angle) + local x,y,dx,dy = points[1][1],points[1][2],math.cos(angle),math.sin(angle) + local distance = first(spec,points,x,y,dx,dy,6,1) + return x+dx*distance,y+dy*distance,distance +end +function P.Trace(spec, points, angle, power, profile) + local paths, contact = {}, nil + if not profile or profile.level == 0 or not points[1] or not points[1][3] then return paths end + angle = Number(angle, -math.pi*2, math.pi*2) or 0 + angle = math.floor(angle/profile.angleStep+0.5)*profile.angleStep + power = Number(power, 0.02, 1) or profile.power + power = math.max(0.02, math.min(1, math.floor(power/profile.powerStep+0.5)*profile.powerStep)) + local function walk(x, y, dx, dy, speed, bounces, self, skip, color, range, capture) + for bounce = 0, bounces do + local limit = math.min(range, speed*speed/(2*DRAG)) + if limit < 0.001 then return nil end + local distance, kind, index = first(spec,points,x,y,dx,dy,limit,self,skip) + local ex, ey = x+dx*distance, y+dy*distance + if capture and not contact and (kind == 'ball' or kind == 'x' or kind == 'y') then contact={ex,ey} end + if distance > 0.00001 then paths[#paths+1] = { x, y, ex, ey, color } end + speed = math.sqrt(math.max(0, speed*speed-2*DRAG*distance)) + if kind == 'ball' then return { x=ex, y=ey, dx=dx, dy=dy, speed=speed, index=index, bounces=bounce, remaining=range-distance } end + if kind ~= 'x' and kind ~= 'y' then return nil end + if kind == 'x' then dx = -dx else dy = -dy end + speed, range = speed*RAIL, range-distance + x, y = ex+dx*0.00001, ey+dy*0.00001 + end + end + local ball = points[1] + local hit = walk(ball[1],ball[2],math.cos(angle),math.sin(angle),0.2+4.3*power, + profile.bounces,1,nil,'#ADEFFFC0',profile.distance,true) + if hit and profile.outgoing then + local object = points[hit.index] + local nx, ny = object[1]-hit.x, object[2]-hit.y + local length = math.sqrt(nx*nx+ny*ny) + nx, ny = nx/math.max(length,1e-8), ny/math.max(length,1e-8) + local transfer = math.max(0, hit.dx*nx+hit.dy*ny)*TRANSFER + walk(object[1],object[2],nx,ny,hit.speed*transfer,profile.bounces,hit.index,1,'#FFD166CC',profile.distance) + local vx, vy = hit.dx-nx*transfer, hit.dy-ny*transfer + local speed = math.sqrt(vx*vx+vy*vy) + if speed > 0.02 then walk(hit.x,hit.y,vx/speed,vy/speed,hit.speed*speed, + math.max(0,profile.bounces-hit.bounces),1,hit.index,'#78DBF0B0',hit.remaining) end + end + return paths, contact +end diff --git a/modules/pool/locales.lua b/modules/pool/locales.lua index b5a68503..4ea69d7b 100644 --- a/modules/pool/locales.lua +++ b/modules/pool/locales.lua @@ -2,6 +2,9 @@ -- @author dop42 local M = OPX.Modules.Get('pool') local EN = { + ['pool.preview1'] = 'Optical impact estimate', + ['pool.preview2'] = 'Optical trajectory estimate', + ['pool.preview3'] = 'Optical rebound estimate', ['pool.enter'] = 'Play pool', ['pool.aim'] = 'Aim / cancel', ['pool.charge'] = 'Hold to charge, release to shoot', @@ -69,6 +72,9 @@ local EN = { ['pool.sessionEnded'] = "The table is available for a new session.", } local FR = { + ['pool.preview1'] = 'Impact estimé par les optiques', + ['pool.preview2'] = 'Trajectoires estimées par les optiques', + ['pool.preview3'] = 'Rebonds estimés par les optiques', ['pool.enter'] = 'Jouer au billard', ['pool.aim'] = 'Viser / annuler', ['pool.charge'] = 'Maintenir pour doser, relâcher pour tirer', diff --git a/modules/pool/server/main.lua b/modules/pool/server/main.lua index 0bdc5540..4756ce04 100644 --- a/modules/pool/server/main.lua +++ b/modules/pool/server/main.lua @@ -4,6 +4,7 @@ local M = OPX.Modules.Get('pool') local Model, P, G = M.Model, M.Physics, M.Match local spec, balls, tickets, watchers, job, epoch, sequence, revision, ticks, phase, readyAt, aim, character, game local before, resetUntil, createdAt, finishedAt, rackDue, sounds, soundSequence +local assistanceSettings local function person(player) local p = character and character.GetPlayer(player) return p and p.PlayerData and p or nil @@ -16,6 +17,16 @@ local function sameIdentity(ticket) return ticket and ticket.session and person(ticket.player) == ticket.person and ticket.person.PlayerData.citizenId == ticket.cid and OPX.Sessions[ticket.player] == ticket.session end +local function observe(ticket) + local installed + if type(character.GetMetadata) == 'function' then + local ok, value = pcall(character.GetMetadata, ticket.player, 'cyberware') + if ok then installed = value end + end + ticket.assistanceAt = OPX.Now() + ticket.assistance = (M.Settings or {}).AIM_GUIDE == false and 0 or M.Assistance.Level(assistanceSettings, installed) + return ticket +end local function valid(ticket, reach) if not sameIdentity(ticket) or OPX.Life.Down(ticket.player) then return false end local snap = Open77.players.get(ticket.player) @@ -72,7 +83,12 @@ local function publish(frames) local state = { epoch = epoch, sequence = sequence, revision = revision, owner = G.Owner(game), phase = phase, readyAt = readyAt, frames = frames, aim = aim, game = game, sounds = sounds } for player, ticket in pairs(watchers) do - if valid(ticket, 18) then TriggerClientEvent(M.Event.STATE, player, state) + if valid(ticket, 18) then + if state.owner == player and OPX.Now()-(ticket.assistanceAt or 0) >= 1000 then observe(ticket) end + local packet = {} + for key, value in pairs(state) do packet[key] = value end + packet.assistance = state.owner == player and (ticket.assistance or 0) or 0 + TriggerClientEvent(M.Event.STATE, player, packet) else watchers[player] = nil if sameIdentity(ticket) then TriggerClientEvent(M.Event.STATE, player, { closed = true }) end @@ -127,13 +143,13 @@ local function request(player, action, data) local channel = action == 'aim' and 'aim' or action == 'watch' and 'watch' or action == 'shoot' and 'shoot' or 'action' if OPX.Cooling(player, 'pool.' .. channel, channel == 'aim' and 90 or channel == 'watch' and 900 or 150) then return false end if not valid(ticket, action == 'watch' and 16 or 6) then return false end - if action == 'watch' then watchers[player] = ticket;return true end + if action == 'watch' then watchers[player] = observe(ticket);return true end -- Refresh expired identity-bound seats before considering a new session. for index = 1, 2 do if tickets[index] and not valid(tickets[index], 6) then depart(index) end end if action == 'enter' then if game.status ~= 'idle' then notice(player, 'busy');return false end if not valid(ticket, 3.5) or phase ~= 'ready' then return false end - game, tickets[1], watchers[player], createdAt = G.New('practice', player, M.Settings), ticket, ticket, OPX.Now() + game, tickets[1], watchers[player], createdAt = G.New('practice', player, M.Settings), ticket, observe(ticket), OPX.Now() rack();return true end -- Mode/lobby/reset commands bind to the exact table revision the player saw. @@ -147,12 +163,12 @@ local function request(player, action, data) if action == 'create' then if game.status ~= 'idle' then notice(player, 'busy');return false end if not G.MODES[data.mode] or not valid(ticket, 3.5) or phase ~= 'ready' then return false end - game, tickets[1], watchers[player], createdAt = G.New(data.mode, player, M.Settings), ticket, ticket, OPX.Now() + game, tickets[1], watchers[player], createdAt = G.New(data.mode, player, M.Settings), ticket, observe(ticket), OPX.Now() rack();return true end if action == 'join' then if not valid(ticket, 3.5) or not G.Join(game, player) then notice(player, 'busy');return false end - tickets[2], watchers[player], revision, aim.visible = ticket, ticket, revision+1, false + tickets[2], watchers[player], revision, aim.visible = ticket, observe(ticket), revision+1, false notify('duelStarted');return true end local index = seat(ticket) @@ -197,6 +213,7 @@ function M.Init() end function M.Start() character, spec = OPX.Api.Get('character'), Model.Table() + assistanceSettings = M.Assistance.Read((M.Settings or {}).TRAJECTORY) if not spec then return end rack() RegisterNetEvent(M.Event.REQUEST, function(action, data) diff --git a/modules/pool/shared/assistance.lua b/modules/pool/shared/assistance.lua new file mode 100644 index 00000000..774aed5c --- /dev/null +++ b/modules/pool/shared/assistance.lua @@ -0,0 +1,52 @@ +--- Installed chrome selects presentation only; all real shot outcomes stay server-owned. +-- @author dop42 +local M = OPX.Modules.Get('pool') +local Number = M.Model.Number +M.Assistance = {} +local A = M.Assistance +local function integer(value, low, high, fallback) + return Number(value, low, high) and value%1 == 0 and value or fallback +end +function A.Read(raw) + raw = type(raw) == 'table' and raw or {} + local settings = { enabled = raw.ENABLED == nil or raw.ENABLED == true, + baseline = integer(raw.BASE_LEVEL, 0, 3, 0), condition = Number(raw.MIN_CONDITION, 0, 100) or 1, + bounces = integer(raw.MAX_BOUNCES, 0, 2, 2), distance = Number(raw.MAX_DISTANCE, 0.5, 6) or 4.5, + angleStep = Number(raw.ANGLE_STEP, 0.001, 0.05) or 0.005, + powerStep = Number(raw.POWER_STEP, 0.01, 0.2) or 0.05, + power = Number(raw.DEFAULT_POWER, 0.02, 1) or 0.5, implants = {} } + local implants = raw.IMPLANTS + if implants == nil then implants = { kiroshi_optics = { SLOT = 'eyes', TIERS = { 1, 2, 3 } } } end + local count = 0 + for id, entry in pairs(type(implants) == 'table' and implants or {}) do + if count >= 32 then break end + count = count+1 + if type(id) == 'string' and #id <= 64 and id:match('^[%w_.-]+$') + and type(entry) == 'table' and type(entry.SLOT) == 'string' and #entry.SLOT > 0 and #entry.SLOT <= 32 and type(entry.TIERS) == 'table' then + local tiers = {} + for tier = 1, 5 do tiers[tier] = integer(entry.TIERS[tier], 0, 3, 0) end + settings.implants[id] = { slot = entry.SLOT, tiers = tiers } + end + end + return settings +end +function A.Level(settings, installed) + if not settings.enabled then return 0 end + local level = settings.baseline + if type(installed) ~= 'table' then return level end + for index = 1, math.min(#installed, 32) do + local entry = installed[index] + local implant = type(entry) == 'table' and settings.implants[entry.chrome] + local tier = implant and integer(entry.tier, 1, 5) + if tier and entry.slot == implant.slot and Number(entry.condition, settings.condition, 100) then + level = math.max(level, implant.tiers[tier]) + end + end + return level +end +function A.Profile(settings, level) + level = settings.enabled and integer(level, 0, 3, 0) or 0 + return { level = level, distance = math.min(settings.distance, level == 1 and 1.5 or level == 2 and 3 or 6), + bounces = level == 3 and settings.bounces or 0, outgoing = level >= 2, + angleStep = settings.angleStep*(4-level), powerStep = settings.powerStep, power = settings.power } +end diff --git a/modules/pool/shared/playback.lua b/modules/pool/shared/playback.lua index 2fb306d4..c0c930ce 100644 --- a/modules/pool/shared/playback.lua +++ b/modules/pool/shared/playback.lua @@ -35,9 +35,11 @@ function B.Push(buffer, packet, now) buffer.offset = math.min(buffer.offset or math.huge, now - previous) for _, frame in ipairs(frames) do buffer.frames[#buffer.frames+1] = frame end while #buffer.frames > 40 do table.remove(buffer.frames, 1) end + local assistance = Number(packet.assistance, 0, 3) + if not assistance or assistance%1 ~= 0 then assistance = 0 end local aim = type(packet.aim) == 'table' and packet.aim or {} buffer.state = { owner = packet.owner, phase = packet.phase, revision = packet.revision, - readyAt = packet.readyAt, game = game, angle = Number(aim.angle, -math.pi*2, math.pi*2) or 0, + readyAt = packet.readyAt, game = game, assistance = assistance, angle = Number(aim.angle, -math.pi*2, math.pi*2) or 0, power = Number(aim.power, 0, 1) or 0, aiming = aim.visible == true } return true end diff --git a/open77.lua b/open77.lua index f3ea482e..5209f3c4 100644 --- a/open77.lua +++ b/open77.lua @@ -812,6 +812,7 @@ client_script "modules/crafting/client/main.lua" shared_script "modules/pool/module.lua" shared_script "modules/pool/locales.lua" shared_script "modules/pool/shared/model.lua" +shared_script "modules/pool/shared/assistance.lua" shared_script "modules/pool/shared/match.lua" shared_script "modules/pool/shared/playback.lua" server_script "modules/pool/server/physics.lua" @@ -819,6 +820,7 @@ server_script "modules/pool/server/main.lua" client_script "modules/pool/client/input.lua" client_script "modules/pool/client/render.lua" client_script "modules/pool/client/camera.lua" +client_script "modules/pool/client/prediction.lua" client_script "modules/pool/client/guide.lua" client_script "modules/pool/client/audio.lua" client_script "modules/pool/client/table.lua" diff --git a/tests/audit-pool-assistance.lua b/tests/audit-pool-assistance.lua new file mode 100644 index 00000000..00878d2d --- /dev/null +++ b/tests/audit-pool-assistance.lua @@ -0,0 +1,69 @@ +--- Installed optics gates, approximate outgoing paths, private grants and VM bounds. +local M, env = {}, nil +env=setmetatable({OPX={Modules={Get=function()return M end},Note=function()end},Open77={debugDraw={}}},{__index=_G}) +for _,f in ipairs({'shared/model','shared/assistance','shared/match','shared/playback','client/prediction','client/guide'})do assert(loadfile('modules/pool/'..f..'.lua','t',env))()end +local A,P,B=M.Assistance,M.Prediction,M.Playback +local settings=A.Read() +local function installed(tier,condition,slot,id)return{{chrome=id or'kiroshi_optics',tier=tier,condition=condition,slot=slot or'eyes'}}end +assert(A.Level(settings,nil)==0 and A.Level(settings,{})==0,'uninstalled optics gave assistance') +for tier=1,3 do assert(A.Level(settings,installed(tier,100))==tier,'installed optics tier ignored')end +assert(A.Level(settings,installed(3,0))==0 and A.Level(settings,installed(3,nil))==0,'broken/unknown-condition optics granted assistance') +assert(A.Level(settings,installed(3,100,'arms'))==0 and A.Level(settings,installed(3,100,nil,'unknown'))==0,'wrong-slot/unknown chrome granted assistance') +assert(A.Level(settings,installed(2.5,100))==0 and A.Level(settings,installed(9,100))==0,'unknown tier granted assistance') +assert(A.Level(A.Read({ENABLED=false,BASE_LEVEL=3}),installed(3,100))==0,'disabled assistance leaked') +assert(A.Level(A.Read({BASE_LEVEL=2}),{})==2,'configured baseline ignored') +local custom=A.Read({IMPLANTS={optic_custom={SLOT='eyes',TIERS={[2]=3}}},MAX_BOUNCES=1}) +assert(A.Level(custom,installed(2,100,'eyes','optic_custom'))==3 and A.Level(custom,installed(3,100))==0,'custom implant map ignored') +assert(A.Profile(custom,3).bounces==1,'custom bounce limit ignored') +local spec={x=100,y=10,z=1,yaw=20,halfLength=1.4,halfWidth=.68} +local function points() + local p={{-.6,0,true},{.2,0,true}} + for i=3,16 do p[i]={.7,(i-9)*.045,false}end + return p +end +local balls=points() +local basic=A.Profile(settings,1);local improved=A.Profile(settings,2);local advanced=A.Profile(settings,3) +local paths,contact=P.Trace(spec,balls,0,.5,basic) +assert(#paths==1 and math.abs(contact[1]-(.2-M.Model.RADIUS*2))<1e-8,'basic estimate ignores first impact') +local outgoing=P.Trace(spec,balls,0,.5,improved);local object=false +for _,leg in ipairs(outgoing)do if leg[5]=='#FFD166CC'then object=true;assert(leg[3]>leg[1],'head-on object moved backwards')end end +assert(object,'tier two lacks object-ball trajectory') +balls[2][2]=.04 +local cut=P.Trace(spec,balls,0,.5,improved);local cue=false;object=false +for _,leg in ipairs(cut)do cue=cue or leg[5]=='#78DBF0B0';object=object or leg[5]=='#FFD166CC'end +assert(cue and object,'cut shot lacks separate cue/object outgoing paths') +balls=points();balls[2][3]=false +local low=P.Trace(spec,balls,0,.02,advanced);local high=P.Trace(spec,balls,0,1,advanced) +assert(#high>#low and #high>1,'power or cushion rebounds did not affect estimate') +balls[1][1]=0 +local pocket=P.Trace(spec,balls,math.pi/2,1,advanced) +assert(#pocket==1,'preview bounced a side-pocket shot') +assert(#P.Trace(spec,balls,0,.5,A.Profile(settings,0))==0,'level zero drew trajectories') +local geometry,clears +function env.Open77.debugDraw.set(_,g)geometry=g;return true end +function env.Open77.debugDraw.clear()clears=true;geometry=nil;return true end +local worst=0 +settings=A.Read({MAX_DISTANCE=6}) +for tier=1,3 do + for angle=-math.pi,math.pi,.13 do + balls=points();for i=3,16 do balls[i][3]=true end + local before=balls[1][1];local cost=0 + M.Guide.Reset();debug.sethook(function()cost=cost+100 end,'',100) + M.Guide.Draw(spec,balls,angle,1000,A.Profile(settings,tier),1) + debug.sethook();worst=math.max(worst,cost) + assert(cost<8000,'optical preview exceeded callback budget: '..cost) + assert(geometry and #geometry.lines<=20,'preview exceeded native geometry budget') + assert(balls[1][1]==before,'preview mutated the authoritative snapshot') + for _,line in ipairs(geometry.lines)do for _,p in ipairs({line.a,line.b})do + assert(p.x==p.x and p.y==p.y and p.z==p.z,'preview produced non-finite geometry') + end end + end +end +M.Guide.Draw(spec,balls,0,1100,A.Profile(settings,0),1);assert(clears and not geometry,'implant removal left visible guidance') +-- Untrusted/malformed assistance data never prevents ball playback or grants optics. +local function packet(level) + return{epoch=1,sequence=1,revision=1,owner=7,readyAt=0,phase='ready',assistance=level,frames={{time=0,balls=points()}}} +end +for _,level in ipairs({false,-1,4,2.5,'3',{}})do local buffer=B.New();assert(B.Push(buffer,packet(level),1000)and buffer.state.assistance==0,'bad grant affected playback or enabled assistance')end +local buffer=B.New();assert(B.Push(buffer,packet(3),1000)and buffer.state.assistance==3,'valid assistance grant lost') +print('pool optical preview: installed tiers/condition/slot, configured grants, impact/outgoing/rebounds/pockets, power, privacy-ready wire and cleanup; worst',worst,'instructions') diff --git a/tests/audit-pool-camera.lua b/tests/audit-pool-camera.lua index 14b47af4..32d8e482 100644 --- a/tests/audit-pool-camera.lua +++ b/tests/audit-pool-camera.lua @@ -15,7 +15,7 @@ end local geometry, cleared function env.Open77.debugDraw.set(_,g) geometry=g;return true end function env.Open77.debugDraw.clear() cleared=true;geometry=nil;return true end -for _,file in ipairs({'shared/model','client/camera','client/guide','client/input'}) do assert(loadfile('modules/pool/'..file..'.lua','t',env))() end +for _,file in ipairs({'shared/model','shared/assistance','client/camera','client/prediction','client/guide','client/input'}) do assert(loadfile('modules/pool/'..file..'.lua','t',env))() end local C,G,I=M.Camera,M.Guide,M.Input local spec={x=100,y=50,z=10,yaw=90,halfLength=1.4,halfWidth=0.68} local balls={{0,0,true},{0.5,0,true},{0.8,0.3,true}} diff --git a/tests/audit-pool-client.lua b/tests/audit-pool-client.lua index 337aaff9..114bfddb 100644 --- a/tests/audit-pool-client.lua +++ b/tests/audit-pool-client.lua @@ -81,7 +81,7 @@ local function wireBalls() return result end local function packet(seq,time,owner) - return {epoch=1,sequence=seq,revision=1,owner=owner or 7,phase='ready',readyAt=0, + return {epoch=1,sequence=seq,revision=1,owner=owner or 7,assistance=3,phase='ready',readyAt=0, frames={{time=time,balls=wireBalls()}}} end M.Client.Receive(packet(1,0)) @@ -200,6 +200,18 @@ local accepted=packet(2,50);accepted.epoch=13;accepted.game=M.Match.New(choice.m M.Client.Receive(accepted);M.Client.Frame();M.Client.Frame() assert(cameraHeld,'real menu selection failed to arm camera') M.Stop() +-- Only a valid local server grant draws the optical preview, with immediate clearing. +M.Stop();M.Init();M.Start();M.Client.Watch() +local optics=packet(1,0);optics.epoch=30;optics.assistance=0 +M.Client.Receive(optics);M.Client.Frame();M.Client.Guide() +assert(not guideShown,'no-implant character received an optical preview') +optics=packet(2,50);optics.epoch=30;optics.assistance=3 +M.Client.Receive(optics);M.Client.Frame();M.Client.Guide() +assert(guideShown,'valid optical grant did not draw preview') +optics=packet(3,100);optics.epoch=30;optics.assistance=0 +M.Client.Receive(optics) +assert(not guideShown,'revoked optics left their preview visible') +M.Stop() -- Registration of all fifteen hosts stays inside one client resume. M.Stop();M.Init();worst=math.max(worst,measured(M.Start,8000));M.Stop() -- A missing/refused registry must never spawn raw meshes or placeholders. diff --git a/tests/audit-pool-modes.lua b/tests/audit-pool-modes.lua index b933f59b..0bb66f4c 100644 --- a/tests/audit-pool-modes.lua +++ b/tests/audit-pool-modes.lua @@ -1,16 +1,17 @@ --- Real scoring, authoritative seats and reset consent; no game or database. local M, now, people, sessions, packets, cooldowns, result = {}, 10000, {}, {}, {}, {}, nil +local chrome={[7]={{chrome='kiroshi_optics',slot='eyes',tier=3,condition=100}}} for _,id in ipairs({7,8,9}) do people[id]={PlayerData={citizenId='POOL'..id},snapshot={fresh=true,bucket=0,position={x=100,y=10,z=1}}};sessions[id]={} end local env=setmetatable({OPX={Modules={Get=function() return M end},Config={MODULES={pool={TABLE={enabled=true,x=100,y=10,z=1,yaw=0,bucket=0,halfLength=1.4,halfWidth=.68}}}}, Text={Clean=function(x)return x end},Now=function()return now end,Sessions=sessions,Life={Down=function(id)return people[id].down end}, - Api={Get=function()return {GetPlayer=function(id)return people[id] end}end},Scheduler={Every=function()return 1 end,Cancel=function()end}, + Api={Get=function()return {GetPlayer=function(id)return people[id] end,GetMetadata=function(id,key)assert(key=='cyberware');return chrome[id] end}end},Scheduler={Every=function()return 1 end,Cancel=function()end}, Cooling=function(id,key,ms)local k=id..key;if cooldowns[k] and now-cooldowns[k]