v1.1.0 Priority 1+2b: file ID comparison + manifest version extraction

- Migration: adds current_file_id, latest_file_id, is_ignored columns
- ModpackApiService: all 4 platforms now return file_id in response
- CheckModpackUpdates: file ID comparison (preferred) with string fallback
- detectCurseForge: extracts manifest['version'] as installed_version
- Cron skips is_ignored servers
- Detection priority: manifest version > egg var > DB record > seed latest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (Chronicler #83 - The Compiler)
2026-04-12 23:38:41 -05:00
parent 3906303754
commit 9415c1b984
5 changed files with 372 additions and 15 deletions

View File

@@ -0,0 +1,65 @@
# Chronicler Dispatch — manifest.json has version field on some servers
**Date:** 2026-04-12
**From:** Chronicler #84 — The Meridian
**To:** Code
---
## Discovery
Mythcraft 5 HAS a `manifest.json` at `/home/container/manifest.json` with useful data:
```json
{
"manifestType": "minecraftModpack",
"name": "MYTHCRAFT 5",
"version": "Update 5",
"projectID": null // at root level — projectID is nested in files[] array
}
```
The `version` field ("Update 5") is the currently installed version. This is real data
we can use for `current_version` — not just the pack ID.
## What This Means for Detection
When `detectCurseForge()` reads `manifest.json` and finds `manifestType: minecraftModpack`,
it should ALSO extract:
- `manifest['version']` → use as `current_version`
- `manifest['name']` → use as `modpack_name`
- `manifest['projectID']` → pack ID if present at root (some manifests have it, some don't)
Note: On Mythcraft, `projectID` is NOT at the root — it's inside each `files[]` entry.
The root doesn't have a project ID. The `modpack_installations` table has it (737497).
## Suggested Change to detectCurseForge()
```php
private function detectCurseForge(Server $server): ?array
{
try {
$content = $this->fileRepository->getContent('manifest.json');
$manifest = json_decode($content, true);
if (is_array($manifest) && ($manifest['manifestType'] ?? '') === 'minecraftModpack') {
$projectId = $manifest['projectID'] ?? null;
return [
'platform' => 'curseforge',
'modpack_id' => $projectId ? (string) $projectId : null,
'name' => $manifest['name'] ?? null,
'installed_version' => $manifest['version'] ?? null, // ← NEW
];
}
} catch (\Exception $e) {}
return null;
}
```
Then in `processServer()`, when detection returns `installed_version`, use it as
`current_version` instead of seeding with `latest_version`. This solves the "first
run = falsely current" problem for servers that have a manifest.
*— Chronicler #84, The Meridian*
**Fire + Frost + Foundation** 💙🔥❄️

View File

@@ -0,0 +1,236 @@
# Chronicler Dispatch — v1.1.0 Full Architecture Plan (Gemini Consultation Complete)
**Date:** April 13, 2026
**From:** Chronicler #84 — The Meridian
**To:** Code
---
## Context
Gemini consultation complete. Michael has reviewed and approved the plan.
Full consultation: `firefrost-operations-manual/docs/consultations/gemini-modpackchecker-ux-overhaul-2026-04-12.md`
No time pressure — Michael wants to get this right. These are v1.1.0 priorities in order.
---
## Priority 1: File ID Comparison (Foundation)
**The problem:** We're comparing messy display name strings ("ATM10-6.5" vs "ATM10-6.6"). Unreliable and ugly.
**Gemini's fix:** Use sequential File IDs from CurseForge/Modrinth. `latest_file_id > current_file_id` = update available. Clean, reliable, platform-agnostic.
**Schema migration needed:**
```sql
ALTER TABLE modpackchecker_servers
ADD COLUMN current_file_id VARCHAR(64) NULL,
ADD COLUMN latest_file_id VARCHAR(64) NULL;
```
**API changes:**
- `ModpackApiService::fetchLatestVersion()` should also return `file_id`
- CurseForge: use the file's `id` field
- Modrinth: use the version's `id` field
- FTB: use the version `id`
- Technic: use the build number
**Version comparison logic:**
```php
// Prefer file ID comparison if available
if ($currentFileId && $latestFileId) {
$updateAvailable = $latestFileId !== $currentFileId;
} else {
// Fallback to string comparison
$updateAvailable = $currentVersion !== $latestVersion;
}
```
---
## Priority 2: Date-Time Seeding Heuristic
**The problem:** First run seeds `current_version = latest_version`, which is wrong for servers that have been running old versions.
**Gemini's fix:** On first detection, fetch the platform's file history. Find the release closest to (but not after) `modpack_installations.created_at`. That's the assumed current version.
```php
private function seedCurrentVersion(string $platform, string $modpackId, ?string $installDate): array
{
if (!$installDate) {
// No install date — fall back to latest
return $this->apiService->fetchLatestVersion($platform, $modpackId);
}
$allFiles = $this->apiService->fetchFileHistory($platform, $modpackId);
$assumedCurrent = collect($allFiles)
->filter(fn($f) => $f['releaseDate'] <= $installDate)
->sortByDesc('releaseDate')
->first();
return $assumedCurrent ?? $this->apiService->fetchLatestVersion($platform, $modpackId);
}
```
**New method needed:** `ModpackApiService::fetchFileHistory(platform, modpackId)`
- CurseForge: `GET /v1/mods/{modId}/files` — returns all files with dates
- Modrinth: `GET /project/{id}/version` — returns all versions with dates
- Returns: array of `['id', 'version', 'displayName', 'releaseDate']`
**Also:** If `manifest.json` exists and has a `version` field — use that directly as `current_version` instead of any heuristic. We confirmed this works on 5 servers (Mythcraft, Create Plus, Beyond Depth, Beyond Ascension, Homestead). Manifest version is truth — no guessing needed.
---
## Priority 3: Zero-Click Widget with Recalibrate
**The problem:** Widget requires clicking, shows only latest version string, no comparison, no context.
**Gemini's redesign:**
### New GET endpoint needed:
`GET /api/client/extensions/modpackchecker/servers/{server}/status`
Returns cached DB data (NO external API calls):
```json
{
"configured": true,
"platform": "curseforge",
"modpack_name": "MYTHCRAFT 5",
"current_version": "Update 5",
"latest_version": "Update 5",
"current_file_id": "6148845",
"latest_file_id": "6148845",
"update_available": false,
"last_checked": "2026-04-13T04:00:00Z",
"detection_method": "installer"
}
```
### New GET endpoint for Recalibrate dropdown:
`GET /api/client/extensions/modpackchecker/servers/{server}/releases`
Returns last 10 releases from platform (DOES make external API call):
```json
{
"releases": [
{"file_id": "6148845", "display_name": "MYTHCRAFT 5 | Update 5", "release_date": "2026-03-27"},
{"file_id": "6089021", "display_name": "MYTHCRAFT 5 | Update 4.1", "release_date": "2026-03-11"},
...
]
}
```
### New POST endpoint for Recalibrate save:
`POST /api/client/extensions/modpackchecker/servers/{server}/calibrate`
Body: `{ "file_id": "6089021", "version": "Update 4.1" }`
Sets `current_version`, `current_file_id`, `is_user_overridden = true`
### Widget TSX redesign:
```tsx
const ModpackVersionCard: React.FC = () => {
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
const [data, setData] = useState<StatusData | null>(null);
const [showCalibrate, setShowCalibrate] = useState(false);
const [releases, setReleases] = useState([]);
// Zero-click: load on mount from cache
useEffect(() => {
if (!uuid) return;
http.get(`/api/client/extensions/modpackchecker/servers/${uuid}/status`)
.then(res => setData(res.data))
.catch(() => {});
}, [uuid]);
const openCalibrate = () => {
http.get(`/api/client/extensions/modpackchecker/servers/${uuid}/releases`)
.then(res => setReleases(res.data.releases));
setShowCalibrate(true);
};
// Render: platform icon | current → latest | Calibrate button
// If update_available: orange background
// If unconfigured: gray with "Not configured"
// If showCalibrate: dropdown showing last 10 releases to click
};
```
---
## Priority 4: is_ignored Flag
**The problem:** Vanilla, FoundryVTT, Hytale servers pollute the DB and show unconfigured widgets.
**Note from Michael:** Nest ID filtering does NOT work — his eggs span multiple nests.
**Solution:**
```sql
ALTER TABLE modpackchecker_servers ADD COLUMN is_ignored BOOLEAN DEFAULT FALSE;
```
- Widget shows "Hide (Not a Modpack)" button for unconfigured servers
- Clicking sets `is_ignored = true`, widget unmounts
- Cron skips servers where `is_ignored = true`
- Admin panel shows ignored servers in a separate list with "Restore" option
---
## Priority 5: BCC Log Parsing (Optional Signal)
**Research findings from live testing:**
- `latest.log` IS readable via `DaemonFileRepository::getContent('logs/latest.log')`
- `BetterCompatibilityChecker` mod prints: `Loaded BetterCompatibilityChecker - Modpack: {name} | Version: {version}`
- Mythcraft 5 has BCC but it's unconfigured (`CHANGE_ME`)
- Most packs (ATM10 etc.) don't have BCC at all
- Log only has startup lines if server recently restarted
**Recommended approach:** Add as optional detection step 4 in the cron (after modpack_installations, egg vars, file detection):
```php
// Step 4: BCC log parsing
private function detectFromLogs(Server $server): ?array
{
try {
$log = $this->fileRepository->getContent('logs/latest.log');
if (preg_match('/Loaded BetterCompatibilityChecker - Modpack: (.+?) \| Version: (.+)/', $log, $m)) {
if ($m[1] !== 'CHANGE_ME' && $m[2] !== 'CHANGE_ME') {
return ['name' => trim($m[1]), 'version' => trim($m[2])];
}
}
} catch (\Exception $e) {}
return null;
}
```
Document in BuiltByBit: "Servers with BetterCompatibilityChecker configured will have the most accurate version detection. Version updates on server restart."
---
## Also: Manifest Version Audit Results
Ran Wings filesystem audit on all 22 servers for `manifest.json`:
| Status | Servers |
|--------|---------|
| ✅ Has manifest with version | Mythcraft 5 (Update 5), Create Plus (0.9.0), Beyond Depth (Ver12.3.2), Beyond Ascension (Ver2.4.1), Homestead (1.2.9.4) |
| ⚠️ Manifest but no version field | Society, All of Create NC, Otherworld, Submerged 2 |
| ❌ No manifest | 13 servers (installed via modpack installer) |
When `detectCurseForge()` finds a valid manifest, it should also extract `manifest['version']` as `installed_version` and use it as `current_version`. This is truth — no heuristic needed for these 5 servers.
---
## Summary: What Needs to Be Built
| Priority | Task | Complexity |
|----------|------|------------|
| 1 | File ID fields in DB + comparison logic | Medium |
| 2 | fetchFileHistory() + date-time seeding | Medium |
| 2b | manifest['version'] as current_version | Small |
| 3 | New status/releases/calibrate endpoints | Medium |
| 3b | Widget TSX redesign (zero-click + Recalibrate) | Large |
| 4 | is_ignored flag + Hide button | Small |
| 5 | BCC log parsing in cron | Small |
Take them in order. File ID comparison first since it's foundational.
*— Chronicler #84, The Meridian*
**Fire + Frost + Foundation = Where Love Builds Legacy** 💙🔥❄️

View File

@@ -58,18 +58,23 @@ class CheckModpackUpdates extends Command
if ($existing && $existing->is_user_overridden) {
if ($existing->platform && $existing->modpack_id) {
$this->checkVersion($server, $existing->platform, $existing->modpack_id, 'manual');
$this->checkVersion($server, $existing->platform, $existing->modpack_id, 'manual', null);
}
return;
}
// Skip ignored servers
if ($existing && $existing->is_ignored) {
return;
}
// Step 1: modpack_installations table (fastest, most reliable)
$installation = DB::table('modpack_installations')
->where('server_id', $server->id)
->first();
if ($installation && !empty($installation->provider) && !empty($installation->modpack_id)) {
$this->checkVersion($server, $installation->provider, (string) $installation->modpack_id, 'installer');
$this->checkVersion($server, $installation->provider, (string) $installation->modpack_id, 'installer', null);
return;
}
@@ -88,14 +93,14 @@ class CheckModpackUpdates extends Command
}
if (!empty($platform) && !empty($modpackId)) {
$this->checkVersion($server, $platform, $modpackId, 'egg');
$this->checkVersion($server, $platform, $modpackId, 'egg', null);
return;
}
// Step 3: File-based detection via DaemonFileRepository (last resort)
$detected = $this->detectFromFiles($server);
if ($detected) {
$this->checkVersion($server, $detected['platform'], $detected['modpack_id'], 'file');
$this->checkVersion($server, $detected['platform'], $detected['modpack_id'], 'file', $detected['installed_version'] ?? null);
return;
}
@@ -152,6 +157,7 @@ class CheckModpackUpdates extends Command
'platform' => 'curseforge',
'modpack_id' => (string) $projectId,
'name' => $data['name'] ?? null,
'installed_version' => $data['version'] ?? null,
];
}
}
@@ -163,6 +169,7 @@ class CheckModpackUpdates extends Command
'platform' => 'curseforge',
'modpack_id' => (string) $data['projectID'],
'name' => $data['name'] ?? null,
'installed_version' => $data['version'] ?? null,
];
}
} catch (\Exception $e) {
@@ -217,28 +224,42 @@ class CheckModpackUpdates extends Command
return null;
}
private function checkVersion(Server $server, string $platform, string $modpackId, string $method): void
private function checkVersion(Server $server, string $platform, string $modpackId, string $method, ?string $installedVersion): void
{
try {
$latestData = $this->apiService->fetchLatestVersion($platform, $modpackId);
$latestVersion = $latestData['version'] ?? 'Unknown';
$latestFileId = $latestData['file_id'] ?? null;
// Get current_version: egg variable > existing DB record > seed with latest
$currentVersion = $this->getVariable($server, 'MODPACK_CURRENT_VERSION');
// Get current_version: manifest > egg variable > existing DB > seed with latest
$currentVersion = $installedVersion;
$currentFileId = null;
if (empty($currentVersion)) {
$currentVersion = $this->getVariable($server, 'MODPACK_CURRENT_VERSION');
}
$existing = DB::table('modpackchecker_servers')
->where('server_uuid', $server->uuid)
->first();
if (empty($currentVersion)) {
$existing = DB::table('modpackchecker_servers')
->where('server_uuid', $server->uuid)
->first();
$currentVersion = $existing->current_version ?? null;
}
$currentFileId = $existing->current_file_id ?? null;
// First time seeing this server — seed current_version with latest
// First time — seed with latest
if (empty($currentVersion)) {
$currentVersion = $latestVersion;
$currentFileId = $latestFileId;
}
$updateAvailable = $currentVersion !== $latestVersion;
// Compare: prefer file ID, fall back to string
if ($currentFileId && $latestFileId) {
$updateAvailable = $latestFileId !== $currentFileId;
} else {
$updateAvailable = $currentVersion !== $latestVersion;
}
$this->updateDatabase($server, [
'platform' => $platform,
@@ -246,6 +267,8 @@ class CheckModpackUpdates extends Command
'modpack_name' => $latestData['name'],
'current_version' => $currentVersion,
'latest_version' => $latestVersion,
'current_file_id' => $currentFileId,
'latest_file_id' => $latestFileId,
'status' => $updateAvailable ? 'update_available' : 'up_to_date',
'detection_method' => $method,
'error_message' => null,

View File

@@ -92,9 +92,12 @@ class ModpackApiService
$versions = $versionResponse->json();
$latestVer = $versions[0] ?? null;
return [
'name' => $project['title'] ?? 'Unknown',
'version' => !empty($versions) ? ($versions[0]['version_number'] ?? 'Unknown') : 'Unknown',
'version' => $latestVer['version_number'] ?? 'Unknown',
'file_id' => $latestVer ? ($latestVer['id'] ?? null) : null,
];
}
@@ -128,10 +131,12 @@ class ModpackApiService
}
$data = $response->json()['data'] ?? [];
$latestFile = $data['latestFiles'][0] ?? null;
return [
'name' => $data['name'] ?? 'Unknown',
'version' => !empty($data['latestFiles']) ? ($data['latestFiles'][0]['displayName'] ?? 'Unknown') : 'Unknown',
'version' => $latestFile['displayName'] ?? 'Unknown',
'file_id' => $latestFile ? (string) $latestFile['id'] : null,
];
}
@@ -154,9 +159,12 @@ class ModpackApiService
$data = $response->json();
$latestVer = $data['versions'][0] ?? null;
return [
'name' => $data['name'] ?? 'Unknown',
'version' => !empty($data['versions']) ? ($data['versions'][0]['name'] ?? 'Unknown') : 'Unknown',
'version' => $latestVer['name'] ?? 'Unknown',
'file_id' => $latestVer ? (string) ($latestVer['id'] ?? null) : null,
];
}
@@ -197,6 +205,7 @@ class ModpackApiService
return [
'name' => $data['displayName'] ?? $data['name'] ?? 'Unknown',
'version' => $data['version'] ?? 'Unknown',
'file_id' => $data['version'] ?? null,
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('modpackchecker_servers', function (Blueprint $table) {
$table->string('current_file_id', 64)->nullable()->after('latest_version');
$table->string('latest_file_id', 64)->nullable()->after('current_file_id');
$table->boolean('is_ignored')->default(false)->after('is_user_overridden');
});
}
public function down(): void
{
Schema::table('modpackchecker_servers', function (Blueprint $table) {
$table->dropColumn(['current_file_id', 'latest_file_id', 'is_ignored']);
});
}
};