Hot loads: visible melt damage, rebuild-over repair, warning glow (t69)

This commit is contained in:
2026-10-29 18:30:26 -04:00
parent b655d754b9
commit 5fd8110470
14 changed files with 246 additions and 15 deletions
+43
View File
@@ -0,0 +1,43 @@
// t69: melt feedback, full-cost repair without disturbing the load, and repair undo.
const {load, suite, flatWorld} = require('./harness');
const {run} = load(), {check, done} = suite();
flatWorld(run, 160, 120, 100);
run(`bot.x=40; bot.y=40; money=500; ledger={}; EV.length=0;
placeBlock(BP_SCREW, 4, 10, 10, true);
part[40*W+40]=0; markPow(40*W+40,false);
setCell(40*W+40,RBAS,950,0);`);
check('same screw builds over damage at full price', run(`undoRecord('repair',()=>placeBlocks(BP_SCREW,4,[[10,10]],null,false)).n===1 && money===475 && blockIntegrity(10*BW+10)===16`));
check('repair keeps the hot load in place', run('mat[40*W+40]===RBAS && temp[40*W+40]===950'));
run('undoLast()');
check('undo restores the hole and refunds repair cost', run('part[40*W+40]===0 && !isPow[40*W+40] && money===500 && blockIntegrity(10*BW+10)===15'));
check('G also repairs without a removal refund', run(`undoRecord('repair',()=>placeBlocks(BP_SCREW,4,[[10,10]],null,true)).n===1 && money===475`));
check('G over an intact screw costs nothing', run('placeBlocks(BP_SCREW,4,[[10,10]],null,true).n===0 && money===475'));
run('part[40*W+40]=0; money=0');
check('unaffordable repair leaves the damage alone', run('!!placeBlock(BP_SCREW,4,10,10,false) && part[40*W+40]===0 && money===0'));
run(`money=500; placeBlock(BP_SCREW,4,10,10,false);
tick=2; tickStamp=2;
for(const i of blockCellList(10,10)) {setCell(i,RBAS,1000,0);}
rebuildPower(); for(let k=0;k<500;k++) { for(const i of blockCellList(10,10)) {mat[i]=RBAS; stamp[i]=0;} machinesTick(); }`);
check('1000 degrees does not melt screws', run('blockIntegrity(10*BW+10)===16'));
run(`for(const i of blockCellList(10,10)) temp[i]=1100;
for(let k=0;k<500;k++) { for(const i of blockCellList(10,10)) {mat[i]=RBAS; stamp[i]=0;} machinesTick(); }`);
check('hot loads still melt cell by cell', run('blockIntegrity(10*BW+10)<16'));
check('first melt explains heat limit and repair once', run(`EV.filter(e=>e.t==='screw_melt').length===1 && ledger.screwMelt===true`));
run('const heatSave=serializeWorld(); deserializeWorld(heatSave.meta,heatSave.buffers)');
check('existing save data retains damage and first-message state', run('blockIntegrity(10*BW+10)<16 && ledger.screwMelt===true'));
run(`placeBlock(BP_SCREW,4,10,10,true); for(const i of blockCellList(10,10)) {mat[i]=EMPTY; temp[i]=1100;}
setCell(41*W+41,RBAS,899,0);`);
check('empty hot cells do not count as a hot load', run('screwLoadHeat(10*BW+10)===899'));
run('temp[41*W+41]=900');
check('warning can start before the melt limit', run('screwLoadHeat(10*BW+10)===900 && blockIntegrity(10*BW+10)===16'));
run('part[40*W+40]=0; ghostBlocks.set(10*BW+10,[BP_SCREW,4,null,1]); money=500; ghostTick()');
check('a queued G repair restores damage without refunding', run('blockIntegrity(10*BW+10)===16 && money===475 && !ghostBlocks.size'));
run(`part[40*W+40]=0; money=500;
undoRecord('queued repair',()=>{ghostBlocks.set(10*BW+10,[BP_SCREW,4,null,1]);undoNote({t:'paste',blocks:[[10,10,BP_SCREW,1]],machines:[],molds:[]});});
ghostTick();undoLast();`);
check('undo of a completed queued repair preserves the original damaged screw',run('blockP[10*BW+10]===BP_SCREW && blockIntegrity(10*BW+10)===15 && money===500'));
for(let v=0;v<8;v++) {
run(`removeBlock(10,10);placeBlock(BP_SCREW,${v},10,10,true); part[40*W+40]=0; markPow(40*W+40,false); placeBlock(BP_SCREW,${v},10,10,true)`);
check(`repair restores direction ${v}`,run(`part[40*W+40]===SCREW_PARTS[${v}] && blockIntegrity(10*BW+10)===16`));
}
done();
+79
View File
@@ -0,0 +1,79 @@
"""t69 only: both looks, persistent damage, hover, first message and real click/G repair."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / 'tools'))
from use_venv import ensure_playwright; ensure_playwright()
from browser import launch, consume_gpu_option
consume_gpu_option()
ROOT = pathlib.Path(__file__).resolve().parents[1]
with launch(viewport={'width': 1280, 'height': 760}) as (_, page, url):
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
page.goto(url)
page.evaluate("""() => {
const enter=enterGame; enterGame=()=>{enter();GAME.paused=true;};
SET.look='pixel';SET.workerCap=0;SET.cameraLead=false;SET.autosave=0;
startNewWorld(7,768,600);
}""")
page.wait_for_function('GAME.running', timeout=60000)
page.evaluate("""() => {
mat.fill(EMPTY);part.fill(0);blockP.fill(0);blockV.fill(0);isPow.fill(0);
flags.fill(0);temp.fill(20);bgm.fill(0);fog.fill(255);
machines=[null];molds=[];ents=[];flyers=[];treeBases=[];saplings=[];
pcells.clear(); weather.next=1e15;weather.shower=null;surfY.fill(300);updateRoof();
bot.x=310;bot.y=210;bot.vx=bot.vy=0; money=500; ledger={};
for(let bx=72;bx<=84;bx++) placeBlock(BP_SCREW,1,bx,52,true);
for(let x=288;x<340;x++)for(let y=209;y<212;y++)setCell(y*W+x,RBAS,20,0);
for(let x=304;x<316;x++)for(let y=209;y<212;y++)temp[y*W+x]=950;
// Melt the right section with the real machine tick, keeping the scene paused.
for(let k=0;k<400;k++) {
for(let x=328;x<340;x++)for(let y=208;y<212;y++) {const i=y*W+x;mat[i]=RBAS;temp[i]=1100;stamp[i]=0;}
tick=2;tickStamp=2;machinesTick();
}
for(let x=288;x<340;x++)for(let y=208;y<212;y++)temp[y*W+x]=x>=304&&x<316?950:20;
document.querySelectorAll('.card-pop,.toast').forEach(e=>e.remove());
SET.cardSeconds=-1;drainEvents();R.floaters=[];
SET.fog=false;SET.zoom=12;SET.lighting='off';SET.glow='off';SET.minimap=false;
SET.cursorLabel='all';GAME.infoOverlay=false;GAME.hidePreviews=true;
resizeRender();snapCamera();renderDirty.fill(1);GPU.world=null;SM.surf=-1;
}""")
assert page.get_by_text('Screw conveyor melted', exact=True).is_visible()
assert page.evaluate("hoverInfo(330,210).t2.join(' ').includes('hotter than 1000°C')")
assert page.evaluate("hoverInfo(310,210).warn==='near heat limit'")
assert page.evaluate("!hoverInfo(290,210).warn")
for look in ['pixel', 'smooth']:
page.evaluate(f"SET.look='{look}'")
page.wait_for_function(f"R.smoothOn === {'true' if look == 'smooth' else 'false'}")
page.wait_for_timeout(150)
# The new overlay must alter pixels in both looks, and hide behind unexplored fog.
assert page.evaluate("""() => {
const sample=()=>{const p=worldToScreenPx(330,210);return Array.from(R.ctx.getImageData(p.x,p.y,1,1).data);};
R.ctx.clearRect(0,0,R.canvas.width,R.canvas.height); drawScrewHeat();
const shown=sample()[3];SET.fog=true;fog.fill(0);
R.ctx.clearRect(0,0,R.canvas.width,R.canvas.height);drawScrewHeat();
const hidden=sample()[3]; SET.fog=false;fog.fill(255);
return shown>0 && hidden===0;
}""")
page.wait_for_timeout(100)
page.screenshot(path=str(ROOT / f'shots/t69-{look}.png'))
print(f'PASS {look}: cold / warning / cooled melt damage, hover, fog and screenshot')
# Exercise the actual build input, then undo, then G + click on the same cooled damage.
page.evaluate("GAME.paused=false;devPaused=true;GAME.hidePreviews=false;GAME.tool=2;GAME.pieceIdx=PIECES.findIndex(p=>p.id==='screw');GAME.pieceDir.screw=1")
target = page.evaluate("worldToScreenPx(330,210)")
for replace in [False, True]:
if replace:
page.keyboard.down('g')
before = page.evaluate('money')
page.mouse.move(target['x'], target['y'])
page.wait_for_timeout(60)
page.mouse.click(target['x'], target['y'])
page.wait_for_timeout(100)
assert page.evaluate('blockIntegrity(52*BW+82)===16')
assert page.evaluate('money') == before - 25
if replace:
page.keyboard.up('g')
page.keyboard.press('Control+z')
assert page.evaluate('blockIntegrity(52*BW+82)<16')
assert page.evaluate('money') == before
assert not errors, errors
print('PASS first melt message; real click and G repair at full cost; keyboard undo; no page errors')