Keeps the repo root clean - only README.md visible on landing page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
366 lines
17 KiB
C#
366 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"">×</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"):
|
|
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); |