From f6a0845a3657bbb85f14fa55773789d520c9119b Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 27 Sep 2026 18:51:12 -0700 Subject: [PATCH] aesel: sync isolated shirt artifacts and response captions with game acknowledgments --- apple/aesel/Sources/ContentView.swift | 183 ++++++++++++++---- apple/aesel/Sources/SessionHost.swift | 2 +- easel/phone/host.html | 2 +- easel/phone/session.mjs | 4 +- easel/test/phone-session.test.mjs | 8 + session-server/oskiewar-live-manager.mjs | 5 +- session-server/oskiewar-live-manager.test.mjs | 13 ++ xbox/live/oskiewar.js | 46 ++++- xbox/live/tests/pool-playground.test.mjs | 19 ++ 9 files changed, 233 insertions(+), 49 deletions(-) diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index a7f2a35cb8..4571d2940f 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -238,7 +238,15 @@ struct ContentView: View { .onChange(of: session.selectedRevision) { writing = false } .onChange(of: session.currentSessionID) { previewHidden = false; expandedPreview = false; attended = true } .onChange(of: preview.failure) { host.automation.previewFailure = preview.failure } - .onChange(of: session.busy) { if session.busy { attended = false }; oskiewar.setIntent(session.busy ? 1 : 0) } + .onChange(of: session.busy) { + if session.busy { attended = false } + else if !oskiewar.room.isEmpty { oskiewar.finish(source: session.source) } + } + .onChange(of: session.entries) { + if session.busy, let response = session.entries.last(where: { $0.kind == .ac }) { + oskiewar.mirror(response.text) + } + } .onChange(of: writing) { if writing { attended = true } } .onChange(of: draft) { attended = true } .onChange(of: session.showSignIn) { if !session.showSignIn { host.cancelSignIn() } } @@ -497,18 +505,9 @@ struct ContentView: View { private func previewBox(container: CGSize) -> some View { ZStack(alignment: .topTrailing) { if !oskiewar.room.isEmpty { - VStack(spacing: 4) { - ZStack { - Image(systemName: "tshirt.fill").resizable().scaledToFit().foregroundStyle(.white) - Image(systemName: "star.fill").resizable().scaledToFit().foregroundStyle(.yellow) - .frame(width: 22, height: 22) - }.frame(maxWidth: .infinity, maxHeight: .infinity).layoutPriority(-1) - Button("Apply star") { oskiewar.applyStar() }.font(.system(size: 12)) - Text(oskiewar.editStatus.isEmpty ? "Shirt preview" : oskiewar.editStatus) - .font(.system(size: 9)).lineLimit(1).minimumScaleFactor(0.7) - }.padding(8).frame(width: previewSize.width, height: previewSize.height) - .background(Color(red: 0.16, green: 0.13, blue: 0.22)) - .foregroundStyle(.white).clipped() + artifactPreview + .frame(width: previewSize.width, height: previewSize.height) + .clipped() } else if let url = session.previewURL { PieceView(url: url, source: session.displayedSource, preview: preview) .frame(width: previewSize.width, height: previewSize.height, alignment: .topTrailing) @@ -535,9 +534,21 @@ struct ContentView: View { } } + private var artifactPreview: some View { + VStack(spacing: 4) { + OskiewarArtifactPreview(artifact: oskiewar.artifact) + .frame(maxWidth: .infinity, maxHeight: .infinity) + if !oskiewar.editStatus.isEmpty { + Text(oskiewar.editStatus).font(.system(size: 10)).lineLimit(2).minimumScaleFactor(0.7) + } + }.padding(8).background(Color(red: 0.16, green: 0.13, blue: 0.22)).foregroundStyle(.white) + } + private var expandedPiece: some View { ZStack(alignment: .topTrailing) { - if let url = session.previewURL { + if !oskiewar.room.isEmpty { + artifactPreview + } else if let url = session.previewURL { PieceView(url: url, source: session.displayedSource, preview: preview) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -773,7 +784,8 @@ struct ContentView: View { default: if session.provider == "ac" && !session.signedIn { host.signIn(); return } guard session.canStartTurn else { openSettings(); return } - host.ask(text) + if oskiewar.room.isEmpty { host.ask(text) } + else { oskiewar.begin(source: session.source); host.ask(text, context: oskiewar.artifactContext) } } draft = "" } @@ -1010,26 +1022,47 @@ private struct CurtainAccountButtonStyle: ButtonStyle { private final class OskiewarAttachment: ObservableObject { @Published var status = "" @Published var editStatus = "" - private var intent = 0 - private var starRequested = false - func setIntent(_ value: Int) { - intent = value - guard let socket else { return } - Task { try? await socket.send(.string("{\"type\":\"oskiewar:flags\",\"content\":{\"aeselIntent\":\(value)}}")) } + @Published var artifact = OskiewarArtifact(shape: "star", color: "#fac83c") + private var caption = "" + private var captionUntil = Date.distantPast + private var pendingArtifact = "" + private var sourceAtStart: String? + var artifactContext: String { + """ + You are editing an isolated Oskiewar shirt-symbol artifact, connected to room \(room). + 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. + 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. + Do not claim the game applied it. Do not create an unrelated piece, publish it, or add UI. If a request needs another artifact type or geometry, explain that it is not supported yet instead of pretending it changed the game. + """ } - func applyStar() { - guard let socket, status.hasPrefix("Connected") else { editStatus = "Connect to a game first"; return } - starRequested = true; intent = 2 - Task { - do { - try await socket.send(.string("{\"type\":\"oskiewar:flags\",\"content\":{\"aeselIntent\":2,\"shirtSymbol\":2}}")) - editStatus = "Star sent to game" - try await Task.sleep(nanoseconds: 3_000_000_000) - guard self.socket === socket else { return } - intent = 0 - try await socket.send(.string("{\"type\":\"oskiewar:flags\",\"content\":{\"aeselIntent\":0}}")) - } catch { editStatus = "Could not send star" } + func begin(source: String) { + sourceAtStart = source; caption = ""; editStatus = "Editing" + } + func mirror(_ text: String) { + let plain = text.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.joined(separator: " ") + let ascii = String(plain.unicodeScalars.filter { $0.value >= 32 && $0.value <= 126 }.map(Character.init)) + guard !ascii.isEmpty else { return } + caption = "Aesel: " + String(ascii.prefix(109)) + (ascii.count > 109 ? "..." : "") + captionUntil = Date().addingTimeInterval(10) + } + func finish(source: String) { + guard let previous = sourceAtStart else { return } + sourceAtStart = nil + guard source != previous else { editStatus = "No artifact change"; return } + guard let value = OskiewarArtifact.read(source) else { + editStatus = "No supported artifact in this response"; return } + artifact = value; pendingArtifact = value.wire; editStatus = "Sending to game" + } + func applyStar() { + artifact = OskiewarArtifact(shape: "star", color: "#fac83c") + pendingArtifact = artifact.wire; editStatus = "Sending to game" + mirror("Changing the shirt symbol to a star.") } private var socket: URLSessionWebSocketTask? private var receiver: Task? @@ -1038,7 +1071,7 @@ private final class OskiewarAttachment: ObservableObject { func disconnect() { receiver?.cancel(); heartbeat?.cancel() - intent = 0; starRequested = false + caption = ""; pendingArtifact = ""; sourceAtStart = nil socket?.cancel(with: .goingAway, reason: nil) socket = nil; receiver = nil; heartbeat = nil; room = ""; status = "Oskiewar disconnected" } @@ -1064,6 +1097,16 @@ private final class OskiewarAttachment: ObservableObject { if value["type"] as? String == "oskiewar:error" { self.status = "Oskiewar connection rejected"; self.heartbeat?.cancel(); return } + if value["type"] as? String == "oskiewar:state", + let state = value["content"] as? [String: Any], + let aesel = state["aesel"] as? [String: Any], + let wire = aesel["artifact"] as? String, !wire.isEmpty { + if wire == self.pendingArtifact { self.editStatus = "Live in game" } + if self.pendingArtifact.isEmpty, let data = wire.data(using: .utf8), + let current = try? JSONDecoder().decode(OskiewarArtifact.self, from: data), current.valid { + self.artifact = current; self.editStatus = "Live in game" + } + } if value["type"] as? String == "oskiewar:state" && self.heartbeat == nil { self.status = "Connected · oskiewar://" + name self.heartbeat = Task { [weak self] in @@ -1072,8 +1115,9 @@ private final class OskiewarAttachment: ObservableObject { while !Task.isCancelled { pulse = (pulse + 1) % 64 guard let self, self.socket === task else { return } - var flags: [String: Int] = ["aeselPulse": pulse, "aeselIntent": self.intent] - if self.starRequested { flags["shirtSymbol"] = 2 } + var flags: [String: Any] = ["aeselPulse": pulse, + "aeselCaption": Date() < self.captionUntil ? self.caption : ""] + if !self.pendingArtifact.isEmpty { flags["aeselArtifact"] = self.pendingArtifact } let data = try JSONSerialization.data(withJSONObject: ["type": "oskiewar:flags", "content": flags]) try await task.send(.string(String(decoding: data, as: UTF8.self))) try await Task.sleep(nanoseconds: 1_000_000_000) @@ -1105,3 +1149,70 @@ private struct OskiewarHeadIcon: View { }.accessibilityHidden(true) } } + + +private struct OskiewarArtifact: Codable, Equatable { + var version = 1 + var kind = "shirt-symbol" + var shape: String + var color: String + var valid: Bool { + version == 1 && kind == "shirt-symbol" && ["star", "heart", "flower", "rainbow"].contains(shape) + && color.range(of: "^#[0-9a-fA-F]{6}$", options: .regularExpression) != nil + } + var wire: String { + let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return String(decoding: (try? encoder.encode(self)) ?? Data(), as: UTF8.self) + } + static func read(_ source: String) -> Self? { + let marker = "/* ac-artifact " + guard let start = source.range(of: marker), + let end = source.range(of: "*/", range: start.upperBound.. CGPoint { CGPoint(x: center.x + x * scale, y: center.y - y * scale) } + func ink(_ hex: String) -> Color { + let rgb = Int(hex.dropFirst(), radix: 16) ?? 0xfac83c + return Color(red: Double((rgb >> 16) & 255)/255, green: Double((rgb >> 8) & 255)/255, blue: Double(rgb & 255)/255) + } + 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 + var path = Path() + for i in 0...12 { let a = Double(i) * .pi / 12; let p = point(cos(a)*outer, sin(a)*outer-3); if i == 0 { path.move(to: p) } else { path.addLine(to: p) } } + for i in (0...12).reversed() { let a = Double(i) * .pi / 12; path.addLine(to: point(cos(a)*inner, sin(a)*inner-3)) } + path.closeSubpath(); context.fill(path, with: .color(ink(colors[band]))) + } + } else if artifact.shape == "flower" { + for i in 0..<6 { + let a = Double(i) * .pi / 3, p = point(sin(a)*3, cos(a)*3) + context.fill(Path(ellipseIn: CGRect(x: p.x-2*scale, y: p.y-2*scale, width: 4*scale, height: 4*scale)), with: .color(.white)) + } + context.fill(Path(ellipseIn: CGRect(x: center.x-2*scale, y: center.y-2*scale, width: 4*scale, height: 4*scale)), with: .color(ink(artifact.color))) + } else { + var path = Path(); let count = artifact.shape == "star" ? 10 : 32 + for i in 0.. session.ask(String(text))), + ask: guard("ask", (text, context = "") => session.ask(String(text), String(context).slice(0, 6000))), stop: () => session.stop(), refreshCredits: () => session.refreshCredits(), signOut: () => session.signOut(), diff --git a/easel/phone/session.mjs b/easel/phone/session.mjs index 05d2889b6d..444bf2b3b7 100644 --- a/easel/phone/session.mjs +++ b/easel/phone/session.mjs @@ -615,7 +615,7 @@ export function createSession({ storage = memoryStore(), emit = () => {}, hostRP if (decision === "always") await refreshProviders(); } - async function ask(text) { + async function ask(text, artifactContext = "") { requireCurrentRevision(); const command = text.trim().match(/^\/model(?:\s+(.+))?$/i); if (command) { @@ -637,7 +637,7 @@ export function createSession({ storage = memoryStore(), emit = () => {}, hostRP say("notice", {scope:"inference",text:""}); say("you", {text}); if (!state.server) state.server = buildServer(); - await state.server.startTurn(text); + await state.server.startTurn(artifactContext ? `${artifactContext}\n\nUser request: ${text}` : text); } catch (error) { say("bad", {text:error.message}); say("status", {text:"failed",kind:"failed"}); diff --git a/easel/test/phone-session.test.mjs b/easel/test/phone-session.test.mjs index 324ebcd92d..bd73c3032b 100644 --- a/easel/test/phone-session.test.mjs +++ b/easel/test/phone-session.test.mjs @@ -457,3 +457,11 @@ test('a blank opened before account restoration publishes after verification',as await session.restore();assert.equal(uploads,1);assert(session.state.published); }finally{globalThis.fetch=originalFetch;} }); + +test('artifact context reaches the model without replacing the visible user prompt',async()=>{ + const events=[];const session=signedInSession({emit:e=>events.push(e)}); + await session.open();let input='';session.state.server={startTurn:async text=>{input=text;}}; + await session.ask('Make it a rainbow','Edit the isolated shirt-symbol descriptor.'); + assert.equal(input,'Edit the isolated shirt-symbol descriptor.\n\nUser request: Make it a rainbow'); + assert.equal(events.findLast(e=>e.type==='you').text,'Make it a rainbow'); +}); diff --git a/session-server/oskiewar-live-manager.mjs b/session-server/oskiewar-live-manager.mjs index c45101e158..2e5cea4d87 100644 --- a/session-server/oskiewar-live-manager.mjs +++ b/session-server/oskiewar-live-manager.mjs @@ -529,7 +529,10 @@ export class OskiewarLiveManager { if (entries.length < 1 || entries.length > 8) return; for (const [key, value] of entries) { if (!/^[a-z][a-zA-Z0-9]{0,23}$/.test(key)) return; - if (typeof value !== "boolean" && + if (key === "aeselCaption" || key === "aeselArtifact") { + const limit = key === "aeselCaption" ? 120 : 192; + 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; } const now = this.now(); diff --git a/session-server/oskiewar-live-manager.test.mjs b/session-server/oskiewar-live-manager.test.mjs index ad22d0533f..573f705f12 100644 --- a/session-server/oskiewar-live-manager.test.mjs +++ b/session-server/oskiewar-live-manager.test.mjs @@ -662,3 +662,16 @@ test("skatepark live frames bound chain state and attachment indices", () => { frame.ropes[0].push([0, 0, 0, 0]); assert.equal(validateOskiewarLiveState(frame), "Invalid ropes"); }); + +test('Aesel captions and artifact descriptors have bounded dedicated string fields',()=>{ + let now=100;const manager=new OskiewarLiveManager({now:()=>now}); + const host=new FakeSocket(),agent=new FakeSocket();const url='/oskiewar-live?match=ow-kikke752'; + manager.handleConnection(host,{url:url+'&role=publisher&surface=web'}); + manager.handleConnection(agent,{url:url+'&role=agent'}); + const send=content=>{now+=1000;agent.emit('message',Buffer.from(JSON.stringify({type:'oskiewar:flags',content})));}; + 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); + assert.equal(host.sent.length,count); +}); diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index acf3282932..42517a16e6 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -4040,6 +4040,18 @@ let navigationPrevious = [[], []]; // walks them and reads the price back out of the live fps telemetry. let renderFlags = {}; let aeselPulseSeen=null,aeselSeenAt=-Infinity; +let aeselArtifact=null,aeselArtifactWire=""; +function acceptAeselArtifact(wire){ + if(typeof wire!=='string'||wire.length>192)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; + if(wire===aeselArtifactWire)return true; + aeselArtifact={...value,rgb:[1,3,5].map(i=>parseInt(value.color.slice(i,i+2),16))}; + aeselArtifactWire=wire; + playDrum('bell',.45,0); + return true; +} function drawAeselFairy(t){ const pulse=globalThis.__oskiewarRenderFlags?.aeselPulse,now=runtime().monotonicUs; if(Number.isFinite(pulse)&&pulse!==aeselPulseSeen){aeselPulseSeen=pulse;aeselSeenAt=now;} @@ -4055,7 +4067,8 @@ function drawAeselFairy(t){ worldQuad(at(0,-3),at(wing,-5),at(wing,8),at(0,0),[238,185,247]); worldQuad(at(-2,5),at(2,5),at(2,-5),at(-2,-5),[255,231,127]); const intent=globalThis.__oskiewarRenderFlags?.aeselIntent; - const label=intent===2?'PUTTING A STAR ON YOUR SHIRT':intent===1?'WORKING IN AESEL':''; + const caption=globalThis.__oskiewarRenderFlags?.aeselCaption; + const label=typeof caption==='string'?caption:intent===2?'PUTTING A STAR ON YOUR SHIRT':intent===1?'WORKING IN AESEL':''; if(label){ const p=cameraDoll.project(c),saved=triangleDepth,size=18,w=handleWidth(label,size); if(Number.isFinite(p.x)&&Number.isFinite(p.y)){ @@ -4407,6 +4420,7 @@ function spectatorState(now, nextRoundId = "") { map: { id: currentMapId, name: currentMapName, highlights: workshopHighlight, ...(workshopMap ? { workshop: workshopMap } : {}) }, at: run.unixMs || 0, phase, + aesel: {artifact:aeselArtifactWire}, course: skateparkMap ? "skatepark" : "station", ropes: skateRopes.length ? skateRopes.map((rope) => rope.nodes.map((node) => [node.x, node.y, node.px, node.py].map((value) => Math.round(value * 100) / 100))) : undefined, @@ -8919,9 +8933,14 @@ function netDrainHostInbox() { if(flags && typeof flags==='object' && !Array.isArray(flags)){ const entries=Object.entries(flags); if(entries.length<=8 && entries.every(([key,value])=>/^[a-z][a-zA-Z0-9]{0,23}$/.test(key) && - (typeof value==='boolean'||Number.isFinite(value)&&Math.abs(value)<=64))){ + ((key==='aeselCaption'||key==='aeselArtifact') + ? typeof value==='string' && value.length<=(key==='aeselCaption'?120:192) && !/[^\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))target[key]=value; + for(const [key,value] of entries)if(!['__proto__','constructor','prototype'].includes(key)){ + if(key==='aeselArtifact'&&!acceptAeselArtifact(value))continue; + target[key]=value; + } } } continue; @@ -18497,13 +18516,24 @@ function drawSpatialRunner(player,world,t,lod=0){ } if(dressed&&lod===0){ const c={x:lerp(hip.x,neck.x,.65),y:lerp(hip.y,neck.y,.65),z:lerp(hip.z,neck.z,.65)}; - if(player===players[0] && globalThis.__oskiewarRenderFlags?.shirtSymbol===2){ - const center=point(c,12*scale,0,0); - const vertex=i=>{const angle=i*Math.PI/5,r=(i%2?2.6:6)*scale;return point(c,12*scale,Math.cos(angle)*r,Math.sin(angle)*r);}; - for(let i=0;i<10;i++)worldTriangle(center,vertex(i),vertex(i+1),[250,200,60]); + 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'){ + 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); + const r=(i%2?2.6:6)*scale;return point(c,12*scale,Math.cos(a)*r,Math.sin(a)*r);}; + for(let i=0;ipoint(c,12*scale,(Math.sin(j*Math.PI/12)*r-3)*scale,Math.cos(j*Math.PI/12)*r*scale); + const r=8-band*.85;worldQuad(v(i,r),v(i+1,r),v(i+1,r-.85),v(i,r-.85),colors[band]); + } }else{ for(let i=0;i<6;i++){const a=i/6*Math.PI*2;ellipsoid(point(c,12*scale,Math.cos(a)*3*scale,Math.sin(a)*3*scale),.8*scale,2*scale,2*scale,[255,255,250],4,2);} - ellipsoid(point(c,13*scale,0,0),1*scale,2*scale,2*scale,[250,200,60],4,2); + ellipsoid(point(c,13*scale,0,0),1*scale,2*scale,2*scale,symbolColor,4,2); } } } diff --git a/xbox/live/tests/pool-playground.test.mjs b/xbox/live/tests/pool-playground.test.mjs index 35b948a5f2..437c8b597d 100644 --- a/xbox/live/tests/pool-playground.test.mjs +++ b/xbox/live/tests/pool-playground.test.mjs @@ -348,3 +348,22 @@ test('live star edits render their fairy captions without stopping the frame',() } }finally{delete globalThis.__oskiewarNetInbox;delete globalThis.__oskiewarRenderFlags;} }); + +test('artifact updates acknowledge applied content and chime only once per change',()=>{ + const a=playground(); + const send=artifact=>{globalThis.__oskiewarNetInbox=[{kind:'render-flags',flags:{aeselArtifact:artifact,aeselCaption:'Aesel: Made a rainbow.'}}];a.netDrainHostInbox();}; + const wire=JSON.stringify({version:1,kind:'shirt-symbol',shape:'rainbow',color:'#ffcc44'}); + try{ + send(wire);assert.equal(a.spectatorState(a.now()).aesel.artifact,wire); + assert.equal(a.drums.filter(d=>d.name==='bell').length,1); + send(wire);assert.equal(a.drums.filter(d=>d.name==='bell').length,1,'heartbeat retry is silent'); + for(const invalid of ['{}','bad json',wire.replace('rainbow','code'),wire.replace('#ffcc44','red')])send(invalid); + assert.equal(a.spectatorState(a.now()).aesel.artifact,wire,'invalid edits preserve current artifact'); + assert.equal(a.drums.filter(d=>d.name==='bell').length,1); + for(const shape of ['star','heart','flower','rainbow']){ + send(wire.replace('rainbow',shape));a.players[0].skin='pastel'; + assert.doesNotThrow(()=>a.drawRunner(a.players[0],0)); + assert.equal(validateOskiewarLiveState(a.spectatorState(a.now())),null); + } + }finally{delete globalThis.__oskiewarNetInbox;delete globalThis.__oskiewarRenderFlags;} +}); -- 2.51.2