xabbo-scripts/Scripts/Furni-Matic Auto-Eco.csx
Administrator 7a548130a3 Move all scripts into Scripts/ subfolder
Keeps the repo root clean - only README.md visible on landing page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 09:49:37 +01:00

367 lines
17 KiB
C#

using System;
using System.Net;
using System.Text;
using System.Diagnostics;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using System.Text.Json.Serialization;
using Xabbo.Messages;
#nullable enable
public class ItemView {
[JsonPropertyName("h")] public string HabboId { get; set; } = "";
[JsonPropertyName("n")] public string Name { get; set; } = "";
[JsonPropertyName("r")] public int Revision { get; set; }
[JsonPropertyName("c")] public int Count { get; set; }
}
public class LiveState {
[JsonPropertyName("items")] public List<ItemView> Items { get; set; } = new();
[JsonPropertyName("status")] public StatusUpdate Status { get; set; } = new();
}
public class RecycleRequest {
[JsonPropertyName("items")] public List<RecycleItem> Items { get; set; } = new();
[JsonPropertyName("delay")] public int Delay { get; set; }
}
public class RecycleItem {
[JsonPropertyName("h")] public string HabboId { get; set; } = "";
[JsonPropertyName("a")] public int Amount { get; set; }
}
public class StatusUpdate {
[JsonPropertyName("done")] public int Done { get; set; }
[JsonPropertyName("total")] public int Total { get; set; }
}
var port = 8226;
var queue = new List<int>();
var progress = 0;
var total = 0;
var delay = 12000;
var lastRun = DateTime.Now;
HttpListener? server = null;
void TryRefreshInventory() {
try { Send(Out["RequestFurniInventory"]); return; } catch { }
}
OnIntercept(In["RecyclerFinished"], e => {
TryRefreshInventory();
});
LiveState GetCurrentState() {
EnsureInventory(10000);
var currentItems = Inventory.Where(x => x.IsRecyclable)
.GroupBy(x => x.GetDescriptor())
.Where(g => g.Count() >= 8)
.Select(g => new ItemView {
HabboId = g.Key.GetInfo().Identifier,
Name = g.Key.GetName(),
Revision = g.Key.GetInfo().Revision,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.ToList();
if (progress >= total && total > 0) {
progress = total = 0;
}
return new LiveState {
Items = currentItems,
Status = new StatusUpdate { Done = progress, Total = total }
};
}
try {
log("starting server...");
var html = @"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8""><title>Recycler</title><meta name=""viewport"" content=""width=device-width, initial-scale=1"">
<link rel=""preconnect"" href=""https://fonts.googleapis.com""><link rel=""preconnect"" href=""https://fonts.gstatic.com"" crossorigin>
<link href=""https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"" rel=""stylesheet"">
<style>
:root{--bg-deep:#111;--bg-med:#1C1C1C;--bg-light:#2C2C2C;--border:#333;--text-bright:#EAEAEA;--text-dim:#888;--accent:#00dd99;--accent-dark:#00b37b;--danger:#ff4757;}
*{margin:0;padding:0;box-sizing:border-box;}
body{background:var(--bg-deep);color:var(--text-bright);font-family:'Inter',sans-serif;padding:1.5rem;font-size:14px;}
.wrap{max-width:1200px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:1rem;}
.panel{background:var(--bg-med);border:1px solid var(--border);border-radius:6px;padding:1rem;}
.header{text-align:center;font-size:1.5rem;font-weight:700;color:var(--text-bright);margin-bottom:1rem;}
.controls{display:flex;align-items:center;justify-content:center;gap:0.75rem;flex-wrap:wrap;}
.controls label{display:flex;align-items:center;gap:0.5rem;color:var(--text-dim);}
button{background:var(--accent);color:#000;border:none;padding:0.5rem 1rem;border-radius:4px;font-weight:600;cursor:pointer;transition:background-color 150ms ease;}
button:hover{background:var(--accent-dark);}
button:disabled{background:var(--bg-light);color:var(--text-dim);cursor:not-allowed;}
button.secondary{background:var(--bg-light);color:var(--text-bright);border:1px solid var(--border);}
button.secondary:hover{background-color:var(--border);}
button.danger{background:var(--danger);}
button.danger:hover{background:#d63031;}
input[type=number],input[type=text]{width:80px;background:var(--bg-light);border:1px solid var(--border);color:var(--text-bright);padding:0.5rem;border-radius:4px;text-align:center;}
input[type=text]{width:100%;text-align:left;}
input:focus{outline:none;border-color:var(--accent);}
.progress-bar{height:2rem;background:var(--bg-light);border-radius:4px;position:relative;overflow:hidden;}
.progress-fill{width:0;height:100%;background:var(--accent);transition:width 300ms ease-out;}
.progress-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-weight:600;text-shadow:0 1px 2px #000;}
.main-layout{display:grid;grid-template-columns:2fr 1fr;gap:1rem;}
.status-bar{display:flex;justify-content:space-between;align-items:center;padding:0.5rem 0;color:var(--text-dim);font-size:0.8rem;}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:0.75rem;max-height:60vh;overflow-y:auto;padding-right:0.5rem;}
.grid::-webkit-scrollbar{width:6px;} .grid::-webkit-scrollbar-track{background:transparent;} .grid::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px;}
.card{background:var(--bg-light);border-radius:4px;padding:0.75rem;text-align:center;cursor:pointer;border:2px solid transparent;transition:border-color 150ms ease, background-color 150ms ease;}
.card.selected{border-color:var(--accent);}
.card .name{font-size:0.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0.25rem 0;}
.card .count{font-size:0.75rem;color:var(--text-dim);}
h2{font-size:1rem;font-weight:600;margin-bottom:0.75rem;}
.queue-list{display:flex;flex-direction:column;gap:0.5rem;max-height:calc(60vh - 2rem);overflow-y:auto;}
.queue-item{display:flex;align-items:center;gap:0.5rem;background:var(--bg-light);padding:0.5rem;border-radius:4px;}
.queue-item .name{flex-grow:1;font-size:0.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.queue-item .remove-btn{background:transparent;border:none;color:var(--text-dim);font-size:1.25rem;padding:0 0.25rem;cursor:pointer;line-height:1;}
.queue-item .remove-btn:hover{color:var(--danger);}
.empty-state{text-align:center;color:var(--text-dim);font-size:0.85rem;padding:3rem 1rem;border:2px dashed var(--border);border-radius:4px;}
@media (max-width: 900px) {.main-layout{grid-template-columns:1fr;}}
</style>
</head>
<body>
<div class=""wrap"">
<div class=""header"">recycler</div>
<div class=""panel""><div class=""progress-bar""><div class=""progress-fill"" id=""progress-fill""></div><div class=""progress-text"" id=""progress-text"">idle</div></div></div>
<div class=""panel"">
<div class=""controls"">
<button id=""start-btn"">start</button><button id=""stop-btn"" class=""danger"">stop</button>
<label>delay <input type=""number"" id=""delay-input"" value=""12000"" min=""500"" step=""100""></label>
<button id=""clear-selection-btn"" class=""secondary"">clear selection</button><button id=""clear-queue-btn"" class=""secondary"">clear queue</button>
</div>
</div>
<div class=""main-layout"">
<div class=""panel grid-container"">
<input type=""text"" id=""search-input"" placeholder=""search items..."">
<div class=""status-bar""><span id=""info-text"">select items</span><button id=""add-selected-btn"" class=""secondary"" style=""display:none;"">add selected to queue</button></div>
<div class=""grid"" id=""grid""></div>
</div>
<div class=""panel queue-container"">
<h2>queue</h2>
<div class=""queue-list"" id=""queue-list""></div>
</div>
</div>
</div>
<script>
const state={items:[],selected:new Set(),queue:[],amounts:{},searchTerm:'',status:{done:0,total:0},wasRunning:false};
const dom={grid:document.getElementById('grid'),queueList:document.getElementById('queue-list'),infoText:document.getElementById('info-text'),addSelectedBtn:document.getElementById('add-selected-btn'),progressFill:document.getElementById('progress-fill'),progressText:document.getElementById('progress-text'),searchInput:document.getElementById('search-input'),delayInput:document.getElementById('delay-input'),startBtn:document.getElementById('start-btn')};
const api={
recycle:()=>fetch('/recycle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({items:state.queue.map(habboId=>({h:habboId,a:state.amounts[habboId]})) ,delay:parseInt(dom.delayInput.value)})}),
stop:()=>fetch('/stop',{method:'POST'}),
getState:()=>fetch('/state').then(r=>r.json())
};
const ui={
renderCard:item=>{
const card=document.createElement('div');
card.className=`card ${state.selected.has(item.h)?'selected':''}`;
card.dataset.id=item.h;
card.innerHTML=`<img src=""https://images.habbo.com/dcr/hof_furni/${item.r}/${item.h}_icon.png"" alt=""""><div class=""name"" title=""${item.n}"">${item.n}</div><div class=""count"">${item.c}x | ${state.amounts[item.h]}</div>`;
return card;
},
renderQueueItem:habboId=>{
const item=state.items.find(x=>x.h===habboId);
if(!item)return null;
const li=document.createElement('div');
li.className='queue-item';li.dataset.id=habboId;
li.innerHTML=`<img src=""https://images.habbo.com/dcr/hof_furni/${item.r}/${item.h}_icon.png"" alt=""""><span class=""name"">${item.n} (${state.amounts[habboId]})</span><button class=""remove-btn"">&times;</button>`;
return li;
},
updateGrid:()=>{
const fragment=document.createDocumentFragment();
const filtered=state.items.filter(item=>item.n.toLowerCase().includes(state.searchTerm));
if(filtered.length===0){dom.grid.innerHTML='<div class=""empty-state"">no items found</div>';return;}
filtered.forEach(item=>fragment.appendChild(ui.renderCard(item)));
dom.grid.innerHTML='';dom.grid.appendChild(fragment);
},
updateQueue:()=>{
state.queue=state.queue.filter(habboId=>state.items.some(item=>item.h===habboId));
if(state.queue.length===0){dom.queueList.innerHTML='<div class=""empty-state"">queue is empty</div>';return;}
const fragment=document.createDocumentFragment();
state.queue.forEach(habboId=>{
const itemElement = ui.renderQueueItem(habboId);
if(itemElement) fragment.appendChild(itemElement);
});
dom.queueList.innerHTML='';dom.queueList.appendChild(fragment);
},
updateInfo:()=>{
dom.infoText.textContent=`${state.selected.size} items selected`;
dom.addSelectedBtn.style.display=state.selected.size>0?'inline-block':'none';
},
updateStatus:()=>{
const currentStatus=state.status;
const isRunning=currentStatus.total>0;
const percent=isRunning?Math.round((currentStatus.done/currentStatus.total)*100):0;
dom.progressFill.style.width=`${percent}%`;
dom.startBtn.disabled=isRunning;
if(isRunning)dom.progressText.textContent=`${currentStatus.done}/${currentStatus.total}`;
else if(state.wasRunning)dom.progressText.textContent='complete';
else dom.progressText.textContent='idle';
if(state.wasRunning&&!isRunning)setTimeout(()=>dom.progressText.textContent='idle',2500);
state.wasRunning=isRunning;
},
renderAll:()=>{ui.updateGrid();ui.updateQueue();ui.updateInfo();ui.updateStatus();}
};
const handlers={
gridClick:e=>{
const card=e.target.closest('.card');if(!card)return;
const habboId=card.dataset.id;
if(e.detail===2){
const inQueue=state.queue.includes(habboId);
if(inQueue)state.queue=state.queue.filter(x=>x!==habboId);
else state.queue.push(habboId);
}else{
state.selected.has(habboId)?state.selected.delete(habboId):state.selected.add(habboId);
}
ui.renderAll();
},
queueClick:e=>{
if(!e.target.classList.contains('remove-btn'))return;
const habboId=e.target.closest('.queue-item').dataset.id;
state.queue=state.queue.filter(x=>x!==habboId);
ui.renderAll();
},
addSelected:()=>{
state.selected.forEach(habboId=>{if(!state.queue.includes(habboId))state.queue.push(habboId);});
state.selected.clear();
ui.renderAll();
},
updateAllAmounts:()=>{
state.items.forEach(item=>{if(!state.amounts[item.h])state.amounts[item.h]=Math.floor(item.c/8)*8;});
},
init:()=>{
document.getElementById('start-btn').onclick=()=>{if(state.queue.length===0){alert('add items to queue');return;}api.recycle();};
document.getElementById('stop-btn').onclick=api.stop;
document.getElementById('clear-selection-btn').onclick=()=>{state.selected.clear();ui.renderAll();};
document.getElementById('clear-queue-btn').onclick=()=>{state.queue=[];ui.renderAll();};
dom.addSelectedBtn.onclick=handlers.addSelected;
dom.grid.addEventListener('click',handlers.gridClick);
dom.queueList.addEventListener('click',handlers.queueClick);
dom.searchInput.addEventListener('input',e=>{state.searchTerm=e.target.value.toLowerCase();ui.updateGrid();});
setInterval(()=>{
api.getState().then(newState=>{
state.items=newState.items;
state.status=newState.status;
handlers.updateAllAmounts();
ui.renderAll();
});
},1500);
}
};
handlers.init();
</script>
</body>
</html>";
server = new HttpListener();
server.Prefixes.Add($"http://localhost:{port}/");
server.Start();
Process.Start(new ProcessStartInfo { FileName = $"http://localhost:{port}/", UseShellExecute = true });
log($"server listening on http://localhost:{port}");
_ = Task.Run(async () => {
while (Run && server.IsListening) {
try {
var ctx = await server.GetContextAsync();
_ = Task.Run(() => HandleContextAsync(ctx, html));
} catch { break; }
}
});
while (Run) {
if (queue.Count >= 8 && (DateTime.Now - lastRun).TotalMilliseconds >= delay) {
var batch = queue.Take(8).ToList();
queue.RemoveRange(0, 8);
Send(Out["RecycleItems"], 8, batch[0], batch[1], batch[2], batch[3], batch[4], batch[5], batch[6], batch[7]);
progress += 8;
lastRun = DateTime.Now;
}
await Task.Delay(10);
}
}
catch (TaskCanceledException) {
log("error: inventory load timed out. ensure you are fully in-game.");
}
catch (Exception ex) {
log($"unhandled exception: {ex.Message}");
}
finally {
server?.Stop();
server?.Close();
log("server shut down.");
}
async Task HandleContextAsync(HttpListenerContext ctx, string html) {
var req = ctx.Request;
var res = ctx.Response;
try {
var endpoint = (req.HttpMethod, req.Url?.AbsolutePath);
switch (endpoint) {
case ("GET", "/"):
await WriteResponseAsync(res, html, "text/html");
break;
case ("GET", "/state"):
var stateJson = JsonSerializer.Serialize(GetCurrentState());
await WriteResponseAsync(res, stateJson, "application/json");
break;
case ("POST", "/recycle"):
TryRefreshInventory();
EnsureInventory(10000);
using (var r = new StreamReader(req.InputStream)) {
var payload = JsonSerializer.Deserialize<RecycleRequest>(await r.ReadToEndAsync());
if (payload != null) {
queue.Clear();
delay = Math.Clamp(payload.Delay, 500, 60000);
foreach (var pItem in payload.Items) {
var itemInstances = Inventory
.Where(x => x.IsRecyclable && x.GetInfo().Identifier == pItem.HabboId)
.Select(i => -(int)i.Id)
.ToList();
int amount = Math.Min(pItem.Amount, itemInstances.Count);
queue.AddRange(itemInstances.Take(amount));
}
total = queue.Count;
progress = 0;
log($"queueing {total} items, {delay}ms delay.");
}
}
res.StatusCode = 200;
break;
case ("POST", "/stop"):
queue.Clear();
progress = total = 0;
log("recycling stopped.");
res.StatusCode = 200;
break;
default:
res.StatusCode = 404;
break;
}
}
catch (Exception ex) {
log($"request error: {ex.Message}");
if(!res.OutputStream.CanWrite) return;
res.StatusCode = 500;
}
finally {
res.Close();
}
}
async Task WriteResponseAsync(HttpListenerResponse res, string content, string type) {
var buffer = Encoding.UTF8.GetBytes(content);
res.ContentType = type;
res.ContentLength64 = buffer.Length;
await res.OutputStream.WriteAsync(buffer, 0, buffer.Length);
}
void log(string message) => Log(message);