From 7c50a284f0ebea342df5cd8e2b811259634111f6 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 27 Sep 2026 18:57:27 -0700 Subject: [PATCH] support custom Aesel drawings and simplify Oskiewar title controls --- apple/aesel/Sources/ContentView.swift | 72 ++++++++++--- session-server/oskiewar-live-manager.mjs | 2 +- session-server/oskiewar-live-manager.test.mjs | 2 +- xbox/live/oskiewar.js | 102 ++++++++++-------- xbox/live/tests/pool-playground.test.mjs | 16 +++ 5 files changed, 133 insertions(+), 61 deletions(-) diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index 4571d2940f..012d52a174 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -1033,7 +1033,10 @@ private final class OskiewarAttachment: ObservableObject { Current artifact: \(artifact.wire) Use the existing source-edit tool to write valid piece JavaScript with exactly one descriptor comment: /* ac-artifact {"version":1,"kind":"shirt-symbol","shape":"star","color":"#fac83c"} */ - Supported shapes: star, heart, flower, rainbow. Color is a six-digit hex; rainbow uses six fixed rainbow bands. + Supported shapes: drawing (freely composed circles, strokes and triangles), custom (outline), star, heart, flower, rainbow. Color is a six-digit hex; rainbow uses six fixed rainbow bands. + For any new outline use shape "custom" plus "points":[[x,y],...] with 3 to 10 vertices, integer coordinates from -9 to 9, y up. Order them around a simple, non-self-intersecting polygon. The compact descriptor must fit 320 ASCII bytes. Example: {"version":1,"kind":"shirt-symbol","shape":"custom","color":"#ff6699","points":[[0,8],[7,-6],[-7,-6]]}. + For custom drawings use shape "drawing" and "draw": an array of up to 12 commands. Circle [0,x,y,r,c]; round stroke [1,x1,y1,x2,y2,width,c]; filled triangle [2,x1,y1,x2,y2,x3,y3,c]. All values integers, positions -16 to 16, circle radius 1 to 9, stroke width 1 to 6. Color index c is 0=artifact color, 1=dark ink, 2=white. Later commands draw on top. Example smiley: {"version":1,"kind":"shirt-symbol","shape":"drawing","color":"#ffcc44","draw":[[0,0,0,8,0],[0,-3,2,1,1],[0,3,2,1,1],[1,-4,-2,0,-4,1,1],[1,0,-4,4,-2,1,1]]}. + You CAN custom draw and make smileys using these primitives. Do not say custom shapes are unavailable. Keep a minimal paint export for valid piece syntax. The native TV renders the descriptor alone, without a shirt or scene. On completion this descriptor is automatically sent to the game. Only the game's acknowledgment confirms application. Briefly describe your actual edit in ordinary text; the butterfly mirrors an excerpt of that response. @@ -1065,17 +1068,30 @@ private final class OskiewarAttachment: ObservableObject { mirror("Changing the shirt symbol to a star.") } private var socket: URLSessionWebSocketTask? + private var retry: Task? private var receiver: Task? private var heartbeat: Task? private(set) var room = "" func disconnect() { - receiver?.cancel(); heartbeat?.cancel() + receiver?.cancel(); heartbeat?.cancel(); retry?.cancel() caption = ""; pendingArtifact = ""; sourceAtStart = nil socket?.cancel(with: .goingAway, reason: nil) - socket = nil; receiver = nil; heartbeat = nil; room = ""; status = "Oskiewar disconnected" + socket = nil; receiver = nil; heartbeat = nil; retry = nil; room = ""; status = "Oskiewar disconnected" } + private func lost(_ task: URLSessionWebSocketTask) { + guard socket === task, retry == nil else { return } + status = "Reconnecting to Oskiewar…" + retry = Task { [weak self] in + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled, let self, self.socket === task else { return } + let wire = self.pendingArtifact, source = self.sourceAtStart, caption = self.caption, until = self.captionUntil + self.retry = nil + self.connect("oskiewar://" + self.room) + self.pendingArtifact = wire; self.sourceAtStart = source; self.caption = caption; self.captionUntil = until + } + } func connect(_ text: String) { let name = String(text.dropFirst("oskiewar://".count)).lowercased() guard name.range(of: "^[a-z0-9-]{5,24}$", options: .regularExpression) != nil else { @@ -1115,18 +1131,19 @@ private final class OskiewarAttachment: ObservableObject { while !Task.isCancelled { pulse = (pulse + 1) % 64 guard let self, self.socket === task else { return } - var flags: [String: Any] = ["aeselPulse": pulse, - "aeselCaption": Date() < self.captionUntil ? self.caption : ""] - if !self.pendingArtifact.isEmpty { flags["aeselArtifact"] = self.pendingArtifact } + var flags: [String: Any] = ["aeselPulse": pulse] + if pulse % 2 == 0 && !self.pendingArtifact.isEmpty { flags["aeselArtifact"] = self.pendingArtifact } + else { flags["aeselCaption"] = Date() < self.captionUntil ? self.caption : "" } let data = try JSONSerialization.data(withJSONObject: ["type": "oskiewar:flags", "content": flags]) - try await task.send(.string(String(decoding: data, as: UTF8.self))) + if data.count <= 512 { try await task.send(.string(String(decoding: data, as: UTF8.self))) } + else { self.editStatus = "Drawing is too large to send" } try await Task.sleep(nanoseconds: 1_000_000_000) } - } catch { if !Task.isCancelled { self?.status = "Oskiewar connection lost" } } + } catch { if !Task.isCancelled { self?.lost(task) } } } } } - } catch { if !Task.isCancelled { self?.status = "Oskiewar connection lost" } } + } catch { if !Task.isCancelled { self?.lost(task) } } } } } @@ -1156,9 +1173,19 @@ private struct OskiewarArtifact: Codable, Equatable { var kind = "shirt-symbol" var shape: String var color: String + var points: [[Int]]? = nil + var draw: [[Int]]? = nil var valid: Bool { - version == 1 && kind == "shirt-symbol" && ["star", "heart", "flower", "rainbow"].contains(shape) + version == 1 && kind == "shirt-symbol" && ["star", "heart", "flower", "rainbow", "custom", "drawing"].contains(shape) && color.range(of: "^#[0-9a-fA-F]{6}$", options: .regularExpression) != nil + && (shape != "custom" || (points.map { (3...10).contains($0.count) && $0.allSatisfy { $0.count == 2 && $0.allSatisfy { (-9...9).contains($0) } } } ?? false)) + && (shape != "drawing" || (draw.map { commands in + (1...12).contains(commands.count) && commands.allSatisfy { c in + guard let op = c.first, (0...2).contains(op), c.count == [5,7,8][op], + c.allSatisfy({ (-16...16).contains($0) }), let ink = c.last, (0...2).contains(ink) else { return false } + return (op != 0 || (1...9).contains(c[3])) && (op != 1 || (1...6).contains(c[5])) + } + } ?? false)) } var wire: String { let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] @@ -1170,7 +1197,7 @@ private struct OskiewarArtifact: Codable, Equatable { let end = source.range(of: "*/", range: start.upperBound..> 16) & 255)/255, green: Double((rgb >> 8) & 255)/255, blue: Double(rgb & 255)/255) } - if artifact.shape == "rainbow" { + if artifact.shape == "drawing", let commands = artifact.draw { + let colors = [ink(artifact.color), Color(red: 24/255, green: 18/255, blue: 26/255), Color.white] + for c in commands { + let color = colors[c.last!] + if c[0] == 0 { + let p = point(Double(c[1]), Double(c[2])), r = Double(c[3]) * scale + context.fill(Path(ellipseIn: CGRect(x: p.x-r, y: p.y-r, width: r*2, height: r*2)), with: .color(color)) + } else { + var path = Path(); path.move(to: point(Double(c[1]), Double(c[2]))) + path.addLine(to: point(Double(c[3]), Double(c[4]))) + if c[0] == 1 { context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: Double(c[5])*scale, lineCap: .round)) } + else { path.addLine(to: point(Double(c[5]), Double(c[6]))); path.closeSubpath(); context.fill(path, with: .color(color)) } + } + } + } else if artifact.shape == "custom", let points = artifact.points { + var path = Path() + for (i, p) in points.enumerated() { + let position = point(Double(p[0]), Double(p[1])) + if i == 0 { path.move(to: position) } else { path.addLine(to: position) } + } + path.closeSubpath(); context.fill(path, with: .color(ink(artifact.color))) + } else if artifact.shape == "rainbow" { let colors = ["#f4535b", "#f99b41", "#fad34c", "#58bf79", "#5199eb", "#a66ddb"] for band in 0..<6 { let outer = 8 - Double(band) * 0.85, inner = outer - 0.85 diff --git a/session-server/oskiewar-live-manager.mjs b/session-server/oskiewar-live-manager.mjs index 2e5cea4d87..e52758cf9f 100644 --- a/session-server/oskiewar-live-manager.mjs +++ b/session-server/oskiewar-live-manager.mjs @@ -530,7 +530,7 @@ export class OskiewarLiveManager { for (const [key, value] of entries) { if (!/^[a-z][a-zA-Z0-9]{0,23}$/.test(key)) return; if (key === "aeselCaption" || key === "aeselArtifact") { - const limit = key === "aeselCaption" ? 120 : 192; + const limit = key === "aeselCaption" ? 120 : 320; if (typeof value !== "string" || value.length > limit || /[^\x20-\x7e]/.test(value)) return; } else if (typeof value !== "boolean" && !(Number.isFinite(value) && Math.abs(value) <= 64)) return; diff --git a/session-server/oskiewar-live-manager.test.mjs b/session-server/oskiewar-live-manager.test.mjs index 573f705f12..bc3f93305f 100644 --- a/session-server/oskiewar-live-manager.test.mjs +++ b/session-server/oskiewar-live-manager.test.mjs @@ -672,6 +672,6 @@ test('Aesel captions and artifact descriptors have bounded dedicated string fiel const content={aeselPulse:2,aeselCaption:'Aesel: Made a rainbow.',aeselArtifact:JSON.stringify({version:1,kind:'shirt-symbol',shape:'rainbow',color:'#ffaa33'})}; send(content);assert.deepEqual(host.sent.at(-1),{type:'oskiewar:flags',content}); const count=host.sent.length; - for(const invalid of [{aeselCaption:'a'.repeat(121)},{aeselCaption:'line\nbreak'},{aeselArtifact:'a'.repeat(193)},{sky:'text'}])send(invalid); + for(const invalid of [{aeselCaption:'a'.repeat(121)},{aeselCaption:'line\nbreak'},{aeselArtifact:'a'.repeat(321)},{sky:'text'}])send(invalid); assert.equal(host.sent.length,count); }); diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 42517a16e6..6c38c01732 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -47,7 +47,7 @@ let netFrameInputs = null; // +/- on a keyboard, in quarter steps between a quarter and double speed; // the offline reel harness drives simMonotonicUs itself and never touches // this, so recordings always run at one. -let gameSpeed = .8; +let gameSpeed = 1; let gameSpeedChangedAt = 0; let scaledClockUs = 0; let lastRawClockUs = null; @@ -4041,13 +4041,42 @@ let navigationPrevious = [[], []]; let renderFlags = {}; let aeselPulseSeen=null,aeselSeenAt=-Infinity; let aeselArtifact=null,aeselArtifactWire=""; +function triangulateAeselOutline(points){ + if(!Array.isArray(points)||points.length<3||points.length>10||points.some(p=>!Array.isArray(p)||p.length!==2||p.some(v=>!Number.isInteger(v)||Math.abs(v)>9)))return null; + const cross=(a,b,c)=>(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]); + const n=points.length; + for(let i=0;i{const q=points[(i+1)%n];return sum+p[0]*q[1]-q[0]*p[1];},0); + if(!area)return null; + const sign=Math.sign(area),indices=points.map((_,i)=>i),faces=[]; + while(indices.length>3){ + let clipped=false; + for(let j=0;jk!==a&&k!==b&&k!==c&&cross(points[a],points[b],points[k])*sign>=0&&cross(points[b],points[c],points[k])*sign>=0&&cross(points[c],points[a],points[k])*sign>=0))continue; + faces.push([a,b,c]);indices.splice(j,1);clipped=true;break; + } + if(!clipped)return null; + } + faces.push(indices);return faces; +} function acceptAeselArtifact(wire){ - if(typeof wire!=='string'||wire.length>192)return false; + if(typeof wire!=='string'||wire.length>320)return false; let value;try{value=JSON.parse(wire);}catch{return false;} if(!value||value.version!==1||value.kind!=='shirt-symbol'|| - !['star','heart','flower','rainbow'].includes(value.shape)||!/^#[0-9a-fA-F]{6}$/.test(value.color))return false; + !['star','heart','flower','rainbow','custom','drawing'].includes(value.shape)||!/^#[0-9a-fA-F]{6}$/.test(value.color))return false; + if(value.shape==='drawing'&&(!Array.isArray(value.draw)||value.draw.length<1||value.draw.length>12||value.draw.some(c=> + !Array.isArray(c)||![0,1,2].includes(c[0])||c.length!==[5,7,8][c[0]]||c.some(v=>!Number.isInteger(v)||Math.abs(v)>16)|| + c.at(-1)<0||c.at(-1)>2||c[0]===0&&(c[3]<1||c[3]>9)||c[0]===1&&(c[5]<1||c[5]>6))))return false; + const faces=value.shape==='custom'?triangulateAeselOutline(value.points):null; + if(value.shape==='custom'&&!faces)return false; if(wire===aeselArtifactWire)return true; - aeselArtifact={...value,rgb:[1,3,5].map(i=>parseInt(value.color.slice(i,i+2),16))}; + aeselArtifact={...value,faces,rgb:[1,3,5].map(i=>parseInt(value.color.slice(i,i+2),16))}; aeselArtifactWire=wire; playDrum('bell',.45,0); return true; @@ -4081,7 +4110,8 @@ function drawAeselConnect(){ if(!poolOnly()||!sessionName)return; const label=sessionName,size=32,safe=hudSafeRect(); const saved=triangleDepth;triangleDepth=-1.49; - const x=safe.right-handleWidth(label,size),y=safe.top+72; + const qr=shellMode==='MENU'?spectatorQrBox():null; + const x=safe.right-handleWidth(label,size),y=qr?qr.top+qr.size+20:safe.top+72; const lit=runtime().monotonicUs-aeselSeenAt<3500000,ink=lit?[163,244,211]:[194,196,220]; filledDisc(x-25,y+7,4,ink); filledCapsule(x-25,y+14,x-25,y+28,4,ink); @@ -7131,20 +7161,7 @@ function enterGame(now) { const paceLocked = () => netSession !== null || versusActive() || roundViewer !== null; function updateMatchPace(now) { - const rawDown = padSnapshots[0]?.down || []; - const tap = (button) => rawDown.includes(button) && - !shellRawPrevious.includes(button); - const faster = tap("SpeedUp"); - const slower = tap("SpeedDown"); - shellRawPrevious = rawDown.slice(); - if (!faster && !slower) return; - const next = paceLocked() ? gameSpeed - : clamp(gameSpeed + (faster ? .25 : -.25), .25, 1); - if (next === gameSpeed) { playDrum("block", .3, 0); return; } - gameSpeed = next; - gameSpeedChangedAt = now; - playDrum("hat", .55, faster ? .35 : -.35); - emitSignal("game-speed", -1, gameSpeed, 0); + shellRawPrevious = (padSnapshots[0]?.down || []).slice(); } function updateShell(now, tapped = false) { @@ -7157,16 +7174,6 @@ function updateShell(now, tapped = false) { const rawDown = pad.down || []; const speedTap = (button) => rawDown.includes(button) && !shellRawPrevious.includes(button); - if (speedTap("SpeedUp") || speedTap("SpeedDown")) { - const stepped = gameSpeed + (speedTap("SpeedUp") ? .25 : -.25); - const next = clamp(stepped, .25, 2); - if (next !== gameSpeed) { - gameSpeed = next; - gameSpeedChangedAt = now; - playDrum("hat", .55, speedTap("SpeedUp") ? .35 : -.35); - emitSignal("game-speed", -1, gameSpeed, 0); - } else playDrum("block", .3, 0); - } shellRawPrevious = rawDown.slice(); // View and Menu are the system's buttons — View toggles the debug HUD and // must not double as "start the game". Everything else, and a real lean on @@ -8934,7 +8941,7 @@ function netDrainHostInbox() { const entries=Object.entries(flags); if(entries.length<=8 && entries.every(([key,value])=>/^[a-z][a-zA-Z0-9]{0,23}$/.test(key) && ((key==='aeselCaption'||key==='aeselArtifact') - ? typeof value==='string' && value.length<=(key==='aeselCaption'?120:192) && !/[^\x20-\x7e]/.test(value) + ? typeof value==='string' && value.length<=(key==='aeselCaption'?120:320) && !/[^\x20-\x7e]/.test(value) : typeof value==='boolean'||Number.isFinite(value)&&Math.abs(value)<=64))){ const target=globalThis.__oskiewarRenderFlags ||= Object.create(null); for(const [key,value] of entries)if(!['__proto__','constructor','prototype'].includes(key)){ @@ -18519,7 +18526,21 @@ function drawSpatialRunner(player,world,t,lod=0){ const artifact=player===players[0]?aeselArtifact:null; const shape=artifact?.shape||(player===players[0]&&globalThis.__oskiewarRenderFlags?.shirtSymbol===2?'star':'flower'); const symbolColor=artifact?.rgb||[250,200,60]; - if(shape==='star'||shape==='heart'){ + if(shape==='drawing'){ + const colors=[symbolColor,[24,18,26],[255,255,250]]; + const vertex=(x,y,layer)=>point(c,(12+layer*.06)*scale,y*scale,x*scale); + const disc=(x,y,r,ink,layer)=>{for(let i=0;i<16;i++){const a=i*Math.PI/8,b=(i+1)*Math.PI/8;worldTriangle(vertex(x,y,layer),vertex(x+Math.cos(a)*r,y+Math.sin(a)*r,layer),vertex(x+Math.cos(b)*r,y+Math.sin(b)*r,layer),ink);}}; + artifact.draw.forEach((cmd,layer)=>{ + const ink=colors[cmd.at(-1)]; + if(cmd[0]===0)disc(cmd[1],cmd[2],cmd[3],ink,layer); + else if(cmd[0]===2)worldTriangle(vertex(cmd[1],cmd[2],layer),vertex(cmd[3],cmd[4],layer),vertex(cmd[5],cmd[6],layer),ink); + else{const [,x,y,xx,yy,w]=cmd,len=Math.hypot(xx-x,yy-y)||1,nx=-(yy-y)/len*w/2,ny=(xx-x)/len*w/2; + worldQuad(vertex(x+nx,y+ny,layer),vertex(xx+nx,yy+ny,layer),vertex(xx-nx,yy-ny,layer),vertex(x-nx,y-ny,layer),ink); + disc(x,y,w/2,ink,layer);disc(xx,yy,w/2,ink,layer);} + }); + }else if(shape==='custom'){ + for(const face of artifact.faces){const v=face.map(i=>point(c,12*scale,artifact.points[i][1]*scale,artifact.points[i][0]*scale));worldTriangle(v[0],v[1],v[2],symbolColor);} + }else if(shape==='star'||shape==='heart'){ const center=point(c,12*scale,0,0),count=shape==='star'?10:32; const vertex=i=>{const a=i*Math.PI*2/count; if(shape==='heart')return point(c,12*scale,(13*Math.cos(a)-5*Math.cos(2*a)-2*Math.cos(3*a)-Math.cos(4*a))*.38*scale,16*Math.pow(Math.sin(a),3)*.38*scale); @@ -21339,21 +21360,7 @@ function drawTitleScreen(t, ink, transitionAge = -1) { typeWrite(prompt, startX + 3, startY + 4, button.textSize, ...contrastShadow(startInk)); typeWrite(prompt, startX, startY, button.textSize, ...startInk); - // The pace dial. Quiet unless the clock is off its default — or was just - // touched, so stepping back to one still answers the keypress — and it - // lives large in the bottom-right corner, a dashboard readout rather - // than a footnote under the start word. - const paceNow = runtime().monotonicUs; - if (gameSpeed !== 1 || (gameSpeedChangedAt && - paceNow - gameSpeedChangedAt < 2400000)) { - const pace = "×" + gameSpeed; - const paceSize = Math.max(30, Math.round(button.textSize * .8)); - const hud = hudSafeRect(); - const paceX = hud.right - handleWidth(pace, paceSize); - const paceY = hud.bottom - paceSize; - typeWrite(pace, paceX + 3, paceY + 4, paceSize, ...shadowInk); - typeWrite(pace, paceX, paceY, paceSize, ...promptInk); - } + } // Touch play keeps its thumbs in the bottom corners, and the fight is live // under this screen now, so the stamp yields the pad rather than sit on it. @@ -22390,6 +22397,7 @@ function gamePaint() { // Meters and screen UI sit in front of the debug geometry. drawFrameMeter(); drawAeselConnect(); + if(poolOnly()&&shellMode==="GAME")for(const player of activePlayers())drawSeatAction(player); drawImpacts(); drawTitleHeadDoor(t, titleInk, reelMinimal); const counting = !roundResult && introAge < roundIntroDurationUs(); @@ -23055,7 +23063,7 @@ function drawSeatPlayerHud(ink){ triangleDepth=old; } function drawSeatAction(p){ - if(poolOnly())return; + if(shellMode!=="GAME")return; if(parkSupply.ko || !p.alive)return; const now=runtime().monotonicUs; const text=p.wheelTurbo?'SUPER TURBO':p.spin?'SPIN':now-(p.lastButtonAt||0)<850000?p.lastButton:''; diff --git a/xbox/live/tests/pool-playground.test.mjs b/xbox/live/tests/pool-playground.test.mjs index 437c8b597d..fb476483c1 100644 --- a/xbox/live/tests/pool-playground.test.mjs +++ b/xbox/live/tests/pool-playground.test.mjs @@ -367,3 +367,19 @@ test('artifact updates acknowledge applied content and chime only once per chang } }finally{delete globalThis.__oskiewarNetInbox;delete globalThis.__oskiewarRenderFlags;} }); + +test('custom drawings and concave outlines are data, applied once without evaluating code',()=>{ + const a=playground();const send=value=>{const wire=JSON.stringify(value);globalThis.__oskiewarNetInbox=[{kind:'render-flags',flags:{aeselArtifact:wire}}];a.netDrainHostInbox();return wire;}; + const base={version:1,kind:'shirt-symbol',shape:'drawing',color:'#ffcc44'}; + try{ + const smiley={...base,draw:[[0,0,0,8,0],[0,-3,2,1,1],[0,3,2,1,1],[1,-4,-2,0,-4,1,1],[1,0,-4,4,-2,1,1]]}; + const wire=send(smiley);assert.equal(a.spectatorState(a.now()).aesel.artifact,wire); + a.players[0].skin='pastel';assert.doesNotThrow(()=>a.drawRunner(a.players[0],0)); + for(const draw of [[[99,0]],[[0,0,0,0,0]],[[1,0,0,2,2,20,0]],[[0,0,0,8,8]]])send({...base,draw}); + assert.equal(a.spectatorState(a.now()).aesel.artifact,wire); + const concave={...base,shape:'custom',points:[[-8,-6],[8,-6],[8,6],[0,1],[-8,6]]}; + const outline=send(concave);assert.equal(a.spectatorState(a.now()).aesel.artifact,outline); + send({...concave,points:[[-8,-6],[8,6],[8,-6],[-8,6]]}); + assert.equal(a.spectatorState(a.now()).aesel.artifact,outline,'crossed outline is rejected'); + }finally{delete globalThis.__oskiewarNetInbox;delete globalThis.__oskiewarRenderFlags;} +}); -- 2.51.2