Created
January 17, 2026 02:01
-
-
Save snoble/20bdf88f0749c7f56fdd12eb6f49a5de to your computer and use it in GitHub Desktop.
BurritoScript Deadweight Loss Demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Deadweight Loss - BurritoScript</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: #0f0f1a; | |
| color: #e4e4e7; | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 20px; | |
| } | |
| .container { | |
| max-width: 740px; | |
| width: 100%; | |
| } | |
| h1 { | |
| font-size: 1.5rem; | |
| margin-bottom: 16px; | |
| color: #a78bfa; | |
| } | |
| canvas { | |
| display: block; | |
| background: #1a1a2e; | |
| border-radius: 8px; | |
| width: 100%; | |
| height: auto; | |
| } | |
| .controls { | |
| margin-top: 16px; | |
| padding: 16px; | |
| background: #1a1a2e; | |
| border-radius: 8px; | |
| } | |
| .control-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| margin-bottom: 12px; | |
| } | |
| .control-row label { | |
| width: 100px; | |
| font-size: 14px; | |
| color: #a1a1aa; | |
| } | |
| .control-row input[type="range"] { | |
| flex: 1; | |
| accent-color: #a78bfa; | |
| } | |
| .control-row span { | |
| width: 50px; | |
| text-align: right; | |
| font-family: monospace; | |
| } | |
| .buttons { | |
| display: flex; | |
| gap: 8px; | |
| margin-top: 16px; | |
| } | |
| button { | |
| flex: 1; | |
| padding: 10px; | |
| border: none; | |
| border-radius: 6px; | |
| font-size: 14px; | |
| cursor: pointer; | |
| transition: opacity 0.15s; | |
| } | |
| button:hover { opacity: 0.9; } | |
| .btn-primary { background: #3b82f6; color: white; } | |
| .btn-secondary { background: #52525b; color: #e4e4e7; } | |
| .footer { | |
| margin-top: 16px; | |
| font-size: 12px; | |
| color: #71717a; | |
| text-align: center; | |
| } | |
| .footer a { color: #a78bfa; text-decoration: none; } | |
| .stats { | |
| position: absolute; | |
| top: 8px; | |
| left: 8px; | |
| font-size: 11px; | |
| color: rgba(255,255,255,0.5); | |
| font-family: monospace; | |
| } | |
| .canvas-wrapper { | |
| position: relative; | |
| } | |
| /* Provenance panel styles */ | |
| .provenance-toggle { | |
| position: absolute; | |
| top: 8px; | |
| right: 8px; | |
| font-size: 11px; | |
| padding: 4px 8px; | |
| background: rgba(167, 139, 250, 0.2); | |
| color: #a78bfa; | |
| border: 1px solid rgba(167, 139, 250, 0.3); | |
| border-radius: 4px; | |
| cursor: pointer; | |
| } | |
| .provenance-toggle.active { | |
| background: rgba(167, 139, 250, 0.4); | |
| border-color: #a78bfa; | |
| } | |
| .provenance-panel { | |
| margin-top: 16px; | |
| padding: 16px; | |
| background: #1a1a2e; | |
| border-radius: 8px; | |
| font-family: monospace; | |
| font-size: 12px; | |
| max-height: 300px; | |
| overflow-y: auto; | |
| } | |
| .provenance-panel.hidden { display: none; } | |
| .provenance-frame { | |
| margin-bottom: 12px; | |
| padding: 8px; | |
| background: rgba(0,0,0,0.3); | |
| border-radius: 4px; | |
| } | |
| .provenance-frame-header { | |
| color: #a78bfa; | |
| font-weight: bold; | |
| margin-bottom: 4px; | |
| } | |
| .provenance-read { color: #3b82f6; } | |
| .provenance-write { color: #22c55e; } | |
| .provenance-computation { color: #f59e0b; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Deadweight Loss</h1> | |
| <div class="canvas-wrapper"> | |
| <canvas id="canvas" width="700" height="500"></canvas> | |
| <div class="stats" id="stats"></div> | |
| <button class="provenance-toggle" id="provenance-toggle">π Provenance</button> | |
| </div> | |
| <div class="controls"> | |
| <div class="control-row"> | |
| <label>Tax</label> | |
| <input type="range" | |
| id="ctrl-taxPerUnit" | |
| min="0" | |
| max="80" | |
| step="5" | |
| value="25"> | |
| <span id="val-taxPerUnit">25</span> | |
| </div> | |
| <div class="buttons"> | |
| <button class="btn-secondary" id="reset">Reset</button> | |
| <button class="btn-primary" id="toggle">Pause</button> | |
| </div> | |
| </div> | |
| <div class="provenance-panel hidden" id="provenance-panel"><div id="provenance-content">Provenance tracking enabled. Traces will appear here.</div></div> | |
| <div class="footer"> | |
| Built with <a href="https://github.com/anthropics/burritoscript">BurritoScript</a> | |
| <br> | |
| <small>Effect analysis discovered: 4 state reads, 1 canvas bindings | Provenance mode enabled</small> | |
| </div> | |
| </div> | |
| <script> | |
| // ========================================================================== | |
| // STATE (from BurritoScript initialState) | |
| // ========================================================================== | |
| let state = { | |
| "goodName": "Concert Ticket", | |
| "goodEmoji": "π«", | |
| "buyers": [ | |
| { | |
| "id": 1, | |
| "name": "Super Fan", | |
| "emoji": "π€©", | |
| "maxWillingToPay": 200, | |
| "reason": "Must see this show!", | |
| "x": 87, | |
| "y": 100, | |
| "targetX": 87, | |
| "targetY": 100, | |
| "state": "wandering", | |
| "happiness": 0 | |
| }, | |
| { | |
| "id": 2, | |
| "name": "Gift Buyer", | |
| "emoji": "π", | |
| "maxWillingToPay": 150, | |
| "reason": "Perfect birthday gift", | |
| "x": 124, | |
| "y": 180, | |
| "targetX": 124, | |
| "targetY": 180, | |
| "state": "wandering", | |
| "happiness": 0 | |
| }, | |
| { | |
| "id": 3, | |
| "name": "Casual", | |
| "emoji": "π", | |
| "maxWillingToPay": 100, | |
| "reason": "Sounds fun", | |
| "x": 61, | |
| "y": 260, | |
| "targetX": 61, | |
| "targetY": 260, | |
| "state": "wandering", | |
| "happiness": 0 | |
| }, | |
| { | |
| "id": 4, | |
| "name": "Curious", | |
| "emoji": "π€", | |
| "maxWillingToPay": 60, | |
| "reason": "Maybe if cheap", | |
| "x": 98, | |
| "y": 340, | |
| "targetX": 98, | |
| "targetY": 340, | |
| "state": "wandering", | |
| "happiness": 0 | |
| }, | |
| { | |
| "id": 5, | |
| "name": "Hesitant", | |
| "emoji": "π", | |
| "maxWillingToPay": 35, | |
| "reason": "Only if a steal", | |
| "x": 135, | |
| "y": 420, | |
| "targetX": 135, | |
| "targetY": 420, | |
| "state": "wandering", | |
| "happiness": 0 | |
| } | |
| ], | |
| "sellers": [ | |
| { | |
| "id": 1, | |
| "name": "Urgent", | |
| "emoji": "π°", | |
| "minWillingToAccept": 20, | |
| "reason": "Can't go, need cash", | |
| "x": 593, | |
| "y": 100, | |
| "targetX": 593, | |
| "targetY": 100, | |
| "state": "wandering", | |
| "happiness": 0 | |
| }, | |
| { | |
| "id": 2, | |
| "name": "Flexible", | |
| "emoji": "π", | |
| "minWillingToAccept": 50, | |
| "reason": "Rather have money", | |
| "x": 636, | |
| "y": 180, | |
| "targetX": 636, | |
| "targetY": 180, | |
| "state": "wandering", | |
| "happiness": 0 | |
| }, | |
| { | |
| "id": 3, | |
| "name": "Fair", | |
| "emoji": "π°", | |
| "minWillingToAccept": 80, | |
| "reason": "Want face value", | |
| "x": 579, | |
| "y": 260, | |
| "targetX": 579, | |
| "targetY": 260, | |
| "state": "wandering", | |
| "happiness": 0 | |
| }, | |
| { | |
| "id": 4, | |
| "name": "Hopeful", | |
| "emoji": "π€", | |
| "minWillingToAccept": 130, | |
| "reason": "Hoping for profit", | |
| "x": 622, | |
| "y": 340, | |
| "targetX": 622, | |
| "targetY": 340, | |
| "state": "wandering", | |
| "happiness": 0 | |
| }, | |
| { | |
| "id": 5, | |
| "name": "Firm", | |
| "emoji": "π€", | |
| "minWillingToAccept": 190, | |
| "reason": "Won't go lower", | |
| "x": 565, | |
| "y": 420, | |
| "targetX": 565, | |
| "targetY": 420, | |
| "state": "wandering", | |
| "happiness": 0 | |
| } | |
| ], | |
| "taxPerUnit": 25, | |
| "time": 0, | |
| "phase": "gathering", | |
| "activePairIndex": -1, | |
| "pairs": [], | |
| "completedDeals": 0, | |
| "blockedDeals": 0, | |
| "totalHappiness": 0, | |
| "happinessLost": 0, | |
| "taxCollected": 0, | |
| "width": 700, | |
| "height": 500, | |
| "running": true | |
| }; | |
| const initialState = {"goodName":"Concert Ticket","goodEmoji":"π«","buyers":[{"id":1,"name":"Super Fan","emoji":"π€©","maxWillingToPay":200,"reason":"Must see this show!","x":87,"y":100,"targetX":87,"targetY":100,"state":"wandering","happiness":0},{"id":2,"name":"Gift Buyer","emoji":"π","maxWillingToPay":150,"reason":"Perfect birthday gift","x":124,"y":180,"targetX":124,"targetY":180,"state":"wandering","happiness":0},{"id":3,"name":"Casual","emoji":"π","maxWillingToPay":100,"reason":"Sounds fun","x":61,"y":260,"targetX":61,"targetY":260,"state":"wandering","happiness":0},{"id":4,"name":"Curious","emoji":"π€","maxWillingToPay":60,"reason":"Maybe if cheap","x":98,"y":340,"targetX":98,"targetY":340,"state":"wandering","happiness":0},{"id":5,"name":"Hesitant","emoji":"π","maxWillingToPay":35,"reason":"Only if a steal","x":135,"y":420,"targetX":135,"targetY":420,"state":"wandering","happiness":0}],"sellers":[{"id":1,"name":"Urgent","emoji":"π°","minWillingToAccept":20,"reason":"Can't go, need cash","x":593,"y":100,"targetX":593,"targetY":100,"state":"wandering","happiness":0},{"id":2,"name":"Flexible","emoji":"π","minWillingToAccept":50,"reason":"Rather have money","x":636,"y":180,"targetX":636,"targetY":180,"state":"wandering","happiness":0},{"id":3,"name":"Fair","emoji":"π°","minWillingToAccept":80,"reason":"Want face value","x":579,"y":260,"targetX":579,"targetY":260,"state":"wandering","happiness":0},{"id":4,"name":"Hopeful","emoji":"π€","minWillingToAccept":130,"reason":"Hoping for profit","x":622,"y":340,"targetX":622,"targetY":340,"state":"wandering","happiness":0},{"id":5,"name":"Firm","emoji":"π€","minWillingToAccept":190,"reason":"Won't go lower","x":565,"y":420,"targetX":565,"targetY":420,"state":"wandering","happiness":0}],"taxPerUnit":25,"time":0,"phase":"gathering","activePairIndex":-1,"pairs":[],"completedDeals":0,"blockedDeals":0,"totalHappiness":0,"happinessLost":0,"taxCollected":0,"width":700,"height":500,"running":true}; | |
| // ========================================================================== | |
| // SUBSCRIPTIONS (discovered by Effect analysis) | |
| // ========================================================================== | |
| const subscriptions = new Map([ | |
| ['ball', ["main"]], | |
| ['config', ["main"]] | |
| ]); | |
| // ========================================================================== | |
| // HELPERS | |
| // ========================================================================== | |
| const getPath = (obj, path) => path.reduce((acc, key) => acc?.[key], obj); | |
| const setPath = (obj, path, value) => { | |
| if (path.length === 0) return value; | |
| if (path.length === 1) return { ...obj, [path[0]]: value }; | |
| return { ...obj, [path[0]]: setPath(obj[path[0]] ?? {}, path.slice(1), value) }; | |
| }; | |
| // ========================================================================== | |
| // PROVENANCE RUNTIME (development mode) | |
| // ========================================================================== | |
| const __prov = { | |
| traces: [], | |
| currentFrame: null, | |
| enabled: false, | |
| startFrame() { | |
| if (!this.enabled) return; | |
| this.currentFrame = { | |
| timestamp: performance.now(), | |
| reads: [], | |
| writes: [], | |
| }; | |
| }, | |
| endFrame() { | |
| if (!this.enabled || !this.currentFrame) return; | |
| this.traces.push(this.currentFrame); | |
| if (this.traces.length > 10) this.traces.shift(); | |
| this.currentFrame = null; | |
| this._updatePanel(); | |
| }, | |
| trackRead(path, value) { | |
| if (!this.enabled || !this.currentFrame) return; | |
| this.currentFrame.reads.push({ path, value: this._snapshot(value) }); | |
| }, | |
| trackWrite(path, oldValue, newValue) { | |
| if (!this.enabled || !this.currentFrame) return; | |
| this.currentFrame.writes.push({ | |
| path, | |
| oldValue: this._snapshot(oldValue), | |
| newValue: this._snapshot(newValue) | |
| }); | |
| }, | |
| _snapshot(v) { | |
| try { return JSON.parse(JSON.stringify(v)); } | |
| catch { return '<non-serializable>'; } | |
| }, | |
| _updatePanel() { | |
| const el = document.getElementById('provenance-content'); | |
| if (!el) return; | |
| const frames = this.traces.slice(-5).reverse(); | |
| if (frames.length === 0) { | |
| el.innerHTML = 'No traces yet. Interact with the simulation.'; | |
| return; | |
| } | |
| el.innerHTML = frames.map((f, i) => { | |
| const reads = f.reads.map(r => | |
| '<div class="provenance-read">READ: ' + r.path + ' = ' + JSON.stringify(r.value).slice(0, 50) + '</div>' | |
| ).join(''); | |
| const writes = f.writes.map(w => | |
| '<div class="provenance-write">WRITE: ' + w.path + ' ' + JSON.stringify(w.oldValue).slice(0, 20) + ' β ' + JSON.stringify(w.newValue).slice(0, 20) + '</div>' | |
| ).join(''); | |
| return '<div class="provenance-frame"><div class="provenance-frame-header">Frame ' + (frames.length - i) + '</div>' + reads + writes + '</div>'; | |
| }).join(''); | |
| }, | |
| toggle() { | |
| this.enabled = !this.enabled; | |
| const btn = document.getElementById('provenance-toggle'); | |
| const panel = document.getElementById('provenance-panel'); | |
| if (btn) btn.classList.toggle('active', this.enabled); | |
| if (panel) panel.classList.toggle('hidden', !this.enabled); | |
| if (this.enabled) this._updatePanel(); | |
| } | |
| }; | |
| document.getElementById('provenance-toggle')?.addEventListener('click', () => __prov.toggle()); | |
| // ========================================================================== | |
| // UPDATE FUNCTION (pure, from .burrito.ts) | |
| // ========================================================================== | |
| // Helper functions | |
| const SPEED = 3; | |
| const WANDER_RADIUS = 30; | |
| const moveToward = (person, speed) => { | |
| const dx = person.targetX - person.x; | |
| const dy = person.targetY - person.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist < speed) { | |
| return { ...person, x: person.targetX, y: person.targetY }; | |
| } | |
| return { | |
| ...person, | |
| x: person.x + (dx / dist) * speed, | |
| y: person.y + (dy / dist) * speed, | |
| }; | |
| }; | |
| const atTarget = (person) => { | |
| const dx = person.targetX - person.x; | |
| const dy = person.targetY - person.y; | |
| return Math.sqrt(dx * dx + dy * dy) < 5; | |
| }; | |
| const calculatePairs = (buyers, sellers, tax, width, height) => { | |
| const sortedBuyers = [...buyers].sort((a, b) => b.maxWillingToPay - a.maxWillingToPay); | |
| const sortedSellers = [...sellers].sort((a, b) => a.minWillingToAccept - b.minWillingToAccept); | |
| const result = sortedBuyers.reduce( | |
| (acc, buyer) => { | |
| const availableSeller = sortedSellers.find( | |
| seller => | |
| !acc.usedSellerIds.has(seller.id) && | |
| buyer.maxWillingToPay - seller.minWillingToAccept >= 0 | |
| ); | |
| if (!availableSeller) return acc; | |
| const surplus = buyer.maxWillingToPay - availableSeller.minWillingToAccept; | |
| const pairIndex = acc.pairs.length; | |
| const newPair = { | |
| buyer, | |
| seller: availableSeller, | |
| surplus, | |
| willTrade: surplus >= tax, | |
| meetingX: width / 2 + (pairIndex - 2) * 60, | |
| meetingY: 120 + pairIndex * 70, | |
| resolved: false, | |
| }; | |
| return { | |
| pairs: [...acc.pairs, newPair], | |
| usedSellerIds: new Set([...acc.usedSellerIds, availableSeller.id]), | |
| }; | |
| }, | |
| { pairs: [], usedSellerIds: new Set() } | |
| ); | |
| return result.pairs; | |
| }; | |
| function update(state, dt) { | |
| if (!state.running) return state; | |
| const newTime = state.time + dt; | |
| // Phase transitions based on time | |
| if (state.phase === 'gathering' && newTime > 1) { | |
| const pairs = calculatePairs(state.buyers, state.sellers, state.taxPerUnit, state.width, state.height); | |
| return { | |
| ...state, | |
| time: newTime, | |
| phase: 'matching', | |
| pairs, | |
| activePairIndex: 0, | |
| buyers: state.buyers.map(b => ({ ...b, state: 'walking' })), | |
| sellers: state.sellers.map(s => ({ ...s, state: 'walking' })), | |
| }; | |
| } | |
| if (state.phase === 'matching') { | |
| const currentPair = state.pairs[state.activePairIndex]; | |
| if (!currentPair) { | |
| return { ...state, time: newTime, phase: 'aftermath' }; | |
| } | |
| const movedBuyers = state.buyers.map(b => { | |
| if (b.id === currentPair.buyer.id) { | |
| const updated = moveToward( | |
| { ...b, targetX: currentPair.meetingX - 40, targetY: currentPair.meetingY }, | |
| SPEED | |
| ); | |
| return { ...updated, state: 'walking' }; | |
| } | |
| return b; | |
| }); | |
| const movedSellers = state.sellers.map(s => { | |
| if (s.id === currentPair.seller.id) { | |
| const updated = moveToward( | |
| { ...s, targetX: currentPair.meetingX + 40, targetY: currentPair.meetingY }, | |
| SPEED | |
| ); | |
| return { ...updated, state: 'walking' }; | |
| } | |
| return s; | |
| }); | |
| const buyer = movedBuyers.find(b => b.id === currentPair.buyer.id); | |
| const seller = movedSellers.find(s => s.id === currentPair.seller.id); | |
| const buyerArrived = Math.abs(buyer.x - (currentPair.meetingX - 40)) < 5; | |
| const sellerArrived = Math.abs(seller.x - (currentPair.meetingX + 40)) < 5; | |
| if (buyerArrived && sellerArrived) { | |
| const negotiatingBuyers = movedBuyers.map(b => | |
| b.id === currentPair.buyer.id ? { ...b, state: 'negotiating' } : b | |
| ); | |
| const negotiatingSellers = movedSellers.map(s => | |
| s.id === currentPair.seller.id ? { ...s, state: 'negotiating' } : s | |
| ); | |
| return { | |
| ...state, | |
| time: newTime, | |
| phase: 'negotiating', | |
| buyers: negotiatingBuyers, | |
| sellers: negotiatingSellers, | |
| }; | |
| } | |
| return { ...state, time: newTime, buyers: movedBuyers, sellers: movedSellers }; | |
| } | |
| if (state.phase === 'negotiating') { | |
| if (newTime - state.time > 0.8) { | |
| return { ...state, time: newTime, phase: 'resolving' }; | |
| } | |
| return { ...state, time: newTime }; | |
| } | |
| if (state.phase === 'resolving') { | |
| const currentPair = state.pairs[state.activePairIndex]; | |
| if (!currentPair) { | |
| return { ...state, time: newTime, phase: 'aftermath' }; | |
| } | |
| const willTrade = currentPair.willTrade; | |
| const afterTaxSurplus = currentPair.surplus - state.taxPerUnit; | |
| const perPersonHappiness = willTrade ? afterTaxSurplus / 2 : 0; | |
| const newTotalHappiness = willTrade ? state.totalHappiness + afterTaxSurplus : state.totalHappiness; | |
| const newHappinessLost = willTrade ? state.happinessLost : state.happinessLost + currentPair.surplus; | |
| const newTaxCollected = willTrade ? state.taxCollected + state.taxPerUnit : state.taxCollected; | |
| const newCompletedDeals = willTrade ? state.completedDeals + 1 : state.completedDeals; | |
| const newBlockedDeals = willTrade ? state.blockedDeals : state.blockedDeals + 1; | |
| const buyers = state.buyers.map(b => { | |
| if (b.id === currentPair.buyer.id) { | |
| return { | |
| ...b, | |
| state: willTrade ? 'happy' : 'sad', | |
| happiness: perPersonHappiness, | |
| targetX: 50 + (currentPair.buyer.id * 7) % 80, | |
| targetY: b.y, | |
| }; | |
| } | |
| return b; | |
| }); | |
| const sellers = state.sellers.map(s => { | |
| if (s.id === currentPair.seller.id) { | |
| return { | |
| ...s, | |
| state: willTrade ? 'happy' : 'sad', | |
| happiness: perPersonHappiness, | |
| targetX: state.width - 130 + (currentPair.seller.id * 13) % 80, | |
| targetY: s.y, | |
| }; | |
| } | |
| return s; | |
| }); | |
| const pairs = state.pairs.map((p, i) => | |
| i === state.activePairIndex ? { ...p, resolved: true } : p | |
| ); | |
| const nextIndex = state.activePairIndex + 1; | |
| if (nextIndex >= pairs.length) { | |
| return { | |
| ...state, | |
| time: newTime, | |
| phase: 'aftermath', | |
| pairs, | |
| buyers, | |
| sellers, | |
| activePairIndex: nextIndex, | |
| totalHappiness: newTotalHappiness, | |
| happinessLost: newHappinessLost, | |
| taxCollected: newTaxCollected, | |
| completedDeals: newCompletedDeals, | |
| blockedDeals: newBlockedDeals, | |
| }; | |
| } | |
| return { | |
| ...state, | |
| time: newTime, | |
| phase: 'matching', | |
| pairs, | |
| buyers, | |
| sellers, | |
| activePairIndex: nextIndex, | |
| totalHappiness: newTotalHappiness, | |
| happinessLost: newHappinessLost, | |
| taxCollected: newTaxCollected, | |
| completedDeals: newCompletedDeals, | |
| blockedDeals: newBlockedDeals, | |
| }; | |
| } | |
| if (state.phase === 'aftermath') { | |
| const buyers = state.buyers.map((b) => { | |
| const updated = moveToward(b, SPEED * 0.5); | |
| if (atTarget(updated) && updated.state !== 'idle') { | |
| const wanderX = ((b.id * 17 + 5) % WANDER_RADIUS) - WANDER_RADIUS / 2; | |
| const wanderY = ((b.id * 23 + 7) % WANDER_RADIUS) - WANDER_RADIUS / 2; | |
| return { | |
| ...updated, | |
| state: 'idle', | |
| targetX: updated.x + wanderX, | |
| targetY: updated.y + wanderY, | |
| }; | |
| } | |
| return updated; | |
| }); | |
| const sellers = state.sellers.map((s) => { | |
| const updated = moveToward(s, SPEED * 0.5); | |
| if (atTarget(updated) && updated.state !== 'idle') { | |
| const wanderX = ((s.id * 19 + 3) % WANDER_RADIUS) - WANDER_RADIUS / 2; | |
| const wanderY = ((s.id * 29 + 11) % WANDER_RADIUS) - WANDER_RADIUS / 2; | |
| return { | |
| ...updated, | |
| state: 'idle', | |
| targetX: updated.x + wanderX, | |
| targetY: updated.y + wanderY, | |
| }; | |
| } | |
| return updated; | |
| }); | |
| return { ...state, time: newTime, buyers, sellers }; | |
| } | |
| return { ...state, time: newTime }; | |
| } | |
| // ========================================================================== | |
| // RENDER FUNCTION (pure, from .burrito.ts) | |
| // ========================================================================== | |
| const EMOJI_SIZE = 32; | |
| const drawPerson = (ctx, x, y, emoji, name, value, valueLabel, valueColor, state, happiness) => { | |
| if (state === 'happy') { | |
| ctx.shadowColor = '#22c55e'; | |
| ctx.shadowBlur = 15; | |
| } else if (state === 'sad') { | |
| ctx.shadowColor = '#ef4444'; | |
| ctx.shadowBlur = 15; | |
| } else if (state === 'negotiating') { | |
| ctx.shadowColor = '#eab308'; | |
| ctx.shadowBlur = 10; | |
| } | |
| ctx.font = EMOJI_SIZE + 'px sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(emoji, x, y); | |
| ctx.shadowBlur = 0; | |
| ctx.font = 'bold 11px sans-serif'; | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillText(name, x, y + 28); | |
| ctx.font = '10px sans-serif'; | |
| ctx.fillStyle = valueColor; | |
| ctx.fillText(valueLabel + ': $' + value, x, y + 42); | |
| if (happiness > 0) { | |
| ctx.fillStyle = '#22c55e'; | |
| ctx.font = 'bold 10px sans-serif'; | |
| ctx.fillText('+$' + happiness.toFixed(0), x, y - 25); | |
| } else if (state === 'sad') { | |
| ctx.fillStyle = '#ef4444'; | |
| ctx.font = '16px sans-serif'; | |
| ctx.fillText('π’', x, y - 25); | |
| } | |
| }; | |
| const drawNegotiation = (ctx, pair, phase, tax) => { | |
| const { buyer, seller, surplus, willTrade, meetingX, meetingY } = pair; | |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; | |
| ctx.beginPath(); | |
| ctx.roundRect(meetingX - 80, meetingY - 70, 160, 50, 8); | |
| ctx.fill(); | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = '12px sans-serif'; | |
| ctx.textAlign = 'center'; | |
| if (phase === 'negotiating') { | |
| ctx.fillText('Buyer: "I\'ll pay up to $' + buyer.maxWillingToPay + '"', meetingX, meetingY - 55); | |
| ctx.fillText('Seller: "I need at least $' + seller.minWillingToAccept + '"', meetingX, meetingY - 38); | |
| } else if (phase === 'resolving' || pair.resolved) { | |
| if (willTrade) { | |
| ctx.fillStyle = '#22c55e'; | |
| ctx.font = 'bold 14px sans-serif'; | |
| ctx.fillText('π€ DEAL!', meetingX, meetingY - 55); | |
| ctx.font = '11px sans-serif'; | |
| ctx.fillStyle = '#ffffff'; | |
| const price = seller.minWillingToAccept + (surplus - tax) / 2; | |
| ctx.fillText('Price: $' + price.toFixed(0) + ' (+$' + tax + ' tax)', meetingX, meetingY - 38); | |
| } else { | |
| ctx.fillStyle = '#ef4444'; | |
| ctx.font = 'bold 14px sans-serif'; | |
| ctx.fillText('β NO DEAL', meetingX, meetingY - 55); | |
| ctx.font = '11px sans-serif'; | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillText('$' + tax + ' tax > $' + surplus + ' benefit', meetingX, meetingY - 38); | |
| } | |
| } | |
| }; | |
| function render(ctx, state) { | |
| const { width, height, buyers, sellers, pairs, phase, activePairIndex, taxPerUnit } = state; | |
| // Background | |
| const gradient = ctx.createLinearGradient(0, 0, 0, height); | |
| gradient.addColorStop(0, '#1a1a2e'); | |
| gradient.addColorStop(1, '#16213e'); | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(0, 0, width, height); | |
| // Ground | |
| ctx.fillStyle = '#0f3460'; | |
| ctx.fillRect(0, height - 60, width, 60); | |
| // Market stalls | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; | |
| ctx.lineWidth = 2; | |
| [0, 1, 2, 3, 4].forEach(i => { | |
| ctx.beginPath(); | |
| ctx.moveTo(60 + i * 140, height - 60); | |
| ctx.lineTo(60 + i * 140, height - 100); | |
| ctx.stroke(); | |
| }); | |
| // Title | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = 'bold 20px sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(state.goodEmoji + ' ' + state.goodName + ' Market ' + state.goodEmoji, width / 2, 30); | |
| // Tax indicator | |
| ctx.font = '14px sans-serif'; | |
| ctx.fillStyle = '#eab308'; | |
| ctx.fillText('Transaction Tax: $' + taxPerUnit + ' per sale', width / 2, 52); | |
| // Side labels | |
| ctx.font = 'bold 14px sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillStyle = '#3b82f6'; | |
| ctx.fillText('BUYERS β', 20, 75); | |
| ctx.textAlign = 'right'; | |
| ctx.fillStyle = '#22c55e'; | |
| ctx.fillText('β SELLERS', width - 20, 75); | |
| // Draw connecting lines for active/resolved pairs | |
| pairs.forEach((pair, i) => { | |
| if (i <= activePairIndex || pair.resolved) { | |
| const buyer = buyers.find(b => b.id === pair.buyer.id); | |
| const seller = sellers.find(s => s.id === pair.seller.id); | |
| ctx.strokeStyle = pair.willTrade | |
| ? 'rgba(34, 197, 94, 0.3)' | |
| : 'rgba(239, 68, 68, 0.3)'; | |
| ctx.lineWidth = pair.resolved ? 2 : 3; | |
| ctx.setLineDash(pair.willTrade ? [] : [5, 5]); | |
| ctx.beginPath(); | |
| ctx.moveTo(buyer.x, buyer.y); | |
| ctx.lineTo(seller.x, seller.y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } | |
| }); | |
| // Draw negotiation bubbles | |
| const currentPair = pairs[activePairIndex]; | |
| if (currentPair && (phase === 'negotiating' || phase === 'resolving')) { | |
| drawNegotiation(ctx, currentPair, phase, taxPerUnit); | |
| } | |
| // Draw buyers | |
| buyers.forEach(buyer => { | |
| drawPerson( | |
| ctx, | |
| buyer.x, | |
| buyer.y, | |
| buyer.emoji, | |
| buyer.name, | |
| buyer.maxWillingToPay, | |
| 'Max', | |
| '#3b82f6', | |
| buyer.state, | |
| buyer.happiness | |
| ); | |
| }); | |
| // Draw sellers | |
| sellers.forEach(seller => { | |
| drawPerson( | |
| ctx, | |
| seller.x, | |
| seller.y, | |
| seller.emoji, | |
| seller.name, | |
| seller.minWillingToAccept, | |
| 'Min', | |
| '#22c55e', | |
| seller.state, | |
| seller.happiness | |
| ); | |
| }); | |
| // Results panel at bottom | |
| if (phase === 'aftermath' || state.completedDeals + state.blockedDeals > 0) { | |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; | |
| ctx.fillRect(10, height - 55, width - 20, 50); | |
| ctx.font = 'bold 13px sans-serif'; | |
| ctx.textAlign = 'left'; | |
| // Completed deals | |
| ctx.fillStyle = '#22c55e'; | |
| ctx.fillText('β ' + state.completedDeals + ' deals', 25, height - 35); | |
| ctx.font = '11px sans-serif'; | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillText('Happiness: +$' + state.totalHappiness.toFixed(0), 25, height - 18); | |
| // Tax collected | |
| ctx.textAlign = 'center'; | |
| ctx.font = 'bold 13px sans-serif'; | |
| ctx.fillStyle = '#eab308'; | |
| ctx.fillText('π° Tax: $' + state.taxCollected.toFixed(0), width / 2, height - 35); | |
| ctx.font = '11px sans-serif'; | |
| ctx.fillStyle = '#71717a'; | |
| ctx.fillText('(goes to government)', width / 2, height - 18); | |
| // Blocked deals | |
| ctx.textAlign = 'right'; | |
| ctx.font = 'bold 13px sans-serif'; | |
| ctx.fillStyle = '#ef4444'; | |
| ctx.fillText('β ' + state.blockedDeals + ' blocked', width - 25, height - 35); | |
| ctx.font = '11px sans-serif'; | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillText('Lost: $' + state.happinessLost.toFixed(0) + ' π', width - 25, height - 18); | |
| } | |
| } | |
| // ========================================================================== | |
| // ANIMATION LOOP | |
| // ========================================================================== | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const statsEl = document.getElementById('stats'); | |
| let running = true; | |
| let lastTime = performance.now(); | |
| let frameCount = 0; | |
| let fps = 0; | |
| let lastFpsUpdate = lastTime; | |
| function animate(now) { | |
| const dt = Math.min((now - lastTime) / 1000, 0.1); | |
| lastTime = now; | |
| frameCount++; | |
| if (now - lastFpsUpdate > 1000) { | |
| fps = frameCount; | |
| frameCount = 0; | |
| lastFpsUpdate = now; | |
| } | |
| if (running && state.running !== false) { | |
| __prov.startFrame(); | |
| __prov.trackRead('state', state); | |
| const oldState = state; | |
| state = update(state, dt); | |
| // Track state changes | |
| for (const key of Object.keys(state)) { | |
| if (oldState[key] !== state[key]) { | |
| __prov.trackWrite(key, oldState[key], state[key]); | |
| } | |
| } | |
| __prov.endFrame(); | |
| } | |
| render(ctx, state); | |
| statsEl.textContent = fps + ' fps'; | |
| requestAnimationFrame(animate); | |
| } | |
| // ========================================================================== | |
| // CONTROLS | |
| // ========================================================================== | |
| document.getElementById('ctrl-taxPerUnit').addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| // Reset simulation with new value | |
| state = setPath(JSON.parse(JSON.stringify(initialState)), ["taxPerUnit"], value); | |
| document.getElementById('val-taxPerUnit').textContent = value; | |
| running = true; | |
| document.getElementById('toggle').textContent = 'Pause'; | |
| document.getElementById('toggle').className = 'btn-primary'; | |
| render(ctx, state); | |
| }); | |
| document.getElementById('toggle').addEventListener('click', function() { | |
| running = !running; | |
| this.textContent = running ? 'Pause' : 'Resume'; | |
| this.className = running ? 'btn-primary' : 'btn-secondary'; | |
| }); | |
| document.getElementById('reset').addEventListener('click', function() { | |
| state = JSON.parse(JSON.stringify(initialState)); | |
| running = true; | |
| document.getElementById('toggle').textContent = 'Pause'; | |
| document.getElementById('toggle').className = 'btn-primary'; | |
| }); | |
| // Start | |
| requestAnimationFrame(animate); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment