From 9d97492f89885831fed93286b24c3102337c1557 Mon Sep 17 00:00:00 2001 From: dop42 Date: Sat, 24 Oct 2026 20:01:23 +0200 Subject: [PATCH] feat(pool): add full fifteen-ball rack and side-view cameras --- config/pool.lua | 6 +++-- docs/MANUAL.md | 37 +++++++++++++++++++------------ modules/pool/client/camera.lua | 14 ++++++------ modules/pool/client/render.lua | 9 ++++---- modules/pool/locales.lua | 16 +++++++------- modules/pool/module.lua | 2 +- modules/pool/server/main.lua | 4 +++- modules/pool/server/physics.lua | 17 +++++++++----- modules/pool/shared/match.lua | 2 +- modules/pool/shared/model.lua | 12 +++++++++- tests/audit-pool-camera.lua | 24 +++++++++++++++++--- tests/audit-pool-client.lua | 28 ++++++++++++++++------- tests/audit-pool-modes.lua | 7 ++++-- tests/audit-pool.lua | 39 ++++++++++++++++++++++++++++++--- 14 files changed, 156 insertions(+), 61 deletions(-) diff --git a/config/pool.lua b/config/pool.lua index a9fc54de..5a573b63 100644 --- a/config/pool.lua +++ b/config/pool.lua @@ -21,7 +21,7 @@ OPX.Config.MODULES.pool = { }, SENSITIVITY = 0.0018, -- radians per physical mouse count CHARGE_SECONDS = 1.8, - OVERVIEW_HEIGHT = 1.65, -- metres above ball centres + OVERVIEW_HEIGHT = 1.65, -- metres above ball centres for the side camera SOLO_SHOTS = 10, DUEL_TARGET = 5, AUDIO_VOLUME = 0.65, -- 0 mutes the recorded table contacts @@ -30,7 +30,9 @@ OPX.Config.MODULES.pool = { -- Do not use them as tents, sleeping bags or gym equipment on this world. MODELS = { cue = 'recreation.tent', - object = 'recreation.sleeping_bag', + object = 'recreation.sleeping_bag', -- retained legacy profile field + -- Object balls use the fifteen vanilla ball_c..ball_q meshes by default. + -- Optional objects = { ... } must name exactly fifteen distinct models. stick = 'recreation.gym_equipment', -- Host-specific heading/pivot calibration, in degrees/metres. stickYaw = 0, stickOffset = 0.7, diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 150a7756..1dbc5291 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -948,12 +948,18 @@ Contract `bar` v1 (server): `Venues()`, `Items()`, `DrunkLevel(player)`. Bus: ### Pool `modules/pool` + `config/pool.lua` offers three original modes using one white -cue ball and two yellow object balls. Cooked models, recorded audio and cleanup +cue ball and fifteen distinct vanilla object balls, arranged in a five-row triangle. +The object balls use the game’s `ball_c_billiard.mesh` through `ball_q_billiard.mesh` +models via the documented [raw mesh prop backend](https://open2077.net/docs/props). +An optional `MODELS.objects` array supplies exactly fifteen model paths. Existing +configs without that field receive the full vanilla set automatically. Cue/white +hosts, recorded audio and cleanup patches live in the separate `opx_pool_assets` resource. Table actions use menu-wide `closeOnSelect`, so selecting a mode dispatches its command before releasing focus. Confirmation submenus dispatch only their confirmed selection. The modes have no wagers, -XP or money rewards, spin, numbered-ball families or persisted match records. +XP or money rewards, spin, full eight-ball group/black-ball rules or persisted +match records. All fifteen object balls score equally in the existing points duel. `TABLE.enabled` ships false. Survey a real, unobstructed table before enabling it: `x/y/z` are the **ball centres above the cloth**, not the floor; `halfLength` and @@ -975,21 +981,24 @@ in place; inspect the client ArchiveXL log. The gameplay module never deletes another resource's props or world nodes. The eye opens **Table and game modes**. Choose **Free practice** (unlimited -shots, automatic rerack after both yellows are pocketed), **Solo challenge** -(clear both within `SOLO_SHOTS`, default 10), or create a **Two-player duel**. +shots, automatic rerack after all fifteen object balls are pocketed), **Solo challenge** +(clear all fifteen within `SOLO_SHOTS`, default 10), or create a **Two-player duel**. A second nearby player chooses **Join the duel**; this starts the game. A solo challenge ends on a clear or the shot limit; scratching while pocketing the last -yellow loses the challenge. Scores count pockets, with a one-point scratch penalty +object ball loses the challenge. Scores count pockets, with a one-point scratch penalty floored at zero. Duel target is `DUEL_TARGET` (default 5): a pocket retains the -turn, a miss or scratch passes it, and the two yellows rerack when cleared. +turn, a miss or scratch passes it, and the fifteen object balls rerack when cleared. Only the server's physical outcomes change these scores and turns. The current shooter automatically gets a camera behind the white ball. Mouse movement aims; **Shift** gives fine control. Hold/release left-click to charge/fire. -**Middle mouse** toggles the overhead view; V remains available to inventory. -The wheel adjusts cue-view height. The overhead default is now **1.65 m** above -ball centres (`OVERVIEW_HEIGHT`, range 1.2–2.4), with a wider lens to keep the -cloth framed. A small lateral cue-camera offset keeps the shaft visible. The +**Middle mouse** toggles the side view; V remains available to inventory. +The wheel adjusts cue-view height. The cue camera lies directly on the shot axis +and targets the white ball itself, centering it without a shoulder offset. The +shot-follow camera shows the cloth from its long side and targets the table centre. +Its distance grows with the table dimensions to fit all corners, including on 4:3 +screens. `OVERVIEW_HEIGHT` retains its name for config compatibility and now sets +the side-view elevation (default **1.65 m**, range 1.2–2.4). The queue sits behind the white ball, retracts with charge and follows through for 220 ms after release. All rendered balls/queue explicitly request `collision = false`, `physics = "none"`, `visible = true`; ball contacts remain server simulation. @@ -1003,7 +1012,7 @@ until the balls stop. **Backspace** leaves at any time. Leaving, dying, changing character/session, changing bucket or moving more than 6 m away forfeits an active duel; the shooter must stay within 3.5 m. Unfilled lobbies expire after two minutes, finished results free the table after 30 seconds. Spectators cannot change a game. -Participants can use middle mouse to watch the overhead view during the other turn. +Participants can use middle mouse to watch the side view during the other turn. The overlay shows the mode, scores, active player, remaining solo shots and a smooth power gauge, and takes no focus. Right-click cancels/resumes aim; while @@ -1038,9 +1047,9 @@ presentation features, not new permissions to decide shots or outcomes. The server owns all ball coordinates, collisions, pockets and scratch recovery. Its fixed 120 Hz solver advances to swept ball/rail/pocket contacts, with a bounded -contact count and dissipative collisions/friction. Six timestamped samples are -sent every 50 ms to valid nearby watchers. Clients keep at most 40 samples and -interpolate 100 ms behind the stream; they never extrapolate or decide outcomes. +contact ceiling of 128 per fixed tick and dissipative collisions/friction. Two +timestamped endpoint samples are sent every 50 ms to valid nearby watchers. +Clients keep at most 40 samples and interpolate 100 ms behind the stream; they never extrapolate or decide outcomes. Creation, joining, shots and resets bind to the viewed epoch/revision. Reset votes and scores remain tied to the two original character/session tickets. Shot and rack requests carry the current epoch and ready revision. Old, duplicate, diff --git a/modules/pool/client/camera.lua b/modules/pool/client/camera.lua index cb27fdd5..bc3a19cb 100644 --- a/modules/pool/client/camera.lua +++ b/modules/pool/client/camera.lua @@ -34,16 +34,16 @@ function C.Pose(spec, points, angle, overview, height) local c, s = math.cos(angle), math.sin(angle) local ball = points[1] if overview then - local at = Model.World(spec, -c*0.45, -s*0.45) + -- A stable broadside view: fit the full cloth instead of hovering over it. + local distance = math.max(spec.halfWidth+1.8, spec.halfLength*1.45+spec.halfWidth) + local at = Model.World(spec, 0, -distance) at.z = at.z+(Model.Number((M.Settings or {}).OVERVIEW_HEIGHT, 1.2, 2.4) or 1.65) - local aim = Model.World(spec, 0, 0) - return { position = at, target = aim, fov = 72 } + return { position = at, target = Model.World(spec, 0, 0), fov = 60 } end - local at = Model.World(spec, ball[1]-c*1.25-s*0.14, ball[2]-s*1.25+c*0.14) + -- The ball is the exact look-at point, on the shot axis with no shoulder offset. + local at = Model.World(spec, ball[1]-c*1.25, ball[2]-s*1.25) at.z = at.z+height - local aim = Model.World(spec, ball[1]+c*0.45, ball[2]+s*0.45) - aim.z = aim.z+0.015 - return { position = at, target = aim, fov = 52 } + return { position = at, target = Model.World(spec, ball[1], ball[2]), fov = 52 } end function C.Update(spec, points, angle, overview, height, now) local wanted = C.Pose(spec, points, angle, overview, height) diff --git a/modules/pool/client/render.lua b/modules/pool/client/render.lua index 7c929155..44586f6b 100644 --- a/modules/pool/client/render.lua +++ b/modules/pool/client/render.lua @@ -5,12 +5,13 @@ local Model = M.Model M.Render = {} local R = M.Render local ids, stick, created, failure, stroke = {}, nil, {}, nil, nil +local models function R.Clear() for index, id in pairs(ids) do Open77.props.remove(id); ids[index] = nil end if stick then Open77.props.remove(stick); stick = nil end created, stroke = {}, nil end -function R.Reset() R.Clear(); failure = nil end +function R.Reset() R.Clear(); failure = nil; models = Model.Models() end local function fail(why) if not failure then failure = tostring(why) @@ -39,7 +40,7 @@ function R.Strike(spec, positions, angle, power) stroke = { at = OPX.Now(), point = { positions[1][1], positions[1][2], true }, angle = angle, power = power } end function R.StickTransform(spec, ball, angle, power, forward) - local models = Model.Models() + models = models or Model.Models() local distance = math.max(0.2, models.stickOffset + power*0.35-(forward or 0)) local at = Model.World(spec, ball[1]-math.cos(angle)*distance, ball[2]-math.sin(angle)*distance) at.z = at.z+0.006 @@ -47,13 +48,13 @@ function R.StickTransform(spec, ball, angle, power, forward) end function R.Draw(spec, positions, aiming, angle, power) if failure then return false end - local models = Model.Models() + models = models or Model.Models() if not models then return fail('invalid model configuration') end for index, ball in ipairs(positions) do if ball[3] then local position = Model.World(spec, ball[1], ball[2]) if not ids[index] then - local id, why = make(index == 1 and models.cue or models.object, position) + local id, why = make(index == 1 and models.cue or models.objects[index-1], position) if not id then return fail(why) end ids[index] = id elseif not move(ids[index], { position = position }) then return false end diff --git a/modules/pool/locales.lua b/modules/pool/locales.lua index 9c89693e..b5a68503 100644 --- a/modules/pool/locales.lua +++ b/modules/pool/locales.lua @@ -12,18 +12,18 @@ local EN = { ['pool.power'] = 'Power {power}%', ['pool.powerLabel'] = 'Shot power', ['pool.precise'] = 'Fine aim', - ['pool.overview'] = 'Switch table / cue view', + ['pool.overview'] = 'Switch side / cue view', ['pool.release'] = 'Release to shoot · RMB to cancel', - ['pool.tableView'] = 'Table view', + ['pool.tableView'] = 'Side view', ['pool.cueView'] = 'Cue view', ['pool.camera'] = 'The aiming view is unavailable. Try again; if this continues, contact an administrator.', ['pool.busy'] = 'This table already has a session. Join an open duel or wait for it to finish.', ['pool.models'] = 'This table is unavailable. If this seems wrong, please contact an administrator.', ['pool.simulation'] = 'This shot could not be completed. Reset the balls; contact an administrator if it happens again.', ['pool.practice'] = "Free practice", - ['pool.practiceInfo'] = "Unlimited practice with two object balls. Reset whenever the balls stop.", + ['pool.practiceInfo'] = "Unlimited practice with fifteen object balls. Reset whenever the balls stop.", ['pool.challenge'] = "Solo challenge", - ['pool.challengeInfo'] = "Pocket both object balls within {shots} shots. Scratching on the final ball loses the challenge.", + ['pool.challengeInfo'] = "Pocket all fifteen object balls within {shots} shots. Scratching on the final ball loses the challenge.", ['pool.duel'] = "Two-player duel", ['pool.duelInfo'] = "First to {target} points. Pocket a ball to keep the turn; miss or scratch to pass. A scratch costs one point.", ['pool.join'] = "Join the duel", @@ -79,18 +79,18 @@ local FR = { ['pool.power'] = 'Puissance {power} %', ['pool.powerLabel'] = 'Puissance du tir', ['pool.precise'] = 'Visée précise', - ['pool.overview'] = 'Vue de table / vue de tir', + ['pool.overview'] = 'Vue de côté / vue de tir', ['pool.release'] = 'Relâchez pour tirer · Clic droit pour annuler', - ['pool.tableView'] = 'Vue de table', + ['pool.tableView'] = 'Vue de côté', ['pool.cueView'] = 'Vue de tir', ['pool.camera'] = 'La vue de tir est indisponible. Réessayez ; si cela persiste, contactez un administrateur.', ['pool.busy'] = 'Une session occupe déjà cette table. Rejoignez un duel ouvert ou attendez la fin.', ['pool.models'] = 'Cette table est indisponible. Si cela vous semble anormal, contactez un administrateur.', ['pool.simulation'] = 'Ce tir n’a pas pu être terminé. Replacez les billes ; contactez un administrateur si cela se reproduit.', ['pool.practice'] = "Entraînement libre", - ['pool.practiceInfo'] = "Deux billes jaunes, autant de tirs que souhaité. Remise à zéro à l’arrêt des billes.", + ['pool.practiceInfo'] = "Quinze billes de couleur, autant de tirs que souhaité. Remise à zéro à l’arrêt des billes.", ['pool.challenge'] = "Défi solo", - ['pool.challengeInfo'] = "Empochez les deux jaunes en {shots} tirs. Empocher aussi la blanche au dernier tir fait perdre le défi.", + ['pool.challengeInfo'] = "Empochez les quinze billes de couleur en {shots} tirs. Empocher aussi la blanche au dernier tir fait perdre le défi.", ['pool.duel'] = "Duel à deux", ['pool.duelInfo'] = "Premier à {target} points. Empochez pour rejouer ; un raté ou une blanche passe la main. La blanche coûte un point.", ['pool.join'] = "Rejoindre le duel", diff --git a/modules/pool/module.lua b/modules/pool/module.lua index 298464b5..e9f5d68f 100644 --- a/modules/pool/module.lua +++ b/modules/pool/module.lua @@ -1,4 +1,4 @@ ---- Three-ball solo and two-player modes. No wagers, XP or imported nc_pool gameplay code. Visuals live in opx_pool_assets. +--- Full-rack solo and two-player modes. No wagers, XP or imported nc_pool gameplay code. Visuals live in opx_pool_assets. -- @author dop42 local M = OPX.Modules.Declare{ id = 'pool', side = 'both', fatal = false, diff --git a/modules/pool/server/main.lua b/modules/pool/server/main.lua index 2ae14af9..4b4f7ef1 100644 --- a/modules/pool/server/main.lua +++ b/modules/pool/server/main.lua @@ -104,7 +104,9 @@ local function step() else phase, readyAt, revision, aim.visible = 'ready', ticks*1000/120, revision+1, false end end end - frames[#frames+1] = P.Frame(balls, ticks*1000/120) + -- Keep fixed-step physics at 120 Hz; two endpoint samples per 50 ms + -- update bound client validation/transport cost for all sixteen balls. + if #frames == 0 or _ == 6 then frames[#frames+1] = P.Frame(balls, ticks*1000/120) end end while sounds[1] and ticks*1000/120-sounds[1].time > 250 do table.remove(sounds, 1) end publish(frames) diff --git a/modules/pool/server/physics.lua b/modules/pool/server/physics.lua index ca51f73f..e4a64e0f 100644 --- a/modules/pool/server/physics.lua +++ b/modules/pool/server/physics.lua @@ -8,11 +8,16 @@ M.Physics = {} local P = M.Physics local DRAG, RESTITUTION, RAIL, EPS = 0.7, 0.97, 0.82, 1e-8 function P.Rack(spec) - return { - { x = -spec.halfLength * 0.5, y = 0, vx = 0, vy = 0, alive = true }, - { x = spec.halfLength * 0.35, y = -R * 1.05, vx = 0, vy = 0, alive = true }, - { x = spec.halfLength * 0.35, y = R * 1.05, vx = 0, vy = 0, alive = true }, - } + local balls = { { x = -spec.halfLength*0.5, y = 0, vx = 0, vy = 0, alive = true } } + -- Five rows, a tiny gap to avoid overlapping contact chains at the break. + local spacing = R*2.02 + for row = 0, 4 do + for column = 0, row do + balls[#balls+1] = { x = spec.halfLength*0.35+row*spacing*math.sqrt(3)/2, + y = (column-row/2)*spacing, vx = 0, vy = 0, alive = true } + end + end + return balls end local function hitTime(x, y, vx, vy, radius, limit) local c = x*x + y*y - radius*radius @@ -75,7 +80,7 @@ function P.Step(balls, spec, dt, contact) remaining = remaining - soon if not kind then break end contacts = contacts + 1 - if contacts > 32 then + if contacts > 128 then for _, ball in ipairs(balls) do ball.vx, ball.vy = 0, 0 end return false end diff --git a/modules/pool/shared/match.lua b/modules/pool/shared/match.lua index 6544eaa9..b5a03800 100644 --- a/modules/pool/shared/match.lua +++ b/modules/pool/shared/match.lua @@ -1,4 +1,4 @@ ---- Closed three-ball modes and pure scoring; the server alone applies them. +--- Closed full-rack modes and pure scoring; the server alone applies them. -- @author dop42 local M = OPX.Modules.Get('pool') local Number = M.Model.Number diff --git a/modules/pool/shared/model.lua b/modules/pool/shared/model.lua index a736ca30..d91a4306 100644 --- a/modules/pool/shared/model.lua +++ b/modules/pool/shared/model.lua @@ -1,7 +1,7 @@ --- Bounded geometry and wire vocabulary; pure in both sandboxes. -- @author dop42 local M = OPX.Modules.Get('pool') -M.Model = { RADIUS = 0.028575, BALLS = 3, STEP = 1 / 120, DELAY = 100 } +M.Model = { RADIUS = 0.028575, BALLS = 16, STEP = 1 / 120, DELAY = 100 } local Model = M.Model function Model.Number(value, low, high) return type(value) == 'number' and value == value and value >= low and value <= high and value or nil @@ -43,6 +43,16 @@ function Model.Models() if type(path) ~= 'string' or #path == 0 or #path > 256 or path:find('%c') then return nil end result[name] = path end + result.objects = {} + local used = {} + if raw.objects ~= nil and (type(raw.objects) ~= 'table' or #raw.objects ~= Model.BALLS-1) then return nil end + for number = 1, Model.BALLS-1 do + local path = raw.objects and raw.objects[number] or + ('base\\environment\\decoration\\recreation\\sports\\ball\\ball_' .. string.char(98+number) .. '_billiard.mesh') + if type(path) ~= 'string' or #path == 0 or #path > 256 or path:find('%c') or used[path] then return nil end + used[path] = true + result.objects[number] = path + end result.stickYaw = Model.Number(raw.stickYaw, -360, 360) or 0 result.stickOffset = Model.Number(raw.stickOffset, 0.2, 2) or 0.7 return result diff --git a/tests/audit-pool-camera.lua b/tests/audit-pool-camera.lua index 9d5a2fa9..14b47af4 100644 --- a/tests/audit-pool-camera.lua +++ b/tests/audit-pool-camera.lua @@ -19,10 +19,28 @@ for _,file in ipairs({'shared/model','client/camera','client/guide','client/inpu 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}} +for index=4,M.Model.BALLS do balls[index]={0,0,false} end local p=C.Pose(spec,balls,0,false,0.5) -assert(math.abs(p.position.x-99.86)<1e-9 and math.abs(p.position.y-48.75)<1e-9 and p.position.z==10.5,'cue camera not behind white ball in world frame') -assert(p.target.y>50 and p.position.z>p.target.z,'cue view does not look down the shot') -assert(C.Pose(spec,balls,0,true,0.5).position.z==11.65,'overhead camera was not lowered') +assert(math.abs(p.position.x-100)<1e-9 and math.abs(p.position.y-48.75)<1e-9 and p.position.z==10.5,'cue camera not behind white ball in world frame') +assert(p.target.x==100 and p.target.y==50 and p.target.z==10,'white ball is not exactly centered') +assert(C.Pose(spec,balls,0,true,0.5).position.z==11.65,'side camera height changed') +-- Project the cloth's corners into a conservative 4:3 camera frustum. +for _,size in ipairs({{0.9,0.4},{1.4,0.68},{2,1}}) do + for _,yaw in ipairs({0,90,179}) do + local tableSpec={x=100,y=50,z=10,yaw=yaw,halfLength=size[1],halfWidth=size[2]} + local pose=C.Pose(tableSpec,balls,2,true,0.5) + local dx,dy,dz=pose.target.x-pose.position.x,pose.target.y-pose.position.y,pose.target.z-pose.position.z + local n=math.sqrt(dx*dx+dy*dy+dz*dz);dx,dy,dz=dx/n,dy/n,dz/n + local h=math.sqrt(dx*dx+dy*dy);local rx,ry=dy/h,-dx/h + local ux,uy,uz=-dz*ry,dz*rx,dx*ry-dy*rx + local tangent=math.tan(math.rad(pose.fov/2)) + for _,x in ipairs({-size[1],size[1]})do for _,y in ipairs({-size[2],size[2]})do + local point=M.Model.World(tableSpec,x,y);local a,b,c=point.x-pose.position.x,point.y-pose.position.y,point.z-pose.position.z + local depth=a*dx+b*dy+c*dz + assert(depth>0 and math.abs(a*rx+b*ry)=300 and cameraCreated==2,'movement respawned props or camera') +assert(creates==17 and moves>=1600 and cameraCreated==2,'movement respawned props or camera') -- Right click cancels the automatically armed view; another press arms it. control.input.down.mouse2=true; M.Client.Frame() assert(not cameraHeld and state.freezePosition==false,'cancel kept camera/body frozen') control.input.down.mouse2=false; M.Client.Frame() control.input.down.mouse2=true; M.Client.Frame() assert(state.freezeRotation==true and state.freezePosition==true and cameraHeld,'aim failed to acquire camera/position') -M.Client.Draw(); assert(creates>=4,'cue missing') +M.Client.Draw(); assert(creates>=17,'cue missing') control.input.down.mouse2=false; control.input.down.mouse1=true; M.Client.Input() control.Advance(1200); control.input.captured=true; M.Client.Input(); M.Client.Draw() assert(state.freezeRotation==false and state.freezePosition==false and not cameraHeld and not guideShown,'chat capture kept view/guide/body frozen') @@ -164,7 +176,7 @@ M.Stop() -- A just-created native entity may not be transformable until streaming ends. M.Render.Reset(); failed=false local geometry={x=100,y=10,z=1,yaw=0} -local points={{-0.6,0,true},{0.4,0,true},{0.5,0.2,true}} +local points=wireBalls() assert(M.Render.Draw(geometry,points,false,0,0)) local initial=creates refused='projection_pending' diff --git a/tests/audit-pool-modes.lua b/tests/audit-pool-modes.lua index a93038a1..b933f59b 100644 --- a/tests/audit-pool-modes.lua +++ b/tests/audit-pool-modes.lua @@ -12,6 +12,7 @@ local env=setmetatable({OPX={Modules={Get=function() return M end},Config={MODUL M.Event={STATE='state',NOTICE='notice'};M.Settings={SOLO_SHOTS=2,DUEL_TARGET=2} for _,file in ipairs({'shared/model','shared/match','server/physics','server/main'}) do assert(loadfile('modules/pool/'..file..'.lua','t',env))() end local G,P=M.Match,M.Physics +local function clearRack() local result={};for i=2,M.Model.BALLS do result[i]=false end;return result end local original=P.Step P.Step=function(balls,spec,dt,contact) if result then @@ -72,7 +73,7 @@ assert(game.game.status=='playing' and game.game.shots==1) assert(call(7,'shoot',{angle=0,power=.5}));result={};game=step() assert(game.game.status=='finished' and game.game.reason=='limit' and game.game.winner==0,'solo shot limit missing') assert(call(7,'reset'));game=step();assert(game.game.mode=='challenge' and game.game.shots==0) -assert(call(7,'shoot',{angle=0,power=.5}));result={[2]=false,[3]=false};game=step() +assert(call(7,'shoot',{angle=0,power=.5}));result=clearRack();game=step() assert(game.game.winner==1 and game.game.reason=='clear','solo clear failed') assert(call(7,'finish'));step();assert(call(7,'create',{mode='duel'}));step();assert(call(8,'watch'));step();assert(call(8,'join'));step() sessions[7]={};game=step() @@ -80,8 +81,10 @@ assert(game.game.status=='finished' and game.game.winner==2,'recycled identity r assert(not call(7,'reset'),'new session controlled old seat') assert(call(8,'finish'));step();assert(call(8,'create',{mode='practice'}));game=step() assert(call(8,'shoot',{angle=0,power=.5}));result={[2]=false,[3]=false};game=step() +assert(game.phase=='ready' and game.game.scores[1]==2 and game.frames[2].balls[16][3],'two pockets cleared the whole fifteen-ball rack') +assert(call(8,'shoot',{angle=0,power=.5}));result=clearRack();game=step() assert(game.phase=='rolling','clear did not preserve a smooth between-rack interval') -now=now+400;game=step();assert(game.phase=='ready' and game.game.scores[1]==2,'practice did not rerack/preserve stats') +now=now+400;game=step();assert(game.phase=='ready' and game.game.scores[1]==15,'practice did not rerack/preserve stats') assert(call(8,'reset'));game=step();assert(game.game.scores[1]==0) -- Closed metadata rejects impossible nested identities/counts before playback. local corrupt=G.New('duel',7);assert(G.Join(corrupt,8));corrupt.players[2]=7;assert(not G.Read(corrupt)) diff --git a/tests/audit-pool.lua b/tests/audit-pool.lua index d78c114c..767018b4 100644 --- a/tests/audit-pool.lua +++ b/tests/audit-pool.lua @@ -50,10 +50,37 @@ for _=1,1400 do check(energy <= previous+1e-7, 'energy created'); previous=energy end check(not P.Moving(balls), 'shot never settled') +-- A full triangle fits every accepted table and never starts overlapped. +for _,size in ipairs({{0.9,0.4},{1.4,0.68},{2,1}}) do + local tableSpec={halfLength=size[1],halfWidth=size[2]} + local rack=P.Rack(tableSpec);check(#rack==16,'full rack is not fifteen objects plus white') + for i,a in ipairs(rack) do + check(math.abs(a.x)+Model.RADIUS(Model.RADIUS*2)^2,'overlapping starting balls') end + end +end +-- Breaks through a dense triangle remain finite, bounded and dissipative. +for _,angle in ipairs({-0.1,-0.025,0,0.025,0.1}) do + for _,power in ipairs({0.2,0.6,1}) do + local rack=P.Rack(spec);check(P.Shoot(rack,angle,power),'break refused') + local previous=math.huge + for tick=1,1000 do + check(P.Step(rack,spec,Model.STEP),'full-rack break exceeded contact ceiling') + local energy=0 + for _,b in ipairs(rack) do + check(b.x==b.x and b.y==b.y and math.abs(b.x)<=spec.halfLength and math.abs(b.y)<=spec.halfWidth,'break escaped table') + energy=energy+b.vx*b.vx+b.vy*b.vy + end + check(energy<=previous+1e-7,'full-rack break created energy');previous=energy + if not P.Moving(rack) then break end + end + check(not P.Moving(rack),'full-rack break never settled') + end +end -- Delayed playback must be smooth, ordered and bounded, including visibility. local function packet(epoch,seq,t,x,alive) - return { epoch=epoch,sequence=seq,revision=1,owner=7,readyAt=0,phase='ready', - frames={ {time=t,balls={{x,0,alive},{0.5,0,true},{0.5,0.2,true}}} } } + local frame=P.Frame(P.Rack(spec),t);frame.balls[1]={x,0,alive} + return {epoch=epoch,sequence=seq,revision=1,owner=7,readyAt=0,phase='ready',frames={frame}} end local buffer=B.New() check(B.Push(buffer,packet(1,1,0,0,true),10000), 'initial playback') @@ -93,6 +120,9 @@ M.Server.Step(); players[8].snapshot.bucket=1; M.Server.Step() check(packets[8].closed==true,'bucket change retained spectator projection') players[8].snapshot.bucket=0 M.Server.Step(); local state=packets[7] +check(#state.frames==2 and #state.frames[1].balls==16,'wire packet omitted full-rack endpoints') +local Host=dofile('tests/host.lua') +check(Host.PayloadNodes(state)