diff --git a/db/143_migrate.sql b/db/143_migrate.sql new file mode 100644 index 00000000..764ece85 --- /dev/null +++ b/db/143_migrate.sql @@ -0,0 +1,24 @@ +USE `moddb`; + +CREATE TABLE IF NOT EXISTS `modRelations` ( + `relationId` INT NOT NULL AUTO_INCREMENT, + `releaseId` INT NOT NULL COMMENT 'Relations are always pinned to a specific release. A mod that hosts multiple identifiers (e.g. linux variant) declares relations per release independently.', + `targetIdentifier` VARCHAR(255) NOT NULL COMMENT 'Always populated. Free-form modid string (e.g. "carrycapacity"). Resolved to targetModId when possible.', + `targetModId` INT NULL COMMENT 'FK to mods.modId when the target is a mod hosted in this moddb. Cache, kept in sync by the relation upsert function.', + `relationType` ENUM('required','optional','incompatible','tested_with') NOT NULL, + `minVersion` BIGINT UNSIGNED NULL COMMENT 'Compiled semver, same encoding as modReleases.version. NULL = any version.', + `maxVersion` BIGINT UNSIGNED NULL COMMENT 'Compiled semver, inclusive upper bound. NULL = no upper bound.', + `origin` ENUM('auto','manual') NOT NULL DEFAULT 'manual' + COMMENT 'auto = derived from modPeekResults.rawDependencies, manual = declared via UI. Sync only touches auto rows.', + `createdByUserId` INT NOT NULL, + `created` DATETIME NOT NULL DEFAULT NOW(), + `lastModified` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`relationId`), + UNIQUE INDEX `uq_relation` (`releaseId`, `targetIdentifier`, `relationType`), + INDEX `targetIdentifier` (`targetIdentifier`), + INDEX `targetModId` (`targetModId`), + CONSTRAINT `FK_modRelations_targetModId` FOREIGN KEY (`targetModId`) REFERENCES `mods`(`modId`) ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT `FK_modRelations_releaseId` FOREIGN KEY (`releaseId`) REFERENCES `modReleases`(`releaseId`) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT `FK_modRelations_userId` FOREIGN KEY (`createdByUserId`) REFERENCES `users`(`userId`) ON UPDATE CASCADE ON DELETE RESTRICT +) +ENGINE = InnoDB; diff --git a/db/144_migrate.php b/db/144_migrate.php new file mode 100644 index 00000000..ffd3bad0 --- /dev/null +++ b/db/144_migrate.php @@ -0,0 +1,43 @@ +getAll(<<getOne("SELECT userId FROM users WHERE roleId = 1 ORDER BY userId LIMIT 1"); +if (!$adminUserId) { fwrite(STDERR, "No admin user found; aborting.\n"); exit(1); } +$user = ['userId' => $adminUserId]; + +$processed = 0; +foreach ($rows as $row) { + syncAutoRelationsForRelease((int)$row['releaseId'], $row['rawDependencies']); + $processed++; +} +echo "Backfilled $processed releases.\n"; diff --git a/edit-release.php b/edit-release.php index 05b56917..fa492572 100644 --- a/edit-release.php +++ b/edit-release.php @@ -214,6 +214,15 @@ if($existingRelease) { $ok = updateRelease($targetMod, $existingRelease, $newData, $newCompatibleGameVersions); if($ok) { + if (!empty($_POST['removeRelations'])) { + foreach ($_POST['removeRelations'] as $rid) { + deleteManualRelation(intval($rid)); + } + } + if (!empty($_POST['relations'])) { + persistManualRelationsFromForm(intval($existingRelease['releaseId']), $_POST['relations']); + } + if(!empty($_POST['saveandback'])) forceRedirect(formatModPath($targetMod).'#tab-files'); else forceRedirectAfterPOST(); exit(); @@ -355,4 +364,13 @@ $view->assign('auditLogs', $auditLogs, null, true); +if (!empty($existingRelease['releaseId'])) { + $relView = getRelationsForReleaseEditView(intval($existingRelease['releaseId'])); + $view->assign('autoRelations', $relView['auto']); + $view->assign('manualRelations', $relView['manual']); +} else { + $view->assign('autoRelations', []); + $view->assign('manualRelations', []); +} + $view->display('edit-release'); diff --git a/lib/api/public/mods.php b/lib/api/public/mods.php index 41965389..285c0332 100644 --- a/lib/api/public/mods.php +++ b/lib/api/public/mods.php @@ -1,5 +1,7 @@ execute(<< (int)$release['releaseId'], + 'identifier' => $release['identifier'], + 'version' => (int)$release['version'], + 'fileName' => $release['name'], + 'fileUrl' => $r['fileUrl'], + ]; } unset($r); } else { // Simple path, no recommends: $releases = $con->execute(<< (int)$release['releaseId'], + 'identifier' => $release['identifier'], + 'version' => (int)$release['version'], + 'fileName' => $release['name'], + 'fileUrl' => $r['fileUrl'], + ]; } unset($r); } } - + if($unknownVersionQueryParams && $gameVersion) { // Complicated path. Can only exist if we have a gameversion to recommend any releases for. // Never recommend retracted releases. $placeholders = substr(str_repeat('?,', count($unknownVersionQueryParams)), 0, -1); $releases = $con->execute(<< (int)$release['releaseId'], + 'identifier' => $release['identifier'], + 'version' => (int)$release['recommendedUpgrade'], + 'fileName' => $release['name'], + 'fileUrl' => $r['fileUrl'], + ]; } unset($r); } @@ -233,9 +264,21 @@ function canIgnoreRetraction($release) } unset($r); - good(['data' => $result]); + $resolvePayload = []; + if (boolval($_GET['resolve-deps'] ?? false)) { + // Don't pass identifiers with already-known errors into the resolver. + // They are unavailable / malformed / retracted and should not surface dependency trees. + $rootIds = array_keys(array_filter($result, fn($r) => empty($r['errorCode']))); + // Seed the resolver with the releases picked above so the dep tree starts from the + // exact (identifier, version) requested in the URL instead of pickReleaseForIdentifier's + // "latest available" fallback. Transitive deps still use that fallback. + $rootMap = array_intersect_key($pickedRootReleases, array_flip($rootIds)); + $deps = resolveTransitiveDeps($rootIds, $gameVersion ?: null, $rootMap); + $resolvePayload = ['resolved' => $deps['resolved'], 'installOrder' => $deps['installOrder'], 'warnings' => $deps['warnings']]; + } + good(['data' => $result] + $resolvePayload); - default: + default: // /mods/{modId} $modId = filter_var($urlparts[0], FILTER_VALIDATE_INT); if(!$modId) break; // fallthrough into the authenticated section. diff --git a/lib/edit-release.php b/lib/edit-release.php index 1ef03ad4..62fb8427 100644 --- a/lib/edit-release.php +++ b/lib/edit-release.php @@ -1,5 +1,7 @@ execute('UPDATE files SET assetId = ? WHERE fileId = ?', [$assetId, $file['fileId']]); } + // Sync auto-derived relations from the parsed modinfo dependencies for this release. + $rawDependencies = $con->getOne('SELECT rawDependencies FROM modPeekResults WHERE fileId = ?', [$file['fileId']]); + syncAutoRelationsForRelease(intval($releaseId), $rawDependencies); + + // Carry manual relations forward from the previous release of the same identifier (template autofill). + cloneManualRelationsFromPreviousRelease(intval($releaseId)); + + // Retro-link previously-orphan relations whose targetIdentifier matches this release's identifier. + $releaseIdentifier = $newData['identifier'] ?? null; + if ($releaseIdentifier) { + resolveDanglingTargets($releaseIdentifier, intval($mod['modId'])); + } + $logInfo = 'v'.formatSemanticVersion($newData['version']); if(($mod['category'] & CATEGORY__MASK) === CATEGORY_GAME_MOD) { diff --git a/lib/relations.php b/lib/relations.php new file mode 100644 index 00000000..b5f406cd --- /dev/null +++ b/lib/relations.php @@ -0,0 +1,671 @@ + compiledMinVersion]. Returns 0 for entries without a version OR with a version + * string that fails to parse. Filters IGNORED_AUTO_IDENTIFIERS and the wildcard '*'. If an + * identifier appears more than once, keeps the highest compiled version. + * + * @return array + */ +function parseRawDeps(?string $rawDependencies): array +{ + if (!$rawDependencies) return []; + $result = []; + foreach (explode(', ', $rawDependencies) as $dep) { + splitOnce($dep, '@', $id, $versionStr); + if ($id === '' || $id === '*' || in_array($id, IGNORED_AUTO_IDENTIFIERS, true)) continue; + $compiled = $versionStr ? (compileSemanticVersion($versionStr) ?: 0) : 0; + if (!isset($result[$id]) || $compiled > $result[$id]) { + $result[$id] = $compiled; + } + } + return $result; +} + +/** + * Intersect a set of version range constraints. Each constraint is ['from'=>string, 'min'=>?int, 'max'=>?int] + * where NULL means open. The result is the tightest enclosing range, plus an `unsatisfiable` flag if the + * effective min would exceed the effective max. + * + * @param array $constraints + * @return array{effectiveMin:?int, effectiveMax:?int, unsatisfiable:bool} + */ +function mergeRanges(array $constraints): array +{ + $effMin = null; + $effMax = null; + foreach ($constraints as $c) { + if ($c['min'] !== null && ($effMin === null || $c['min'] > $effMin)) $effMin = $c['min']; + if ($c['max'] !== null && ($effMax === null || $c['max'] < $effMax)) $effMax = $c['max']; + } + $unsat = ($effMin !== null && $effMax !== null && $effMin > $effMax); + return ['effectiveMin' => $effMin, 'effectiveMax' => $effMax, 'unsatisfiable' => $unsat]; +} + +/** + * Pure BFS resolver. Same shape as before; relations are now release-scoped so the loader callback + * resolves identifier -> picked release -> relations of that specific release. + * + * The `resolved` map is emitted in install order (dependencies before their dependents), and + * `installOrder` lists the same identifiers as a plain array for clients whose JSON handling + * does not preserve object key order. Applying entries front to back never installs a mod + * before its resolved requirements. + * + * @param string[] $rootIdentifiers + * @param callable(string): array $relationsLoader identifier -> array of relation rows + * @param callable(string): ?array $releasePicker identifier -> ['fileName'=>..., 'fileUrl'=>..., ...] or null + * @return array{resolved: array>, installOrder: string[], warnings: array>} + */ +function bfsResolve(array $rootIdentifiers, callable $relationsLoader, callable $releasePicker): array +{ + $queue = []; + $resolved = []; + $versionConstrs = []; + $incompatDeclared = []; + $informational = []; + $warnings = []; + + foreach ($rootIdentifiers as $id) { + $queue[] = [$id, []]; + } + + while ($queue) { + [$id, $chain] = array_shift($queue); + + if (in_array($id, $chain, true)) { + $warnings[] = ['kind' => WARN_CYCLE, 'path' => array_merge($chain, [$id])]; + continue; + } + if (count($chain) > MAX_DEPS_DEPTH) { + $warnings[] = ['kind' => WARN_DEPTH_LIMIT, 'stoppedAt' => $id, 'limit' => MAX_DEPS_DEPTH]; + continue; + } + if (isset($resolved[$id])) { + $parent = end($chain) !== false ? end($chain) : ''; + if (!in_array($parent, $resolved[$id]['requiredBy'], true)) { + $resolved[$id]['requiredBy'][] = $parent; + } + continue; + } + + $release = $releasePicker($id); + if ($release === null) { + $warnings[] = ['kind' => WARN_MISSING_DEP, 'identifier' => $id, 'requiredBy' => $chain]; + continue; + } + $parent = end($chain); + $resolved[$id] = $release + [ + 'requiredBy' => [$parent !== false ? $parent : ''], + 'depth' => count($chain), + ]; + + foreach ($relationsLoader($id) as $rel) { + switch ($rel['relationType']) { + case REL_REQUIRED: + $versionConstrs[$rel['targetIdentifier']][] = [ + 'from' => $id, + 'min' => $rel['minVersion'] ?? null, + 'max' => $rel['maxVersion'] ?? null, + ]; + $queue[] = [$rel['targetIdentifier'], array_merge($chain, [$id])]; + break; + case REL_INCOMPATIBLE: + $incompatDeclared[$id][] = $rel['targetIdentifier']; + break; + case REL_OPTIONAL: + case REL_TESTED_WITH: + $informational[] = [ + 'kind' => $rel['relationType'] === REL_OPTIONAL ? WARN_OPTIONAL_UNMET : WARN_TESTED_WITH_UNMET, + 'from' => $id, + 'identifier' => $rel['targetIdentifier'], + ]; + break; + } + } + } + + foreach ($incompatDeclared as $declarer => $targets) { + foreach ($targets as $target) { + if (isset($resolved[$target]) || in_array($target, $rootIdentifiers, true)) { + $warnings[] = ['kind' => WARN_INCOMPATIBLE, 'between' => [$declarer, $target], 'declaredBy' => $declarer]; + } + } + } + + foreach ($versionConstrs as $target => $constraints) { + $merged = mergeRanges($constraints); + if ($merged['unsatisfiable']) { + $warnings[] = ['kind' => WARN_VERSION_CONFLICT, 'identifier' => $target, 'ranges' => $constraints]; + } + } + + foreach ($informational as $info) { + if (!isset($resolved[$info['identifier']]) && !in_array($info['identifier'], $rootIdentifiers, true)) { + $warnings[] = $info; + } + } + + // Reorder the resolved set so every dependency precedes its dependents (Kahn's algorithm + // over the requiredBy edges). BFS discovery order breaks ties, keeping output deterministic. + $dependentsOf = []; + $depCount = array_fill_keys(array_keys($resolved), 0); + foreach ($resolved as $id => $entry) { + foreach ($entry['requiredBy'] as $parent) { + if (!isset($resolved[$parent])) continue; // '' markers and unresolved parents carry no edge + $dependentsOf[$id][] = $parent; + $depCount[$parent]++; + } + } + $ready = []; + foreach ($resolved as $id => $entry) { + if ($depCount[$id] === 0) $ready[] = $id; + } + $ordered = []; + for ($i = 0; $i < count($ready); $i++) { + $id = $ready[$i]; + $ordered[$id] = $resolved[$id]; + foreach ($dependentsOf[$id] ?? [] as $parent) { + if (--$depCount[$parent] === 0) $ready[] = $parent; + } + } + // The requiredBy edges cannot cycle (cyclic expansions are cut before the edge is recorded), + // so $ordered always covers $resolved; keep the fallback in case that invariant ever breaks. + $resolved = $ordered + $resolved; + + return ['resolved' => $resolved, 'installOrder' => array_keys($resolved), 'warnings' => $warnings]; +} + +/** + * Pure cycle-guard helper. Returns true iff adding (sourceIdentifier -required-> targetIdentifier) + * would close a cycle in the required-relation graph supplied as $graph. + * + * @param array> $graph identifier -> outbound edges + */ +function wouldCreateCycleInGraph(string $sourceIdentifier, string $targetIdentifier, string $relationType, array $graph): bool +{ + if ($relationType !== REL_REQUIRED) return false; + if ($sourceIdentifier === $targetIdentifier) return true; + + $stack = [$targetIdentifier]; + $visited = []; + while ($stack) { + $cur = array_pop($stack); + if (isset($visited[$cur])) continue; + $visited[$cur] = true; + if ($cur === $sourceIdentifier) return true; + foreach ($graph[$cur] ?? [] as $edge) { + if ($edge['type'] === REL_REQUIRED) $stack[] = $edge['target']; + } + } + return false; +} + +/** + * DB-backed wrapper: true if declaring (releaseId -required-> targetIdentifier) would create a cycle + * in the latest-release-per-identifier required-relation graph. Used by the UI to warn at relation + * declaration time. Returns false if the source release has no identifier (release without modinfo). + */ +function wouldCreateCycle(int $sourceReleaseId, string $targetIdentifier): bool +{ + global $con; + + $sourceIdentifier = $con->getOne( + "SELECT identifier FROM modReleases WHERE releaseId = ? AND identifier IS NOT NULL", + [$sourceReleaseId] + ); + if (!$sourceIdentifier) return false; + + // Build the required-only adjacency map keyed on identifier, using each identifier's most recent release. + $rows = $con->getAll( + "SELECT srcr.identifier AS src, r.targetIdentifier AS tgt + FROM modRelations r + JOIN ( + SELECT mr.releaseId, mr.identifier + FROM modReleases mr + JOIN ( + SELECT identifier, MAX(releaseId) AS maxRel + FROM modReleases + WHERE identifier IS NOT NULL + GROUP BY identifier + ) latest ON latest.identifier = mr.identifier AND latest.maxRel = mr.releaseId + ) srcr ON srcr.releaseId = r.releaseId + WHERE r.relationType = ?", + [REL_REQUIRED] + ); + $graph = []; + foreach ($rows as $row) { + $graph[$row['src']][] = ['target' => $row['tgt'], 'type' => REL_REQUIRED]; + } + + return wouldCreateCycleInGraph($sourceIdentifier, $targetIdentifier, REL_REQUIRED, $graph); +} + +/** Internal: resolve targetIdentifier to a targetModId via modReleases.identifier. Returns null if not found. */ +function _resolveTargetModId(string $targetIdentifier): ?int +{ + global $con; + $id = $con->getOne( + "SELECT m.modId FROM mods m JOIN modReleases r ON r.modId = m.modId WHERE r.identifier = ? LIMIT 1", + [$targetIdentifier] + ); + return $id !== null ? (int)$id : null; +} + +/** + * Manually upsert a relation declared via the UI. Always origin='manual'. + * If a row with the same (releaseId, targetIdentifier, relationType) already exists, + * its versions and origin are updated to manual. + */ +function upsertManualRelation(int $releaseId, string $targetIdentifier, string $relationType, ?int $minVersion, ?int $maxVersion): int +{ + global $con, $user; + + $existingId = $con->getOne( + "SELECT relationId FROM modRelations + WHERE releaseId = ? AND targetIdentifier = ? AND relationType = ?", + [$releaseId, $targetIdentifier, $relationType] + ); + + $targetModId = _resolveTargetModId($targetIdentifier); + + if ($existingId) { + $con->execute( + "UPDATE modRelations SET minVersion = ?, maxVersion = ?, origin = ?, targetModId = ? WHERE relationId = ?", + [$minVersion, $maxVersion, REL_ORIGIN_MANUAL, $targetModId, $existingId] + ); + return (int)$existingId; + } + + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, targetModId, relationType, minVersion, maxVersion, origin, createdByUserId) + VALUES (?,?,?,?,?,?,?,?)", + [$releaseId, $targetIdentifier, $targetModId, $relationType, $minVersion, $maxVersion, REL_ORIGIN_MANUAL, $user['userId']] + ); + return (int)$con->Insert_ID(); +} + +/** Removes a manual relation. Auto rows are not affected. */ +function deleteManualRelation(int $relationId): void +{ + global $con; + $con->execute("DELETE FROM modRelations WHERE relationId = ? AND origin = ?", [$relationId, REL_ORIGIN_MANUAL]); +} + +/** Internal: hydrate a list of relation rows with their resolvedMod link (null if target doesn't exist). */ +function _hydrateResolvedMod(array $rows): array +{ + global $con; + if (!$rows) return $rows; + $modIds = array_filter(array_unique(array_column($rows, 'targetModId'))); + $modsByModId = []; + if ($modIds) { + $in = implode(',', array_map('intval', $modIds)); + // @security: $in built from integer column values, sql inert. + $modsRows = $con->getAll(" + SELECT m.modId, m.assetId, a.name, m.urlAlias, m.summary + FROM mods m JOIN assets a ON a.assetId = m.assetId + WHERE m.modId IN ($in)"); + foreach ($modsRows as $m) $modsByModId[(int)$m['modId']] = $m; + } + foreach ($rows as &$r) { + $r['resolvedMod'] = isset($modsByModId[(int)$r['targetModId']]) ? $modsByModId[(int)$r['targetModId']] : null; + } + return $rows; +} + +/** + * Internal: load the deduped (manual-wins) relation rows for a release, WITHOUT hydrating + * resolvedMod data. Used by display helpers (which add hydration on top) and by the BFS + * resolver (which only needs targetIdentifier / relationType / version bounds for graph walking). + */ +function _loadDedupedRelationsForRelease(int $releaseId, ?string $relationType = null): array +{ + global $con; + + $where = "releaseId = ?"; + $params = [$releaseId]; + if ($relationType !== null) { + $where .= " AND relationType = ?"; + $params[] = $relationType; + } + + $rows = $con->getAll("SELECT * FROM modRelations WHERE $where", $params); + + // Re-resolve targetModId on every call (cheap, catches mods uploaded after relation declaration). + foreach ($rows as &$r) { + if (!$r['targetModId']) $r['targetModId'] = _resolveTargetModId($r['targetIdentifier']); + } + unset($r); + + // Dedupe with manual winning. + $byKey = []; + foreach ($rows as $row) { + $key = $row['targetIdentifier'].'|'.$row['relationType']; + if (!isset($byKey[$key]) || $row['origin'] === REL_ORIGIN_MANUAL) $byKey[$key] = $row; + } + return array_values($byKey); +} + +/** + * Merged display view for a specific release: manual rows ∪ auto rows of the same release. + * Manual wins on (targetIdentifier, relationType) collisions. Caller specifies the release. + */ +function getRelationsForRelease(int $releaseId, ?string $relationType = null): array +{ + return _hydrateResolvedMod(_loadDedupedRelationsForRelease($releaseId, $relationType)); +} + +/** + * Edit-view: returns auto rows and manual rows of a release separately so the UI can render + * two distinct sections. + * + * @return array{auto:array, manual:array} + */ +function getRelationsForReleaseEditView(int $releaseId): array +{ + global $con; + + $autoRows = $con->getAll( + "SELECT * FROM modRelations WHERE releaseId = ? AND origin = ?", + [$releaseId, REL_ORIGIN_AUTO] + ); + $byKey = []; + foreach ($autoRows as $row) $byKey[$row['targetIdentifier'].'|'.$row['relationType']] = $row; + $auto = array_values($byKey); + + $manual = $con->getAll( + "SELECT * FROM modRelations WHERE releaseId = ? AND origin = ?", + [$releaseId, REL_ORIGIN_MANUAL] + ); + + return ['auto' => _hydrateResolvedMod($auto), 'manual' => _hydrateResolvedMod($manual)]; +} + +/** + * Public infobox helper: returns the merged relations for the latest non-retracted release of a mod. + * If the mod hosts multiple identifiers, the most recently released release is used (matching the + * "latest release" surfaced elsewhere on the mod page). + */ +function getRelationsForLatestReleaseOfMod(int $modId, ?string $relationType = null): array +{ + global $con; + + $latestReleaseId = $con->getOne( + "SELECT r.releaseId + FROM modReleases r + LEFT JOIN modReleaseRetractions rr ON rr.releaseId = r.releaseId + WHERE r.modId = ? AND rr.reason IS NULL + ORDER BY r.releaseId DESC LIMIT 1", + [$modId] + ); + if (!$latestReleaseId) return []; + return getRelationsForRelease((int)$latestReleaseId, $relationType); +} + +/** + * Re-syncs the auto-derived 'required' relations for a release based on its parsed modinfo dependencies. + * Idempotent. Manual rows are never touched. Auto rows for this release that no longer appear in + * $rawDependencies are deleted; new ones are inserted; existing ones are updated when minVersion changes. + */ +function syncAutoRelationsForRelease(int $releaseId, ?string $rawDependencies): void +{ + global $con, $user; + + $desired = parseRawDeps($rawDependencies); + + $existing = $con->getAll( + "SELECT relationId, targetIdentifier, minVersion FROM modRelations + WHERE releaseId = ? AND origin = ? AND relationType = ?", + [$releaseId, REL_ORIGIN_AUTO, REL_REQUIRED] + ); + $existingByTarget = []; + foreach ($existing as $row) $existingByTarget[$row['targetIdentifier']] = $row; + + $con->startTrans(); + + foreach ($desired as $target => $minVersion) { + if (isset($existingByTarget[$target])) { + if ((int)$existingByTarget[$target]['minVersion'] !== (int)$minVersion) { + $con->execute( + "UPDATE modRelations SET minVersion = ?, targetModId = ? WHERE relationId = ?", + [$minVersion ?: null, _resolveTargetModId($target), $existingByTarget[$target]['relationId']] + ); + } + unset($existingByTarget[$target]); + } else { + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, targetModId, relationType, minVersion, origin, createdByUserId) + VALUES (?,?,?,?,?,?,?)", + [$releaseId, $target, _resolveTargetModId($target), REL_REQUIRED, $minVersion ?: null, REL_ORIGIN_AUTO, $user['userId'] ?? 0] + ); + } + } + + foreach ($existingByTarget as $stale) { + $con->execute("DELETE FROM modRelations WHERE relationId = ?", [$stale['relationId']]); + } + + $con->completeTrans(); +} + +/** + * Copy manual relations from the previous release of the same identifier into a freshly-created release. + * Acts as a per-release template so authors don't have to re-declare optional / incompatible / tested-with + * relations on every upload (auto-detected 'required' rows are populated separately by syncAutoRelationsForRelease + * from the new release's own rawDependencies). + * + * No-op when the new release has no identifier, no prior release exists, or the new release already has + * manual relations (prevents accidental overwrite if called twice). + */ +function cloneManualRelationsFromPreviousRelease(int $newReleaseId): int +{ + global $con, $user; + + $newRelease = $con->getRow( + "SELECT releaseId, modId, identifier FROM modReleases WHERE releaseId = ?", + [$newReleaseId] + ); + if (!$newRelease || !$newRelease['identifier']) return 0; + + $alreadyHasManual = $con->getOne( + "SELECT 1 FROM modRelations WHERE releaseId = ? AND origin = ? LIMIT 1", + [$newReleaseId, REL_ORIGIN_MANUAL] + ); + if ($alreadyHasManual) return 0; + + $previousReleaseId = $con->getOne( + "SELECT releaseId FROM modReleases + WHERE modId = ? AND identifier = ? AND releaseId != ? + ORDER BY releaseId DESC LIMIT 1", + [$newRelease['modId'], $newRelease['identifier'], $newReleaseId] + ); + if (!$previousReleaseId) return 0; + + $previousManual = $con->getAll( + "SELECT targetIdentifier, targetModId, relationType, minVersion, maxVersion + FROM modRelations WHERE releaseId = ? AND origin = ?", + [$previousReleaseId, REL_ORIGIN_MANUAL] + ); + if (!$previousManual) return 0; + + $copied = 0; + $con->startTrans(); + foreach ($previousManual as $row) { + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, targetModId, relationType, minVersion, maxVersion, origin, createdByUserId) + VALUES (?,?,?,?,?,?,?,?)", + [$newReleaseId, $row['targetIdentifier'], $row['targetModId'], $row['relationType'], $row['minVersion'], $row['maxVersion'], REL_ORIGIN_MANUAL, $user['userId'] ?? 0] + ); + $copied++; + } + $con->completeTrans(); + return $copied; +} + +/** + * Updates all rows where targetIdentifier == $newlyPublishedIdentifier and targetModId IS NULL, + * setting their targetModId to $newModId. Returns the number of rows updated. Called when a release + * publishes an identifier so previously-orphan relations get retro-linked. + */ +function resolveDanglingTargets(string $newlyPublishedIdentifier, int $newModId): int +{ + global $con; + $con->execute( + "UPDATE modRelations SET targetModId = ? WHERE targetIdentifier = ? AND targetModId IS NULL", + [$newModId, $newlyPublishedIdentifier] + ); + return (int)$con->Affected_Rows(); +} + +/** + * Returns the release that should be installed for this identifier, given an optional target game version. + * Light wrapper used during transitive resolution. Returns null if no compatible release exists. + * + * @return ?array{releaseId:int, identifier:string, version:int, fileName:string, fileUrl:string} + */ +function pickReleaseForIdentifier(string $identifier, ?int $gameVersion): ?array +{ + global $con; + + if ($gameVersion) { + $row = $con->getRow( + "SELECT r.releaseId, r.identifier, r.version, f.fileId, f.name, f.cdnPath + FROM modReleases r + JOIN modReleaseCompatibleGameVersions cgv ON cgv.releaseId = r.releaseId AND cgv.gameVersion = ? + LEFT JOIN files f ON f.assetId = r.assetId + LEFT JOIN modReleaseRetractions rr ON rr.releaseId = r.releaseId + WHERE r.identifier = ? AND rr.reason IS NULL + ORDER BY r.version DESC, f.`order` ASC, f.fileId ASC LIMIT 1", + [$gameVersion, $identifier] + ); + } else { + $row = $con->getRow( + "SELECT r.releaseId, r.identifier, r.version, f.fileId, f.name, f.cdnPath + FROM modReleases r + LEFT JOIN files f ON f.assetId = r.assetId + LEFT JOIN modReleaseRetractions rr ON rr.releaseId = r.releaseId + WHERE r.identifier = ? AND rr.reason IS NULL + ORDER BY r.version DESC, f.`order` ASC, f.fileId ASC LIMIT 1", + [$identifier] + ); + } + + if (!$row || !$row['fileId']) return null; + return [ + 'releaseId' => (int)$row['releaseId'], + 'identifier' => $row['identifier'], + 'version' => (int)$row['version'], + 'fileName' => $row['name'], + 'fileUrl' => formatDownloadTrackingUrl($row), + ]; +} + +/** + * Public DB-backed transitive resolver. Wires bfsResolve to real DB-backed loaders. The relations + * loader is release-aware: it picks the release for each identifier first, then loads relations + * of that specific release. + * + * $rootReleaseMap optionally pre-seeds the resolver with caller-chosen releases for the root + * identifiers (e.g. the install-information API uses this to honor the @version specified in the + * URL instead of falling back to the latest version of that identifier). Each entry must have the + * same shape as pickReleaseForIdentifier returns: {releaseId, identifier, version, fileName, fileUrl}. + * Transitive deps still fall through to pickReleaseForIdentifier (constrained by $gameVersion if given). + * + * @param string[] $rootIdentifiers + * @param array $rootReleaseMap + */ +function resolveTransitiveDeps(array $rootIdentifiers, ?int $gameVersion, array $rootReleaseMap = []): array +{ + $pickedReleases = $rootReleaseMap; + + $releasePicker = function (string $identifier) use ($gameVersion, &$pickedReleases) { + if (array_key_exists($identifier, $pickedReleases)) return $pickedReleases[$identifier]; + $rel = pickReleaseForIdentifier($identifier, $gameVersion); + $pickedReleases[$identifier] = $rel; + return $rel; + }; + + $relationsLoader = function (string $identifier) use (&$pickedReleases) { + $picked = $pickedReleases[$identifier] ?? null; + if (!$picked || !isset($picked['releaseId'])) return []; + // Resolver doesn't need resolvedMod hydration - skip it for cheaper BFS nodes. + return _loadDedupedRelationsForRelease((int)$picked['releaseId']); + }; + + return bfsResolve($rootIdentifiers, $relationsLoader, $releasePicker); +} + +/** + * Persist relation form data submitted from edit-release.tpl. Each row's key is either an existing + * relationId (integer) for updates, or a "new-N" pseudo-id for inserts. All operations are scoped + * to the given release and origin=manual; auto rows are not touched here. + * + * Form row shape: ['type'=>string, 'target'=>string, 'minVersion'=>string, 'maxVersion'=>string] + * + * @param array $formRows + */ +function persistManualRelationsFromForm(int $releaseId, array $formRows): void +{ + global $con; + + $allowedTypes = [REL_REQUIRED, REL_OPTIONAL, REL_INCOMPATIBLE, REL_TESTED_WITH]; + + $con->startTrans(); + foreach ($formRows as $key => $row) { + $type = $row['type'] ?? ''; + $target = trim($row['target'] ?? ''); + if ($target === '' || !in_array($type, $allowedTypes, true)) continue; + + $min = ($row['minVersion'] ?? '') !== '' ? (compileSemanticVersion($row['minVersion']) ?: null) : null; + $max = ($row['maxVersion'] ?? '') !== '' ? (compileSemanticVersion($row['maxVersion']) ?: null) : null; + + if (is_numeric($key)) { + // Update existing row (must belong to this release and be manual). + $existing = $con->getRow( + "SELECT * FROM modRelations WHERE relationId = ? AND releaseId = ? AND origin = ?", + [intval($key), $releaseId, REL_ORIGIN_MANUAL] + ); + if (!$existing) continue; + // If type or target changed, fall back to delete+insert to respect the unique index. + if ($existing['relationType'] !== $type || $existing['targetIdentifier'] !== $target) { + deleteManualRelation((int)$existing['relationId']); + upsertManualRelation($releaseId, $target, $type, $min, $max); + } else { + $con->execute( + "UPDATE modRelations SET minVersion = ?, maxVersion = ?, targetModId = ? WHERE relationId = ?", + [$min, $max, _resolveTargetModId($target), $existing['relationId']] + ); + } + } else { + upsertManualRelation($releaseId, $target, $type, $min, $max); + } + } + $con->completeTrans(); +} diff --git a/show-mod.php b/show-mod.php index 2185d691..f5f591a2 100644 --- a/show-mod.php +++ b/show-mod.php @@ -2,6 +2,7 @@ include $config['basepath']. 'lib/recommend-release.php'; include $config['basepath']. 'lib/moderation.php'; +include_once $config['basepath']. 'lib/relations.php'; $assetId = $urlparts[2] ?? 0; @@ -378,6 +379,18 @@ $view->assign('pagetitle', "{$asset['name']} - "); +$relationsList = getRelationsForLatestReleaseOfMod(intval($asset['modId'])); +$relations = [ + REL_REQUIRED => [], + REL_OPTIONAL => [], + REL_INCOMPATIBLE => [], + REL_TESTED_WITH => [], +]; +foreach ($relationsList as $rel) { + $relations[$rel['relationType']][] = $rel; +} +$view->assign('relations', $relations); + $view->display("show-mod"); /** Fold several sequential version tags, e.g. 1.2.3, 1.2.4, 1.2.5 into '1.2.3 - 1.2.5' with a description containing the original versions. diff --git a/templates/edit-release-relations.tpl b/templates/edit-release-relations.tpl new file mode 100644 index 00000000..ef773fd5 --- /dev/null +++ b/templates/edit-release-relations.tpl @@ -0,0 +1,124 @@ +
+ +
+
+ Auto-detected dependencies + Required dependencies from this release's modinfo.json. Read-only here - re-synced on every release upload. +
+ {if empty($autoRelations)} +
No auto-detected dependencies on this release.
+ {else} +
    + {foreach from=$autoRelations item=rel} +
  • + {$rel['relationType']} + + {if $rel['resolvedMod']} + {$rel['resolvedMod']['name']} + {else} + {$rel['targetIdentifier']} + {/if} + + {if $rel['minVersion']}≥ {formatSemanticVersion($rel['minVersion'])}{/if} +
  • + {/foreach} +
+ {/if} +
+ +
+
+ Manual relations + Add optional / incompatible / tested-with entries, or override an auto-detected version by creating a manual entry with the same target. +
+
    + {foreach from=$manualRelations item=rel} +
  • +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
  • + {/foreach} +
+ +
+ +
+ + diff --git a/templates/edit-release.tpl b/templates/edit-release.tpl index 34a783ca..df5cb96b 100644 --- a/templates/edit-release.tpl +++ b/templates/edit-release.tpl @@ -139,6 +139,13 @@ {/if} + + {if $release['assetId']} +

Mod relations

+
+ {include file="edit-release-relations" autoRelations=$autoRelations manualRelations=$manualRelations} +
+ {/if} {if $release['assetId']} diff --git a/templates/header.tpl b/templates/header.tpl index a0d954e4..6536f613 100644 --- a/templates/header.tpl +++ b/templates/header.tpl @@ -27,7 +27,7 @@ - + {if isset($assetserver) && str_starts_with($assetserver, 'http')}{/if} diff --git a/templates/show-mod-relation-list.tpl b/templates/show-mod-relation-list.tpl new file mode 100644 index 00000000..16e62fb9 --- /dev/null +++ b/templates/show-mod-relation-list.tpl @@ -0,0 +1 @@ +{foreach from=$list item=rel key=i}{if $i > 0}, {/if}{if $rel['resolvedMod']}{$rel['resolvedMod']['name']}{else}{$rel['targetIdentifier']}{/if}{/foreach} diff --git a/templates/show-mod.tpl b/templates/show-mod.tpl index 0466e2c4..ec67feed 100644 --- a/templates/show-mod.tpl +++ b/templates/show-mod.tpl @@ -168,6 +168,24 @@ {/if}
Side:
{ucfirst($asset['side'])}
+ {if !empty($relations['required'])} +
Requires:
+
+ {foreach from=$relations['required'] item=rel key=i}{if $i > 0}, {/if}{if $rel['resolvedMod']}{$rel['resolvedMod']['name']}{if $rel['minVersion']} ≥{formatSemanticVersion($rel['minVersion'])}{/if}{else}{$rel['targetIdentifier']}{if $rel['minVersion']} ≥{formatSemanticVersion($rel['minVersion'])}{/if}{/if}{/foreach} +
+ {/if} + {if !empty($relations['optional'])} +
Recommended:
+
{include file="show-mod-relation-list" list=$relations['optional']}
+ {/if} + {if !empty($relations['tested_with'])} +
Verified compatible with:
+
{include file="show-mod-relation-list" list=$relations['tested_with']}
+ {/if} + {if !empty($relations['incompatible'])} +
Incompatible with:
+
{include file="show-mod-relation-list" list=$relations['incompatible']}
+ {/if}
Created:
{fancyDate($asset['created'])}
Last modified:
{fancyDate($asset['lastReleased'])}
Downloads:
{intval($asset['downloads'])}
diff --git a/tests/api-install-information.php b/tests/api-install-information.php new file mode 100644 index 00000000..8e3fcb01 --- /dev/null +++ b/tests/api-install-information.php @@ -0,0 +1,51 @@ + true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_RESOLVE => ['mods.vintagestory.stage:443:'.$gatewayIp], + ]); + $body = curl_exec($ch); + curl_close($ch); + return json_decode($body, true) ?: []; +} + +final class ApiInstallInfoTest extends TestCase +{ + public function testBackwardCompatNoResolveDepsKey(): void + { + $r = callInstallInfo(['ids' => 'rel-test-A@1.0.0']); + $this->assertArrayNotHasKey('resolved', $r); + $this->assertArrayNotHasKey('installOrder', $r); + $this->assertArrayNotHasKey('warnings', $r); + $this->assertArrayHasKey('data', $r); + } + + public function testWithResolveDepsReturnsResolvedAndWarnings(): void + { + $r = callInstallInfo(['ids' => 'rel-test-A@1.0.0', 'resolve-deps' => '1']); + $this->assertArrayHasKey('resolved', $r); + $this->assertArrayHasKey('installOrder', $r); + $this->assertArrayHasKey('warnings', $r); + $this->assertSame($r['installOrder'], array_keys($r['resolved']), + 'installOrder must list the resolved identifiers in emission order'); + } +} diff --git a/tests/relations-integration.php b/tests/relations-integration.php new file mode 100644 index 00000000..c2ba2a95 --- /dev/null +++ b/tests/relations-integration.php @@ -0,0 +1,560 @@ +getOne("SELECT userId FROM users LIMIT 1"); + + $result = ['userId' => $userId]; + foreach (['A','B','C'] as $suffix) { + $alias = 'rel-test-'.$suffix; + + $existingMod = $con->getOne("SELECT modId FROM mods WHERE urlAlias = ?", [$alias]); + if ($existingMod) { + $modId = (int)$existingMod; + $relAssetIds = $con->getCol("SELECT assetId FROM modReleases WHERE modId = ?", [$modId]); + foreach ($relAssetIds as $relAssetId) { + if (!$con->getOne("SELECT fileId FROM files WHERE assetId = ?", [$relAssetId])) { + $con->execute("INSERT INTO files (assetId, assetTypeId, userId, name, cdnPath, size, `order`) VALUES (?,?,?,?,?,?,?)", + [$relAssetId, ASSETTYPE_MOD, $userId, $alias.'.zip', '/cdn/'.$alias.'.zip', 1024, 0]); + } + } + } else { + $con->execute("INSERT INTO assets (createdByUserId, statusId, assetTypeId, name, text) VALUES (?,?,?,?,?)", + [$userId, STATUS_RELEASED, ASSETTYPE_MOD, 'Rel Test '.$alias, '']); + $assetId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO mods (assetId, urlAlias, summary, descriptionSearchable, side, category, lastReleased) VALUES (?,?,?,?,?,?,NOW())", + [$assetId, $alias, $alias, $alias, 'both', CATEGORY_GAME_MOD]); + $modId = (int)$con->Insert_ID(); + + $con->execute("INSERT INTO assets (createdByUserId, statusId, assetTypeId, name) VALUES (?,?,?,?)", + [$userId, STATUS_RELEASED, ASSETTYPE_MOD, $alias.'-r1']); + $relAssetId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO modReleases (assetId, modId, identifier, version) VALUES (?,?,?,?)", + [$relAssetId, $modId, $alias, compileSemanticVersion('1.0.0')]); + $con->execute("INSERT INTO files (assetId, assetTypeId, userId, name, cdnPath, size, `order`) VALUES (?,?,?,?,?,?,?)", + [$relAssetId, ASSETTYPE_MOD, $userId, $alias.'.zip', '/cdn/'.$alias.'.zip', 1024, 0]); + } + + $result['mod'.$suffix] = $modId; + $result['release'.$suffix] = (int)$con->getOne("SELECT releaseId FROM modReleases WHERE modId = ? ORDER BY releaseId ASC LIMIT 1", [$modId]); + } + + return $result; +} + +final class RelationsIntegrationTest extends TestCase +{ + private static $fx; + + public static function setUpBeforeClass(): void + { + self::$fx = relationsTestFixture(); + } + + protected function setUp(): void + { + global $con; + $releaseIds = [self::$fx['releaseA'], self::$fx['releaseB'], self::$fx['releaseC']]; + $in = implode(',', array_map('intval', $releaseIds)); + $con->execute("DELETE FROM modRelations WHERE releaseId IN ($in)"); + } + + public function testTableExistsWithExpectedColumns(): void + { + global $con; + $cols = $con->getCol("SHOW COLUMNS FROM modRelations"); + $expected = [ + 'relationId','releaseId','targetIdentifier','targetModId', + 'relationType','minVersion','maxVersion','origin','createdByUserId','created','lastModified', + ]; + sort($cols); sort($expected); + $this->assertEquals($expected, $cols); + } + + public function testUniqueIndexPreventsDuplicates(): void + { + global $con; + $userId = self::$fx['userId']; + $releaseId = self::$fx['releaseA']; + + $con->execute("DELETE FROM modRelations WHERE targetIdentifier = 'pluq-test-target'"); + $threw = false; + try { + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, relationType, origin, createdByUserId) VALUES (?,?,?,?,?)", + [$releaseId, 'pluq-test-target', REL_REQUIRED, REL_ORIGIN_MANUAL, $userId] + ); + try { + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, relationType, origin, createdByUserId) VALUES (?,?,?,?,?)", + [$releaseId, 'pluq-test-target', REL_REQUIRED, REL_ORIGIN_MANUAL, $userId] + ); + } catch (\Exception $e) { + $threw = true; + } + } finally { + $con->execute("DELETE FROM modRelations WHERE targetIdentifier = 'pluq-test-target'"); + } + $this->assertTrue($threw, 'Expected unique index violation on second insert'); + } + + public function testUpsertManualInsertsRow(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + $id = upsertManualRelation(self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, compileSemanticVersion('1.0.0'), null); + $row = $con->getRow("SELECT * FROM modRelations WHERE relationId = ?", [$id]); + $this->assertEquals('rel-test-B', $row['targetIdentifier']); + $this->assertEquals(REL_ORIGIN_MANUAL, $row['origin']); + $this->assertEquals(self::$fx['releaseA'], (int)$row['releaseId']); + $this->assertEquals((int)self::$fx['modB'], (int)$row['targetModId']); // auto-resolved + } + + public function testUpsertManualConvertsAutoToManual(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, relationType, minVersion, origin, createdByUserId) VALUES (?,?,?,?,?,?)", + [self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, compileSemanticVersion('1.0.0'), REL_ORIGIN_AUTO, self::$fx['userId']] + ); + upsertManualRelation(self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, compileSemanticVersion('2.0.0'), null); + $row = $con->getRow( + "SELECT * FROM modRelations WHERE releaseId = ? AND targetIdentifier = ? AND relationType = ?", + [self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED] + ); + $this->assertEquals(REL_ORIGIN_MANUAL, $row['origin']); + $this->assertEquals((string)compileSemanticVersion('2.0.0'), (string)$row['minVersion']); + } + + public function testDeleteManualRemovesRow(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + $id = upsertManualRelation(self::$fx['releaseA'], 'rel-test-B', REL_OPTIONAL, null, null); + deleteManualRelation($id); + $this->assertEmpty($con->getRow("SELECT * FROM modRelations WHERE relationId = ?", [$id])); + } + + public function testDeleteManualRefusesToTouchAutoRows(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, relationType, origin, createdByUserId) VALUES (?,?,?,?,?)", + [self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, REL_ORIGIN_AUTO, self::$fx['userId']] + ); + $id = (int)$con->Insert_ID(); + deleteManualRelation($id); + $this->assertNotEmpty($con->getRow("SELECT * FROM modRelations WHERE relationId = ?", [$id])); + } + + public function testGetRelationsForReleaseResolvesTargetMod(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + upsertManualRelation(self::$fx['releaseA'], 'rel-test-B', REL_OPTIONAL, null, null); + $rels = getRelationsForRelease(self::$fx['releaseA']); + $this->assertCount(1, $rels); + $this->assertEquals('Rel Test rel-test-B', $rels[0]['resolvedMod']['name']); + } + + public function testGetRelationsForReleaseMergesAutoAndManualWithManualWinning(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, relationType, minVersion, origin, createdByUserId) VALUES (?,?,?,?,?,?)", + [self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, compileSemanticVersion('1.0.0'), REL_ORIGIN_AUTO, self::$fx['userId']] + ); + upsertManualRelation(self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, compileSemanticVersion('3.0.0'), null); + + $rels = getRelationsForRelease(self::$fx['releaseA']); + $required = array_filter($rels, fn($r) => $r['targetIdentifier'] === 'rel-test-B' && $r['relationType'] === REL_REQUIRED); + $this->assertCount(1, $required); + $first = array_values($required)[0]; + $this->assertEquals(REL_ORIGIN_MANUAL, $first['origin']); + $this->assertEquals((string)compileSemanticVersion('3.0.0'), (string)$first['minVersion']); + } + + public function testGetRelationsForLatestReleaseOfModUsesLatestRelease(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + + // Drop any leftover v2.0.0 from a previous crashed run before recreating it. + $staleReleaseId = $con->getOne("SELECT releaseId FROM modReleases WHERE modId = ? AND identifier = ? AND version = ?", + [self::$fx['modA'], 'rel-test-A', compileSemanticVersion('2.0.0')]); + if ($staleReleaseId) { + $staleAssetId = $con->getOne("SELECT assetId FROM modReleases WHERE releaseId = ?", [$staleReleaseId]); + $con->execute("DELETE FROM modReleases WHERE releaseId = ?", [$staleReleaseId]); + $con->execute("DELETE FROM files WHERE assetId = ?", [$staleAssetId]); + $con->execute("DELETE FROM assets WHERE assetId = ?", [$staleAssetId]); + } + + $con->execute("INSERT INTO assets (createdByUserId, statusId, assetTypeId, name) VALUES (?,?,?,?)", + [self::$fx['userId'], STATUS_RELEASED, ASSETTYPE_MOD, 'rel-test-A-r2']); + $assetId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO modReleases (assetId, modId, identifier, version) VALUES (?,?,?,?)", + [$assetId, self::$fx['modA'], 'rel-test-A', compileSemanticVersion('2.0.0')]); + $newReleaseId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO files (assetId, assetTypeId, userId, name, cdnPath, size, `order`) VALUES (?,?,?,?,?,?,?)", + [$assetId, ASSETTYPE_MOD, self::$fx['userId'], 'rel-test-A-2.0.0.zip', '/cdn/rel-test-A-2.zip', 1024, 0]); + + // Auto row only on the older release. + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, relationType, origin, createdByUserId) VALUES (?,?,?,?,?)", + [self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, REL_ORIGIN_AUTO, self::$fx['userId']] + ); + // Manual row on the newer release. + upsertManualRelation($newReleaseId, 'rel-test-C', REL_OPTIONAL, null, null); + + $rels = getRelationsForLatestReleaseOfMod(self::$fx['modA']); + $targets = array_column($rels, 'targetIdentifier'); + $this->assertContains('rel-test-C', $targets); + $this->assertNotContains('rel-test-B', $targets, 'relations from older releases should not surface on latest-release view'); + + $con->execute("DELETE FROM modReleases WHERE releaseId = ?", [$newReleaseId]); + $con->execute("DELETE FROM files WHERE assetId = ?", [$assetId]); + $con->execute("DELETE FROM assets WHERE assetId = ?", [$assetId]); + } + + public function testEditViewSplitsAutoAndManualForSameRelease(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, relationType, origin, createdByUserId) VALUES (?,?,?,?,?)", + [self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, REL_ORIGIN_AUTO, self::$fx['userId']] + ); + upsertManualRelation(self::$fx['releaseA'], 'rel-test-C', REL_OPTIONAL, null, null); + + $view = getRelationsForReleaseEditView(self::$fx['releaseA']); + $this->assertCount(1, $view['auto']); + $this->assertCount(1, $view['manual']); + $this->assertEquals('rel-test-B', $view['auto'][0]['targetIdentifier']); + $this->assertEquals('rel-test-C', $view['manual'][0]['targetIdentifier']); + } + + public function testEditViewKeepsDistinctRelationTypesForSameTarget(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, relationType, origin, createdByUserId) VALUES (?,?,?,?,?)", + [self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, REL_ORIGIN_AUTO, self::$fx['userId']] + ); + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, relationType, origin, createdByUserId) VALUES (?,?,?,?,?)", + [self::$fx['releaseA'], 'rel-test-B', REL_TESTED_WITH, REL_ORIGIN_AUTO, self::$fx['userId']] + ); + $view = getRelationsForReleaseEditView(self::$fx['releaseA']); + $autoTypes = array_column(array_filter($view['auto'], fn($r) => $r['targetIdentifier'] === 'rel-test-B'), 'relationType'); + sort($autoTypes); + $this->assertEquals([REL_REQUIRED, REL_TESTED_WITH], $autoTypes, + 'EditView must keep distinct relation types for the same target'); + } + + public function testCascadeOnReleaseDelete(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + + $con->execute("INSERT INTO assets (createdByUserId, statusId, assetTypeId, name, text) VALUES (?,?,?,?,?)", + [self::$fx['userId'], STATUS_RELEASED, ASSETTYPE_MOD, 'rel-test-temp', '']); + $assetId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO mods (assetId, urlAlias, summary, descriptionSearchable, side, category, lastReleased) VALUES (?,?,?,?,?,?,NOW())", + [$assetId, 'rel-test-temp', 'tmp', 'tmp', 'both', CATEGORY_GAME_MOD]); + $tempModId = (int)$con->Insert_ID(); + + $con->execute("INSERT INTO assets (createdByUserId, statusId, assetTypeId, name) VALUES (?,?,?,?)", + [self::$fx['userId'], STATUS_RELEASED, ASSETTYPE_MOD, 'rel-test-temp-r1']); + $relAssetId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO modReleases (assetId, modId, identifier, version) VALUES (?,?,?,?)", + [$relAssetId, $tempModId, 'rel-test-temp', compileSemanticVersion('1.0.0')]); + $tempReleaseId = (int)$con->Insert_ID(); + + upsertManualRelation($tempReleaseId, 'rel-test-A', REL_REQUIRED, null, null); + $con->execute("DELETE FROM modReleases WHERE releaseId = ?", [$tempReleaseId]); + + $count = (int)$con->getOne("SELECT COUNT(*) FROM modRelations WHERE releaseId = ?", [$tempReleaseId]); + $this->assertEquals(0, $count, 'modRelations must cascade-delete with their release'); + + $con->execute("DELETE FROM mods WHERE modId = ?", [$tempModId]); + } + + public function testSyncAutoInsertsNewRelations(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + syncAutoRelationsForRelease(self::$fx['releaseA'], 'rel-test-B@1.0.0, rel-test-C@2.0.0'); + $rows = $con->getAll("SELECT * FROM modRelations WHERE releaseId = ? AND origin = ?", + [self::$fx['releaseA'], REL_ORIGIN_AUTO]); + $this->assertCount(2, $rows); + } + + public function testSyncAutoIsIdempotent(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + syncAutoRelationsForRelease(self::$fx['releaseA'], 'rel-test-B@1.0.0'); + syncAutoRelationsForRelease(self::$fx['releaseA'], 'rel-test-B@1.0.0'); + $count = (int)$con->getOne("SELECT COUNT(*) FROM modRelations WHERE releaseId = ?", [self::$fx['releaseA']]); + $this->assertEquals(1, $count); + } + + public function testSyncAutoDiffsCorrectly(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + syncAutoRelationsForRelease(self::$fx['releaseA'], 'rel-test-B@1.0.0, rel-test-C@2.0.0'); + syncAutoRelationsForRelease(self::$fx['releaseA'], 'rel-test-B@2.0.0'); + $rows = $con->getAll( + "SELECT targetIdentifier, minVersion FROM modRelations WHERE releaseId = ? ORDER BY targetIdentifier", + [self::$fx['releaseA']] + ); + $this->assertCount(1, $rows); + $this->assertEquals('rel-test-B', $rows[0]['targetIdentifier']); + $this->assertEquals((string)compileSemanticVersion('2.0.0'), (string)$rows[0]['minVersion']); + } + + public function testSyncAutoDoesNotTouchManualOnSameRelease(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + upsertManualRelation(self::$fx['releaseA'], 'rel-test-B', REL_OPTIONAL, compileSemanticVersion('5.0.0'), null); + syncAutoRelationsForRelease(self::$fx['releaseA'], 'rel-test-B@1.0.0'); + $manual = $con->getRow( + "SELECT minVersion, relationType FROM modRelations WHERE releaseId = ? AND targetIdentifier = ? AND origin = ?", + [self::$fx['releaseA'], 'rel-test-B', REL_ORIGIN_MANUAL] + ); + $this->assertNotNull($manual); + $this->assertEquals(REL_OPTIONAL, $manual['relationType']); + $this->assertEquals((string)compileSemanticVersion('5.0.0'), (string)$manual['minVersion']); + } + + public function testCloneManualRelationsFromPreviousReleaseCopiesManualOnly(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + + upsertManualRelation(self::$fx['releaseA'], 'rel-test-B', REL_TESTED_WITH, null, null); + $con->execute( + "INSERT INTO modRelations (releaseId, targetIdentifier, relationType, origin, createdByUserId) VALUES (?,?,?,?,?)", + [self::$fx['releaseA'], 'rel-test-C', REL_REQUIRED, REL_ORIGIN_AUTO, self::$fx['userId']] + ); + + // Drop any leftover v2.0.0 from a previous crashed run before recreating it. + $staleReleaseId = $con->getOne("SELECT releaseId FROM modReleases WHERE modId = ? AND identifier = ? AND version = ?", + [self::$fx['modA'], 'rel-test-A', compileSemanticVersion('2.0.0')]); + if ($staleReleaseId) { + $staleAssetId = $con->getOne("SELECT assetId FROM modReleases WHERE releaseId = ?", [$staleReleaseId]); + $con->execute("DELETE FROM modReleases WHERE releaseId = ?", [$staleReleaseId]); + $con->execute("DELETE FROM files WHERE assetId = ?", [$staleAssetId]); + $con->execute("DELETE FROM assets WHERE assetId = ?", [$staleAssetId]); + } + + $con->execute("INSERT INTO assets (createdByUserId, statusId, assetTypeId, name) VALUES (?,?,?,?)", + [self::$fx['userId'], STATUS_RELEASED, ASSETTYPE_MOD, 'rel-test-A-r2']); + $newAssetId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO modReleases (assetId, modId, identifier, version) VALUES (?,?,?,?)", + [$newAssetId, self::$fx['modA'], 'rel-test-A', compileSemanticVersion('2.0.0')]); + $newReleaseId = (int)$con->Insert_ID(); + + $copied = cloneManualRelationsFromPreviousRelease($newReleaseId); + $this->assertEquals(1, $copied); + + $rows = $con->getAll( + "SELECT targetIdentifier, relationType, origin FROM modRelations WHERE releaseId = ?", + [$newReleaseId] + ); + $this->assertCount(1, $rows, 'only the manual row should be cloned, never auto'); + $this->assertEquals('rel-test-B', $rows[0]['targetIdentifier']); + $this->assertEquals(REL_TESTED_WITH, $rows[0]['relationType']); + $this->assertEquals(REL_ORIGIN_MANUAL, $rows[0]['origin']); + + $con->execute("DELETE FROM modReleases WHERE releaseId = ?", [$newReleaseId]); + $con->execute("DELETE FROM assets WHERE assetId = ?", [$newAssetId]); + } + + public function testResolveDanglingTargetsLinksLaterPublishedMod(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + upsertManualRelation(self::$fx['releaseA'], 'rel-test-newcomer', REL_REQUIRED, null, null); + $row = $con->getRow("SELECT * FROM modRelations WHERE releaseId = ? AND targetIdentifier = ?", + [self::$fx['releaseA'], 'rel-test-newcomer']); + $this->assertNull($row['targetModId']); + + $con->execute("INSERT INTO assets (createdByUserId, statusId, assetTypeId, name, text) VALUES (?,?,?,?,?)", + [self::$fx['userId'], STATUS_RELEASED, ASSETTYPE_MOD, 'Newcomer', '']); + $assetId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO mods (assetId, urlAlias, summary, descriptionSearchable, side, category, lastReleased) VALUES (?,?,?,?,?,?,NOW())", + [$assetId, 'rel-test-newcomer', 'n', 'n', 'both', CATEGORY_GAME_MOD]); + $newModId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO assets (createdByUserId, statusId, assetTypeId, name) VALUES (?,?,?,?)", + [self::$fx['userId'], STATUS_RELEASED, ASSETTYPE_MOD, 'newcomer-r1']); + $relAssetId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO modReleases (assetId, modId, identifier, version) VALUES (?,?,?,?)", + [$relAssetId, $newModId, 'rel-test-newcomer', compileSemanticVersion('1.0.0')]); + + $updated = resolveDanglingTargets('rel-test-newcomer', $newModId); + $this->assertGreaterThanOrEqual(1, $updated); + + $row = $con->getRow("SELECT * FROM modRelations WHERE releaseId = ? AND targetIdentifier = ?", + [self::$fx['releaseA'], 'rel-test-newcomer']); + $this->assertEquals($newModId, (int)$row['targetModId']); + + $con->execute("DELETE FROM modRelations WHERE targetIdentifier = ?", ['rel-test-newcomer']); + $con->execute("DELETE FROM mods WHERE modId = ?", [$newModId]); + } + + public function testPickReleaseForKnownIdentifierReturnsRelease(): void + { + $out = pickReleaseForIdentifier('rel-test-A', null); + $this->assertNotNull($out); + $this->assertArrayHasKey('releaseId', $out); + $this->assertArrayHasKey('fileName', $out); + $this->assertArrayHasKey('fileUrl', $out); + } + + public function testPickReleaseForUnknownIdentifierReturnsNull(): void + { + $this->assertNull(pickReleaseForIdentifier('this-identifier-does-not-exist-anywhere', null)); + } + + public function testResolveTransitiveDepsResolvesChain(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + upsertManualRelation(self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, null, null); + upsertManualRelation(self::$fx['releaseB'], 'rel-test-C', REL_REQUIRED, null, null); + + $out = resolveTransitiveDeps(['rel-test-A'], null); + $this->assertArrayHasKey('rel-test-B', $out['resolved']); + $this->assertArrayHasKey('rel-test-C', $out['resolved']); + $this->assertSame(['rel-test-C', 'rel-test-B', 'rel-test-A'], $out['installOrder'], + 'dependencies must come before their dependents'); + $this->assertEquals([], $out['warnings']); + } + + public function testResolveTransitiveDepsRespectsExplicitRootRelease(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + + // Drop any leftover v2.0.0 from a previous crashed run before recreating it. + $staleReleaseId = $con->getOne("SELECT releaseId FROM modReleases WHERE modId = ? AND identifier = ? AND version = ?", + [self::$fx['modA'], 'rel-test-A', compileSemanticVersion('2.0.0')]); + if ($staleReleaseId) { + $staleAssetId = $con->getOne("SELECT assetId FROM modReleases WHERE releaseId = ?", [$staleReleaseId]); + $con->execute("DELETE FROM modReleases WHERE releaseId = ?", [$staleReleaseId]); + $con->execute("DELETE FROM files WHERE assetId = ?", [$staleAssetId]); + $con->execute("DELETE FROM assets WHERE assetId = ?", [$staleAssetId]); + } + + $con->execute("INSERT INTO assets (createdByUserId, statusId, assetTypeId, name) VALUES (?,?,?,?)", + [self::$fx['userId'], STATUS_RELEASED, ASSETTYPE_MOD, 'rel-test-A-r2-explicit']); + $newAssetId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO modReleases (assetId, modId, identifier, version) VALUES (?,?,?,?)", + [$newAssetId, self::$fx['modA'], 'rel-test-A', compileSemanticVersion('2.0.0')]); + $newReleaseId = (int)$con->Insert_ID(); + $con->execute("INSERT INTO files (assetId, assetTypeId, userId, name, cdnPath, size, `order`) VALUES (?,?,?,?,?,?,?)", + [$newAssetId, ASSETTYPE_MOD, self::$fx['userId'], 'rel-test-A-2.0.0.zip', '/cdn/rel-test-A-2.zip', 1024, 0]); + + // v1 (releaseA) requires B; v2 (newReleaseId) requires C. Without the rootReleaseMap, the resolver + // picks the latest (v2) and would see C only. With the explicit map pointing at v1, it must see B. + upsertManualRelation(self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, null, null); + upsertManualRelation($newReleaseId, 'rel-test-C', REL_REQUIRED, null, null); + + $rootMap = [ + 'rel-test-A' => [ + 'releaseId' => self::$fx['releaseA'], + 'identifier' => 'rel-test-A', + 'version' => compileSemanticVersion('1.0.0'), + 'fileName' => 'rel-test-A.zip', + 'fileUrl' => '/cdn/rel-test-A.zip', + ], + ]; + $out = resolveTransitiveDeps(['rel-test-A'], null, $rootMap); + $this->assertArrayHasKey('rel-test-B', $out['resolved'], 'rootReleaseMap must pin the resolver to v1, which requires B'); + $this->assertArrayNotHasKey('rel-test-C', $out['resolved'], 'v2-only dep C must not surface when explicit root is v1'); + + $con->execute("DELETE FROM modReleases WHERE releaseId = ?", [$newReleaseId]); + $con->execute("DELETE FROM files WHERE assetId = ?", [$newAssetId]); + $con->execute("DELETE FROM assets WHERE assetId = ?", [$newAssetId]); + } + + public function testResolveTransitiveDepsReportsCycle(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + upsertManualRelation(self::$fx['releaseA'], 'rel-test-B', REL_REQUIRED, null, null); + upsertManualRelation(self::$fx['releaseB'], 'rel-test-A', REL_REQUIRED, null, null); + + $out = resolveTransitiveDeps(['rel-test-A'], null); + $kinds = array_column($out['warnings'], 'kind'); + $this->assertContains('cycle', $kinds); + } + + public function testPickReleaseForIdentifierWithGameVersion(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + $gv = compileSemanticVersion('1.20.0'); + $con->execute( + "INSERT IGNORE INTO modReleaseCompatibleGameVersions (releaseId, gameVersion) VALUES (?,?)", + [self::$fx['releaseA'], $gv] + ); + $out = pickReleaseForIdentifier('rel-test-A', $gv); + $this->assertNotNull($out, 'gv-matching release should be picked'); + + $gvOther = compileSemanticVersion('99.0.0'); + $outNone = pickReleaseForIdentifier('rel-test-A', $gvOther); + $this->assertNull($outNone, 'no release matches a non-existent gv'); + + $con->execute("DELETE FROM modReleaseCompatibleGameVersions WHERE releaseId = ? AND gameVersion = ?", [self::$fx['releaseA'], $gv]); + } + + public function testPersistManualRelationsFromFormHandlesInsertUpdateRemove(): void + { + global $con, $user; + $user = ['userId' => self::$fx['userId']]; + $existingId = upsertManualRelation(self::$fx['releaseA'], 'rel-test-B', REL_OPTIONAL, null, null); + + persistManualRelationsFromForm(self::$fx['releaseA'], [ + $existingId => [ + 'type' => REL_INCOMPATIBLE, + 'target' => 'rel-test-B', + 'minVersion' => '', + 'maxVersion' => '', + ], + 'new-1' => [ + 'type' => REL_TESTED_WITH, + 'target' => 'rel-test-C', + 'minVersion' => '1.0.0', + 'maxVersion' => '', + ], + ]); + + $rows = $con->getAll( + "SELECT * FROM modRelations WHERE releaseId = ? ORDER BY targetIdentifier", + [self::$fx['releaseA']] + ); + $this->assertCount(2, $rows); + $byTarget = []; + foreach ($rows as $r) $byTarget[$r['targetIdentifier']] = $r; + $this->assertEquals(REL_INCOMPATIBLE, $byTarget['rel-test-B']['relationType']); + $this->assertEquals(REL_TESTED_WITH, $byTarget['rel-test-C']['relationType']); + $this->assertEquals((string)compileSemanticVersion('1.0.0'), (string)$byTarget['rel-test-C']['minVersion']); + } +} diff --git a/tests/relations-pure.php b/tests/relations-pure.php new file mode 100644 index 00000000..6997274d --- /dev/null +++ b/tests/relations-pure.php @@ -0,0 +1,359 @@ +assertCount(2, $r); + $this->assertEquals(compileSemanticVersion('1.0.0'), $r['modA']); + $this->assertEquals(compileSemanticVersion('2.5.0'), $r['modB']); + } + + public function testReturnsZeroForUnversionedEntry(): void + { + $r = parseRawDeps('modA'); + $this->assertEquals(0, $r['modA']); + } + + public function testFiltersIgnoredIdentifiers(): void + { + $r = parseRawDeps('game@1.20.0, survival@1.20.0, creative@1.20.0, modA@1.0.0'); + $this->assertEquals(['modA' => compileSemanticVersion('1.0.0')], $r); + } + + public function testFiltersWildcardAndEmpty(): void + { + $r = parseRawDeps('*, modA@1.0.0'); + $this->assertEquals(['modA' => compileSemanticVersion('1.0.0')], $r); + } + + public function testDeduplicatesByKeepingHighestVersion(): void + { + $r = parseRawDeps('modA@1.0.0, modA@2.0.0, modA@1.5.0'); + $this->assertEquals(compileSemanticVersion('2.0.0'), $r['modA']); + } + + public function testEmptyInputReturnsEmptyArray(): void + { + $this->assertEquals([], parseRawDeps('')); + $this->assertEquals([], parseRawDeps(null)); + } + + public function testInvalidVersionStringFallsBackToZero(): void + { + $r = parseRawDeps('modA@garbage, modB@1.0'); // 1.0 is also invalid (needs three components) + $this->assertEquals(0, $r['modA']); + $this->assertEquals(0, $r['modB']); + } + + public function testFiltersEmptyIdentifierFromLeadingComma(): void + { + // Leading ", " produces an empty first segment after explode. + $r = parseRawDeps(', modA@1.0.0'); + $this->assertEquals(['modA' => compileSemanticVersion('1.0.0')], $r); + } +} + +final class RelationsMergeRangesTest extends TestCase +{ + public function testSingleConstraintReturnsItself(): void + { + $out = mergeRanges([['from' => 'A', 'min' => 100, 'max' => 200]]); + $this->assertEquals(100, $out['effectiveMin']); + $this->assertEquals(200, $out['effectiveMax']); + $this->assertFalse($out['unsatisfiable']); + } + + public function testNullBoundsAreOpen(): void + { + $out = mergeRanges([ + ['from' => 'A', 'min' => null, 'max' => 200], + ['from' => 'B', 'min' => 100, 'max' => null], + ]); + $this->assertEquals(100, $out['effectiveMin']); + $this->assertEquals(200, $out['effectiveMax']); + $this->assertFalse($out['unsatisfiable']); + } + + public function testOverlappingTakesIntersection(): void + { + $out = mergeRanges([ + ['from' => 'A', 'min' => 100, 'max' => 300], + ['from' => 'B', 'min' => 200, 'max' => 400], + ]); + $this->assertEquals(200, $out['effectiveMin']); + $this->assertEquals(300, $out['effectiveMax']); + $this->assertFalse($out['unsatisfiable']); + } + + public function testNonOverlappingIsUnsatisfiable(): void + { + $out = mergeRanges([ + ['from' => 'A', 'min' => 100, 'max' => 200], + ['from' => 'B', 'min' => 300, 'max' => 400], + ]); + $this->assertTrue($out['unsatisfiable']); + } + + public function testMinOnlyConstraintsTakeMaxOfMins(): void + { + $out = mergeRanges([ + ['from' => 'A', 'min' => 100, 'max' => null], + ['from' => 'B', 'min' => 250, 'max' => null], + ]); + $this->assertEquals(250, $out['effectiveMin']); + $this->assertNull($out['effectiveMax']); + $this->assertFalse($out['unsatisfiable']); + } + + public function testEmptyConstraintsReturnsOpenRange(): void + { + $out = mergeRanges([]); + $this->assertNull($out['effectiveMin']); + $this->assertNull($out['effectiveMax']); + $this->assertFalse($out['unsatisfiable']); + } + + public function testSingleContradictoryConstraintIsUnsatisfiable(): void + { + $out = mergeRanges([['from' => 'A', 'min' => 300, 'max' => 100]]); + $this->assertTrue($out['unsatisfiable']); + } +} + +final class RelationsBfsResolveTest extends TestCase +{ + private function loaderFromGraph(array $graph): callable + { + return function (string $id) use ($graph) { + return array_map(function ($edge) { + return [ + 'targetIdentifier' => $edge[0], + 'relationType' => $edge[1] ?? REL_REQUIRED, + 'minVersion' => $edge[2] ?? null, + 'maxVersion' => $edge[3] ?? null, + ]; + }, $graph[$id] ?? []); + }; + } + + private function defaultPicker(array $available): callable + { + return function (string $id) use ($available) { + return in_array($id, $available, true) ? ['fileName' => $id.'.zip', 'fileUrl' => '/dl/'.$id] : null; + }; + } + + public function testLinearChainIsFullyResolved(): void + { + $graph = ['A' => [['B']], 'B' => [['C']], 'C' => []]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B','C'])); + $this->assertEquals([], $out['warnings']); + $this->assertCount(3, $out['resolved']); + $this->assertEquals(0, $out['resolved']['A']['depth']); + $this->assertEquals(1, $out['resolved']['B']['depth']); + $this->assertEquals(2, $out['resolved']['C']['depth']); + } + + public function testDiamondInstallsTargetOnce(): void + { + $graph = ['A' => [['B'], ['C']], 'B' => [['D']], 'C' => [['D']], 'D' => []]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B','C','D'])); + $this->assertEquals([], $out['warnings']); + $this->assertCount(4, $out['resolved']); + $this->assertContains('B', $out['resolved']['D']['requiredBy']); + $this->assertContains('C', $out['resolved']['D']['requiredBy']); + } + + public function testDirectCycleEmitsWarning(): void + { + $graph = ['A' => [['B']], 'B' => [['A']]]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B'])); + $cycles = array_filter($out['warnings'], fn($w) => $w['kind'] === 'cycle'); + $this->assertNotEmpty($cycles); + $first = array_values($cycles)[0]; + $this->assertContains('A', $first['path']); + $this->assertContains('B', $first['path']); + } + + public function testIndirectCycleEmitsWarning(): void + { + $graph = ['A' => [['B']], 'B' => [['C']], 'C' => [['A']]]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B','C'])); + $cycles = array_filter($out['warnings'], fn($w) => $w['kind'] === 'cycle'); + $this->assertNotEmpty($cycles); + } + + public function testSelfCycleEmitsWarning(): void + { + $graph = ['A' => [['A']]]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A'])); + $cycles = array_filter($out['warnings'], fn($w) => $w['kind'] === 'cycle'); + $this->assertNotEmpty($cycles); + } + + public function testMissingDepEmitsWarning(): void + { + $graph = ['A' => [['MissingMod']]]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A'])); + $missing = array_filter($out['warnings'], fn($w) => $w['kind'] === 'missing_dep'); + $this->assertNotEmpty($missing); + $first = array_values($missing)[0]; + $this->assertEquals('MissingMod', $first['identifier']); + $this->assertEquals(['A'], $first['requiredBy']); + } + + public function testIncompatBetweenRootsEmitsWarning(): void + { + $graph = ['A' => [['B', REL_INCOMPATIBLE]], 'B' => []]; + $out = bfsResolve(['A','B'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B'])); + $incompat = array_filter($out['warnings'], fn($w) => $w['kind'] === 'incompatible'); + $this->assertNotEmpty($incompat); + } + + public function testIncompatWithTransitiveDepEmitsWarning(): void + { + $graph = ['A' => [['B'], ['C', REL_INCOMPATIBLE]], 'B' => [['C']], 'C' => []]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B','C'])); + $incompat = array_filter($out['warnings'], fn($w) => $w['kind'] === 'incompatible'); + $this->assertNotEmpty($incompat); + } + + public function testDepthLimitCutsOffDeepGraphs(): void + { + $graph = []; + $names = []; + for ($i = 0; $i < MAX_DEPS_DEPTH + 5; $i++) { + $names[] = "M$i"; + $graph["M$i"] = [["M".($i+1)]]; + } + $graph["M".count($names)] = []; + $out = bfsResolve(['M0'], $this->loaderFromGraph($graph), $this->defaultPicker(array_merge($names, ["M".count($names)]))); + $depthLimit = array_filter($out['warnings'], fn($w) => $w['kind'] === 'depth_limit'); + $this->assertNotEmpty($depthLimit); + } + + public function testOptionalAndTestedWithEmitInformationalWhenUnmet(): void + { + $graph = ['A' => [['B', REL_OPTIONAL], ['C', REL_TESTED_WITH]]]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A'])); + $kinds = array_column($out['warnings'], 'kind'); + $this->assertContains('optional_unmet', $kinds); + $this->assertContains('tested_with_unmet', $kinds); + } + + public function testMultipleRootsSharingDirectDepDedupRequiredBy(): void + { + // Two roots A and B both directly require D. requiredBy should not contain duplicate '' entries. + $graph = ['A' => [['D']], 'B' => [['D']], 'D' => []]; + $out = bfsResolve(['A','B'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B','D'])); + $this->assertEquals([], $out['warnings']); + $this->assertSame(array_unique($out['resolved']['D']['requiredBy']), $out['resolved']['D']['requiredBy'], + 'requiredBy must not contain duplicates'); + } + + public function testInstallOrderPutsDependenciesFirstOnLinearChain(): void + { + $graph = ['A' => [['B']], 'B' => [['C']], 'C' => []]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B','C'])); + $this->assertSame(['C','B','A'], $out['installOrder']); + $this->assertSame($out['installOrder'], array_keys($out['resolved']), + 'resolved must be emitted in the same order as installOrder'); + } + + public function testInstallOrderPutsSharedDepBeforeBothDependentsInDiamond(): void + { + $graph = ['A' => [['B'], ['C']], 'B' => [['D']], 'C' => [['D']], 'D' => []]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B','C','D'])); + $order = array_flip($out['installOrder']); + $this->assertLessThan($order['B'], $order['D'], 'D must install before B'); + $this->assertLessThan($order['C'], $order['D'], 'D must install before C'); + $this->assertSame('A', end($out['installOrder']), 'the root installs last'); + } + + public function testInstallOrderHandlesSameDepthCrossDependency(): void + { + // B and C are both at depth 1, but B also requires C: C must still install before B. + // Plain reversed BFS order would get this wrong when C is discovered before B's edge to it. + $graph = ['A' => [['B'], ['C']], 'B' => [['C']], 'C' => []]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B','C'])); + $order = array_flip($out['installOrder']); + $this->assertLessThan($order['B'], $order['C'], 'C must install before B, which requires it'); + } + + public function testInstallOrderKeepsDiscoveryOrderForIndependentRoots(): void + { + $graph = ['A' => [], 'B' => []]; + $out = bfsResolve(['A','B'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B'])); + $this->assertSame(['A','B'], $out['installOrder']); + } + + public function testInstallOrderOmitsMissingDeps(): void + { + $graph = ['A' => [['MissingMod']]]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A'])); + $this->assertSame(['A'], $out['installOrder']); + } + + public function testInstallOrderStaysCompleteWhenCycleIsCut(): void + { + $graph = ['A' => [['B']], 'B' => [['A']]]; + $out = bfsResolve(['A'], $this->loaderFromGraph($graph), $this->defaultPicker(['A','B'])); + $this->assertSame(['B','A'], $out['installOrder'], + 'the surviving edge A->B still orders B first; the cut back-edge must not drop nodes'); + } +} + +final class RelationsCycleGuardTest extends TestCase +{ + public function testDirectCycleDetected(): void + { + // graph: B -required-> A. Adding A -required-> B should be a cycle. + $graph = ['B' => [['target' => 'A', 'type' => REL_REQUIRED]]]; + $this->assertTrue(wouldCreateCycleInGraph('A', 'B', REL_REQUIRED, $graph)); + } + + public function testIndirectCycleDetected(): void + { + // graph: B->C, C->A. Adding A->B should be a cycle (A->B->C->A). + $graph = [ + 'B' => [['target' => 'C', 'type' => REL_REQUIRED]], + 'C' => [['target' => 'A', 'type' => REL_REQUIRED]], + ]; + $this->assertTrue(wouldCreateCycleInGraph('A', 'B', REL_REQUIRED, $graph)); + } + + public function testNonCyclicAdditionAllowed(): void + { + $graph = ['B' => [['target' => 'C', 'type' => REL_REQUIRED]]]; + $this->assertFalse(wouldCreateCycleInGraph('A', 'B', REL_REQUIRED, $graph)); + } + + public function testIgnoresOptionalAndIncompat(): void + { + // Even if optional cycles exist, they don't form an install cycle. + $graph = ['B' => [['target' => 'A', 'type' => REL_OPTIONAL]]]; + $this->assertFalse(wouldCreateCycleInGraph('A', 'B', REL_REQUIRED, $graph)); + } + + public function testNonRequiredAdditionNeverCycles(): void + { + $graph = ['B' => [['target' => 'A', 'type' => REL_REQUIRED]]]; + $this->assertFalse(wouldCreateCycleInGraph('A', 'B', REL_OPTIONAL, $graph)); + $this->assertFalse(wouldCreateCycleInGraph('A', 'B', REL_INCOMPATIBLE, $graph)); + $this->assertFalse(wouldCreateCycleInGraph('A', 'B', REL_TESTED_WITH, $graph)); + } + + public function testSelfCycleDetected(): void + { + // Adding A -required-> A in any (even empty) graph is a cycle. + $this->assertTrue(wouldCreateCycleInGraph('A', 'A', REL_REQUIRED, [])); + } +} diff --git a/web/_sass/_style.scss b/web/_sass/_style.scss index 6a4a2ac6..ff8bee57 100644 --- a/web/_sass/_style.scss +++ b/web/_sass/_style.scss @@ -15,3 +15,4 @@ @use 'layout-account-settings'; @use 'layout-ticket'; @use 'layout-ticket-list'; +@use 'mod-relations'; diff --git a/web/_sass/mod-relations.scss b/web/_sass/mod-relations.scss new file mode 100644 index 00000000..223bb1ce --- /dev/null +++ b/web/_sass/mod-relations.scss @@ -0,0 +1,221 @@ +// Public mod page relation display (4 sections in
) +.mod-relations { + .mod-link { + &.unresolved { + color: var(--color-text-weak); + font-style: italic; + cursor: help; + border-bottom: 1px dotted; + } + } + &.incompatible .mod-link { + text-decoration: line-through; + opacity: 0.85; + } +} +.infobox dt.warn { color: #d63; } + +// Edit-mod relations editor +.mod-relations-editor { + display: flex; + flex-direction: column; + gap: 0.6em; + width: 100%; + + // Each section mimics the .editbox visual (cream background, thin border, no rounded corners) + .rel-section { + border: solid 1px #CCC; + background: rgba(255, 255, 255, 0.25); + padding: 0.6em 0.8em; + + .rel-section-header { + display: flex; + flex-direction: column; + gap: 0.1em; + margin-bottom: 0.5em; + padding-bottom: 0.4em; + border-bottom: 1px solid #CCC; + + strong { + font-size: 1em; + color: var(--color-text); + } + .hint { + font-size: 0.82em; + color: var(--color-text-weak); + } + } + } + + .rel-empty { + font-style: italic; + color: var(--color-text-weak); + padding: 0.2em 0; + } + + .rel-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.3em; + } + + // Auto section: read-only item with badge + target + version + .rel-auto-section .rel-item { + display: flex; + align-items: center; + gap: 0.6em; + padding: 0.3em 0.4em; + + .rel-target { flex: 1; min-width: 8em; } + .rel-version { + font-size: 0.85em; + color: var(--color-text-weak); + min-width: 6em; + text-align: right; + } + .unresolved { + color: var(--color-text-weak); + font-style: italic; + cursor: help; + border-bottom: 1px dotted; + } + a { + color: var(--color-link); + &:hover { color: var(--color-link-active); } + } + } + + // Color-coded type badges (auto section) + .rel-badge { + display: inline-block; + padding: 0.05em 0.6em; + font-size: 0.75em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + min-width: 5em; + text-align: center; + color: white; + border: 1px solid transparent; + + &.badge-required { background: #4a6e9a; border-color: #3a5b80; } + &.badge-optional { background: #7a7a7a; border-color: #5e5e5e; } + &.badge-incompatible { background: #a64545; border-color: #843636; } + &.badge-tested_with { background: #5c8a5c; border-color: #4a6f4a; } + } + + // Manual section: editable form rows with labeled fields + .manual-list .rel-item { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.4em 0.6em; + padding: 0.5em 0.6em; + background: rgba(255, 255, 255, 0.4); + border: 1px solid #CCC; + border-left: 4px solid #CCC; + + &.rel-type-required { border-left-color: #4a6e9a; } + &.rel-type-optional { border-left-color: #7a7a7a; } + &.rel-type-incompatible { border-left-color: #a64545; } + &.rel-type-tested_with { border-left-color: #5c8a5c; } + + // New (unsaved) row gets a subtle tint instead of a dashed border to stay on-brand + &.rel-item-new { + background: rgba(255, 247, 220, 0.7); // pale warm yellow, hints "new/draft" + border-color: #b89a55; + } + } + + .rel-field { + display: flex; + flex-direction: column; + gap: 0.15em; + + &.rel-field-target { flex: 1; min-width: 10em; } + &.rel-field-type { min-width: 9em; } + &.rel-field-version { width: 7em; } + + .rel-field-label { + font-size: 0.72em; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-weak); + } + + // Inputs/selects inherit the project's flat style from input.scss + input, select { + width: 100%; + margin: 0; + } + } + + .rel-remove { + cursor: pointer; + user-select: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.6em; + height: 1.6em; + margin-bottom: 0.05em; + position: relative; + border: 1px solid transparent; + + input[type=checkbox] { + position: absolute; + opacity: 0; + pointer-events: none; + } + .rel-remove-icon { + font-size: 1.3em; + line-height: 1; + color: var(--color-text-weak); + } + &:hover { + border-color: #a64545; + .rel-remove-icon { color: #a64545; } + } + input[type=checkbox]:checked ~ .rel-remove-icon { + color: #a64545; + font-weight: bold; + } + } + + .rel-discard { + cursor: pointer; + background: transparent; + border: 1px solid transparent; + width: 1.6em; + height: 1.6em; + color: var(--color-text-weak); + font-size: 1.3em; + line-height: 1; + margin-bottom: 0.05em; + padding: 0; + + &:hover { + color: #a64545; + border-color: #a64545; + } + } + + // Existing rows marked for removal: faded + strikethrough on text inputs + .manual-list .rel-item:has(input[type=checkbox]:checked) { + opacity: 0.55; + + .rel-target-input, + .rel-version-input { + text-decoration: line-through; + } + } + + .rel-add-btn { + align-self: flex-start; + margin-top: 0.3em; + // Inherits .button-like style if explicit class added, but ensure flat look here + } +} diff --git a/web/css/style.css b/web/css/style.css index 6b405934..0d111373 100644 --- a/web/css/style.css +++ b/web/css/style.css @@ -1,5 +1,5 @@ -*{margin:0;padding:0;box-sizing:border-box}input:focus,input:active,.chosen-container-active .chosen-choices{outline:none}code:focus-visible{outline:none}:root{--color-content-bg: hsl(42 100% 98%);--color-text: #333;--color-text-weak: #6f6f6f;--color-text-inv: white;--c-accent: 30 19%;--color-accent: hsl(var(--c-accent) 10%);--c-input: 40 67.7%;--color-input: hsl(var(--c-input) 95%);--c-input-btn: 0 0%;--color-input-btn: hsl(var(--c-input-btn) 80%);--color-input-g: #9bc45e;--color-input-r: #c45e5e;--color-input-disabled: hsl(0 0% 85%);--color-input-text-disabled:hsl(0 0% 43%);--color-border: #aaa;--color-border-tag: gray;--color-border-active: #333;--color-link: #3d6594;--color-link-active: #608CBE;--c-mod-bg: 0 0% 100%;--color-mod-bg: hsl(var(--c-mod-bg));--color-highlight: hsl(45 51% 54%);--color-backdrop: hsl(0 0% 35% / .85);--color-flair-author: #535c88;--color-flair-moderator: #2a6b6f;--color-flair-admin: #0e7239;--color-diff-added: #3bca3b;--color-diff-removed: #c87474}html{overflow:hidden}body{background-color:#80786e;background-image:url(https://account.vintagestory.at/public/images/background.jpg);background-attachment:fixed;background-size:cover;font-family:"Roboto","Helvetica Neue",Helvetica,Arial,sans-serif;color:var(--color-text);font-size:16px;line-height:1.2;text-rendering:optimizeSpeed;max-width:100vw;max-height:100vh;overflow:auto;scrollbar-width:thin;scrollbar-gutter:stable;scroll-behavior:smooth}blockquote{border:solid 1px #ccc;padding-left:.5em;border-left-width:.5em;background:hsla(0,0%,100%,.25);font-style:italic}p:not(:last-child){margin-bottom:.5em}h1:not(:first-child),h2:not(:first-child),h3:not(:first-child),h4:not(:first-child),h5:not(:first-child),h6:not(:first-child){margin-top:1.5em}a{color:var(--color-link)}a:hover{color:var(--color-link-active)}a.external::before{content:"";display:inline-block;height:1.1em;width:1.1em;vertical-align:sub;margin-right:.5ch;background-size:contain;background-repeat:round;background-image:url(/web/img/externallink.png)}a[href*="github.com"].external::before{background-image:url(/web/img/logo-github.svg)}a[href*="gitlab.com"].external::before{background-image:url(/web/img/logo-gitlab.svg)}a[href*="patreon.com"].external::before{background-image:url(/web/img/logo-patreon-b.png)}a[href*="ko-fi.com"].external::before{background-image:url(/web/img/logo-kofi-c.svg)}a[href*="discord.gg"].external::before,a[href*="discord.com"].external::before{background-image:url(/web/img/logo-discord.svg)}ul.no-mark,ol.no-mark{list-style:none}li{margin-left:1em}ul.no-mark>li,ol.no-mark>li{margin-left:unset}small{display:inline-block;font-size:.8em}pre{overflow-x:auto}code,[class*=language-]{word-wrap:break-word;font-family:"Courier New",Courier,monospace;text-align:left}pre[class*=language-]{line-height:unset !important}img{max-width:100%;height:auto;font-style:italic;text-align:center}img::before{display:block;height:100%;inset:0;text-align:center;border:1px red dashed;white-space:pre-wrap;text-overflow:ellipsis;overflow:hidden}.whitetext{color:var(--color-text-inv)}.flex-list{display:flex;flex-wrap:wrap;gap:.5em}.flex-spacer{flex-grow:1;min-width:0 !important;visibility:hidden}.flex-fill{flex-basis:100%}.template{display:none !important}@media(max-width: 450px){.not-mobile{display:none}}[data-label]{display:inline-block;position:relative;margin-top:1.5rem}[data-label]::before{content:attr(data-label);position:absolute;left:0;top:-1.25rem;white-space:nowrap}.ribbon-tr{--d: 1.5em;position:absolute;top:0;left:100%;transform-origin:top center;transform:translateX(-50%) rotate(45deg) translateY(var(--d));width:150%;text-align:center;padding:.25em 0}.ribbon-tr.d2{--d: 3.5em}@keyframes highlight{30%{background-color:var(--color-highlight)}}.highlight{animation:2s alternate ease-out highlight}.logo{display:block;height:60px}.logo img{width:280px;padding-top:2px;padding-left:5px}.mods{display:grid;grid-template-columns:repeat(auto-fill, minmax(300px, 1fr));grid-template-rows:auto;gap:1em;justify-content:start}@media(max-width: 450px){.mods{grid-template-columns:100%}}.mods>h3{grid-column:1/-1}.mod{position:relative;aspect-ratio:1;overflow:hidden;background-color:hsl(var(--c-mod-bg));border-radius:.25rem;border:1px solid white;box-shadow:1px 1px 4px var(--color-border)}.mod.draft{border:1.5px dashed gray}.mods .mod:only-child{max-width:300px}.mod a{color:var(--color-text);text-decoration:none}.mod>a>img{width:100%;border-radius:.25rem .25rem 0 0}.mod.legacy>a>img{height:66.6666666667%;object-fit:contain}.mod .moddesc{position:absolute;top:66.6666666667%;bottom:0;width:100%;padding:.25em;background-color:white;background-color:hsl(var(--c-mod-bg)/0.8);text-shadow:1px white}body.opaque-desc .mod .moddesc{background-color:white}.mod .stats{float:right}.mod .stats a{vertical-align:middle;padding:0px 2px}.mod .stats a:hover{background:var(--color-input)}.mod .stats img{height:16px;vertical-align:middle;margin-right:3px}#message-container{background-color:var(--color-content-bg);display:flex;flex-direction:column;gap:.5em}#message-container:not(:empty){position:sticky;top:0;z-index:99;padding:.5em;border-bottom:solid 1px var(--color-border)}#message-container>*{position:relative;padding:.5em 1em;border:solid 1px var(--color-border);border-radius:.25em}#message-container>*:not(.permanent)::before{content:"✖";position:absolute;left:.25em;top:50%;transform:translateY(-50%);font-size:200%}#message-container>*.bg-success:not(.permanent)::before{content:"✓"}#message-container>*:not(.permanent){padding-left:3em;animation:.5s bounce}@keyframes bounce{50%{transform:translateY(-0.25em)}100%{transform:translateY(0)}}#message-container>*>.dismiss{display:block;position:absolute;top:0;right:0;border-radius:.25em;padding:0 .25em;cursor:pointer}#message-container>*>.dismiss::before{font-size:150%;content:"✖"}.interactbox{display:inline-block;text-decoration:none}.interactbox .off,.interactbox .on{text-decoration:underline}.interactbox i{padding-right:5px}.interactbox .count{background:rgba(0, 0, 0, 0.1);border-radius:5px;padding:3px}.interactbox.off .on{display:none}.interactbox.on .off{display:none}.followed-star{position:absolute;top:0;right:0;width:1em;color:#5189ca;text-shadow:rgba(0, 0, 0, 0.5) 1px 1px 3px}.notificationcount.visible{display:inline-block}.notificationcount{display:none;position:absolute;top:-5px;font-size:13px;color:#fff;text-indent:0%;line-height:18px;padding:0 6px;border-radius:8px;z-index:2;font-weight:bold;background:#91a357;right:-1px}.content{margin:1em 1em 0 1em}.content,footer{margin-left:1em;margin-right:calc(1em - (100vw - 100%))}.padded{padding:1em}.innercontent{position:relative;background-color:var(--color-content-bg);--content-min-h: 100vh - 80px - 6em;min-height:calc(var(--content-min-h))}.mod-draft,.mod-locked{position:relative}.edit-asset.mods{position:relative}.showmod-draftnotice{position:absolute;right:0;top:0;padding:.25em 1.5em;text-align:right}.stdtable{border:1px solid gray;border-collapse:collapse}.stdtable thead th{background-color:rgba(255, 255, 255, 0.5);padding:4px}.stdtable>a{display:block;color:black;text-decoration:none}.stdtable th,.stdtable td:not(.collapsable){border:1px solid gray;padding:2px}.stdtable tbody tr:nth-child(odd){background-color:rgba(230, 223, 208, 0.9)}.stdtable tbody tr:nth-child(even){background-color:rgba(255, 248, 234, 0.9)}.stdtable tbody tr:hover{background-color:#ddd}.bg-error{background-color:#f6d2ca}.bg-success{background-color:#e1f6ca}.bg-warning{background-color:#f6f1ca}.text-warning{color:#d1c13b}.text-error{color:#9b0906}.text-success{color:#468847}.text-info{color:#3a87ad}.text-weak{color:var(--color-text-weak)}.mod-draft .tabs,.mod-locked .tabs{border-bottom-style:dashed}.tab-trigger#tab-description:not(:checked)~.tab-container>.tab-content.description{display:none}.tab-trigger#tab-description:checked~.tabs>li:first-child{background-color:hsl(var(--c-input-btn) 90%)}.tab-trigger#tab-files:not(:checked)~.tab-container>.tab-content.files{display:none}.tab-trigger#tab-files:checked~.tabs>li:nth-child(2){background-color:hsl(var(--c-input-btn) 90%)}td{position:relative}h2 .title{display:inline-block;padding:2px}.spoiler-toggle:not(.expanded)~*{display:none}.spoiler.crash-report .spoiler-toggle~*{font-family:"Courier New",Courier,monospace}.spoiler.crash-report .spoiler-toggle.expanded~*{display:block;padding:.25em;overflow-x:auto}.editbox{background-color:hsl(var(--c-accent) 86%);padding:.25rem;border:1px solid hsl(var(--c-accent) 58%);border-radius:2px;width:calc(30ch + .5em)}.editbox.short{width:15ch}.editbox.wide{width:45ch}.editbox.file-upload{width:46ch}.editbox.mandatory{color:#790202;background-color:#e9d9d7}.editbox label{display:block;margin-bottom:.25em}.editbox input,.editbox select{width:100%}@media(max-width: 450px){.editbox,.editbox.wide,.editbox.short,.editbox.file-upload{width:100%}}.file{min-width:calc(30ch + .5em);position:relative;background-color:hsl(var(--c-accent) 86%);padding:.25rem;padding-right:2.5rem;border:1px solid hsl(var(--c-accent) 58%)}.file>a{text-decoration:none;display:flex;flex-direction:row;gap:.5em}.file>a>div:nth-of-type(2){flex-grow:1}.file img{width:60px;height:60px;object-fit:contain}.file .fi{height:50px}.file .details>*{display:block}.file small{font-size:x-small}.file .delete,.file .download{position:absolute;z-index:5;right:.25em;width:1em;height:1em;line-height:.65em;padding:2px;text-align:center}.file .delete{bottom:.25em;line-height:.55em}.file .download{font-weight:bold;top:.25em}.ck.ck-editor{width:100%}.ck.ck-content.ck-editor__editable{min-height:100px}p:first-child{margin-top:0px}p:last-child{margin-bottom:0px}.tinymce-mobile-outer-container{height:max-content !important;max-height:400px}.tox-tinymce,.tox-editor-container{overflow:visible !important}.tox .tox-editor-header{position:sticky;top:0;z-index:2;border-bottom:1px solid #ccc}.tox .tox-edit-area__iframe{background-color:var(--color-input) !important}.tox .tox-statusbar{background-color:var(--color-content-bg) !important}.tox .tox-menubar,.tox .tox-toolbar{background:left 0 top 0 var(--color-content-bg) !important}a.add{text-decoration:none;border:2px solid #a19487;border-radius:3px;padding:3px 0px 0px 3px;font-size:30px;box-sizing:content-box;width:20px;height:20px;display:inline-block;vertical-align:middle;background-color:#e4e0dc;background:radial-gradient(ellipse at center, #E4E0DC 0%, #E4E0DC 54%, #90A2B8 100%)}a.edit:before{content:"edit"}a.delete,a.download{text-decoration:none;border:2px solid #a19487;border-radius:3px;padding:0px 1px 5px 5px;font-size:24px;width:15px;height:15px;display:inline-block;vertical-align:middle;color:#b94a48;background-color:#e4e0dc;background:radial-gradient(ellipse at center, #E4E0DC 0%, #E4E0DC 54%, #CD9696 100%)}a.delete:before{content:"x"}a.add:hover,a.delete:hover,a.download:hover{border-color:#bbb1a7;background-color:#f8f6f4}a.add:before{content:"+"}.edit-asset h2:first-child{margin-bottom:1em}.edit-asset h3{vertical-align:middle;border-bottom:1px solid #ccc;margin-top:40px;font-size:120%;line-height:19px}.edit-asset a.add{position:relative;top:-14px;float:right}footer{margin-top:1em;margin-bottom:1em}footer>ul{display:flex;flex-direction:row;flex-wrap:wrap;gap:1em;justify-content:space-between;padding:.5em;background:hsl(0 0% 100%/0.6)}/*! +*{margin:0;padding:0;box-sizing:border-box}input:focus,input:active,.chosen-container-active .chosen-choices{outline:none}code:focus-visible{outline:none}:root{--color-content-bg: hsl(42 100% 98%);--color-text: #333;--color-text-weak: #6f6f6f;--color-text-inv: white;--c-accent: 30 19%;--color-accent: hsl(var(--c-accent) 10%);--c-input: 40 67.7%;--color-input: hsl(var(--c-input) 95%);--c-input-btn: 0 0%;--color-input-btn: hsl(var(--c-input-btn) 80%);--color-input-g: #9bc45e;--color-input-r: #c45e5e;--color-input-disabled: hsl(0 0% 85%);--color-input-text-disabled:hsl(0 0% 43%);--color-border: #aaa;--color-border-tag: gray;--color-border-active: #333;--color-link: #3d6594;--color-link-active: #608CBE;--c-mod-bg: 0 0% 100%;--color-mod-bg: hsl(var(--c-mod-bg));--color-highlight: hsl(45 51% 54%);--color-backdrop: hsl(0 0% 35% / .85);--color-flair-author: #535c88;--color-flair-moderator: #2a6b6f;--color-flair-admin: #0e7239;--color-diff-added: #3bca3b;--color-diff-removed: #c87474}html{overflow:hidden}body{background-color:#80786e;background-image:url(https://account.vintagestory.at/public/images/background.jpg);background-attachment:fixed;background-size:cover;font-family:"Roboto","Helvetica Neue",Helvetica,Arial,sans-serif;color:var(--color-text);font-size:16px;line-height:1.2;text-rendering:optimizeSpeed;max-width:100vw;max-height:100vh;overflow:auto;scrollbar-width:thin;scrollbar-gutter:stable;scroll-behavior:smooth}blockquote{border:solid 1px #ccc;padding-left:.5em;border-left-width:.5em;background:rgba(255,255,255,.25);font-style:italic}p:not(:last-child){margin-bottom:.5em}h1:not(:first-child),h2:not(:first-child),h3:not(:first-child),h4:not(:first-child),h5:not(:first-child),h6:not(:first-child){margin-top:1.5em}a{color:var(--color-link)}a:hover{color:var(--color-link-active)}a.external::before{content:"";display:inline-block;height:1.1em;width:1.1em;vertical-align:sub;margin-right:.5ch;background-size:contain;background-repeat:round;background-image:url(/web/img/externallink.png)}a[href*="github.com"].external::before{background-image:url(/web/img/logo-github.svg)}a[href*="gitlab.com"].external::before{background-image:url(/web/img/logo-gitlab.svg)}a[href*="patreon.com"].external::before{background-image:url(/web/img/logo-patreon-b.png)}a[href*="ko-fi.com"].external::before{background-image:url(/web/img/logo-kofi-c.svg)}a[href*="discord.gg"].external::before,a[href*="discord.com"].external::before{background-image:url(/web/img/logo-discord.svg)}ul.no-mark,ol.no-mark{list-style:none}li{margin-left:1em}ul.no-mark>li,ol.no-mark>li{margin-left:unset}small{display:inline-block;font-size:.8em}pre{overflow-x:auto}code,[class*=language-]{word-wrap:break-word;font-family:"Courier New",Courier,monospace;text-align:left}pre[class*=language-]{line-height:unset !important}img{max-width:100%;height:auto;font-style:italic;text-align:center}img::before{display:block;height:100%;inset:0;text-align:center;border:1px red dashed;white-space:pre-wrap;text-overflow:ellipsis;overflow:hidden}.whitetext{color:var(--color-text-inv)}.flex-list{display:flex;flex-wrap:wrap;gap:.5em}.flex-spacer{flex-grow:1;min-width:0 !important;visibility:hidden}.flex-fill{flex-basis:100%}.template{display:none !important}@media(max-width: 450px){.not-mobile{display:none}}[data-label]{display:inline-block;position:relative;margin-top:1.5rem}[data-label]::before{content:attr(data-label);position:absolute;left:0;top:-1.25rem;white-space:nowrap}.ribbon-tr{--d: 1.5em;position:absolute;top:0;left:100%;transform-origin:top center;transform:translateX(-50%) rotate(45deg) translateY(var(--d));width:150%;text-align:center;padding:.25em 0}.ribbon-tr.d2{--d: 3.5em}@keyframes highlight{30%{background-color:var(--color-highlight)}}.highlight{animation:2s alternate ease-out highlight}.logo{display:block;height:60px}.logo img{width:280px;padding-top:2px;padding-left:5px}.mods{display:grid;grid-template-columns:repeat(auto-fill, minmax(300px, 1fr));grid-template-rows:auto;gap:1em;justify-content:start}@media(max-width: 450px){.mods{grid-template-columns:100%}}.mods>h3{grid-column:1/-1}.mod{position:relative;aspect-ratio:1;overflow:hidden;background-color:hsl(var(--c-mod-bg));border-radius:.25rem;border:1px solid white;box-shadow:1px 1px 4px var(--color-border)}.mod.draft{border:1.5px dashed gray}.mods .mod:only-child{max-width:300px}.mod a{color:var(--color-text);text-decoration:none}.mod>a>img{width:100%;border-radius:.25rem .25rem 0 0}.mod.legacy>a>img{height:66.6666666667%;object-fit:contain}.mod .moddesc{position:absolute;top:66.6666666667%;bottom:0;width:100%;padding:.25em;background-color:white;background-color:hsl(var(--c-mod-bg)/0.8);text-shadow:1px white}body.opaque-desc .mod .moddesc{background-color:white}.mod .stats{float:right}.mod .stats a{vertical-align:middle;padding:0px 2px}.mod .stats a:hover{background:var(--color-input)}.mod .stats img{height:16px;vertical-align:middle;margin-right:3px}#message-container{background-color:var(--color-content-bg);display:flex;flex-direction:column;gap:.5em}#message-container:not(:empty){position:sticky;top:0;z-index:99;padding:.5em;border-bottom:solid 1px var(--color-border)}#message-container>*{position:relative;padding:.5em 1em;border:solid 1px var(--color-border);border-radius:.25em}#message-container>*:not(.permanent)::before{content:"✖";position:absolute;left:.25em;top:50%;transform:translateY(-50%);font-size:200%}#message-container>*.bg-success:not(.permanent)::before{content:"✓"}#message-container>*:not(.permanent){padding-left:3em;animation:.5s bounce}@keyframes bounce{50%{transform:translateY(-0.25em)}100%{transform:translateY(0)}}#message-container>*>.dismiss{display:block;position:absolute;top:0;right:0;border-radius:.25em;padding:0 .25em;cursor:pointer}#message-container>*>.dismiss::before{font-size:150%;content:"✖"}.interactbox{display:inline-block;text-decoration:none}.interactbox .off,.interactbox .on{text-decoration:underline}.interactbox i{padding-right:5px}.interactbox .count{background:rgba(0, 0, 0, 0.1);border-radius:5px;padding:3px}.interactbox.off .on{display:none}.interactbox.on .off{display:none}.followed-star{position:absolute;top:0;right:0;width:1em;color:#5189ca;text-shadow:rgba(0, 0, 0, 0.5) 1px 1px 3px}.notificationcount.visible{display:inline-block}.notificationcount{display:none;position:absolute;top:-5px;font-size:13px;color:#fff;text-indent:0%;line-height:18px;padding:0 6px;border-radius:8px;z-index:2;font-weight:bold;background:#91a357;right:-1px}.content{margin:1em 1em 0 1em}.content,footer{margin-left:1em;margin-right:calc(1em - (100vw - 100%))}.padded{padding:1em}.innercontent{position:relative;background-color:var(--color-content-bg);--content-min-h: 100vh - 80px - 6em;min-height:calc(var(--content-min-h))}.mod-draft,.mod-locked{position:relative}.edit-asset.mods{position:relative}.showmod-draftnotice{position:absolute;right:0;top:0;padding:.25em 1.5em;text-align:right}.stdtable{border:1px solid gray;border-collapse:collapse}.stdtable thead th{background-color:rgba(255, 255, 255, 0.5);padding:4px}.stdtable>a{display:block;color:black;text-decoration:none}.stdtable th,.stdtable td:not(.collapsable){border:1px solid gray;padding:2px}.stdtable tbody tr:nth-child(odd){background-color:rgba(230, 223, 208, 0.9)}.stdtable tbody tr:nth-child(even){background-color:rgba(255, 248, 234, 0.9)}.stdtable tbody tr:hover{background-color:#ddd}.bg-error{background-color:#f6d2ca}.bg-success{background-color:#e1f6ca}.bg-warning{background-color:#f6f1ca}.text-warning{color:#d1c13b}.text-error{color:#9b0906}.text-success{color:#468847}.text-info{color:#3a87ad}.text-weak{color:var(--color-text-weak)}.mod-draft .tabs,.mod-locked .tabs{border-bottom-style:dashed}.tab-trigger#tab-description:not(:checked)~.tab-container>.tab-content.description{display:none}.tab-trigger#tab-description:checked~.tabs>li:first-child{background-color:hsl(var(--c-input-btn) 90%)}.tab-trigger#tab-files:not(:checked)~.tab-container>.tab-content.files{display:none}.tab-trigger#tab-files:checked~.tabs>li:nth-child(2){background-color:hsl(var(--c-input-btn) 90%)}td{position:relative}h2 .title{display:inline-block;padding:2px}.spoiler-toggle:not(.expanded)~*{display:none}.spoiler.crash-report .spoiler-toggle~*{font-family:"Courier New",Courier,monospace}.spoiler.crash-report .spoiler-toggle.expanded~*{display:block;padding:.25em;overflow-x:auto}.editbox{background-color:hsl(var(--c-accent) 86%);padding:.25rem;border:1px solid hsl(var(--c-accent) 58%);border-radius:2px;width:calc(30ch + .5em)}.editbox.short{width:15ch}.editbox.wide{width:45ch}.editbox.file-upload{width:46ch}.editbox.mandatory{color:#790202;background-color:#e9d9d7}.editbox label{display:block;margin-bottom:.25em}.editbox input,.editbox select{width:100%}@media(max-width: 450px){.editbox,.editbox.wide,.editbox.short,.editbox.file-upload{width:100%}}.file{min-width:calc(30ch + .5em);position:relative;background-color:hsl(var(--c-accent) 86%);padding:.25rem;padding-right:2.5rem;border:1px solid hsl(var(--c-accent) 58%)}.file>a{text-decoration:none;display:flex;flex-direction:row;gap:.5em}.file>a>div:nth-of-type(2){flex-grow:1}.file img{width:60px;height:60px;object-fit:contain}.file .fi{height:50px}.file .details>*{display:block}.file small{font-size:x-small}.file .delete,.file .download{position:absolute;z-index:5;right:.25em;width:1em;height:1em;line-height:.65em;padding:2px;text-align:center}.file .delete{bottom:.25em;line-height:.55em}.file .download{font-weight:bold;top:.25em}.ck.ck-editor{width:100%}.ck.ck-content.ck-editor__editable{min-height:100px}p:first-child{margin-top:0px}p:last-child{margin-bottom:0px}.tinymce-mobile-outer-container{height:max-content !important;max-height:400px}.tox-tinymce,.tox-editor-container{overflow:visible !important}.tox .tox-editor-header{position:sticky;top:0;z-index:2;border-bottom:1px solid #ccc}.tox .tox-edit-area__iframe{background-color:var(--color-input) !important}.tox .tox-statusbar{background-color:var(--color-content-bg) !important}.tox .tox-menubar,.tox .tox-toolbar{background:left 0 top 0 var(--color-content-bg) !important}a.add{text-decoration:none;border:2px solid #a19487;border-radius:3px;padding:3px 0px 0px 3px;font-size:30px;box-sizing:content-box;width:20px;height:20px;display:inline-block;vertical-align:middle;background-color:#e4e0dc;background:radial-gradient(ellipse at center, #E4E0DC 0%, #E4E0DC 54%, #90A2B8 100%)}a.edit:before{content:"edit"}a.delete,a.download{text-decoration:none;border:2px solid #a19487;border-radius:3px;padding:0px 1px 5px 5px;font-size:24px;width:15px;height:15px;display:inline-block;vertical-align:middle;color:#b94a48;background-color:#e4e0dc;background:radial-gradient(ellipse at center, #E4E0DC 0%, #E4E0DC 54%, #CD9696 100%)}a.delete:before{content:"x"}a.add:hover,a.delete:hover,a.download:hover{border-color:#bbb1a7;background-color:#f8f6f4}a.add:before{content:"+"}.edit-asset h2:first-child{margin-bottom:1em}.edit-asset h3{vertical-align:middle;border-bottom:1px solid #ccc;margin-top:40px;font-size:120%;line-height:19px}.edit-asset a.add{position:relative;top:-14px;float:right}footer{margin-top:1em;margin-bottom:1em}footer>ul{display:flex;flex-direction:row;flex-wrap:wrap;gap:1em;justify-content:space-between;padding:.5em;background:hsl(0 0% 100%/0.6)}/*! * CSS file icons v0.0.5 (https://colorswall.github.io/CSS-file-icons) * Copyright 2018 The CSS file icons Authors * Licensed under MIT - */.fi{width:36px;height:46px;padding:10px 0 0;position:relative;margin:0 auto;transition:all .2s ease-in-out;cursor:pointer;box-sizing:border-box;font-family:sans-serif;text-decoration:none;display:block}.fi:after,.fi:before{position:absolute;content:"";pointer-events:none}.fi:before{top:0;height:100%;left:0;background-color:#007bff;right:10px}.fi:after{width:0;height:0;border-style:solid;border-width:10px 0 0 10px;border-color:transparent transparent transparent #66b0ff;top:0;right:0}.fi-content{background-color:#007bff;top:10px;color:#fff;left:0;bottom:0;right:0;padding:16.5px .3em 0;font-size:13px;font-weight:500;position:absolute}.fi-doc.fi:before{background-color:#235d9c}.fi-doc.fi:after{border-left-color:#317dd1}.fi-doc.fi .fi-content{background-color:#235d9c;color:#fff}.fi-docx.fi:before{background-color:#2980b9}.fi-docx.fi:after{border-left-color:#4da1d8}.fi-docx.fi .fi-content{background-color:#2980b9;color:#fff}.fi-log.fi:before{background-color:#accff3}.fi-log.fi:after{border-left-color:#e6f0fb}.fi-log.fi .fi-content{background-color:#accff3;color:#fff}.fi-txt.fi:before{background-color:#8bc6d6}.fi-txt.fi:after{border-left-color:#bcdee7}.fi-txt.fi .fi-content{background-color:#8bc6d6;color:#fff}.fi-wps.fi:before{background-color:#297eff}.fi-wps.fi:after{border-left-color:#6ba6ff}.fi-wps.fi .fi-content{background-color:#297eff;color:#fff}.fi-csv.fi:before{background-color:#579704}.fi-csv.fi:after{border-left-color:#7cd806}.fi-csv.fi .fi-content{background-color:#579704;color:#fff}.fi-dat.fi:before{background-color:#0463ea}.fi-dat.fi:after{border-left-color:#3587fc}.fi-dat.fi .fi-content{background-color:#0463ea;color:#fff}.fi-ppt.fi:before{background-color:#ce4123}.fi-ppt.fi:after{border-left-color:#e26b52}.fi-ppt.fi .fi-content{background-color:#ce4123;color:#fff}.fi-xml.fi:before{background-color:#0e886b}.fi-xml.fi:after{border-left-color:#14c49a}.fi-xml.fi .fi-content{background-color:#0e886b;color:#fff}.fi-mp3.fi:before{background-color:#156aea}.fi-mp3.fi:after{border-left-color:#5291ef}.fi-mp3.fi .fi-content{background-color:#156aea;color:#fff}.fi-wav.fi:before{background-color:#36af14}.fi-wav.fi:after{border-left-color:#4be520}.fi-wav.fi .fi-content{background-color:#36af14;color:#fff}.fi-avi.fi:before{background-color:#40c1e6}.fi-avi.fi:after{border-left-color:#7bd4ee}.fi-avi.fi .fi-content{background-color:#40c1e6;color:#fff}.fi-mov.fi:before{background-color:#ff5838}.fi-mov.fi:after{border-left-color:#ff907a}.fi-mov.fi .fi-content{background-color:#ff5838;color:#fff}.fi-mp4.fi:before{background-color:#4163b4}.fi-mp4.fi:after{border-left-color:#6d89ca}.fi-mp4.fi .fi-content{background-color:#4163b4;color:#fff}.fi-3ds.fi:before{background-color:#015051}.fi-3ds.fi:after{border-left-color:#029192}.fi-3ds.fi .fi-content{background-color:#015051;color:#fff}.fi-max.fi:before{background-color:#02b4b6}.fi-max.fi:after{border-left-color:#03f4f7}.fi-max.fi .fi-content{background-color:#02b4b6;color:#fff}.fi-gif.fi:before{background-color:#aaa}.fi-gif.fi:after{border-left-color:#cbcbcb}.fi-gif.fi .fi-content{background-color:#aaa;color:#fff}.fi-ai.fi:before{background-color:#f67503}.fi-ai.fi:after{border-left-color:#fd983f}.fi-ai.fi .fi-content{background-color:#f67503;color:#fff}.fi-svg.fi:before{background-color:#e6a420}.fi-svg.fi:after{border-left-color:#edbc5c}.fi-svg.fi .fi-content{background-color:#e6a420;color:#fff}.fi-pdf.fi:before{background-color:#f88e21}.fi-pdf.fi:after{border-left-color:#faaf61}.fi-pdf.fi .fi-content{background-color:#f88e21;color:#fff}.fi-xls.fi:before{background-color:#86d44c}.fi-xls.fi:after{border-left-color:#aae181}.fi-xls.fi .fi-content{background-color:#86d44c;color:#fff}.fi-xlsx.fi:before{background-color:#6cbf2e}.fi-xlsx.fi:after{border-left-color:#8ed758}.fi-xlsx.fi .fi-content{background-color:#6cbf2e;color:#fff}.fi-sql.fi:before{background-color:#157efb}.fi-sql.fi:after{border-left-color:#56a2fc}.fi-sql.fi .fi-content{background-color:#157efb;color:#fff}.fi-exe.fi:before{background-color:#0e63ab}.fi-exe.fi:after{border-left-color:#1386e8}.fi-exe.fi .fi-content{background-color:#0e63ab;color:#fff}.fi-js.fi:before{background-color:#f0db4f}.fi-js.fi:after{border-left-color:#f5e78c}.fi-js.fi .fi-content{background-color:#f0db4f;color:#323330}.fi-html.fi:before{background-color:#e54c21}.fi-html.fi:after{border-left-color:#ec7c5c}.fi-html.fi .fi-content{background-color:#e54c21;color:#fff}.fi-xhtml.fi:before{background-color:#55a9ef}.fi-xhtml.fi:after{border-left-color:#92c8f5}.fi-xhtml.fi .fi-content{background-color:#55a9ef;color:#fff}.fi-css.fi:before{background-color:#264de4}.fi-css.fi:after{border-left-color:#617deb}.fi-css.fi .fi-content{background-color:#264de4;color:#fff}.fi-asp.fi:before{background-color:#5c2d91}.fi-asp.fi:after{border-left-color:#7c3dc3}.fi-asp.fi .fi-content{background-color:#5c2d91;color:#fff}.fi-ttf.fi:before{background-color:#14444b}.fi-ttf.fi:after{border-left-color:#22737f}.fi-ttf.fi .fi-content{background-color:#14444b;color:#fff}.fi-dll.fi:before{background-color:#960a4a}.fi-dll.fi:after{border-left-color:#d40e69}.fi-dll.fi .fi-content{background-color:#960a4a;color:#fff}.fi-7z.fi:before{background-color:#f63}.fi-7z.fi:after{border-left-color:#ff9875}.fi-7z.fi .fi-content{background-color:#f63;color:#fff}.fi-zip.fi:before{background-color:#ffb229}.fi-zip.fi:after{border-left-color:#ffca6b}.fi-zip.fi .fi-content{background-color:#ffb229;color:#fff}.fi-c.fi:before{background-color:#3747a5}.fi-c.fi:after{border-left-color:#5767c7}.fi-c.fi .fi-content{background-color:#3747a5;color:#fff}.fi-cs.fi:before{background-color:#013467}.fi-cs.fi:after{border-left-color:#0255a9}.fi-cs.fi .fi-content{background-color:#013467;color:#fff}.fi-java.fi:before{background-color:#ea2c2e}.fi-java.fi:after{border-left-color:#f0686a}.fi-java.fi .fi-content{background-color:#ea2c2e;color:#fff}.fi-jsp.fi:before{background-color:#e5000c}.fi-jsp.fi:after{border-left-color:#ff2834}.fi-jsp.fi .fi-content{background-color:#e5000c;color:#161419}.fi-swift.fi:before{background-color:#f32a20}.fi-swift.fi:after{border-left-color:#f6665f}.fi-swift.fi .fi-content{background-color:#f32a20;color:#fff}.fi-torrent.fi:before{background-color:#55ac44}.fi-torrent.fi:after{border-left-color:#7bc56d}.fi-torrent.fi .fi-content{background-color:#55ac44;color:#fff}.fi-php.fi:before{background-color:#4f5b93}.fi-php.fi:after{border-left-color:#717db3}.fi-php.fi .fi-content{background-color:#4f5b93;color:#fff}.fi-hh.fi:before{background-color:#505050}.fi-hh.fi:after{border-left-color:#717171}.fi-hh.fi .fi-content{background-color:#505050;color:#fff}.fi-go.fi:before{background-color:#e0ebf5}.fi-go.fi:after{border-left-color:#fff}.fi-go.fi .fi-content{background-color:#e0ebf5;color:#000}.fi-py.fi:before{background-color:#ffd542}.fi-py.fi:after{border-left-color:#ffe484}.fi-py.fi .fi-content{background-color:#ffd542;color:#3472a3}.fi-rss.fi:before{background-color:#fd8b33}.fi-rss.fi:after{border-left-color:#feb075}.fi-rss.fi .fi-content{background-color:#fd8b33;color:#fff}.fi-rb.fi:before{background-color:#a20d01}.fi-rb.fi:after{border-left-color:#e41201}.fi-rb.fi .fi-content{background-color:#a20d01;color:#fff}.fi-psd.fi:before{background-color:#181040}.fi-psd.fi:after{border-left-color:#2c1d75}.fi-psd.fi .fi-content{background-color:#181040;color:#3db6f2}.fi-png.fi:before{background-color:#dc7460}.fi-png.fi:after{border-left-color:#e8a496}.fi-png.fi .fi-content{background-color:#dc7460;color:#fff}.fi-bmp.fi:before{background-color:#459fa0}.fi-bmp.fi:after{border-left-color:#69bdbe}.fi-bmp.fi .fi-content{background-color:#459fa0;color:#fff}.fi-vb.fi:before{background-color:#19aad9}.fi-vb.fi:after{border-left-color:#4ac3ea}.fi-vb.fi .fi-content{background-color:#19aad9;color:#fff}.fi-size-xs.fi{width:28.8px;height:36.8px;padding-top:8px}.fi-size-xs.fi:before{right:8px}.fi-size-xs.fi:after{border-top-width:8px;border-left-width:8px}.fi-size-xs.fi .fi-content{top:8px;padding-top:13.2px;font-size:10.4px}.fi-size-sm.fi{width:36px;height:46px;padding-top:10px}.fi-size-sm.fi:before{right:10px}.fi-size-sm.fi:after{border-top-width:10px;border-left-width:10px}.fi-size-sm.fi .fi-content{top:10px;padding-top:16.5px;font-size:13px}.fi-size-md.fi{width:43.2px;height:55.2px;padding-top:12px}.fi-size-md.fi:before{right:12px}.fi-size-md.fi:after{border-top-width:12px;border-left-width:12px}.fi-size-md.fi .fi-content{top:12px;padding-top:19.8px;font-size:15.6px}.fi-size-lg.fi{width:54px;height:69px;padding-top:15px}.fi-size-lg.fi:before{right:15px}.fi-size-lg.fi:after{border-top-width:15px;border-left-width:15px}.fi-size-lg.fi .fi-content{top:15px;padding-top:24.75px;font-size:19.5px}.fi-size-xl.fi{width:72px;height:92px;padding-top:20px}.fi-size-xl.fi:before{right:20px}.fi-size-xl.fi:after{border-top-width:20px;border-left-width:20px}.fi-size-xl.fi .fi-content{top:20px;padding-top:33px;font-size:26px}.fi-content-xs .fi-content{font-size:11px;padding-top:55%}.fi-list{font-size:0;margin:0 -10px}.fi-list .fi{margin:0 10px 10px;display:inline-block}i.ico{font-style:normal}i.ico,i.ico::before{display:inline-block}i.ico.alert::before{content:"⚠"}.ico.star::before{content:"★"}div.betanotice{width:114px;height:36px;position:absolute;color:white;left:275px;top:13px;font-size:130%;transform:rotate(-19deg);font-weight:bold}.stdtable.latestcomments .textCol{word-wrap:anywhere}.stdtable.latestcomments .textCol>div{max-height:100px;overflow:auto;cursor:pointer}.stdtable.latestcomments .textCol pre{white-space:break-spaces}.stdtable.latestmods a{display:block}.mention{border-radius:4px;padding:2px 3px;text-decoration:none}.mention.username:before{content:"@"}.mention.username{background-color:rgba(159, 207, 52, 0.5)}.rte-autocomplete{position:absolute;top:0px;left:0px;display:block;z-index:1000;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;list-style:none;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0, 0, 0, 0.2);-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);-moz-box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);-webkit-background-clip:padding-box;-moz-background-clip:padding;background-clip:padding-box;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px}.rte-autocomplete:before{content:"";display:inline-block;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:7px solid #ccc;border-bottom-color:rgba(0, 0, 0, 0.2);position:absolute;top:-7px;left:9px}.rte-autocomplete:after{content:"";display:inline-block;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid white;position:absolute;top:-6px;left:10px}.rte-autocomplete>li.loading{background:url("/web/img/loading.gif") center no-repeat;height:16px}.rte-autocomplete>li>a{display:block;padding:3px 20px;clear:both;font-weight:normal;line-height:20px;color:#333;white-space:nowrap;text-decoration:none}.rte-autocomplete>li>a:hover,.rte-autocomplete>li>a:focus,.rte-autocomplete:hover>a,.rte-autocomplete:focus>a{color:#fff;text-decoration:none;background-color:#0081c2;background-image:linear-gradient(to bottom, #08c, #0077b3);background-repeat:repeat-x}.rte-autocomplete>.active>a,.rte-autocomplete>.active>a:hover,.rte-autocomplete>.active>a:focus{color:#fff;text-decoration:none;background-color:#0081c2;background-image:linear-gradient(to bottom, #08c, #0077b3);background-repeat:repeat-x;outline:0}.flair{display:unset}.flair::before{padding:1px .25em;border:1px solid #3e372f;border-radius:5px;unicode-bidi:isolate;line-height:1rem;background:var(--color-content-bg);color:var(--color-text)}.flair-moderator::before{content:"Moderator";background:var(--color-flair-moderator);color:var(--color-text-inv)}.flair-admin::before{content:"Administrator";background:var(--color-flair-admin);color:var(--color-text-inv)}.flair-author::before{content:"Author";background:var(--color-flair-author);color:var(--color-text-inv)}body.banned .overlay-when-banned{position:relative;pointer-events:none}body.banned .overlay-when-banned::before{content:"Unavailable";position:absolute;width:100%;height:100%;background-color:rgba(255, 116, 116, 0.353);color:red;display:flex;justify-content:center;align-items:center}body.banned .strikethrough-when-banned{pointer-events:none;background-color:rgba(255, 116, 116, 0.353) !important;color:red !important;text-decoration:line-through !important}body.readonly .overlay-when-readonly{position:relative;pointer-events:none}body.readonly .overlay-when-readonly::before{content:"Unavailable";position:absolute;width:100%;height:100%;background-color:rgba(255, 116, 116, 0.353);display:flex;justify-content:center;align-items:center}body.readonly .strikethrough-when-readonly{pointer-events:none;background-color:rgba(255, 116, 116, 0.353) !important;text-decoration:line-through !important}.teaminvite{display:flex;flex-direction:column;justify-content:center;align-items:center;padding:25px;background:linear-gradient(0deg, rgba(104, 80, 55, 0.65) 0%, rgba(179, 154, 121, 0.65) 100%);border:1px solid #3e372f;border-radius:5px;padding:5px}.teaminvite .button{min-width:175px}.pending-markers .search-choice:not(.accepted)::after,.pending-markers .result-selected:not(.accepted)::after,#teameditors-box .active-result:not(.accepted)::after{content:" [pending]";color:gray}.pending-markers .result-selected::before{content:"✔ "}@media(max-width: 767px){.teaminvite{flex-direction:column;align-items:start;width:fit-content}}#notifications-list>*{width:100%;display:flex;margin:0;padding:1em 0;flex-direction:row;gap:1em}#notifications-list>[data-label]::before{font-size:small;top:0}#notifications-list>:hover{background-color:var(--color-input)}textarea{resize:vertical}input:focus,.chosen-container-active .chosen-choices{border-color:var(--color-border-active)}input:not(.chosen-search-input,[type=checkbox]),button,select,.chosen-container,.chosen-container-single .chosen-single,.chosen-container-multi .chosen-choices,.chosen-container-single .chosen-single span,.prefixed-input{height:1.75rem;vertical-align:bottom}.chosen-container-multi,.chosen-container-multi .chosen-choices,.prefixed-input{height:unset;min-height:1.75rem}input,select,textarea,.chosen-container-single .chosen-single,.prefixed-input{border-radius:0;background-color:var(--color-input);border:1px solid var(--color-border)}input,button,select.no-chosen,textarea,.prefixed-input{padding:0 .25em}input:disabled,button:disabled,select:disabled,.button:disabled,.prefixed-input.disabled{background-color:var(--color-input-disabled);cursor:not-allowed}button[type=submit],input[type=submit],.button{display:inline-block;text-align:center;color:var(--color-text);background-color:var(--color-input-btn);border:none;padding:.25em .75em;cursor:pointer;text-decoration:none;border-radius:.25em;box-shadow:0px 2px 3px rgba(0,0,0,.5333333333);user-select:none}button[type=submit]:not(:disabled):hover,input[type=submit]:not(:disabled):hover,.button:not(:disabled):hover{color:var(--color-text);background-color:hsl(var(--c-input-btn) 87%)}.button.submit:not(:disabled):hover,.button.btndelete:not(:disabled):hover{color:var(--color-text-inv)}button[type=submit]:not(:disabled):active,input[type=submit]:not(:disabled):active,.button:not(:disabled):active{color:var(--color-text);background-color:hsl(var(--c-input-btn) 90%)}.prefixed-input{display:inline-block;white-space:nowrap;height:1.75rem}.prefixed-input:focus-within{border-color:var(--color-border-active)}.prefixed-input::before{content:attr(data-prefix);display:inline-block}.prefixed-input::before,.prefixed-input input{font-size:initial;vertical-align:baseline;background-color:rgba(0,0,0,0)}.prefixed-input input{padding:0;border:none;height:calc(1.75em - 1px)}input:disabled,.prefixed-input.disabled{color:var(--color-input-text-disabled)}.button.large{font-weight:bold;padding:.5em 0;min-width:150px}.button.submit{background-color:var(--color-input-g)}.button.btndelete{background-color:var(--color-input-r)}.shine{position:relative}.shine::after{content:"";position:absolute;top:0;left:0;background:linear-gradient(to bottom, rgba(255, 255, 255, 0.7) 0%, rgba(255, 255, 255, 0) 100%);height:17px;width:100%;box-shadow:inset 0px 2px 1px hsla(0,0%,100%,.25);border-radius:10px;border-bottom-right-radius:100px 40px;border-bottom-left-radius:100px 40px}#buttons-overlay{position:absolute;top:1em;right:1em;display:flex;flex-direction:column;gap:.5em;z-index:999}.button.square{font-size:120%;border-radius:0;box-shadow:1px 1px 3px #aaa}.button.ico-button{position:relative;padding-left:calc(2em + .5ch)}.button.ico-button::before{content:"";position:absolute;left:.75em;top:0;height:100%;width:1.2em;background-repeat:no-repeat;background-size:contain;background-position:center}.button.ico-button.mod-dl{background-color:hsl(217,45%,51%);color:#fff}.button.ico-button.mod-dl:hover{background-color:hsl(217,45%,54%)}.button.ico-button.mod-dl::before{vertical-align:sub;background-image:url(/web/img/download-w.png)}.button.ico-button.deps{padding:.25em .25em;width:2em}.button.ico-button.deps::before{position:relative;font-family:boxicons !important;font-weight:400;line-height:1;content:"";left:unset;top:2px}.button.ico-button.one-click-dl::before{vertical-align:text-top;background-image:url(/web/favicon/favicon-32x32.png)}.chosen-container-single .chosen-single{background:linear-gradient(hsl(var(--c-input) 95%) 20%, hsl(var(--c-input) 98%) 50%, hsl(var(--c-input) 95%) 52%, hsl(var(--c-input) 97%) 100%)}.chosen-container-multi .chosen-choices{background:var(--color-input);cursor:text}.chosen-container-multi .search-field{width:60px}label.toggle{position:relative;display:inline-block;vertical-align:middle;width:3rem;height:1.75rem;background-color:var(--color-input);border:solid 1px var(--color-border);border-radius:.25em;cursor:pointer;user-select:none}label.toggle>input[type=checkbox]{appearance:none;position:absolute;left:2px;top:2px;height:calc(1.75rem - 6px);width:calc(1.75rem - 6px);border-radius:.25em;cursor:pointer;transform:translateX(0);background-color:var(--color-input);transition:transform .3s,background-color .3s}label.toggle>input[type=checkbox]:checked{transform:translateX(calc(1.25rem - 2px));background-color:var(--color-input-g)}.button.moderator,button.moderator{padding-top:1.25em;padding-bottom:.5em;position:relative}.button.moderator::before,button.moderator::before{content:"Moderator Action";white-space:pre;background-color:var(--color-flair-moderator);color:var(--color-text-inv);position:absolute;top:0;left:50%;transform:translateX(-50%);border-radius:0 0 .25em .25em;padding:0 .25em}.with-buttons-bottom{display:flex;flex-direction:column}.with-buttons-bottom>.buttons{position:sticky;z-index:99;bottom:0;display:flex;gap:1em;justify-content:space-between;flex-wrap:wrap;margin-top:1em;padding:1em;border-top:solid 1px var(--color-border);background-color:var(--color-content-bg)}.with-buttons-bottom>.buttons>*{min-height:3em;align-content:center}@keyframes shake-h{0%{transform:translateX(0)}25%{transform:translateX(5px)}50%{transform:translateX(-5px)}75%{transform:translateX(5px)}100%{transform:translateX(0)}}.invalid{animation:.5s shake-h}.invalid,.invalid *,.invalid.tox .tox-menubar,.invalid.tox .tox-toolbar{background-color:var(--color-input-r) !important}details.version-selector{position:relative;background-color:var(--color-input);border:1px solid var(--color-border)}details.version-selector>summary{list-style:none;display:block}details.version-selector>:last-child{position:absolute;z-index:100;width:100%;max-height:min(30em,50vh);overflow-y:auto;scrollbar-width:thin;background-color:var(--color-input);border:1px solid var(--color-border);user-select:none;--c1w: 3rem;--c2w: 3rem;--c3w: 3rem;--c4w: 3rem}details.version-selector>:last-child>h4,details.version-selector>:last-child div.h{display:flex;flex-direction:row;flex-wrap:nowrap}details.version-selector>:last-child>h4>*,details.version-selector>:last-child div.h>*{flex-shrink:0;flex-grow:0}details.version-selector>:last-child>h4>*:last-child,details.version-selector>:last-child div.h>*:last-child{flex-grow:1}details.version-selector>:last-child>h4{padding-top:.25em;margin-top:0;position:sticky;top:0;background-color:var(--color-input);border-bottom:solid var(--color-border) 1px;z-index:101}details.version-selector>:last-child>h4>*{text-align:right;padding:0 .125em}details.version-selector>:last-child>h4>:nth-child(1){width:var(--c1w)}details.version-selector>:last-child>h4>:nth-child(2){width:var(--c2w)}details.version-selector>:last-child>h4>:nth-child(3){width:var(--c3w)}details.version-selector>:last-child>h4>:nth-child(4){width:var(--c4w)}details.version-selector>:last-child div.h{border:solid var(--color-border) 0;border-left-width:1px;cursor:pointer}details.version-selector>:last-child div.h:not(:last-child){border-bottom-width:1px}details.version-selector>:last-child div.h:hover:not(:has(.h:hover)){background-color:hsl(var(--c-input) 80%)}details.version-selector>:last-child>div{padding-bottom:.25em}details.version-selector>:last-child>div>div>div>span{width:var(--c1w);position:sticky;top:1.75em}details.version-selector>:last-child>div>div>div>div>div>span{width:var(--c2w);position:sticky;top:1.75em}details.version-selector>:last-child>div>div>div>div>div>div>div>span{width:var(--c3w);position:sticky;top:1.75em}details.version-selector>:last-child>div>div>div>div>div>div>div>div>div>span{width:var(--c4w)}details.version-selector>:last-child>div span{display:block;text-align:right}details.version-selector input,details.version-selector select{width:fit-content}#report-mod-btn>i{vertical-align:center}#report-mod-btn>span{text-decoration:underline;cursor:pointer}ul.tabs{width:100%;border-bottom:2px solid #888;display:flex;flex-direction:row;flex-wrap:wrap}ul.tabs>li,ul.tabs>li>*{height:2em;line-height:2em;user-select:none}ul.tabs>li{border:1px solid var(--color-border);border-bottom:none;box-sizing:content-box;background-color:var(--color-input-btn);flex-grow:1}@media(min-width: 768px){ul.tabs>li{flex-grow:unset}}ul.tabs>li:not(.active):hover{background-color:hsl(var(--c-input-btn) 95%)}ul.tabs>li>*{cursor:pointer;text-decoration:none;color:#000;display:block;font-size:1em;font-weight:bold;padding:0 1em;border:1px solid #fff;border-bottom:none;outline:none}ul.tabs>li>* img{vertical-align:sub}.tab-trigger{display:none}.tab-content{padding:1em 0}.tab-trigger:not(:checked)+.tab-content{display:none}dialog{margin:auto;background-color:var(--color-content-bg);color:var(--color-text);border:solid 2px var(--color-border-active);max-height:calc(100vh - 2em);max-width:40em}dialog.full-screen{max-width:calc(100vw - 2em)}dialog:not([open]){display:none}dialog h1{text-align:center;padding-top:.5em;margin-bottom:.5em;position:sticky;top:0;background-color:var(--color-content-bg);z-index:2}dialog h1::after{content:"";display:block;width:calc(100% - 1em);margin:.5em 1em 0 .5em;border-bottom:solid 1px var(--color-border)}dialog.with-buttons-bottom>*:not(.buttons):not(h1),dialog>:only-child.with-buttons-bottom>*:not(.buttons):not(h1){margin-left:1em;margin-right:1em}dialog .err-container{color:var(--color-input-r)}dialog::backdrop{background-color:var(--color-backdrop)}.comment{width:unset;word-break:break-word}.comment>.title{padding:.25em}.comment>.reference{display:block;font-size:small;color:var(--color-text-weak);padding:.125em .25em;text-wrap:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none}.comment>.reference::before{content:"⬐"}.comment>.reference:hover>span{text-decoration:underline}.comment>.body{padding:4px;background-color:hsla(0,0%,100%,.4)}.comment:target{background-color:#dde5cb;border-color:#abb29c}.comment.editbox{position:relative;overflow:hidden;padding:0px;background:rgba(219,208,182,.7)}.comment.deleted{background:#e48383}.comment.deleted .ribbon-tr{background-color:#b82525;color:var(--color-text-inv)}.comments.threaded .comment .reference{display:none}.comments,.comments .convo{display:flex;flex-direction:column;flex-wrap:nowrap;gap:.5em;align-items:stretch}.comments .convo{border:solid var(--color-border);border-radius:2px 0 0 2px;border-width:0 0 1px 1px}.comments .convo>*{margin-left:1rem}.comments .convo>*:first-child{margin-left:-1px}.comments .convo>*:last-child{margin-bottom:-1px}.comment .body img,table.latestcomments img{position:static;opacity:initial}.tag{padding:.125em .25em;border-radius:.25em;background-color:#ccc;border:1px solid var(--color-border-tag);display:inline-block;font-size:90%;line-height:13px;user-select:none}.tag,.tag *{text-decoration:none;color:#333}.tag:hover{color:#333;opacity:.5}.tag .add::after{content:"+"}.tag .rem::after{content:"-"}.tags{margin-top:.25em;margin-bottom:.25em;display:flex;flex-direction:row;flex-wrap:wrap;gap:.25em}.tags #more-tags-trigger{display:none}.tags #more-tags-trigger:not(:checked)~.tag.hidden{display:none}.tags #more-tags-trigger~label{cursor:pointer}.tags #more-tags-trigger~label::before{content:"Show "}.tags #more-tags-trigger~label::after{content:" more tags..."}.tags #more-tags-trigger:checked~label::before{content:"Show "}.tags #more-tags-trigger:checked~label::after{content:" less tags..."}.tags.votable .tag{padding-right:0}.tags.votable .tag:hover{color:unset;opacity:unset}.tags.votable .tag.downvoted{filter:saturate(0.4)}.tags.votable .tag.downvoted a{color:var(--color-text-weak)}.tags.votable .tag>a{padding-right:.25em;border-right:solid var(--color-border-tag) 1px}.tags.votable .tag>.add,.tags.votable .tag>.rem{cursor:pointer}.tags.votable .tag>.add:hover,.tags.votable .tag>.rem:hover{background-color:rgba(51,51,51,.2)}.tags.votable .tag[data-vote="-1"]>.add{color:var(--color-text-weak)}.tags.votable .tag[data-vote="-1"]>.rem{font-weight:bold}.tags.votable .tag[data-vote="1"]>.add{font-weight:bold}.tags.votable .tag[data-vote="1"]>.rem{color:var(--color-text-weak)}.tags.votable>a{text-decoration:none;color:var(--color-text)}.gallery{position:relative;max-width:min(800px,100%)}.gallery.is-fullscreen{position:fixed;top:0;left:0;width:100vw;height:100vh;max-width:none;z-index:9999;background-color:#000}.gallery.is-fullscreen>.viewport{height:calc(100vh - 64px)}.gallery>.viewport{position:relative}.gallery>.viewport>.stage{position:relative;display:flex;width:var(--gallery-w, 800px);height:var(--gallery-h, 600px);max-width:100%;overflow-x:auto;scrollbar-width:none;scroll-snap-type:x mandatory;scroll-behavior:smooth;background:rgba(166,159,125,.3);user-select:none}.is-fullscreen.gallery>.viewport>.stage{width:100%;height:100%}.gallery>.viewport>.stage::-webkit-scrollbar{display:none}.gallery>.viewport>.stage.manually-dragging{scroll-snap-type:none;scroll-behavior:auto}.is-fullscreen.gallery>.viewport>.stage{background:rgba(0,0,0,0)}.gallery>.viewport>.stage>*{flex:0 0 100%;height:100%;scroll-snap-align:start;scroll-snap-stop:always}.gallery>.viewport>.stage>*>*{display:block;width:100%;height:100%}.gallery>.viewport>.stage>*>img{object-fit:contain}.gallery>.viewport>.stage>*>iframe{border:none}.gallery>.viewport .ctrl{position:absolute;cursor:pointer;border:none;padding:0;background:none;opacity:0;transition:opacity .3s}.gallery:hover>.viewport>.ctrl{opacity:1}.is-fullscreen.gallery>.viewport .ctrl{opacity:1}.gallery>.viewport>.arr{position:absolute;top:0;bottom:0;height:100%;width:50%}.gallery>.viewport>.arr::after{content:"";position:absolute;top:50%;width:0;height:0;border-style:solid;border-width:5px 9px 5px 0;border-color:rgba(0,0,0,0) #fff rgba(0,0,0,0) rgba(0,0,0,0);margin-right:1px;filter:drop-shadow(#000 0 0 5px)}.gallery>.viewport>.arr.prev{left:0}.gallery>.viewport>.arr.prev::after{left:2em;transform:translate(-5px, -50%)}.gallery>.viewport>.arr.next{right:0}.gallery>.viewport>.arr.next::after{right:2em;transform:translate(-5px, -50%) scaleX(-1)}.gallery>.viewport>.fullscreen{top:2px;right:2px;width:32px;height:32px;background:url(data:image/png;base64,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) no-repeat 0 -32px}.is-fullscreen.gallery>.viewport>.fullscreen{background-position:-32px -32px}.gallery nav{position:relative;display:block;margin-top:2px;height:64px;overflow:hidden;text-align:center}.gallery nav>*{position:relative;display:inline-block;white-space:nowrap;line-height:0;font-size:0}.gallery nav>*>*{position:relative;display:inline-block;width:64px;height:64px;margin-right:2px;padding:0;vertical-align:middle;cursor:pointer;border:none;outline:none}.gallery nav>*>*>img{width:100%;height:100%;object-fit:cover}.gallery nav>*>*.video-thumb>img{filter:brightness(0.4)}.gallery nav>*>*.video-thumb::before,.gallery nav>*>*.video-thumb::after{content:"";position:absolute;z-index:2;top:50%;left:50%;transform:translate(-50%, -50%)}.gallery nav>*>*.video-thumb::before{width:0;height:0;border-style:solid;border-width:5px 0 5px 9px;border-color:rgba(0,0,0,0) rgba(0,0,0,0) rgba(0,0,0,0) #fff;margin-left:1px}.gallery nav>*>*.video-thumb::after{width:24px;height:24px;border-radius:50%;border:2px solid #fff;background:rgba(0,0,0,0)}.gallery nav>*>*.indicator{position:absolute;top:0;left:0;width:64px;height:64px;border:2px solid #00afea;pointer-events:none;transition:transform .36s cubic-bezier(0.1, 0, 0.25, 1)}.audit-log-wrap{overflow-x:auto}.audit-log-wrap thead{position:sticky;top:0;z-index:1;background-color:var(--color-content-bg)}.audit-log-wrap td.date{min-width:14ch}.audit-log-wrap td.target{min-width:14ch}.audit-log-wrap td.info{white-space:pre}.audit-log-wrap .added{background-color:var(--color-diff-added)}.audit-log-wrap .removed{background-color:var(--color-diff-removed)}#main-nav{font-size:125%;width:100%;background:var(--color-accent);background:linear-gradient(0deg, rgba(104, 80, 55, 0.65) 0%, rgba(179, 154, 121, 0.65) 100%);color:var(--color-text-inv);border-top-left-radius:.25em;border-top-right-radius:.25em;display:flex;flex-direction:row;flex-wrap:wrap;gap:.125em .5em;user-select:none}#main-nav>*{min-width:10ch;flex-shrink:0;background-color:hsla(0,0%,100%,.2);color:var(--color-text-inv);text-align:center;white-space:nowrap;padding:.125em .5em}#main-nav>*:first-child{border-top-left-radius:.25em}#main-nav>*:last-child{border-top-right-radius:.25em}#main-nav>*.flex-spacer{padding:0;margin:0 -0.5em}#main-nav>*.icon-only{min-width:0}#main-nav>*.icon-only i{font-size:22px;width:22px;vertical-align:middle}#main-nav>*.active{background-color:var(--color-content-bg);color:var(--color-text)}#main-nav>*.active img{filter:invert(80%)}#main-nav>*:not(.active):hover{background-color:hsla(0,0%,100%,.4)}#main-nav a{display:block;text-decoration:none;color:currentColor}#main-nav a.external::before{filter:invert(100%)}#main-nav img{height:.8em;width:.8em;margin-right:.25em}@media(max-width: 767px){#main-nav{border-top-left-radius:0;border-top-right-radius:0}#main-nav>*{flex-grow:1}#main-nav>*:first-child{border-top-left-radius:0}#main-nav>*:last-child{border-top-right-radius:0}#main-nav>*.flex-spacer{display:none}}@media(max-width: 1050px){#main-nav #account-menu{text-indent:-99999px;line-height:0;min-width:2em}#main-nav #account-menu:after{text-indent:0;content:"";font-family:boxicons !important;font-weight:400;font-style:normal;font-variant:normal;line-height:1.4;text-rendering:auto;display:block;text-transform:none;speak:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#main-nav #account-menu>nav{line-height:1.2;text-indent:0}}#main-nav>.submenu{position:relative;padding:0}#main-nav>.submenu>:first-child{padding:.125em .5em;vertical-align:middle}#main-nav>.submenu>nav{position:absolute;z-index:9999;background-color:hsl(var(--c-accent) 33%/0.9);color:var(--color-text-inv);left:0;top:100%;display:none}#main-nav>.submenu>nav>*{text-align:initial;padding:.5em 1em}#main-nav>.submenu>nav>*:hover{background-color:hsla(0,0%,100%,.2)}#main-nav>.submenu:hover>nav,#main-nav>.submenu:focus-within>nav,#main-nav>.submenu:active>nav{display:block}#main-nav>.submenu.notifications>nav{min-width:400px;font-size:75%;left:unset;right:0px}#main-nav>.submenu.site-settings>nav,#main-nav>.submenu#account-menu>nav{left:unset;right:0px}.infobox{float:left;display:grid;grid-template-columns:auto 1fr;gap:.5em}.infobox>dt{color:var(--color-text-weak)}.infobox>.full-width{grid-column:1/-1}#tag-input-wrap{position:relative}#tag-input-wrap>input{width:100%}#tag-input-wrap>div{overflow-y:auto;height:10em}#tag-input-wrap>div>*{border:solid var(--color-border) 1px;cursor:pointer;padding:.5em;user-select:none}#tag-input-wrap>div>*:hover{background-color:var(--color-input)}.release-table{min-width:900px}.release-table td:not(.collapsable){text-align:center}.release-table.gv tr>td:nth-child(3) .tag{text-wrap:nowrap}.release-table.gv tr>td:nth-child(4),.release-table.no-gv tr>td:nth-child(2){text-align:right}.release-table.gv tr>td:nth-child(5),.release-table.no-gv tr>td:nth-child(3){text-wrap:nowrap}.release-table.gv tr>td:nth-child(6)>*,.release-table.no-gv tr>td:nth-child(4)>*{width:100%;text-align:center}.release-table .mod-dl{width:100%}@media screen and (max-width: 1050px){.release-table .mod-dl{text-indent:-99999px;line-height:0}.release-table .mod-dl:after{content:"Download";text-indent:0;line-height:initial;display:block}}.release-table.oc td:not(.collapsable):nth-last-child(n+4),.release-table.no-oc td:not(.collapsable):nth-last-child(n+3){padding-left:.25em;padding-right:.25em}.release-table tr.retracted{background-color:#d68989 !important}.release-table tr.retracted>td:nth-child(1){text-decoration:line-through}.release-table.gv tr.retracted>td:nth-child(2){text-decoration:line-through}.collapsable>input{display:none;overflow-y:hidden}.collapsable>div{display:grid;grid-template-rows:0fr;transition:grid-template-rows .5s ease-out}.collapsable>input:checked+div{grid-template-rows:1fr}.collapsable>div>*{overflow-y:hidden}@media(max-width: 767px){.collapsable.cl-changelog{position:absolute;z-index:99;background-color:var(--color-content-bg);margin:0 -0.5em;max-width:calc(100vw - 3em)}}.release-changelog{padding:.25em}#followed-mods-settings{background-color:hsl(var(--c-accent) 86%);padding:.25rem;border:1px solid hsl(var(--c-accent) 58%);border-radius:2px}#followed-mods-settings tr>*:nth-child(n+2){text-align:center;padding-left:1em}#gen-ai .slider-wrapper{position:relative}#gen-ai .slider-wrapper::before,#gen-ai .slider-wrapper::after{position:absolute;font-size:x-small;top:50%}#gen-ai .slider-wrapper::before{content:"0";left:0}#gen-ai .slider-wrapper::after{content:"Inf.";right:0}.moderation-request .by-user{font-size:small;color:var(--color-text-weak)}.moderation-request>div>h3{margin-bottom:1em}.moderation-request .buttons{display:block}.moderation-request .buttons textarea{width:100%;min-height:15.5em}.moderation-request .buttons>div{display:flex;flex-direction:row;gap:.5em;margin-top:.5em}.moderation-request .buttons>div>button{height:unset}.ticket-list{display:grid;grid-template-columns:repeat(auto-fit, minmax(600px, 1fr));grid-template-rows:auto;gap:.5em;justify-content:start}.ticket-list>*{border:solid var(--color-border) 1px;text-decoration:none;color:var(--color-text);padding:.25em;background-color:var(--color-mod-bg)}.ticket-list>*.closed,.ticket-list>*.closed .tag{color:var(--color-text-weak)}#reports-wrapper{display:flex;flex-direction:row;flex-wrap:nowrap;gap:2em}#reports-wrapper>*{flex:0 0 calc(50% - 1em)}#reports-wrapper>*>:last-child{overflow-y:auto;max-height:100vh}@media(max-width: 1050px){#reports-wrapper{flex-direction:column}#reports-wrapper>*{flex:unset}#reports-wrapper>*>:last-child{max-height:50vh}}#reported-mods,#reported-comments{display:flex;flex-direction:column;flex-wrap:nowrap;gap:1em}#reported-mods>*,#reported-comments>*{border:solid var(--color-border) 1px}#reported-mods>*>*:first-child,#reported-comments>*>*:first-child{text-decoration:none;color:var(--color-text);border-bottom:solid var(--color-border) 1px;background-color:var(--color-mod-bg);position:sticky;top:0}#reported-mods>*>*:first-child:hover,#reported-comments>*>*:first-child:hover{color:var(--color-link-active)}#reported-mods>*>.ticket-list,#reported-comments>*>.ticket-list{padding:.5em}#reported-mods>*>:first-child{display:flex;flex-direction:row;flex-wrap:nowrap}#reported-mods>*>:first-child>img{width:5em;height:5em}#reported-mods>*>:first-child>div{flex-grow:1;padding:.25em .5em;display:flex;flex-direction:column;flex-wrap:nowrap}#reported-mods>*>:first-child>div>:last-child{flex-grow:1}#reported-comments>*>:first-child{padding:0}#reported-comments>*>:first-child>*{display:block}#reported-comments>*>:first-child>*:first-child{padding:.25em;text-decoration:none;color:var(--color-text)}#reported-comments>*>:first-child>*:first-child:hover{color:var(--color-link-active)}#reported-comments>*>:first-child>*:first-child h4{margin:.25em}#reported-comments>*>:first-child>*:last-child{margin:.125em .25em .25em .25em;border-top:solid var(--color-border) 1px;padding-top:.125em}/*# sourceMappingURL=style.css.map */ + */.fi{width:36px;height:46px;padding:10px 0 0;position:relative;margin:0 auto;transition:all .2s ease-in-out;cursor:pointer;box-sizing:border-box;font-family:sans-serif;text-decoration:none;display:block}.fi:after,.fi:before{position:absolute;content:"";pointer-events:none}.fi:before{top:0;height:100%;left:0;background-color:#007bff;right:10px}.fi:after{width:0;height:0;border-style:solid;border-width:10px 0 0 10px;border-color:transparent transparent transparent #66b0ff;top:0;right:0}.fi-content{background-color:#007bff;top:10px;color:#fff;left:0;bottom:0;right:0;padding:16.5px .3em 0;font-size:13px;font-weight:500;position:absolute}.fi-doc.fi:before{background-color:#235d9c}.fi-doc.fi:after{border-left-color:#317dd1}.fi-doc.fi .fi-content{background-color:#235d9c;color:#fff}.fi-docx.fi:before{background-color:#2980b9}.fi-docx.fi:after{border-left-color:#4da1d8}.fi-docx.fi .fi-content{background-color:#2980b9;color:#fff}.fi-log.fi:before{background-color:#accff3}.fi-log.fi:after{border-left-color:#e6f0fb}.fi-log.fi .fi-content{background-color:#accff3;color:#fff}.fi-txt.fi:before{background-color:#8bc6d6}.fi-txt.fi:after{border-left-color:#bcdee7}.fi-txt.fi .fi-content{background-color:#8bc6d6;color:#fff}.fi-wps.fi:before{background-color:#297eff}.fi-wps.fi:after{border-left-color:#6ba6ff}.fi-wps.fi .fi-content{background-color:#297eff;color:#fff}.fi-csv.fi:before{background-color:#579704}.fi-csv.fi:after{border-left-color:#7cd806}.fi-csv.fi .fi-content{background-color:#579704;color:#fff}.fi-dat.fi:before{background-color:#0463ea}.fi-dat.fi:after{border-left-color:#3587fc}.fi-dat.fi .fi-content{background-color:#0463ea;color:#fff}.fi-ppt.fi:before{background-color:#ce4123}.fi-ppt.fi:after{border-left-color:#e26b52}.fi-ppt.fi .fi-content{background-color:#ce4123;color:#fff}.fi-xml.fi:before{background-color:#0e886b}.fi-xml.fi:after{border-left-color:#14c49a}.fi-xml.fi .fi-content{background-color:#0e886b;color:#fff}.fi-mp3.fi:before{background-color:#156aea}.fi-mp3.fi:after{border-left-color:#5291ef}.fi-mp3.fi .fi-content{background-color:#156aea;color:#fff}.fi-wav.fi:before{background-color:#36af14}.fi-wav.fi:after{border-left-color:#4be520}.fi-wav.fi .fi-content{background-color:#36af14;color:#fff}.fi-avi.fi:before{background-color:#40c1e6}.fi-avi.fi:after{border-left-color:#7bd4ee}.fi-avi.fi .fi-content{background-color:#40c1e6;color:#fff}.fi-mov.fi:before{background-color:#ff5838}.fi-mov.fi:after{border-left-color:#ff907a}.fi-mov.fi .fi-content{background-color:#ff5838;color:#fff}.fi-mp4.fi:before{background-color:#4163b4}.fi-mp4.fi:after{border-left-color:#6d89ca}.fi-mp4.fi .fi-content{background-color:#4163b4;color:#fff}.fi-3ds.fi:before{background-color:#015051}.fi-3ds.fi:after{border-left-color:#029192}.fi-3ds.fi .fi-content{background-color:#015051;color:#fff}.fi-max.fi:before{background-color:#02b4b6}.fi-max.fi:after{border-left-color:#03f4f7}.fi-max.fi .fi-content{background-color:#02b4b6;color:#fff}.fi-gif.fi:before{background-color:#aaa}.fi-gif.fi:after{border-left-color:#cbcbcb}.fi-gif.fi .fi-content{background-color:#aaa;color:#fff}.fi-ai.fi:before{background-color:#f67503}.fi-ai.fi:after{border-left-color:#fd983f}.fi-ai.fi .fi-content{background-color:#f67503;color:#fff}.fi-svg.fi:before{background-color:#e6a420}.fi-svg.fi:after{border-left-color:#edbc5c}.fi-svg.fi .fi-content{background-color:#e6a420;color:#fff}.fi-pdf.fi:before{background-color:#f88e21}.fi-pdf.fi:after{border-left-color:#faaf61}.fi-pdf.fi .fi-content{background-color:#f88e21;color:#fff}.fi-xls.fi:before{background-color:#86d44c}.fi-xls.fi:after{border-left-color:#aae181}.fi-xls.fi .fi-content{background-color:#86d44c;color:#fff}.fi-xlsx.fi:before{background-color:#6cbf2e}.fi-xlsx.fi:after{border-left-color:#8ed758}.fi-xlsx.fi .fi-content{background-color:#6cbf2e;color:#fff}.fi-sql.fi:before{background-color:#157efb}.fi-sql.fi:after{border-left-color:#56a2fc}.fi-sql.fi .fi-content{background-color:#157efb;color:#fff}.fi-exe.fi:before{background-color:#0e63ab}.fi-exe.fi:after{border-left-color:#1386e8}.fi-exe.fi .fi-content{background-color:#0e63ab;color:#fff}.fi-js.fi:before{background-color:#f0db4f}.fi-js.fi:after{border-left-color:#f5e78c}.fi-js.fi .fi-content{background-color:#f0db4f;color:#323330}.fi-html.fi:before{background-color:#e54c21}.fi-html.fi:after{border-left-color:#ec7c5c}.fi-html.fi .fi-content{background-color:#e54c21;color:#fff}.fi-xhtml.fi:before{background-color:#55a9ef}.fi-xhtml.fi:after{border-left-color:#92c8f5}.fi-xhtml.fi .fi-content{background-color:#55a9ef;color:#fff}.fi-css.fi:before{background-color:#264de4}.fi-css.fi:after{border-left-color:#617deb}.fi-css.fi .fi-content{background-color:#264de4;color:#fff}.fi-asp.fi:before{background-color:#5c2d91}.fi-asp.fi:after{border-left-color:#7c3dc3}.fi-asp.fi .fi-content{background-color:#5c2d91;color:#fff}.fi-ttf.fi:before{background-color:#14444b}.fi-ttf.fi:after{border-left-color:#22737f}.fi-ttf.fi .fi-content{background-color:#14444b;color:#fff}.fi-dll.fi:before{background-color:#960a4a}.fi-dll.fi:after{border-left-color:#d40e69}.fi-dll.fi .fi-content{background-color:#960a4a;color:#fff}.fi-7z.fi:before{background-color:#f63}.fi-7z.fi:after{border-left-color:#ff9875}.fi-7z.fi .fi-content{background-color:#f63;color:#fff}.fi-zip.fi:before{background-color:#ffb229}.fi-zip.fi:after{border-left-color:#ffca6b}.fi-zip.fi .fi-content{background-color:#ffb229;color:#fff}.fi-c.fi:before{background-color:#3747a5}.fi-c.fi:after{border-left-color:#5767c7}.fi-c.fi .fi-content{background-color:#3747a5;color:#fff}.fi-cs.fi:before{background-color:#013467}.fi-cs.fi:after{border-left-color:#0255a9}.fi-cs.fi .fi-content{background-color:#013467;color:#fff}.fi-java.fi:before{background-color:#ea2c2e}.fi-java.fi:after{border-left-color:#f0686a}.fi-java.fi .fi-content{background-color:#ea2c2e;color:#fff}.fi-jsp.fi:before{background-color:#e5000c}.fi-jsp.fi:after{border-left-color:#ff2834}.fi-jsp.fi .fi-content{background-color:#e5000c;color:#161419}.fi-swift.fi:before{background-color:#f32a20}.fi-swift.fi:after{border-left-color:#f6665f}.fi-swift.fi .fi-content{background-color:#f32a20;color:#fff}.fi-torrent.fi:before{background-color:#55ac44}.fi-torrent.fi:after{border-left-color:#7bc56d}.fi-torrent.fi .fi-content{background-color:#55ac44;color:#fff}.fi-php.fi:before{background-color:#4f5b93}.fi-php.fi:after{border-left-color:#717db3}.fi-php.fi .fi-content{background-color:#4f5b93;color:#fff}.fi-hh.fi:before{background-color:#505050}.fi-hh.fi:after{border-left-color:#717171}.fi-hh.fi .fi-content{background-color:#505050;color:#fff}.fi-go.fi:before{background-color:#e0ebf5}.fi-go.fi:after{border-left-color:#fff}.fi-go.fi .fi-content{background-color:#e0ebf5;color:#000}.fi-py.fi:before{background-color:#ffd542}.fi-py.fi:after{border-left-color:#ffe484}.fi-py.fi .fi-content{background-color:#ffd542;color:#3472a3}.fi-rss.fi:before{background-color:#fd8b33}.fi-rss.fi:after{border-left-color:#feb075}.fi-rss.fi .fi-content{background-color:#fd8b33;color:#fff}.fi-rb.fi:before{background-color:#a20d01}.fi-rb.fi:after{border-left-color:#e41201}.fi-rb.fi .fi-content{background-color:#a20d01;color:#fff}.fi-psd.fi:before{background-color:#181040}.fi-psd.fi:after{border-left-color:#2c1d75}.fi-psd.fi .fi-content{background-color:#181040;color:#3db6f2}.fi-png.fi:before{background-color:#dc7460}.fi-png.fi:after{border-left-color:#e8a496}.fi-png.fi .fi-content{background-color:#dc7460;color:#fff}.fi-bmp.fi:before{background-color:#459fa0}.fi-bmp.fi:after{border-left-color:#69bdbe}.fi-bmp.fi .fi-content{background-color:#459fa0;color:#fff}.fi-vb.fi:before{background-color:#19aad9}.fi-vb.fi:after{border-left-color:#4ac3ea}.fi-vb.fi .fi-content{background-color:#19aad9;color:#fff}.fi-size-xs.fi{width:28.8px;height:36.8px;padding-top:8px}.fi-size-xs.fi:before{right:8px}.fi-size-xs.fi:after{border-top-width:8px;border-left-width:8px}.fi-size-xs.fi .fi-content{top:8px;padding-top:13.2px;font-size:10.4px}.fi-size-sm.fi{width:36px;height:46px;padding-top:10px}.fi-size-sm.fi:before{right:10px}.fi-size-sm.fi:after{border-top-width:10px;border-left-width:10px}.fi-size-sm.fi .fi-content{top:10px;padding-top:16.5px;font-size:13px}.fi-size-md.fi{width:43.2px;height:55.2px;padding-top:12px}.fi-size-md.fi:before{right:12px}.fi-size-md.fi:after{border-top-width:12px;border-left-width:12px}.fi-size-md.fi .fi-content{top:12px;padding-top:19.8px;font-size:15.6px}.fi-size-lg.fi{width:54px;height:69px;padding-top:15px}.fi-size-lg.fi:before{right:15px}.fi-size-lg.fi:after{border-top-width:15px;border-left-width:15px}.fi-size-lg.fi .fi-content{top:15px;padding-top:24.75px;font-size:19.5px}.fi-size-xl.fi{width:72px;height:92px;padding-top:20px}.fi-size-xl.fi:before{right:20px}.fi-size-xl.fi:after{border-top-width:20px;border-left-width:20px}.fi-size-xl.fi .fi-content{top:20px;padding-top:33px;font-size:26px}.fi-content-xs .fi-content{font-size:11px;padding-top:55%}.fi-list{font-size:0;margin:0 -10px}.fi-list .fi{margin:0 10px 10px;display:inline-block}i.ico{font-style:normal}i.ico,i.ico::before{display:inline-block}i.ico.alert::before{content:"⚠"}.ico.star::before{content:"★"}div.betanotice{width:114px;height:36px;position:absolute;color:white;left:275px;top:13px;font-size:130%;transform:rotate(-19deg);font-weight:bold}.stdtable.latestcomments .textCol{word-wrap:anywhere}.stdtable.latestcomments .textCol>div{max-height:100px;overflow:auto;cursor:pointer}.stdtable.latestcomments .textCol pre{white-space:break-spaces}.stdtable.latestmods a{display:block}.mention{border-radius:4px;padding:2px 3px;text-decoration:none}.mention.username:before{content:"@"}.mention.username{background-color:rgba(159, 207, 52, 0.5)}.rte-autocomplete{position:absolute;top:0px;left:0px;display:block;z-index:1000;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;list-style:none;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0, 0, 0, 0.2);-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);-moz-box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);-webkit-background-clip:padding-box;-moz-background-clip:padding;background-clip:padding-box;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px}.rte-autocomplete:before{content:"";display:inline-block;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:7px solid #ccc;border-bottom-color:rgba(0, 0, 0, 0.2);position:absolute;top:-7px;left:9px}.rte-autocomplete:after{content:"";display:inline-block;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid white;position:absolute;top:-6px;left:10px}.rte-autocomplete>li.loading{background:url("/web/img/loading.gif") center no-repeat;height:16px}.rte-autocomplete>li>a{display:block;padding:3px 20px;clear:both;font-weight:normal;line-height:20px;color:#333;white-space:nowrap;text-decoration:none}.rte-autocomplete>li>a:hover,.rte-autocomplete>li>a:focus,.rte-autocomplete:hover>a,.rte-autocomplete:focus>a{color:#fff;text-decoration:none;background-color:#0081c2;background-image:linear-gradient(to bottom, #08c, #0077b3);background-repeat:repeat-x}.rte-autocomplete>.active>a,.rte-autocomplete>.active>a:hover,.rte-autocomplete>.active>a:focus{color:#fff;text-decoration:none;background-color:#0081c2;background-image:linear-gradient(to bottom, #08c, #0077b3);background-repeat:repeat-x;outline:0}.flair{display:unset}.flair::before{padding:1px .25em;border:1px solid #3e372f;border-radius:5px;unicode-bidi:isolate;line-height:1rem;background:var(--color-content-bg);color:var(--color-text)}.flair-moderator::before{content:"Moderator";background:var(--color-flair-moderator);color:var(--color-text-inv)}.flair-admin::before{content:"Administrator";background:var(--color-flair-admin);color:var(--color-text-inv)}.flair-author::before{content:"Author";background:var(--color-flair-author);color:var(--color-text-inv)}body.banned .overlay-when-banned{position:relative;pointer-events:none}body.banned .overlay-when-banned::before{content:"Unavailable";position:absolute;width:100%;height:100%;background-color:rgba(255, 116, 116, 0.353);color:red;display:flex;justify-content:center;align-items:center}body.banned .strikethrough-when-banned{pointer-events:none;background-color:rgba(255, 116, 116, 0.353) !important;color:red !important;text-decoration:line-through !important}body.readonly .overlay-when-readonly{position:relative;pointer-events:none}body.readonly .overlay-when-readonly::before{content:"Unavailable";position:absolute;width:100%;height:100%;background-color:rgba(255, 116, 116, 0.353);display:flex;justify-content:center;align-items:center}body.readonly .strikethrough-when-readonly{pointer-events:none;background-color:rgba(255, 116, 116, 0.353) !important;text-decoration:line-through !important}.teaminvite{display:flex;flex-direction:column;justify-content:center;align-items:center;padding:25px;background:linear-gradient(0deg, rgba(104, 80, 55, 0.65) 0%, rgba(179, 154, 121, 0.65) 100%);border:1px solid #3e372f;border-radius:5px;padding:5px}.teaminvite .button{min-width:175px}.pending-markers .search-choice:not(.accepted)::after,.pending-markers .result-selected:not(.accepted)::after,#teameditors-box .active-result:not(.accepted)::after{content:" [pending]";color:gray}.pending-markers .result-selected::before{content:"✔ "}@media(max-width: 767px){.teaminvite{flex-direction:column;align-items:start;width:fit-content}}#notifications-list>*{width:100%;display:flex;margin:0;padding:1em 0;flex-direction:row;gap:1em}#notifications-list>[data-label]::before{font-size:small;top:0}#notifications-list>:hover{background-color:var(--color-input)}textarea{resize:vertical}input:focus,.chosen-container-active .chosen-choices{border-color:var(--color-border-active)}input:not(.chosen-search-input,[type=checkbox]),button,select,.chosen-container,.chosen-container-single .chosen-single,.chosen-container-multi .chosen-choices,.chosen-container-single .chosen-single span,.prefixed-input{height:1.75rem;vertical-align:bottom}.chosen-container-multi,.chosen-container-multi .chosen-choices,.prefixed-input{height:unset;min-height:1.75rem}input,select,textarea,.chosen-container-single .chosen-single,.prefixed-input{border-radius:0;background-color:var(--color-input);border:1px solid var(--color-border)}input,button,select.no-chosen,textarea,.prefixed-input{padding:0 .25em}input:disabled,button:disabled,select:disabled,.button:disabled,.prefixed-input.disabled{background-color:var(--color-input-disabled);cursor:not-allowed}button[type=submit],input[type=submit],.button{display:inline-block;text-align:center;color:var(--color-text);background-color:var(--color-input-btn);border:none;padding:.25em .75em;cursor:pointer;text-decoration:none;border-radius:.25em;box-shadow:0px 2px 3px rgba(0,0,0,.5333333333);user-select:none}button[type=submit]:not(:disabled):hover,input[type=submit]:not(:disabled):hover,.button:not(:disabled):hover{color:var(--color-text);background-color:hsl(var(--c-input-btn) 87%)}.button.submit:not(:disabled):hover,.button.btndelete:not(:disabled):hover{color:var(--color-text-inv)}button[type=submit]:not(:disabled):active,input[type=submit]:not(:disabled):active,.button:not(:disabled):active{color:var(--color-text);background-color:hsl(var(--c-input-btn) 90%)}.prefixed-input{display:inline-block;white-space:nowrap;height:1.75rem}.prefixed-input:focus-within{border-color:var(--color-border-active)}.prefixed-input::before{content:attr(data-prefix);display:inline-block}.prefixed-input::before,.prefixed-input input{font-size:initial;vertical-align:baseline;background-color:rgba(0,0,0,0)}.prefixed-input input{padding:0;border:none;height:calc(1.75em - 1px)}input:disabled,.prefixed-input.disabled{color:var(--color-input-text-disabled)}.button.large{font-weight:bold;padding:.5em 0;min-width:150px}.button.submit{background-color:var(--color-input-g)}.button.btndelete{background-color:var(--color-input-r)}.shine{position:relative}.shine::after{content:"";position:absolute;top:0;left:0;background:linear-gradient(to bottom, rgba(255, 255, 255, 0.7) 0%, rgba(255, 255, 255, 0) 100%);height:17px;width:100%;box-shadow:inset 0px 2px 1px rgba(255,255,255,.25);border-radius:10px;border-bottom-right-radius:100px 40px;border-bottom-left-radius:100px 40px}#buttons-overlay{position:absolute;top:1em;right:1em;display:flex;flex-direction:column;gap:.5em;z-index:999}.button.square{font-size:120%;border-radius:0;box-shadow:1px 1px 3px #aaa}.button.ico-button{position:relative;padding-left:calc(2em + .5ch)}.button.ico-button::before{content:"";position:absolute;left:.75em;top:0;height:100%;width:1.2em;background-repeat:no-repeat;background-size:contain;background-position:center}.button.ico-button.mod-dl{background-color:hsl(217,45%,51%);color:#fff}.button.ico-button.mod-dl:hover{background-color:hsl(217,45%,54%)}.button.ico-button.mod-dl::before{vertical-align:sub;background-image:url(/web/img/download-w.png)}.button.ico-button.deps{padding:.25em .25em;width:2em}.button.ico-button.deps::before{position:relative;font-family:boxicons !important;font-weight:400;line-height:1;content:"";left:unset;top:2px}.button.ico-button.one-click-dl::before{vertical-align:text-top;background-image:url(/web/favicon/favicon-32x32.png)}.chosen-container-single .chosen-single{background:linear-gradient(hsl(var(--c-input) 95%) 20%, hsl(var(--c-input) 98%) 50%, hsl(var(--c-input) 95%) 52%, hsl(var(--c-input) 97%) 100%)}.chosen-container-multi .chosen-choices{background:var(--color-input);cursor:text}.chosen-container-multi .search-field{width:60px}label.toggle{position:relative;display:inline-block;vertical-align:middle;width:3rem;height:1.75rem;background-color:var(--color-input);border:solid 1px var(--color-border);border-radius:.25em;cursor:pointer;user-select:none}label.toggle>input[type=checkbox]{appearance:none;position:absolute;left:2px;top:2px;height:calc(1.75rem - 6px);width:calc(1.75rem - 6px);border-radius:.25em;cursor:pointer;transform:translateX(0);background-color:var(--color-input);transition:transform .3s,background-color .3s}label.toggle>input[type=checkbox]:checked{transform:translateX(calc(1.25rem - 2px));background-color:var(--color-input-g)}.button.moderator,button.moderator{padding-top:1.25em;padding-bottom:.5em;position:relative}.button.moderator::before,button.moderator::before{content:"Moderator Action";white-space:pre;background-color:var(--color-flair-moderator);color:var(--color-text-inv);position:absolute;top:0;left:50%;transform:translateX(-50%);border-radius:0 0 .25em .25em;padding:0 .25em}.with-buttons-bottom{display:flex;flex-direction:column}.with-buttons-bottom>.buttons{position:sticky;z-index:99;bottom:0;display:flex;gap:1em;justify-content:space-between;flex-wrap:wrap;margin-top:1em;padding:1em;border-top:solid 1px var(--color-border);background-color:var(--color-content-bg)}.with-buttons-bottom>.buttons>*{min-height:3em;align-content:center}@keyframes shake-h{0%{transform:translateX(0)}25%{transform:translateX(5px)}50%{transform:translateX(-5px)}75%{transform:translateX(5px)}100%{transform:translateX(0)}}.invalid{animation:.5s shake-h}.invalid,.invalid *,.invalid.tox .tox-menubar,.invalid.tox .tox-toolbar{background-color:var(--color-input-r) !important}details.version-selector{position:relative;background-color:var(--color-input);border:1px solid var(--color-border)}details.version-selector>summary{list-style:none;display:block}details.version-selector>:last-child{position:absolute;z-index:100;width:100%;max-height:min(30em,50vh);overflow-y:auto;scrollbar-width:thin;background-color:var(--color-input);border:1px solid var(--color-border);user-select:none;--c1w: 3rem;--c2w: 3rem;--c3w: 3rem;--c4w: 3rem}details.version-selector>:last-child>h4,details.version-selector>:last-child div.h{display:flex;flex-direction:row;flex-wrap:nowrap}details.version-selector>:last-child>h4>*,details.version-selector>:last-child div.h>*{flex-shrink:0;flex-grow:0}details.version-selector>:last-child>h4>*:last-child,details.version-selector>:last-child div.h>*:last-child{flex-grow:1}details.version-selector>:last-child>h4{padding-top:.25em;margin-top:0;position:sticky;top:0;background-color:var(--color-input);border-bottom:solid var(--color-border) 1px;z-index:101}details.version-selector>:last-child>h4>*{text-align:right;padding:0 .125em}details.version-selector>:last-child>h4>:nth-child(1){width:var(--c1w)}details.version-selector>:last-child>h4>:nth-child(2){width:var(--c2w)}details.version-selector>:last-child>h4>:nth-child(3){width:var(--c3w)}details.version-selector>:last-child>h4>:nth-child(4){width:var(--c4w)}details.version-selector>:last-child div.h{border:solid var(--color-border) 0;border-left-width:1px}details.version-selector>:last-child div.h:not(:last-child){border-bottom-width:1px}details.version-selector>:last-child div.h{cursor:pointer}details.version-selector>:last-child div.h:hover:not(:has(.h:hover)){background-color:hsl(var(--c-input) 80%)}details.version-selector>:last-child>div{padding-bottom:.25em}details.version-selector>:last-child>div>div>div>span{width:var(--c1w);position:sticky;top:1.75em}details.version-selector>:last-child>div>div>div>div>div>span{width:var(--c2w);position:sticky;top:1.75em}details.version-selector>:last-child>div>div>div>div>div>div>div>span{width:var(--c3w);position:sticky;top:1.75em}details.version-selector>:last-child>div>div>div>div>div>div>div>div>div>span{width:var(--c4w)}details.version-selector>:last-child>div span{display:block;text-align:right}details.version-selector input,details.version-selector select{width:fit-content}#report-mod-btn>i{vertical-align:center}#report-mod-btn>span{text-decoration:underline;cursor:pointer}ul.tabs{width:100%;border-bottom:2px solid #888;display:flex;flex-direction:row;flex-wrap:wrap}ul.tabs>li,ul.tabs>li>*{height:2em;line-height:2em;user-select:none}ul.tabs>li{border:1px solid var(--color-border);border-bottom:none;box-sizing:content-box;background-color:var(--color-input-btn);flex-grow:1}@media(min-width: 768px){ul.tabs>li{flex-grow:unset}}ul.tabs>li:not(.active):hover{background-color:hsl(var(--c-input-btn) 95%)}ul.tabs>li>*{cursor:pointer;text-decoration:none;color:#000;display:block;font-size:1em;font-weight:bold;padding:0 1em;border:1px solid #fff;border-bottom:none;outline:none}ul.tabs>li>* img{vertical-align:sub}.tab-trigger{display:none}.tab-content{padding:1em 0}.tab-trigger:not(:checked)+.tab-content{display:none}dialog{margin:auto;background-color:var(--color-content-bg);color:var(--color-text);border:solid 2px var(--color-border-active);max-height:calc(100vh - 2em);max-width:40em}dialog.full-screen{max-width:calc(100vw - 2em)}dialog:not([open]){display:none}dialog h1{text-align:center;padding-top:.5em;margin-bottom:.5em;position:sticky;top:0;background-color:var(--color-content-bg);z-index:2}dialog h1::after{content:"";display:block;width:calc(100% - 1em);margin:.5em 1em 0 .5em;border-bottom:solid 1px var(--color-border)}dialog.with-buttons-bottom>*:not(.buttons):not(h1),dialog>:only-child.with-buttons-bottom>*:not(.buttons):not(h1){margin-left:1em;margin-right:1em}dialog .err-container{color:var(--color-input-r)}dialog::backdrop{background-color:var(--color-backdrop)}.comment{width:unset;word-break:break-word}.comment>.title{padding:.25em}.comment>.reference{display:block;font-size:small;color:var(--color-text-weak);padding:.125em .25em;text-wrap:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none}.comment>.reference::before{content:"⬐"}.comment>.reference:hover>span{text-decoration:underline}.comment>.body{padding:4px;background-color:rgba(255,255,255,.4)}.comment:target{background-color:#dde5cb;border-color:#abb29c}.comment.editbox{position:relative;overflow:hidden;padding:0px;background:rgba(219,208,182,.7)}.comment.deleted{background:#e48383}.comment.deleted .ribbon-tr{background-color:#b82525;color:var(--color-text-inv)}.comments.threaded .comment .reference{display:none}.comments,.comments .convo{display:flex;flex-direction:column;flex-wrap:nowrap;gap:.5em;align-items:stretch}.comments .convo{border:solid var(--color-border);border-radius:2px 0 0 2px;border-width:0 0 1px 1px}.comments .convo>*{margin-left:1rem}.comments .convo>*:first-child{margin-left:-1px}.comments .convo>*:last-child{margin-bottom:-1px}.comment .body img,table.latestcomments img{position:static;opacity:initial}.tag{padding:.125em .25em;border-radius:.25em;background-color:#ccc;border:1px solid var(--color-border-tag);display:inline-block;font-size:90%;line-height:13px;user-select:none}.tag,.tag *{text-decoration:none;color:#333}.tag:hover{color:#333;opacity:.5}.tag .add::after{content:"+"}.tag .rem::after{content:"-"}.tags{margin-top:.25em;margin-bottom:.25em;display:flex;flex-direction:row;flex-wrap:wrap;gap:.25em}.tags #more-tags-trigger{display:none}.tags #more-tags-trigger:not(:checked)~.tag.hidden{display:none}.tags #more-tags-trigger~label{cursor:pointer}.tags #more-tags-trigger~label::before{content:"Show "}.tags #more-tags-trigger~label::after{content:" more tags..."}.tags #more-tags-trigger:checked~label::before{content:"Show "}.tags #more-tags-trigger:checked~label::after{content:" less tags..."}.tags.votable .tag{padding-right:0}.tags.votable .tag:hover{color:unset;opacity:unset}.tags.votable .tag.downvoted{filter:saturate(0.4)}.tags.votable .tag.downvoted a{color:var(--color-text-weak)}.tags.votable .tag>a{padding-right:.25em;border-right:solid var(--color-border-tag) 1px}.tags.votable .tag>.add,.tags.votable .tag>.rem{cursor:pointer}.tags.votable .tag>.add:hover,.tags.votable .tag>.rem:hover{background-color:rgba(51,51,51,.2)}.tags.votable .tag[data-vote="-1"]>.add{color:var(--color-text-weak)}.tags.votable .tag[data-vote="-1"]>.rem{font-weight:bold}.tags.votable .tag[data-vote="1"]>.add{font-weight:bold}.tags.votable .tag[data-vote="1"]>.rem{color:var(--color-text-weak)}.tags.votable>a{text-decoration:none;color:var(--color-text)}.gallery{position:relative;max-width:min(800px,100%)}.gallery.is-fullscreen{position:fixed;top:0;left:0;width:100vw;height:100vh;max-width:none;z-index:9999}.gallery.is-fullscreen>.viewport{height:calc(100vh - 64px)}.gallery.is-fullscreen{background-color:#000}.gallery>.viewport{position:relative}.gallery>.viewport>.stage{position:relative;display:flex;width:var(--gallery-w, 800px);height:var(--gallery-h, 600px);max-width:100%}.is-fullscreen.gallery>.viewport>.stage{width:100%;height:100%}.gallery>.viewport>.stage{overflow-x:auto;scrollbar-width:none}.gallery>.viewport>.stage::-webkit-scrollbar{display:none}.gallery>.viewport>.stage{scroll-snap-type:x mandatory;scroll-behavior:smooth}.gallery>.viewport>.stage.manually-dragging{scroll-snap-type:none;scroll-behavior:auto}.gallery>.viewport>.stage{background:rgba(166,159,125,.3)}.is-fullscreen.gallery>.viewport>.stage{background:rgba(0,0,0,0)}.gallery>.viewport>.stage{user-select:none}.gallery>.viewport>.stage>*{flex:0 0 100%;height:100%;scroll-snap-align:start;scroll-snap-stop:always}.gallery>.viewport>.stage>*>*{display:block;width:100%;height:100%}.gallery>.viewport>.stage>*>img{object-fit:contain}.gallery>.viewport>.stage>*>iframe{border:none}.gallery>.viewport .ctrl{position:absolute;cursor:pointer;border:none;padding:0;background:none;opacity:0;transition:opacity .3s}.gallery:hover>.viewport>.ctrl{opacity:1}.is-fullscreen.gallery>.viewport .ctrl{opacity:1}.gallery>.viewport>.arr{position:absolute;top:0;bottom:0;height:100%;width:50%}.gallery>.viewport>.arr::after{content:"";position:absolute;top:50%;width:0;height:0;border-style:solid;border-width:5px 9px 5px 0;border-color:rgba(0,0,0,0) #fff rgba(0,0,0,0) rgba(0,0,0,0);margin-right:1px;filter:drop-shadow(#000 0 0 5px)}.gallery>.viewport>.arr.prev{left:0}.gallery>.viewport>.arr.prev::after{left:2em;transform:translate(-5px, -50%)}.gallery>.viewport>.arr.next{right:0}.gallery>.viewport>.arr.next::after{right:2em;transform:translate(-5px, -50%) scaleX(-1)}.gallery>.viewport>.fullscreen{top:2px;right:2px;width:32px;height:32px;background:url(data:image/png;base64,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) no-repeat 0 -32px}.is-fullscreen.gallery>.viewport>.fullscreen{background-position:-32px -32px}.gallery nav{position:relative;display:block;margin-top:2px;height:64px;overflow:hidden;text-align:center}.gallery nav>*{position:relative;display:inline-block;white-space:nowrap;line-height:0;font-size:0}.gallery nav>*>*{position:relative;display:inline-block;width:64px;height:64px;margin-right:2px;padding:0;vertical-align:middle;cursor:pointer;border:none;outline:none}.gallery nav>*>*>img{width:100%;height:100%;object-fit:cover}.gallery nav>*>*.video-thumb>img{filter:brightness(0.4)}.gallery nav>*>*.video-thumb::before,.gallery nav>*>*.video-thumb::after{content:"";position:absolute;z-index:2;top:50%;left:50%;transform:translate(-50%, -50%)}.gallery nav>*>*.video-thumb::before{width:0;height:0;border-style:solid;border-width:5px 0 5px 9px;border-color:rgba(0,0,0,0) rgba(0,0,0,0) rgba(0,0,0,0) #fff;margin-left:1px}.gallery nav>*>*.video-thumb::after{width:24px;height:24px;border-radius:50%;border:2px solid #fff;background:rgba(0,0,0,0)}.gallery nav>*>*.indicator{position:absolute;top:0;left:0;width:64px;height:64px;border:2px solid #00afea;pointer-events:none;transition:transform .36s cubic-bezier(0.1, 0, 0.25, 1)}.audit-log-wrap{overflow-x:auto}.audit-log-wrap thead{position:sticky;top:0;z-index:1;background-color:var(--color-content-bg)}.audit-log-wrap td.date{min-width:14ch}.audit-log-wrap td.target{min-width:14ch}.audit-log-wrap td.info{white-space:pre}.audit-log-wrap .added{background-color:var(--color-diff-added)}.audit-log-wrap .removed{background-color:var(--color-diff-removed)}#main-nav{font-size:125%;width:100%;background:var(--color-accent);background:linear-gradient(0deg, rgba(104, 80, 55, 0.65) 0%, rgba(179, 154, 121, 0.65) 100%);color:var(--color-text-inv);border-top-left-radius:.25em;border-top-right-radius:.25em;display:flex;flex-direction:row;flex-wrap:wrap;gap:.125em .5em;user-select:none}#main-nav>*{min-width:10ch;flex-shrink:0;background-color:rgba(255,255,255,.2);color:var(--color-text-inv);text-align:center;white-space:nowrap;padding:.125em .5em}#main-nav>*:first-child{border-top-left-radius:.25em}#main-nav>*:last-child{border-top-right-radius:.25em}#main-nav>*.flex-spacer{padding:0;margin:0 -0.5em}#main-nav>*.icon-only{min-width:0}#main-nav>*.icon-only i{font-size:22px;width:22px;vertical-align:middle}#main-nav>*.active{background-color:var(--color-content-bg);color:var(--color-text)}#main-nav>*.active img{filter:invert(80%)}#main-nav>*:not(.active):hover{background-color:rgba(255,255,255,.4)}#main-nav a{display:block;text-decoration:none;color:currentColor}#main-nav a.external::before{filter:invert(100%)}#main-nav img{height:.8em;width:.8em;margin-right:.25em}@media(max-width: 767px){#main-nav{border-top-left-radius:0;border-top-right-radius:0}#main-nav>*{flex-grow:1}#main-nav>*:first-child{border-top-left-radius:0}#main-nav>*:last-child{border-top-right-radius:0}#main-nav>*.flex-spacer{display:none}}@media(max-width: 1050px){#main-nav #account-menu{text-indent:-99999px;line-height:0;min-width:2em}#main-nav #account-menu:after{text-indent:0;content:"";font-family:boxicons !important;font-weight:400;font-style:normal;font-variant:normal;line-height:1.4;text-rendering:auto;display:block;text-transform:none;speak:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#main-nav #account-menu>nav{line-height:1.2;text-indent:0}}#main-nav>.submenu{position:relative;padding:0}#main-nav>.submenu>:first-child{padding:.125em .5em;vertical-align:middle}#main-nav>.submenu>nav{position:absolute;z-index:9999;background-color:hsl(var(--c-accent) 33%/0.9);color:var(--color-text-inv);left:0;top:100%;display:none}#main-nav>.submenu>nav>*{text-align:initial;padding:.5em 1em}#main-nav>.submenu>nav>*:hover{background-color:rgba(255,255,255,.2)}#main-nav>.submenu:hover>nav,#main-nav>.submenu:focus-within>nav,#main-nav>.submenu:active>nav{display:block}#main-nav>.submenu.notifications>nav{min-width:400px;font-size:75%;left:unset;right:0px}#main-nav>.submenu.site-settings>nav,#main-nav>.submenu#account-menu>nav{left:unset;right:0px}.infobox{float:left;display:grid;grid-template-columns:auto 1fr;gap:.5em}.infobox>dt{color:var(--color-text-weak)}.infobox>.full-width{grid-column:1/-1}#tag-input-wrap{position:relative}#tag-input-wrap>input{width:100%}#tag-input-wrap>div{overflow-y:auto;height:10em}#tag-input-wrap>div>*{border:solid var(--color-border) 1px;cursor:pointer;padding:.5em;user-select:none}#tag-input-wrap>div>*:hover{background-color:var(--color-input)}.release-table{min-width:900px}.release-table td:not(.collapsable){text-align:center}.release-table.gv tr>td:nth-child(3) .tag{text-wrap:nowrap}.release-table.gv tr>td:nth-child(4),.release-table.no-gv tr>td:nth-child(2){text-align:right}.release-table.gv tr>td:nth-child(5),.release-table.no-gv tr>td:nth-child(3){text-wrap:nowrap}.release-table.gv tr>td:nth-child(6)>*,.release-table.no-gv tr>td:nth-child(4)>*{width:100%;text-align:center}.release-table .mod-dl{width:100%}@media screen and (max-width: 1050px){.release-table .mod-dl{text-indent:-99999px;line-height:0}.release-table .mod-dl:after{content:"Download";text-indent:0;line-height:initial;display:block}}.release-table.oc td:not(.collapsable):nth-last-child(n+4),.release-table.no-oc td:not(.collapsable):nth-last-child(n+3){padding-left:.25em;padding-right:.25em}.release-table tr.retracted>td:nth-child(1){text-decoration:line-through}.release-table.gv tr.retracted>td:nth-child(2){text-decoration:line-through}.release-table tr.retracted{background-color:#d68989 !important}.collapsable>input{display:none;overflow-y:hidden}.collapsable>div{display:grid;grid-template-rows:0fr;transition:grid-template-rows .5s ease-out}.collapsable>input:checked+div{grid-template-rows:1fr}.collapsable>div>*{overflow-y:hidden}@media(max-width: 767px){.collapsable.cl-changelog{position:absolute;z-index:99;background-color:var(--color-content-bg);margin:0 -0.5em;max-width:calc(100vw - 3em)}}.release-changelog{padding:.25em}#followed-mods-settings{background-color:hsl(var(--c-accent) 86%);padding:.25rem;border:1px solid hsl(var(--c-accent) 58%);border-radius:2px}#followed-mods-settings tr>*:nth-child(n+2){text-align:center;padding-left:1em}#gen-ai .slider-wrapper{position:relative}#gen-ai .slider-wrapper::before,#gen-ai .slider-wrapper::after{position:absolute;font-size:x-small;top:50%}#gen-ai .slider-wrapper::before{content:"0";left:0}#gen-ai .slider-wrapper::after{content:"Inf.";right:0}.moderation-request .by-user{font-size:small;color:var(--color-text-weak)}.moderation-request>div>h3{margin-bottom:1em}.moderation-request .buttons{display:block}.moderation-request .buttons textarea{width:100%;min-height:15.5em}.moderation-request .buttons>div{display:flex;flex-direction:row;gap:.5em;margin-top:.5em}.moderation-request .buttons>div>button{height:unset}.ticket-list{display:grid;grid-template-columns:repeat(auto-fit, minmax(600px, 1fr));grid-template-rows:auto;gap:.5em;justify-content:start}.ticket-list>*{border:solid var(--color-border) 1px;text-decoration:none;color:var(--color-text);padding:.25em;background-color:var(--color-mod-bg)}.ticket-list>*.closed,.ticket-list>*.closed .tag{color:var(--color-text-weak)}#reports-wrapper{display:flex;flex-direction:row;flex-wrap:nowrap;gap:2em}#reports-wrapper>*{flex:0 0 calc(50% - 1em)}#reports-wrapper>*>:last-child{overflow-y:auto;max-height:100vh}@media(max-width: 1050px){#reports-wrapper{flex-direction:column}#reports-wrapper>*{flex:unset}#reports-wrapper>*>:last-child{max-height:50vh}}#reported-mods,#reported-comments{display:flex;flex-direction:column;flex-wrap:nowrap;gap:1em}#reported-mods>*,#reported-comments>*{border:solid var(--color-border) 1px}#reported-mods>*>*:first-child,#reported-comments>*>*:first-child{text-decoration:none;color:var(--color-text)}#reported-mods>*>*:first-child:hover,#reported-comments>*>*:first-child:hover{color:var(--color-link-active)}#reported-mods>*>*:first-child,#reported-comments>*>*:first-child{border-bottom:solid var(--color-border) 1px;background-color:var(--color-mod-bg);position:sticky;top:0}#reported-mods>*>.ticket-list,#reported-comments>*>.ticket-list{padding:.5em}#reported-mods>*>:first-child{display:flex;flex-direction:row;flex-wrap:nowrap}#reported-mods>*>:first-child>img{width:5em;height:5em}#reported-mods>*>:first-child>div{flex-grow:1;padding:.25em .5em;display:flex;flex-direction:column;flex-wrap:nowrap}#reported-mods>*>:first-child>div>:last-child{flex-grow:1}#reported-comments>*>:first-child{padding:0}#reported-comments>*>:first-child>*{display:block}#reported-comments>*>:first-child>*:first-child{padding:.25em;text-decoration:none;color:var(--color-text)}#reported-comments>*>:first-child>*:first-child:hover{color:var(--color-link-active)}#reported-comments>*>:first-child>*:first-child h4{margin:.25em}#reported-comments>*>:first-child>*:last-child{margin:.125em .25em .25em .25em;border-top:solid var(--color-border) 1px;padding-top:.125em}.mod-relations .mod-link.unresolved{color:var(--color-text-weak);font-style:italic;cursor:help;border-bottom:1px dotted}.mod-relations.incompatible .mod-link{text-decoration:line-through;opacity:.85}.infobox dt.warn{color:#d63}.mod-relations-editor{display:flex;flex-direction:column;gap:.6em;width:100%}.mod-relations-editor .rel-section{border:solid 1px #ccc;background:rgba(255,255,255,.25);padding:.6em .8em}.mod-relations-editor .rel-section .rel-section-header{display:flex;flex-direction:column;gap:.1em;margin-bottom:.5em;padding-bottom:.4em;border-bottom:1px solid #ccc}.mod-relations-editor .rel-section .rel-section-header strong{font-size:1em;color:var(--color-text)}.mod-relations-editor .rel-section .rel-section-header .hint{font-size:.82em;color:var(--color-text-weak)}.mod-relations-editor .rel-empty{font-style:italic;color:var(--color-text-weak);padding:.2em 0}.mod-relations-editor .rel-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.3em}.mod-relations-editor .rel-auto-section .rel-item{display:flex;align-items:center;gap:.6em;padding:.3em .4em}.mod-relations-editor .rel-auto-section .rel-item .rel-target{flex:1;min-width:8em}.mod-relations-editor .rel-auto-section .rel-item .rel-version{font-size:.85em;color:var(--color-text-weak);min-width:6em;text-align:right}.mod-relations-editor .rel-auto-section .rel-item .unresolved{color:var(--color-text-weak);font-style:italic;cursor:help;border-bottom:1px dotted}.mod-relations-editor .rel-auto-section .rel-item a{color:var(--color-link)}.mod-relations-editor .rel-auto-section .rel-item a:hover{color:var(--color-link-active)}.mod-relations-editor .rel-badge{display:inline-block;padding:.05em .6em;font-size:.75em;font-weight:600;text-transform:uppercase;letter-spacing:.05em;min-width:5em;text-align:center;color:#fff;border:1px solid rgba(0,0,0,0)}.mod-relations-editor .rel-badge.badge-required{background:#4a6e9a;border-color:#3a5b80}.mod-relations-editor .rel-badge.badge-optional{background:#7a7a7a;border-color:#5e5e5e}.mod-relations-editor .rel-badge.badge-incompatible{background:#a64545;border-color:#843636}.mod-relations-editor .rel-badge.badge-tested_with{background:#5c8a5c;border-color:#4a6f4a}.mod-relations-editor .manual-list .rel-item{display:flex;flex-wrap:wrap;align-items:flex-end;gap:.4em .6em;padding:.5em .6em;background:rgba(255,255,255,.4);border:1px solid #ccc;border-left:4px solid #ccc}.mod-relations-editor .manual-list .rel-item.rel-type-required{border-left-color:#4a6e9a}.mod-relations-editor .manual-list .rel-item.rel-type-optional{border-left-color:#7a7a7a}.mod-relations-editor .manual-list .rel-item.rel-type-incompatible{border-left-color:#a64545}.mod-relations-editor .manual-list .rel-item.rel-type-tested_with{border-left-color:#5c8a5c}.mod-relations-editor .manual-list .rel-item.rel-item-new{background:rgba(255,247,220,.7);border-color:#b89a55}.mod-relations-editor .rel-field{display:flex;flex-direction:column;gap:.15em}.mod-relations-editor .rel-field.rel-field-target{flex:1;min-width:10em}.mod-relations-editor .rel-field.rel-field-type{min-width:9em}.mod-relations-editor .rel-field.rel-field-version{width:7em}.mod-relations-editor .rel-field .rel-field-label{font-size:.72em;text-transform:uppercase;letter-spacing:.04em;color:var(--color-text-weak)}.mod-relations-editor .rel-field input,.mod-relations-editor .rel-field select{width:100%;margin:0}.mod-relations-editor .rel-remove{cursor:pointer;user-select:none;display:inline-flex;align-items:center;justify-content:center;width:1.6em;height:1.6em;margin-bottom:.05em;position:relative;border:1px solid rgba(0,0,0,0)}.mod-relations-editor .rel-remove input[type=checkbox]{position:absolute;opacity:0;pointer-events:none}.mod-relations-editor .rel-remove .rel-remove-icon{font-size:1.3em;line-height:1;color:var(--color-text-weak)}.mod-relations-editor .rel-remove:hover{border-color:#a64545}.mod-relations-editor .rel-remove:hover .rel-remove-icon{color:#a64545}.mod-relations-editor .rel-remove input[type=checkbox]:checked~.rel-remove-icon{color:#a64545;font-weight:bold}.mod-relations-editor .rel-discard{cursor:pointer;background:rgba(0,0,0,0);border:1px solid rgba(0,0,0,0);width:1.6em;height:1.6em;color:var(--color-text-weak);font-size:1.3em;line-height:1;margin-bottom:.05em;padding:0}.mod-relations-editor .rel-discard:hover{color:#a64545;border-color:#a64545}.mod-relations-editor .manual-list .rel-item:has(input[type=checkbox]:checked){opacity:.55}.mod-relations-editor .manual-list .rel-item:has(input[type=checkbox]:checked) .rel-target-input,.mod-relations-editor .manual-list .rel-item:has(input[type=checkbox]:checked) .rel-version-input{text-decoration:line-through}.mod-relations-editor .rel-add-btn{align-self:flex-start;margin-top:.3em}/*# sourceMappingURL=style.css.map */