From e732858b8c30012aa71cba83f412425bb870f21896bfd9a71d9fc540083ae328 Mon Sep 17 00:00:00 2001 From: JohannesBOT Date: Fri, 29 May 2026 19:13:57 +0200 Subject: [PATCH] changes --- .env.example | 12 +++++ .gitea/workflows/build-and-deploy.yml | 58 ++++++++++++++++++++++++ .gitignore | 5 ++- computercraft/me_uploader.lua | 63 ++++++++++++++++++++------- package.json | 5 ++- src/config.ts | 11 +++++ src/index.ts | 3 ++ src/repositories/meSystem.ts | 6 ++- src/schemas/meSystem.ts | 3 ++ src/server.ts | 13 +++--- src/services/meSystemService.ts | 21 ++++++--- 11 files changed, 168 insertions(+), 32 deletions(-) create mode 100644 .gitea/workflows/build-and-deploy.yml diff --git a/.env.example b/.env.example index c1d954d..89947ba 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,15 @@ TLS_KEY_PATH=./certs/key.pem CORS_ORIGIN=* # Base URL advertised in the OpenAPI document. PUBLIC_URL=https://localhost:3000 + +# --- Icons ------------------------------------------------------------------- +# Directory containing the mod .jar files; scanned once on startup to extract +# item / block textures into the icon cache. +MODS_DIR=/mods +# Optional path to the Minecraft client jar. Needed to extract vanilla +# `minecraft:*` textures, since those live in the client jar and not in MODS_DIR. +# Example: VANILLA_JAR=C:/Users/you/AppData/Roaming/.minecraft/versions/1.20.1/1.20.1.jar +VANILLA_JAR= +# Where the extracted PNGs are cached on disk. The cache survives restarts via +# a manifest.json that tracks jar mtime+size. +ICON_CACHE_DIR=/cache/icons diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml new file mode 100644 index 0000000..c8ab63c --- /dev/null +++ b/.gitea/workflows/build-and-deploy.yml @@ -0,0 +1,58 @@ +name: Build and Deploy + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Gitea Registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | \ + docker login https://gitea.johannesbot.de -u ${{ secrets.REGISTRY_USER }} --password-stdin + + - name: Build Image + run: docker build -t gitea.johannesbot.de/johannesbot/mc-computer-craft-api-backend:latest . + + - name: Push Image + run: docker push gitea.johannesbot.de/johannesbot/mc-computer-craft-api-backend:latest + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create deployment directory + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + password: ${{ secrets.SSH_PASSWORD }} + port: ${{ secrets.SSH_PORT || 22 }} + script: mkdir -p /home/${{ secrets.SSH_USER }}/mc-computer-craft-api-backend + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + password: ${{ secrets.SSH_PASSWORD }} + port: ${{ secrets.SSH_PORT || 22 }} + script: | + cd /home/${{ secrets.SSH_USER }}/mc-computer-craft-api + mv docker-compose.prod.yml docker-compose.yml + # Login as root/sudo to ensure we can pull + echo "${{ secrets.REGISTRY_PASSWORD }}" | sudo docker login https://gitea.johannesbot.de -u ${{ secrets.REGISTRY_USER }} --password-stdin + sudo docker compose pull + sudo docker compose up -d --remove-orphans + sudo docker image prune -f + + diff --git a/.gitignore b/.gitignore index 0afd6f8..70be6c4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,10 @@ lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - +.idea +package-lock.json +mods/ +cache/ # Runtime data pids *.pid diff --git a/computercraft/me_uploader.lua b/computercraft/me_uploader.lua index 1d86974..5bf2d21 100644 --- a/computercraft/me_uploader.lua +++ b/computercraft/me_uploader.lua @@ -1,10 +1,11 @@ --[[ - ME system uploader for ComputerCraft (CC: Tweaked). + Storage system uploader for ComputerCraft (CC: Tweaked). - Requires an "ME Bridge" peripheral (Advanced Peripherals) connected to the - computer and wired to an Applied Energistics 2 / Refined Storage network. + Works with both Advanced Peripherals bridges: + * "meBridge" -> Applied Energistics 2 (listItems / item.amount) + * "rsBridge" -> Refined Storage (getItems / item.count) - It periodically reads the network contents and POSTs them to the backend's + Periodically reads the network contents and POSTs them to the backend's /api/me-system/update endpoint. NOTE: the backend serves HTTPS with a self-signed certificate by default. @@ -14,25 +15,55 @@ * terminate TLS with a proxy that uses a certificate CC trusts. ]] -local API_URL = "http://your-server:3000/api/me-system/update" +local API_URL = "https://192.168.2.150:3000/api/me-system/update" local INTERVAL = 10 -- seconds between uploads +-- Resolve the bridge peripheral and pick the right method for it. local bridge = peripheral.find("meBridge") +local listFn +if bridge then + listFn = "listItems" +else + bridge = peripheral.find("rsBridge") + if bridge then + listFn = "getItems" + end +end if not bridge then - error("No ME Bridge peripheral found") + error("No meBridge or rsBridge peripheral found") +end +if type(bridge[listFn]) ~= "function" then + error("Bridge peripheral does not expose " .. listFn .. "()") end +local function cleanDisplayName(raw) + if type(raw) ~= "string" then return nil end + -- ME/RS display names are wrapped in [brackets] — strip them. + return (raw:gsub("[%[%]]", "")) +end + +-- Bridges report the same item id once per storage cell. Merge the rows here +-- so the backend receives a clean snapshot. local function collect() - local items = bridge.listItems() - local payload = {} + local items = bridge[listFn](bridge) + local byName = {} + local order = {} for _, item in ipairs(items) do - payload[#payload + 1] = { - name = item.name, - amount = item.amount or item.count or 0, - displayName = item.displayName, - } + local amount = item.amount or item.count or 0 + local entry = byName[item.name] + if entry then + entry.amount = entry.amount + amount + else + entry = { + name = item.name, + amount = amount, + displayName = cleanDisplayName(item.displayName), + } + byName[item.name] = entry + order[#order + 1] = entry + end end - return payload + return order end local function upload(payload) @@ -48,7 +79,9 @@ local function upload(payload) response.close() end -print("ME uploader started. Target: " .. API_URL) +print("Storage uploader started.") +print(" Bridge: " .. listFn) +print(" Target: " .. API_URL) while true do local ok, payload = pcall(collect) if ok then diff --git a/package.json b/package.json index ef406f1..ffd4792 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:update-loop": "tsx test/update-loop.ts" }, "dependencies": { "@fastify/cors": "^10.0.1", @@ -19,12 +20,14 @@ "@fastify/type-provider-typebox": "^5.1.0", "@fastify/websocket": "^11.0.1", "@sinclair/typebox": "^0.34.9", + "adm-zip": "^0.5.17", "dotenv": "^16.4.7", "fastify": "^5.2.0", "pg": "^8.13.1", "selfsigned": "^2.4.1" }, "devDependencies": { + "@types/adm-zip": "^0.5.8", "@types/node": "^22.10.2", "@types/pg": "^8.11.10", "tsx": "^4.19.2", diff --git a/src/config.ts b/src/config.ts index 94df3ba..db21f93 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,11 @@ function readBool(key: string, fallback: boolean): boolean { return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); } +function readOptional(key: string): string | null { + const value = process.env[key]; + return value === undefined || value === '' ? null : value; +} + export const config = { /** Interface the HTTP/WS server binds to. */ host: readEnv('HOST', '0.0.0.0'), @@ -34,6 +39,12 @@ export const config = { corsOrigin: readEnv('CORS_ORIGIN', '*'), /** Public base URL advertised in the OpenAPI document. */ publicUrl: readEnv('PUBLIC_URL', 'https://localhost:3000'), + /** Directory containing mod `.jar` files; scanned for item textures on startup. */ + modsDir: readEnv('MODS_DIR', './mods'), + /** Optional path to the Minecraft client jar to extract vanilla `minecraft:*` textures from. */ + vanillaJar: readOptional('VANILLA_JAR'), + /** Directory where extracted item PNGs are cached on disk. */ + iconCacheDir: readEnv('ICON_CACHE_DIR', './cache/icons'), } as const; export type AppConfig = typeof config; diff --git a/src/index.ts b/src/index.ts index aac2bde..cd94e7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,15 @@ import { config } from './config.js'; import { closePool } from './db/pool.js'; import { runMigrations } from './db/migrate.js'; +import { iconCount, loadIcons } from './lib/iconRegistry.js'; import { buildServer } from './server.js'; async function main(): Promise { await runMigrations(); + await loadIcons(); const app = await buildServer(); + app.log.info(`Icon registry ready: ${iconCount()} item textures available`); await app.listen({ host: config.host, port: config.port }); const scheme = config.tls.enabled ? 'https' : 'http'; diff --git a/src/repositories/meSystem.ts b/src/repositories/meSystem.ts index 5393e65..bc932a4 100644 --- a/src/repositories/meSystem.ts +++ b/src/repositories/meSystem.ts @@ -1,4 +1,5 @@ import { pool } from '../db/pool.js'; +import { iconUrl } from '../lib/iconRegistry.js'; /* ------------------------------------------------------------------ */ /* Types */ @@ -10,6 +11,7 @@ export interface LiveItem { mod: string; amount: number; updatedAt: string; + icon: string | null; } export interface ModInfo { @@ -59,12 +61,14 @@ function toIso(value: unknown): string { } function rowToLiveItem(row: Record): LiveItem { + const name = row.name as string; return { - name: row.name as string, + name, displayName: (row.display_name as string | null) ?? null, mod: row.mod as string, amount: Number(row.amount), updatedAt: toIso(row.updated_at), + icon: iconUrl(name), }; } diff --git a/src/schemas/meSystem.ts b/src/schemas/meSystem.ts index 992cfe6..9724b39 100644 --- a/src/schemas/meSystem.ts +++ b/src/schemas/meSystem.ts @@ -38,6 +38,9 @@ export const LiveItemSchema = Type.Object({ mod: Type.String(), amount: Type.Integer(), updatedAt: Type.String({ format: 'date-time' }), + icon: Type.Union([Type.String(), Type.Null()], { + description: 'URL to a 64×64 PNG, or null when no texture is available.', + }), }); export const ModInfoSchema = Type.Object({ diff --git a/src/server.ts b/src/server.ts index 660f8de..44529de 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { config } from './config.js'; import { ensureTlsMaterial } from './lib/tls.js'; import { healthRoutes } from './routes/health.js'; +import { iconRoutes } from './routes/icon.js'; import { meSystemRoutes } from './routes/meSystem.js'; import { wsRoutes } from './routes/ws.js'; @@ -19,17 +20,12 @@ export async function buildServer(): Promise { ? ensureTlsMaterial(config.tls.certPath, config.tls.keyPath) : null; - const baseOptions = { + const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' }, // ME systems can hold thousands of distinct items in one snapshot. bodyLimit: 16 * 1024 * 1024, - }; - - const app = ( - tls - ? Fastify({ ...baseOptions, https: { key: tls.key, cert: tls.cert } }) - : Fastify(baseOptions) - ).withTypeProvider(); + ...(tls ? { https: { key: tls.key, cert: tls.cert } } : {}), + }).withTypeProvider(); await app.register(cors, { origin: config.corsOrigin }); @@ -56,6 +52,7 @@ export async function buildServer(): Promise { await app.register(healthRoutes); await app.register(meSystemRoutes); + await app.register(iconRoutes); await app.register(wsRoutes); return app; diff --git a/src/services/meSystemService.ts b/src/services/meSystemService.ts index a88c4d4..867f9be 100644 --- a/src/services/meSystemService.ts +++ b/src/services/meSystemService.ts @@ -24,12 +24,21 @@ let lastHistoryUpdate = 0; * data to all connected websocket clients. */ export async function processUpdate(items: RawUpdateItem[]): Promise { - const normalized: repo.NormalizedItem[] = items.map((item) => ({ - name: item.name, - amount: item.amount, - displayName: item.displayName ?? null, - mod: modFromName(item.name), - })); + const seen = new Map(); + for (const item of items) { + const existing = seen.get(item.name); + if (existing) { + existing.amount += item.amount; + } else { + seen.set(item.name, { + name: item.name, + amount: item.amount, + displayName: item.displayName ?? null, + mod: modFromName(item.name), + }); + } + } + const normalized = [...seen.values()]; await repo.upsertLive(normalized);