diff --git a/battle-tanks/scripts/app.js b/battle-tanks/scripts/app.js index a115eda..831fe81 100644 --- a/battle-tanks/scripts/app.js +++ b/battle-tanks/scripts/app.js @@ -68,10 +68,10 @@ function visibleProjectile(){const sample=mode==='online'&&onlineProjectileSampl function renderProjectiles(){const projectile=visibleProjectile();if(!projectile)return;const id=WEAPON_REGISTRY[projectile.weaponId]?projectile.weaponId:'shell';ctx.shadowColor='#ffdf7d';ctx.shadowBlur=16;ctx.fillStyle='#182720';ctx.strokeStyle='#fff0a5';ctx.lineWidth=3;ctx.beginPath();if(id==='heavy-shell')ctx.rect(projectile.x-7,projectile.y-7,14,14);else if(id==='homing'){ctx.moveTo(projectile.x+9,projectile.y);ctx.lineTo(projectile.x-7,projectile.y-6);ctx.lineTo(projectile.x-3,projectile.y);ctx.lineTo(projectile.x-7,projectile.y+6);ctx.closePath();}else ctx.arc(projectile.x,projectile.y,PROJECTILE_R,0,Math.PI*2);ctx.fill();ctx.stroke();ctx.shadowBlur=0;} function render(){renderArena();renderEffects();renderPickups();renderTanks();renderProjectiles();} -function syncInventory(locked){const side=mode==='online'?(mySide??state.activePlayer):state.activePlayer,items=Array.isArray(state.inventories?.[side])?state.inventories[side]:[],box=document.querySelector('#inventory');box.replaceChildren();items.forEach(id=>{const item=POWER_UP_CATALOG[id];if(!item)return;const button=document.createElement('button');button.type='button';button.textContent=item.label;button.disabled=locked;button.addEventListener('click',()=>usePowerUp(id));box.append(button);});if(!box.children.length)box.textContent='Empty — drive over a pickup';const requested=state.equippedWeapons?.[side]||'shell',weapon=WEAPON_REGISTRY[requested]||WEAPON_REGISTRY.shell,weaponId=weapon.id,ammo=weapon.ammo.unlimited?'Unlimited':String(state.weaponAmmo?.[side]?.[weaponId]||0);document.querySelector('#weapon-status').textContent=`Weapon: ${weapon.label} · ${ammo}`;document.querySelector('#ammo-status').textContent=weapon.ammo.unlimited?'Unlimited ammunition':`${ammo} rounds remaining`;document.querySelector('#fullscreen-ammo').textContent=weapon.ammo.unlimited?'Unlimited':`${ammo} rounds`;for(const select of [document.querySelector('#weapon-select'),document.querySelector('#fullscreen-weapon')]){select.value=weaponId;select.disabled=locked;}const effects=Array.isArray(state.activeEffects?.[side])?state.activeEffects[side]:[];document.querySelector('#effect-status').textContent=effects.length?effects.map(effect=>effect.effect==='absorb'?`Shield ${effect.remainingTurns??0} turns, ${effect.remainingCapacity??0} capacity`:`${POWER_UP_CATALOG[effect.id]?.label||'Effect'} ${effect.remainingTurns??0} turns`).join(' · '):'No active effects';} +function syncInventory(locked){const side=mode==='online'?(mySide??state.activePlayer):state.activePlayer,items=Array.isArray(state.inventories?.[side])?state.inventories[side]:[],box=document.querySelector('#inventory');box.replaceChildren();items.forEach(id=>{const item=POWER_UP_CATALOG[id];if(!item)return;const button=document.createElement('button'),unavailable=item.onlineOnly&&mode!=='online';button.type='button';button.textContent=unavailable?`${item.label} (online only)`:item.label;button.disabled=locked||unavailable;if(unavailable)button.title='This power-up can only be activated in an online room.';button.addEventListener('click',()=>usePowerUp(id));box.append(button);});if(!box.children.length)box.textContent='Empty — drive over a pickup';const requested=state.equippedWeapons?.[side]||'shell',weapon=WEAPON_REGISTRY[requested]||WEAPON_REGISTRY.shell,weaponId=weapon.id,ammo=weapon.ammo.unlimited?'Unlimited':String(state.weaponAmmo?.[side]?.[weaponId]||0);document.querySelector('#weapon-status').textContent=`Weapon: ${weapon.label} · ${ammo}`;document.querySelector('#ammo-status').textContent=weapon.ammo.unlimited?'Unlimited ammunition':`${ammo} rounds remaining`;document.querySelector('#fullscreen-ammo').textContent=weapon.ammo.unlimited?'Unlimited':`${ammo} rounds`;for(const select of [document.querySelector('#weapon-select'),document.querySelector('#fullscreen-weapon')]){select.value=weaponId;select.disabled=locked;}const effects=Array.isArray(state.activeEffects?.[side])?state.activeEffects[side]:[];document.querySelector('#effect-status').textContent=effects.length?effects.map(effect=>effect.effect==='absorb'?`Shield ${effect.remainingTurns??0} turns, ${effect.remainingCapacity??0} capacity`:`${POWER_UP_CATALOG[effect.id]?.label||'Effect'} ${effect.remainingTurns??0} turns`).join(' · '):'No active effects';} function chooseWeapon(id){if(mode==='online')command('select-weapon',{weaponId:id});else selectWeapon(state,state.activePlayer,id);sync();render();} for(const select of [document.querySelector('#weapon-select'),document.querySelector('#fullscreen-weapon')]){Object.values(WEAPON_REGISTRY).forEach(weapon=>{const option=document.createElement('option');option.value=weapon.id;option.textContent=weapon.label;select.append(option);});select.addEventListener('change',event=>chooseWeapon(event.target.value));} -function usePowerUp(id){const item=POWER_UP_CATALOG[id];if(mode==='online'){command(item.kind==='weapon'?'equip':'activate',{itemId:id});return;}const result=activatePowerUp(state,state.activePlayer,id);if(result?.consumesTurn){endTurnEffects(state,state.activePlayer);advancePickupSchedule(state);beginTurn(state,1-state.activePlayer);}sync();render();} +function usePowerUp(id){const item=POWER_UP_CATALOG[id];if(!item||item.onlineOnly&&mode!=='online')return;if(mode==='online'){command(item.kind==='weapon'?'equip':'activate',{itemId:id});return;}const result=activatePowerUp(state,state.activePlayer,id);if(result?.consumesTurn){endTurnEffects(state,state.activePlayer);advancePickupSchedule(state);beginTurn(state,1-state.activePlayer);}sync();render();} let announcedImpactSerial=0,previousAnnouncement='',previousEffects=[[],[]],previousHealth=[100,100],previousWeapons=['shell','shell']; function tankHealth(index){const value=state.tanks?.[index]?.health;return Number.isFinite(value)?Math.max(0,Math.min(100,value)):0;} function syncTankHud(){[0,1].forEach(index=>{const health=tankHealth(index),bar=document.querySelector(index?'#health-bar-right':'#health-bar-left');document.querySelector(index?'#health-right':'#health-left').textContent=health;bar.style.width=`${health}%`;bar.style.background=health>50?'#78d294':health>0?'#ffc65c':'#df604f';});} diff --git a/battle-tanks/scripts/game.js b/battle-tanks/scripts/game.js index 7243f4a..18bcebe 100644 --- a/battle-tanks/scripts/game.js +++ b/battle-tanks/scripts/game.js @@ -25,7 +25,7 @@ const POWER_UP_CATALOG = Object.freeze({ 'health-pack': Object.freeze({ id: 'health-pack', label: 'Health pack', kind: 'consumable', effect: 'heal', amount: 35, consumesTurn: true }), shield: Object.freeze({ id: 'shield', label: 'Shield', kind: 'consumable', effect: 'absorb', capacityRange: Object.freeze({ min: 40, max: 60 }), durationRange: Object.freeze({ min: 2, max: 4 }), consumesTurn: true }), - invisibility: Object.freeze({ id: 'invisibility', label: 'Invisibility', kind: 'consumable', effect: 'invisible', durationRange: Object.freeze({ min: 1, max: 3 }), consumesTurn: true }), + invisibility: Object.freeze({ id: 'invisibility', label: 'Invisibility', kind: 'consumable', effect: 'invisible', durationRange: Object.freeze({ min: 1, max: 3 }), consumesTurn: true, onlineOnly: true }), 'weapon-heavy-shell': Object.freeze({ id: 'weapon-heavy-shell', label: 'Heavy shell ammo', kind: 'weapon', weaponId: 'heavy-shell', weapon: WEAPON_REGISTRY['heavy-shell'], consumesTurn: false }), 'weapon-homing': Object.freeze({ id: 'weapon-homing', label: 'Homing missile ammo', kind: 'weapon', weaponId: 'homing', weapon: WEAPON_REGISTRY.homing, consumesTurn: false }), 'weapon-laser': Object.freeze({ id: 'weapon-laser', label: 'Ricochet laser ammo', kind: 'weapon', weaponId: 'laser', weapon: WEAPON_REGISTRY.laser, consumesTurn: false }), diff --git a/server/battle-tanks-rooms.js b/server/battle-tanks-rooms.js index 5bd42ed..a387b20 100644 --- a/server/battle-tanks-rooms.js +++ b/server/battle-tanks-rooms.js @@ -118,6 +118,12 @@ class BattleTanksRooms { // Health remains public, but no positional or aiming property crosses the trust boundary. state.tanks[opponent] = { health: room.game.tanks[opponent].health, concealed: true }; if (state.activePlayer === opponent) state.announcement = 'Opponent concealed.'; + // Rays resolve synchronously, so their complete path must be removed just + // like a projectile that has not crossed the disclosure boundary. Impact + // history contains the same path and must not provide a second side channel. + if (state.laserPath?.owner === opponent) state.laserPath = null; + state.impacts = state.impacts.map(impact => impact.type === 'laser' && impact.owner === opponent ? { ...impact, path: null } : impact); + if (state.lastImpact?.type === 'laser' && state.lastImpact.owner === opponent) state.lastImpact = { ...state.lastImpact, path: null }; const projectile = room.game.projectile; if (projectile?.owner === opponent) { const barrier = room.game.arena.barrier; diff --git a/tests/battle-tanks-page.test.js b/tests/battle-tanks-page.test.js index 936f3f0..731173b 100644 --- a/tests/battle-tanks-page.test.js +++ b/tests/battle-tanks-page.test.js @@ -60,6 +60,13 @@ test('Battle Tanks collects pickups along local pointer-drag movement', () => { assert.match(app, /pointermove[\s\S]*?tank\.y=tankYAt\(state,tank\);collectPickup\(state,state\.activePlayer\);sync\(\)/); }); +test('Battle Tanks disables online-only power-ups in local matches and guards activation', () => { + const app = read('battle-tanks/scripts/app.js'), core = require('../battle-tanks/scripts/game'); + assert.equal(core.POWER_UP_CATALOG.invisibility.onlineOnly, true); + assert.match(app, /button\.disabled=locked\|\|unavailable/); + assert.match(app, /if\(!item\|\|item\.onlineOnly&&mode!==['"]online['"]\)return/); +}); + test('Battle Tanks exposes an accessible, queued, local-only acquisition card', () => { const page = read('battle-tanks/index.html'), app = read('battle-tanks/scripts/app.js'), styles = read('battle-tanks/styles.css'); assert.match(page, /id="power-card"[^>]*role="dialog"/); assert.match(page, /id="dismiss-power-card"[^>]*aria-label="Dismiss power-up card"/); assert.match(page, /id="power-card-live"[^>]*aria-live="polite"/); diff --git a/tests/battle-tanks-rooms.test.js b/tests/battle-tanks-rooms.test.js index 3f8061e..095dcfc 100644 --- a/tests/battle-tanks-rooms.test.js +++ b/tests/battle-tanks-rooms.test.js @@ -81,6 +81,20 @@ test('viewer-aware states conceal an invisible opponent and their launch vectors assert.equal(visible.x, state.projectile.x); assert.equal(visible.vx, state.projectile.vx); }); +test('viewer-aware states redact an invisible opponent laser path from every history field', () => { + const { rooms, host } = started(), state = host.room.game; + state.activeEffects[0].push({ id: 'invisibility', effect: 'invisible', remainingTurns: 3 }); + state.weaponAmmo[0].laser = 1; + rooms.command(host.room, host.player, command(host.room, 'select-weapon', { weaponId: 'laser' })); + rooms.command(host.room, host.player, command(host.room, 'fire')); + const owner = rooms.stateFor(host.room, 0), opponent = rooms.stateFor(host.room, 1); + assert.ok(owner.laserPath?.segments.length > 0, 'the owner retains the authoritative laser rendering'); + assert.equal(opponent.opponentConcealed, true); + assert.equal(opponent.laserPath, null); + assert.equal(opponent.lastImpact?.path, null); + assert.ok(opponent.impacts.filter(impact => impact.type === 'laser' && impact.owner === 0).every(impact => impact.path === null)); +}); + test('concealment survives reconnect, expires by owner turns, resets on rematch, and reveals at game over', () => { const { rooms, host, guest } = started({ random: () => .999999 }), state = host.room.game; state.inventories[0].push('invisibility'); rooms.command(host.room, host.player, command(host.room, 'activate', { itemId: 'invisibility', remainingTurns: 99 }));