fix: Drizzle transaction execution, canvas re-init, crowd removal

- Add .run() to all Drizzle queries inside sqlite.transaction() in
  orchestrator.ts and mock.ts — queries were building but never executing,
  leaving fights stuck as 'live' forever
- Remove spectator crowd from drawArenaDecor()
- Rename duplicate spawnProp to spawnWeaponProp (was crashing module load)
- Replace canvas element on re-init to avoid "KAPLAY already initialized"
- Add safeText() to strip brackets from k.text() calls (Kaplay treats
  [ ] as style markup, crashes on unclosed tags)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 00:53:15 +00:00
co-authored by Claude Opus 4.6
parent 4d8b18a58a
commit e49735002c
4 changed files with 31 additions and 180 deletions
+9 -3
View File
@@ -90,13 +90,19 @@ onUnmounted(() => {
async function initScene() {
if (!canvasRef.value || !props.fight.botA || !props.fight.botB) return
// Destroy previous scene to avoid "KAPLAY already initialized" warning
// Destroy previous scene fully — replace canvas to avoid "KAPLAY already initialized"
if (scene) { scene.destroy(); scene = null }
const container = canvasRef.value.parentElement
if (container) {
canvasRef.value.width = container.clientWidth
canvasRef.value.height = container.clientHeight
// Replace canvas element so Kaplay gets a fresh context
const oldCanvas = canvasRef.value
const newCanvas = document.createElement('canvas')
newCanvas.className = oldCanvas.className
newCanvas.width = container.clientWidth
newCanvas.height = container.clientHeight
oldCanvas.replaceWith(newCanvas)
canvasRef.value = newCanvas
}
scene = await createFightScene({
+11 -166
View File
@@ -1414,164 +1414,6 @@ export async function createFightScene(config: FightSceneConfig) {
}
}
}
// === SPECTATORS — multi-row pixel-art crowd ===
const skinTones = ['#e8c49a', '#d4a574', '#c68c53', '#a0704a', '#7a5230', '#f5deb3', '#deb887', '#cc9966']
const shirtColors = ['#cc3333', '#3366cc', '#33aa33', '#cc9933', '#9933cc', '#cc3399', '#33aaaa', '#ee6622',
'#5555dd', '#dd5555', '#55bb55', '#dd8822', '#aa44aa', '#44aaaa', '#ddaa33', '#cc5588']
const hatColors = ['#222222', '#443322', '#663333', '#224466', '#336633', '#882222', '#444488', '#884400']
const hairColors = ['#1a1a1a', '#3a2210', '#6b3a14', '#8b6340', '#c4a060', '#ddb870', '#883322', '#aa4422']
// 3 rows of crowd — back row (small/faded), middle row, front row
const rows = [
{ y: GROUND_Y - 22, count: 14 + Math.floor(Math.random() * 4), scale: 0.6, opacity: 0.35, z: 4 },
{ y: GROUND_Y - 13, count: 12 + Math.floor(Math.random() * 4), scale: 0.8, opacity: 0.5, z: 5 },
{ y: GROUND_Y - 3, count: 10 + Math.floor(Math.random() * 3), scale: 1.0, opacity: 0.65, z: 6 },
]
for (const row of rows) {
for (let i = 0; i < row.count; i++) {
const sx = 15 + (i / (row.count - 1)) * (W - 30) + (Math.random() - 0.5) * 12
const sy = row.y + (Math.random() - 0.5) * 4
const sc = row.scale
const op = row.opacity
const skin = skinTones[Math.floor(Math.random() * skinTones.length)]
const shirt = shirtColors[Math.floor(Math.random() * shirtColors.length)]
const bobSpeed = 1.2 + Math.random() * 2.5
const bobPhase = Math.random() * Math.PI * 2
const bobAmt = (0.8 + Math.random() * 1.5) * sc
// Torso
const torsoW = Math.round(7 * sc)
const torsoH = Math.round(9 * sc)
const torso = k.add([
k.rect(torsoW, torsoH), k.pos(sx, sy - torsoH),
k.color(safeColor(k,shirt)), k.opacity(op), k.z(row.z),
])
// Head
const headR = Math.round(4 * sc)
const head = k.add([
k.circle(headR), k.pos(sx + torsoW / 2, sy - torsoH - headR + 1),
k.anchor('center'),
k.color(safeColor(k,skin)), k.opacity(op), k.z(row.z),
])
// Hair or hat (70% chance)
let accessory: any = null
if (Math.random() < 0.7) {
if (Math.random() < 0.4) {
// Hat
const hatC = hatColors[Math.floor(Math.random() * hatColors.length)]
accessory = k.add([
k.rect(Math.round(10 * sc), Math.round(3 * sc)),
k.pos(sx + torsoW / 2 - Math.round(5 * sc), sy - torsoH - headR * 2),
k.color(safeColor(k,hatC)), k.opacity(op), k.z(row.z + 0.1),
])
} else {
// Hair tuft
const hairC = hairColors[Math.floor(Math.random() * hairColors.length)]
accessory = k.add([
k.rect(Math.round(6 * sc), Math.round(4 * sc)),
k.pos(sx + torsoW / 2 - Math.round(3 * sc), sy - torsoH - headR * 2 + Math.round(sc)),
k.color(safeColor(k,hairC)), k.opacity(op * 0.9), k.z(row.z + 0.1),
])
}
}
// Arms — little rectangles on each side
const armW = Math.round(2 * sc)
const armH = Math.round(6 * sc)
const armL = k.add([
k.rect(armW, armH), k.pos(sx - armW, sy - torsoH + Math.round(2 * sc)),
k.color(safeColor(k,shirt)), k.opacity(op * 0.9), k.z(row.z - 0.1),
])
const armR = k.add([
k.rect(armW, armH), k.pos(sx + torsoW, sy - torsoH + Math.round(2 * sc)),
k.color(safeColor(k,shirt)), k.opacity(op * 0.9), k.z(row.z - 0.1),
])
// Hands (skin-colored dots at arm tips)
const handL = k.add([
k.circle(Math.max(1, Math.round(1.5 * sc))),
k.pos(sx - armW + Math.round(sc), sy - torsoH + Math.round(2 * sc) + armH),
k.anchor('center'),
k.color(safeColor(k,skin)), k.opacity(op * 0.8), k.z(row.z - 0.1),
])
const handR = k.add([
k.circle(Math.max(1, Math.round(1.5 * sc))),
k.pos(sx + torsoW + Math.round(sc), sy - torsoH + Math.round(2 * sc) + armH),
k.anchor('center'),
k.color(safeColor(k,skin)), k.opacity(op * 0.8), k.z(row.z - 0.1),
])
// Signs (10% of front row spectators)
let sign: any = null
let signText: any = null
if (row.z === 6 && Math.random() < 0.15) {
const signW = Math.round(18 * sc)
const signH = Math.round(12 * sc)
const signColors = ['#ffffff', '#ffee44', '#ff4444', '#44ff44', '#ff88ff']
const signC = signColors[Math.floor(Math.random() * signColors.length)]
sign = k.add([
k.rect(signW, signH), k.pos(sx + torsoW / 2 - signW / 2, sy - torsoH - headR * 2 - signH - 2),
k.color(safeColor(k,signC)), k.opacity(op * 0.8), k.z(row.z + 0.2),
])
const texts = ['GO!', 'KO!', 'WIN', '!!!', '#1', 'LOL', 'GG', 'WOW', 'BOT']
signText = k.add([
k.text(texts[Math.floor(Math.random() * texts.length)], { size: Math.round(6 * sc) }),
k.pos(sx + torsoW / 2, sy - torsoH - headR * 2 - signH / 2 - 2),
k.anchor('center'),
k.color(safeColor(k,'#111111')), k.opacity(op * 0.7), k.z(row.z + 0.3),
])
}
// Store base positions for animation
const tBaseY = torso.pos.y
const hBaseY = head.pos.y
const alBaseY = armL.pos.y
const arBaseY = armR.pos.y
const hlBaseY = handL.pos.y
const hrBaseY = handR.pos.y
const accBaseY = accessory?.pos.y
const signBaseY = sign?.pos.y
const stBaseY = signText?.pos.y
// Randomize behavior: some bob gently, some pump fists, some wave
const behavior = Math.random()
const isWaver = behavior < 0.25
const isPumper = behavior < 0.45 && !isWaver
torso.onUpdate(() => {
const bob = Math.sin(k.time() * bobSpeed + bobPhase) * bobAmt
const pump = isPumper ? Math.max(0, Math.sin(k.time() * 3 + bobPhase)) * 2 * sc : 0
torso.pos.y = tBaseY + bob - pump
head.pos.y = hBaseY + bob - pump
if (accessory) accessory.pos.y = accBaseY! + bob - pump
// Arms: wavers raise one arm rhythmically, pumpers raise both
if (isWaver) {
const wave = Math.sin(k.time() * 2.5 + bobPhase) * armH * 0.7
armR.pos.y = arBaseY + bob - Math.max(0, -wave)
handR.pos.y = hrBaseY + bob - Math.max(0, -wave)
armL.pos.y = alBaseY + bob
handL.pos.y = hlBaseY + bob
} else if (isPumper) {
armL.pos.y = alBaseY + bob - pump * 1.5
armR.pos.y = arBaseY + bob - pump * 1.5
handL.pos.y = hlBaseY + bob - pump * 1.5
handR.pos.y = hrBaseY + bob - pump * 1.5
} else {
armL.pos.y = alBaseY + bob
armR.pos.y = arBaseY + bob
handL.pos.y = hlBaseY + bob
handR.pos.y = hrBaseY + bob
}
if (sign) { sign.pos.y = signBaseY! + bob - pump; signText.pos.y = stBaseY! + bob - pump }
})
}
}
}
k.scene('fight', () => {
@@ -1912,7 +1754,7 @@ export async function createFightScene(config: FightSceneConfig) {
const gunX = atk.pos.x + dir * 15
const gunY = atk.pos.y - 40
const gunDef = PROPS.gun
const gunObjs = spawnProp('gun', gunX, gunY, dir < 0, 18)
const gunObjs = spawnWeaponProp('gun', gunX, gunY, dir < 0, 18)
await k.wait(0.15)
const bulletCount = isCritical ? 8 : 4
const muzzleX = gunX + dir * 30
@@ -2943,7 +2785,7 @@ export async function createFightScene(config: FightSceneConfig) {
const gpx = atk.pos.x + dir * 15
const gpy = atk.pos.y - 38
const mgDef = PROPS.minigun
const mgObjs = spawnProp('minigun', gpx, gpy, dir < 0, 18)
const mgObjs = spawnWeaponProp('minigun', gpx, gpy, dir < 0, 18)
await k.wait(0.1)
sfxSpecial()
const bulletCount = isCritical ? 16 : 10
@@ -3765,7 +3607,7 @@ export async function createFightScene(config: FightSceneConfig) {
}
// Spawn a multi-part pixel prop at position, returns array of game objects
function spawnProp(propName: string, x: number, y: number, flipX: boolean = false, zIndex: number = 16): any[] {
function spawnWeaponProp(propName: string, x: number, y: number, flipX: boolean = false, zIndex: number = 16): any[] {
const def = PROPS[propName]
if (!def) {
// Fallback: single colored circle
@@ -3849,7 +3691,7 @@ export async function createFightScene(config: FightSceneConfig) {
if (!defender) return
const dir = side === 'a' ? 1 : -1
// Spawn shield in front
const shieldObjs = spawnProp('shield', defender.pos.x + dir * 20, defender.pos.y - 25, side === 'b', 19)
const shieldObjs = spawnWeaponProp('shield', defender.pos.x + dir * 20, defender.pos.y - 25, side === 'b', 19)
sfxBlock()
// Flash shield
shieldObjs.forEach(o => { o.opacity = 0.9 })
@@ -3910,7 +3752,7 @@ export async function createFightScene(config: FightSceneConfig) {
for (let i = 0; i < total; i++) {
const fx = atk.pos.x + dir * 25, fy = atk.pos.y - 35
const tx = def.pos.x + (Math.random() - 0.5) * 30, ty = def.pos.y - 25 + (Math.random() - 0.5) * 20
const objs = spawnProp(propName, fx, fy, dir < 0)
const objs = spawnWeaponProp(propName, fx, fy, dir < 0)
k.tween(0, 1, 0.2, (t) => {
const cx = fx + (tx - fx) * t
const cy = fy + (ty - fy) * t - Math.sin(t * Math.PI) * arc
@@ -3936,7 +3778,7 @@ export async function createFightScene(config: FightSceneConfig) {
atk.play('special'); sfxSpecial()
// Spawn gun prop on attacker
const gx = atk.pos.x + dir * 15, gy = atk.pos.y - 35
const gunObjs = spawnProp(propName, gx, gy, dir < 0, 18)
const gunObjs = spawnWeaponProp(propName, gx, gy, dir < 0, 18)
const gunDef = PROPS[propName]
await k.wait(0.15)
// Fire bullets
@@ -5697,10 +5539,13 @@ export async function createFightScene(config: FightSceneConfig) {
'No hard feelings!', 'Honor to fight you!', 'GG.',
]
// Sanitize text for Kaplay (treats [ ] as styled text tags)
function safeText(t: string): string { return t.replace(/[\[\]]/g, '') }
// Spawn floating text above a position
function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) {
const label = k.add([
k.text(text, { size: 10 }),
k.text(safeText(text), { size: 10 }),
k.pos(x, y - 10),
k.color(safeColor(k,color)),
k.opacity(1),
@@ -5753,7 +5598,7 @@ export async function createFightScene(config: FightSceneConfig) {
})
if (text) {
const label = k.add([
k.text(text, { size: 6 }), k.pos(sx, sy),
k.text(safeText(text), { size: 6 }), k.pos(sx, sy),
k.color(safeColor(k,'#000000')), k.opacity(0.8), k.z(3), k.anchor('center'),
])
label.onUpdate(() => { label.pos.x = sign.pos.x; label.pos.y = sign.pos.y })
+4 -4
View File
@@ -380,7 +380,7 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
botAHp: hpA,
botBHp: hpB,
totalRounds: round,
}).where(eq(schema.fights.id, fightId))
}).where(eq(schema.fights.id, fightId)).run()
if (hpA <= 0 || hpB <= 0) {
winnerId = hpA <= 0 ? botB.id : botA.id
@@ -398,7 +398,7 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
status: 'finished',
winnerId,
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
}).where(eq(schema.fights.id, fightId)).run()
if (winnerId) {
const loserId = winnerId === botA.id ? botB.id : botA.id
@@ -415,7 +415,7 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
bestStreak: sql`MAX(${schema.bots.bestStreak}, ${newWinStreak})`,
tier: calculateTier(newWinnerElo, winner.wins + 1),
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, winnerId))
}).where(eq(schema.bots.id, winnerId)).run()
db.update(schema.bots).set({
losses: sql`${schema.bots.losses} + 1`,
@@ -423,7 +423,7 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
winStreak: 0,
tier: calculateTier(newLoserElo, loser.wins),
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, loserId))
}).where(eq(schema.bots.id, loserId)).run()
}
})
+7 -7
View File
@@ -360,7 +360,7 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
botAHp: hpA,
botBHp: hpB,
totalRounds: round,
}).where(eq(schema.fights.id, fightId))
}).where(eq(schema.fights.id, fightId)).run()
// Check for KO
if (hpA <= KO_THRESHOLD || hpB <= KO_THRESHOLD) {
@@ -390,7 +390,7 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
status: 'finished',
winnerId,
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
}).where(eq(schema.fights.id, fightId)).run()
// Update bot stats
if (winnerId) {
@@ -409,7 +409,7 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
bestStreak: newBestStreak,
tier: calculateTier(newWinnerElo, winner.wins + 1),
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, winnerId))
}).where(eq(schema.bots.id, winnerId)).run()
db.update(schema.bots).set({
losses: sql`${schema.bots.losses} + 1`,
@@ -417,11 +417,11 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
winStreak: 0,
tier: calculateTier(newLoserElo, loser.wins),
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, loserId))
}).where(eq(schema.bots.id, loserId)).run()
} else {
// Draw — update lastFightAt for both
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botA.id))
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botB.id))
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botA.id)).run()
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botB.id)).run()
}
})
@@ -479,7 +479,7 @@ export async function runFightAsync(botAId: string, botBId: string): Promise<str
db.update(schema.fights).set({
status: 'cancelled',
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
}).where(eq(schema.fights.id, fightId)).run()
fightEvents.cleanup(fightId)
})
.finally(() => {