fix: for-in parser token mismatch + enhanced step sequencer

- Fix for...in parser: TokenKind::In → TokenKind::InKw (was using wrong token)
- Remove dead In variant from TokenKind enum
- Fix pre-existing test: When pattern match 2→3 fields
- Enhanced step-sequencer.ds: 16 steps, 4 instrument arrays, play/pause,
  BPM controls with limits, presets, dynamic variants, streaming output
- All 118 tests pass
This commit is contained in:
enzotar 2026-02-26 23:42:29 -08:00
parent f7f7363230
commit 62830fa82a
4 changed files with 578 additions and 54 deletions

View file

@ -48,7 +48,7 @@ pub enum TokenKind {
Scene,
Animate,
For,
In,
// In variant removed — use InKw for the `in` keyword
Component,
Route,
Navigate,

View file

@ -1044,7 +1044,7 @@ impl Parser {
None
};
self.expect(&TokenKind::In)?;
self.expect(&TokenKind::InKw)?;
let iter_expr = self.parse_comparison()?;
self.expect(&TokenKind::Arrow)?;
self.skip_newlines();
@ -1692,7 +1692,7 @@ mod tests {
Declaration::View(v) => {
match &v.body {
Expr::Container(c) => {
assert!(matches!(&c.children[0], Expr::When(_, _)));
assert!(matches!(&c.children[0], Expr::When(_, _, _)));
}
other => panic!("expected Container, got {other:?}"),
}

487
examples/game-pong.html Normal file
View file

@ -0,0 +1,487 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>🏓 DreamStack Pong — Streamed</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
background: #0a0a0f;
color: #e2e8f0;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 1.5rem;
user-select: none;
}
h1 {
font-size: 2rem;
font-weight: 800;
margin-bottom: 0.25rem;
}
.subtitle {
color: #94a3b8;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.scores {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.9rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.badge-p1 {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
border: 1px solid rgba(99, 102, 241, 0.3);
}
.badge-p2 {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.badge-info {
background: rgba(56, 189, 248, 0.15);
color: #38bdf8;
border: 1px solid rgba(56, 189, 248, 0.2);
}
.badge-live {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
canvas {
border: 2px solid rgba(99, 102, 241, 0.3);
border-radius: 12px;
background: #0f0f1a;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), inset 0 0 60px rgba(99, 102, 241, 0.03);
}
.controls {
margin-top: 1rem;
display: flex;
gap: 1rem;
align-items: center;
}
.btn {
padding: 0.6rem 1.2rem;
border-radius: 12px;
border: 1px solid rgba(99, 102, 241, 0.3);
background: rgba(99, 102, 241, 0.15);
color: #a5b4fc;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btn:hover {
background: rgba(99, 102, 241, 0.3);
transform: scale(1.03);
}
.btn:active {
transform: scale(0.97);
}
.btn-reset {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: #f87171;
}
.btn-start {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.status {
margin-top: 0.75rem;
font-size: 0.75rem;
color: #64748b;
}
.mode-btns {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.mode-active {
background: rgba(99, 102, 241, 0.4) !important;
}
</style>
</head>
<body>
<h1>🏓 DreamStack Pong</h1>
<p class="subtitle">Mouse/touch to move paddle · Streamed live via relay</p>
<div class="mode-btns">
<button class="btn mode-active" id="btn-ai" onclick="setMode('ai')">vs AI</button>
<button class="btn" id="btn-2p" onclick="setMode('2p')">2 Player</button>
</div>
<div class="scores">
<span class="badge badge-p1" id="s1">Player: 0</span>
<span class="badge badge-info" id="rally-badge">Rally: 0</span>
<span class="badge badge-p2" id="s2">AI: 0</span>
<span class="badge badge-live" id="stream-badge">⏳ Connecting…</span>
</div>
<canvas id="c" width="600" height="400"></canvas>
<div class="controls">
<button class="btn btn-start" onclick="serve()">🏓 Serve</button>
<button class="btn btn-reset" onclick="resetGame()">🔄 Reset</button>
</div>
<p class="status" id="status">Connecting to relay…</p>
<script>
const W = 600, H = 400;
const PADDLE_W = 12, PADDLE_H = 80, BALL_R = 8;
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
// ── State ──
let mode = 'ai'; // 'ai' or '2p'
let p1y = H / 2, p2y = H / 2;
let ballX = W / 2, ballY = H / 2, ballVX = 0, ballVY = 0;
let score1 = 0, score2 = 0, rally = 0;
let serving = true;
let maxRally = 0;
let lastTime = 0;
// ── Mode ──
function setMode(m) {
mode = m;
document.getElementById('btn-ai').classList.toggle('mode-active', m === 'ai');
document.getElementById('btn-2p').classList.toggle('mode-active', m === '2p');
document.getElementById('s2').textContent = m === 'ai' ? `AI: ${score2}` : `P2: ${score2}`;
resetGame();
}
// ── Input ──
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const y = (e.clientY - rect.top) * (H / rect.height);
p1y = Math.max(PADDLE_H / 2, Math.min(H - PADDLE_H / 2, y));
if (mode === '2p') {
// In 2P mode, second player uses vertical position mirrored
// Actually let's use keyboard for P2
}
});
// Touch support
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const y = (e.touches[0].clientY - rect.top) * (H / rect.height);
p1y = Math.max(PADDLE_H / 2, Math.min(H - PADDLE_H / 2, y));
}, { passive: false });
// Keyboard for 2P mode
const keys = {};
document.addEventListener('keydown', e => { keys[e.key] = true; });
document.addEventListener('keyup', e => { keys[e.key] = false; });
function serve() {
ballX = W / 2; ballY = H / 2;
const angle = (Math.random() - 0.5) * Math.PI * 0.5;
const side = Math.random() > 0.5 ? 1 : -1;
ballVX = side * Math.cos(angle) * 5;
ballVY = Math.sin(angle) * 4;
rally = 0;
serving = false;
}
function resetGame() {
score1 = 0; score2 = 0; rally = 0; maxRally = 0;
ballX = W / 2; ballY = H / 2; ballVX = 0; ballVY = 0;
p1y = H / 2; p2y = H / 2;
serving = true;
updateUI();
}
// ── Game Loop ──
function update(ts) {
const dt = Math.min((ts - lastTime) / 16.67, 2); // Normalize to ~60fps
lastTime = ts;
// 2P keyboard controls
if (mode === '2p') {
if (keys['ArrowUp']) p2y = Math.max(PADDLE_H / 2, p2y - 6 * dt);
if (keys['ArrowDown']) p2y = Math.min(H - PADDLE_H / 2, p2y + 6 * dt);
}
if (!serving) {
// Move ball
ballX += ballVX * dt;
ballY += ballVY * dt;
// Wall bounce (top/bottom)
if (ballY - BALL_R <= 0) { ballY = BALL_R; ballVY = Math.abs(ballVY); }
if (ballY + BALL_R >= H) { ballY = H - BALL_R; ballVY = -Math.abs(ballVY); }
// Left paddle collision (P1)
if (ballX - BALL_R <= PADDLE_W + 16 && ballVX < 0) {
if (ballY >= p1y - PADDLE_H / 2 - 4 && ballY <= p1y + PADDLE_H / 2 + 4) {
ballX = PADDLE_W + 16 + BALL_R;
ballVX = Math.abs(ballVX) * 1.03; // Slight speedup
// Angle based on where ball hits paddle
const offset = (ballY - p1y) / (PADDLE_H / 2);
ballVY = offset * 5;
rally++;
}
}
// Right paddle collision (P2/AI)
if (ballX + BALL_R >= W - PADDLE_W - 16 && ballVX > 0) {
if (ballY >= p2y - PADDLE_H / 2 - 4 && ballY <= p2y + PADDLE_H / 2 + 4) {
ballX = W - PADDLE_W - 16 - BALL_R;
ballVX = -Math.abs(ballVX) * 1.03;
const offset = (ballY - p2y) / (PADDLE_H / 2);
ballVY = offset * 5;
rally++;
}
}
// AI paddle
if (mode === 'ai') {
const aiSpeed = 3.5 + Math.min(rally * 0.1, 2); // Gets harder
const aiTarget = ballVX > 0 ? ballY : H / 2; // Track ball when coming toward AI
const diff = aiTarget - p2y;
if (Math.abs(diff) > 3) {
p2y += Math.sign(diff) * Math.min(aiSpeed * dt, Math.abs(diff));
}
p2y = Math.max(PADDLE_H / 2, Math.min(H - PADDLE_H / 2, p2y));
}
// Score — ball passed left
if (ballX < -20) {
score2++;
maxRally = Math.max(maxRally, rally);
serving = true;
ballVX = 0; ballVY = 0;
ballX = W / 2; ballY = H / 2;
}
// Score — ball passed right
if (ballX > W + 20) {
score1++;
maxRally = Math.max(maxRally, rally);
serving = true;
ballVX = 0; ballVY = 0;
ballX = W / 2; ballY = H / 2;
}
}
draw();
updateUI();
broadcastState();
requestAnimationFrame(update);
}
// ── Drawing ──
function draw() {
// Background
ctx.fillStyle = '#0f0f1a';
ctx.fillRect(0, 0, W, H);
// Center line
ctx.setLineDash([8, 8]);
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(W / 2, 0);
ctx.lineTo(W / 2, H);
ctx.stroke();
ctx.setLineDash([]);
// Center circle
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.beginPath();
ctx.arc(W / 2, H / 2, 50, 0, Math.PI * 2);
ctx.stroke();
// Score display on court
ctx.fillStyle = 'rgba(255,255,255,0.04)';
ctx.font = 'bold 80px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(score1, W / 4, H / 2 + 28);
ctx.fillText(score2, W * 3 / 4, H / 2 + 28);
// Left paddle (P1) — indigo glow
const p1Gradient = ctx.createLinearGradient(16, p1y - PADDLE_H / 2, 16 + PADDLE_W, p1y + PADDLE_H / 2);
p1Gradient.addColorStop(0, '#818cf8');
p1Gradient.addColorStop(1, '#6366f1');
ctx.fillStyle = p1Gradient;
ctx.shadowColor = '#6366f1';
ctx.shadowBlur = 15;
roundRect(ctx, 16, p1y - PADDLE_H / 2, PADDLE_W, PADDLE_H, 6);
ctx.fill();
// Right paddle (P2/AI) — red glow
const p2Gradient = ctx.createLinearGradient(W - 16 - PADDLE_W, p2y - PADDLE_H / 2, W - 16, p2y + PADDLE_H / 2);
p2Gradient.addColorStop(0, '#f87171');
p2Gradient.addColorStop(1, '#ef4444');
ctx.fillStyle = p2Gradient;
ctx.shadowColor = '#ef4444';
ctx.shadowBlur = 15;
roundRect(ctx, W - 16 - PADDLE_W, p2y - PADDLE_H / 2, PADDLE_W, PADDLE_H, 6);
ctx.fill();
ctx.shadowBlur = 0;
// Ball — white glow
ctx.fillStyle = '#f1f5f9';
ctx.shadowColor = '#f1f5f9';
ctx.shadowBlur = 12;
ctx.beginPath();
ctx.arc(ballX, ballY, BALL_R, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// Ball trail
if (!serving && (Math.abs(ballVX) > 0.1 || Math.abs(ballVY) > 0.1)) {
for (let i = 1; i <= 4; i++) {
ctx.fillStyle = `rgba(241,245,249,${0.15 - i * 0.03})`;
ctx.beginPath();
ctx.arc(ballX - ballVX * i * 0.8, ballY - ballVY * i * 0.8, BALL_R - i, 0, Math.PI * 2);
ctx.fill();
}
}
// Serving text
if (serving) {
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '500 16px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Press SERVE or SPACE to start', W / 2, H - 30);
}
// Rally counter
if (rally > 0) {
ctx.fillStyle = `rgba(250,204,21,${Math.min(0.8, 0.3 + rally * 0.05)})`;
ctx.font = 'bold 14px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`Rally: ${rally}`, W / 2, 24);
}
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function updateUI() {
document.getElementById('s1').textContent = `Player: ${score1}`;
document.getElementById('s2').textContent = (mode === 'ai' ? 'AI' : 'P2') + `: ${score2}`;
document.getElementById('rally-badge').textContent = `Rally: ${rally}`;
}
// Space to serve
document.addEventListener('keydown', e => {
if (e.key === ' ' && serving) { e.preventDefault(); serve(); }
});
// ── DreamStack Relay Streaming ──
let ws = null;
let frameCount = 0;
function connectRelay() {
try {
ws = new WebSocket('ws://localhost:9100/peer/pong');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
document.getElementById('stream-badge').textContent = '🔴 STREAMING';
document.getElementById('status').textContent = 'Connected to relay — game is streaming live!';
};
ws.onclose = () => {
document.getElementById('stream-badge').textContent = '⏳ Reconnecting…';
setTimeout(connectRelay, 3000);
};
ws.onerror = () => {
document.getElementById('status').textContent = 'Relay unavailable — playing offline. Start: cargo run -p ds-stream';
};
} catch (e) {
document.getElementById('status').textContent = 'Playing offline';
}
}
function broadcastState() {
if (!ws || ws.readyState !== 1) return;
frameCount++;
if (frameCount % 3 !== 0) return; // Throttle: every 3rd frame
const state = {
score1, score2, rally, maxRally, mode, serving,
p1y: Math.round(p1y), p2y: Math.round(p2y),
ballX: Math.round(ballX), ballY: Math.round(ballY),
speed: Math.round(Math.sqrt(ballVX * ballVX + ballVY * ballVY) * 10)
};
const json = JSON.stringify(state);
const enc = new TextEncoder();
const payload = enc.encode(json);
const frame = new Uint8Array(1 + payload.length);
frame[0] = 0x31;
frame.set(payload, 1);
ws.send(frame);
if (frameCount % 150 === 0) {
const sync = new Uint8Array(1 + payload.length);
sync[0] = 0x30;
sync.set(payload, 1);
ws.send(sync);
}
}
// ── Start ──
requestAnimationFrame(update);
connectRelay();
</script>
</body>
</html>

View file

@ -1,72 +1,109 @@
-- DreamStack Step Sequencer
-- Collaborative beat grid: two tabs, same pads, real-time sync
--
-- Run:
-- Tab 1: cargo run -p ds-stream (relay)
-- Tab 2: dreamstack dev examples/step-sequencer.ds (player 1)
-- Tab 3: dreamstack dev examples/step-sequencer.ds --port 3001 (player 2)
let bpm = 120
let step = 0
let playing = 1
-- 4 instruments × 8 steps = 32 pads (flat array, index with row*8+col)
let pads = [0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0]
-- 4 instruments × 16 steps = 64 pads
let kick = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let snare = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let hihat = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let bass = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-- Playhead advances every beat
every (60000 / bpm / 2) -> step = (step + 1) % 8
-- Playhead advances every beat (8th note = 60000 / bpm / 2)
every (60000 / bpm / 2) -> step = if playing then (step + 1) % 16 else step
-- Stream for multiplayer
stream sequencer on "ws://localhost:9100/source/beats" { mode: signal }
-- Stream for multiplayer collaboration
stream beats on "ws://localhost:9100/peer/beats" {
mode: signal,
output: bpm, step, playing, kick, snare, hihat, bass
}
view sequencer =
view beats =
column [
text "DreamStack Beats"
text "BPM: {bpm}"
text "🎹 DreamStack Beats" { variant: "title" }
text "Collaborative step sequencer — synced via bitstream relay" { variant: "subtitle" }
-- Kick (pads 0-7)
text "Kick"
-- Transport controls
row [
for i in [0, 1, 2, 3, 4, 5, 6, 7] ->
button (if pads[i] then "●" else "○") {
click: pads[i] = if pads[i] then 0 else 1
}
]
-- Snare (pads 8-15)
text "Snare"
row [
for i in [8, 9, 10, 11, 12, 13, 14, 15] ->
button (if pads[i] then "●" else "○") {
click: pads[i] = if pads[i] then 0 else 1
}
]
-- Hi-hat (pads 16-23)
text "HiHat"
row [
for i in [16, 17, 18, 19, 20, 21, 22, 23] ->
button (if pads[i] then "●" else "○") {
click: pads[i] = if pads[i] then 0 else 1
}
]
-- Bass (pads 24-31)
text "Bass"
row [
for i in [24, 25, 26, 27, 28, 29, 30, 31] ->
button (if pads[i] then "●" else "○") {
click: pads[i] = if pads[i] then 0 else 1
}
button (if playing then "⏸ Pause" else "▶ Play") {
click: playing = if playing then 0 else 1,
variant: "primary"
}
button "⏮ Reset" {
click: step = 0,
variant: "ghost"
}
text "BPM: {bpm}" { variant: "caption" }
button "" { click: bpm = if bpm > 40 then bpm - 10 else bpm, variant: "secondary" }
button "+" { click: bpm = if bpm < 300 then bpm + 10 else bpm, variant: "secondary" }
]
-- Playhead indicator
row [
for i in [0, 1, 2, 3, 4, 5, 6, 7] ->
text (if i == step then "▼" else "·")
text " " { variant: "muted" }
for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] ->
text (if i == step then "▼" else "·") { variant: "muted" }
]
-- BPM controls
-- Kick row
row [
button "10" { click: bpm -= 10 }
text "{bpm}"
button "+10" { click: bpm += 10 }
text "KICK " { variant: "caption" }
for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] ->
button (if kick[i] then "●" else "○") {
click: kick[i] = if kick[i] then 0 else 1,
variant: (if kick[i] then "primary" else "secondary")
}
]
-- Snare row
row [
text "SNRE " { variant: "caption" }
for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] ->
button (if snare[i] then "●" else "○") {
click: snare[i] = if snare[i] then 0 else 1,
variant: (if snare[i] then "primary" else "secondary")
}
]
-- HiHat row
row [
text "HHAT " { variant: "caption" }
for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] ->
button (if hihat[i] then "●" else "○") {
click: hihat[i] = if hihat[i] then 0 else 1,
variant: (if hihat[i] then "primary" else "secondary")
}
]
-- Bass row
row [
text "BASS " { variant: "caption" }
for i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] ->
button (if bass[i] then "●" else "○") {
click: bass[i] = if bass[i] then 0 else 1,
variant: (if bass[i] then "primary" else "secondary")
}
]
-- Presets
text "Presets" { variant: "caption" }
row [
button "Four on the Floor" {
click: kick[0] = 1; kick[4] = 1; kick[8] = 1; kick[12] = 1; snare[4] = 1; snare[12] = 1; hihat[0] = 1; hihat[2] = 1; hihat[4] = 1; hihat[6] = 1; hihat[8] = 1; hihat[10] = 1; hihat[12] = 1; hihat[14] = 1,
variant: "ghost"
}
button "Clear All" {
click: kick[0] = 0; kick[1] = 0; kick[2] = 0; kick[3] = 0; kick[4] = 0; kick[5] = 0; kick[6] = 0; kick[7] = 0; kick[8] = 0; kick[9] = 0; kick[10] = 0; kick[11] = 0; kick[12] = 0; kick[13] = 0; kick[14] = 0; kick[15] = 0; snare[0] = 0; snare[1] = 0; snare[2] = 0; snare[3] = 0; snare[4] = 0; snare[5] = 0; snare[6] = 0; snare[7] = 0; snare[8] = 0; snare[9] = 0; snare[10] = 0; snare[11] = 0; snare[12] = 0; snare[13] = 0; snare[14] = 0; snare[15] = 0; hihat[0] = 0; hihat[1] = 0; hihat[2] = 0; hihat[3] = 0; hihat[4] = 0; hihat[5] = 0; hihat[6] = 0; hihat[7] = 0; hihat[8] = 0; hihat[9] = 0; hihat[10] = 0; hihat[11] = 0; hihat[12] = 0; hihat[13] = 0; hihat[14] = 0; hihat[15] = 0; bass[0] = 0; bass[1] = 0; bass[2] = 0; bass[3] = 0; bass[4] = 0; bass[5] = 0; bass[6] = 0; bass[7] = 0; bass[8] = 0; bass[9] = 0; bass[10] = 0; bass[11] = 0; bass[12] = 0; bass[13] = 0; bass[14] = 0; bass[15] = 0,
variant: "destructive"
}
]
text "🔴 Streaming via ws://localhost:9100 — open another tab to collab!" { variant: "muted" }
]