diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index b724def3..ad8b4ac5 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,17 +1,19 @@ module.exports = { apps: [ { - name: "epicnextcms", - script: "pnpm", - args: "start", - instances: "max", - exec_mode: "cluster", - node_args: "--v8-pool-size=4", - max_memory_restart: "1G", + name: 'epic-next-app', + script: 'node_modules/next/dist/bin/next', + args: 'start', + cwd: '/var/www/atom-nexst', + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '1G', + node_args: '--max-old-space-size=1024', env: { - NODE_ENV: "production", + NODE_ENV: 'production', PORT: 3002 } } ] -}; +}; \ No newline at end of file diff --git a/src/app/admin/import/furni/nitro-editor-dialog.tsx b/src/app/admin/import/furni/nitro-editor-dialog.tsx index 5311c452..8ca8faf9 100644 --- a/src/app/admin/import/furni/nitro-editor-dialog.tsx +++ b/src/app/admin/import/furni/nitro-editor-dialog.tsx @@ -32,9 +32,14 @@ import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { adminFetch } from "@/lib/admin-fetch"; +import { + autoDetectInteraction, + getInteractionTypeLabel, +} from "@/lib/furni/auto-interaction"; interface NitroEditorDialogProps { classname: string; + name?: string; open: boolean; onOpenChange: (open: boolean) => void; } @@ -122,6 +127,7 @@ function CopyClassname({ value }: { value: string }) { export function NitroEditorDialog({ classname, + name = "", open, onOpenChange, }: NitroEditorDialogProps) { @@ -142,8 +148,22 @@ export function NitroEditorDialog({ cansiton: false, canlayon: false, }); + const [autoDetectResult, setAutoDetectResult] = useState | null>(null); const [jsonError, setJsonError] = useState(null); + const handleAutoDetect = () => { + const result = autoDetectInteraction(classname, name); + setAutoDetectResult(result); + setFlags({ + canstandon: result.canStand, + cansiton: result.canSit, + canlayon: result.canLay, + }); + toast.success(`Auto-detected: ${result.reason}`); + }; + const patchMeta = (path: string, value: unknown) => { setMetadata((prev) => prev == null ? prev : updateNested(prev, path, value), @@ -497,6 +517,53 @@ export function NitroEditorDialog({ /> +
+ + {autoDetectResult && ( +
+
+ + {autoDetectResult.canLay ? ( + + ) : autoDetectResult.canSit ? ( + + ) : ( + + )} + {getInteractionTypeLabel( + autoDetectResult.interactionType, + )} + + {autoDetectResult.clickLimit > 0 && ( + + Click limit: {autoDetectResult.clickLimit} + + )} + {autoDetectResult.interactionModesCount > 0 && ( + + Modes: {autoDetectResult.interactionModesCount} + + )} +
+

+ {autoDetectResult.reason} +

+
+ )} +
diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 68d875eb..3ec7bc1c 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -1374,6 +1374,7 @@ export function StudioClient({ !open && setEditingClassname(null)} /> diff --git a/src/lib/furni/auto-interaction.ts b/src/lib/furni/auto-interaction.ts new file mode 100644 index 00000000..263d87e4 --- /dev/null +++ b/src/lib/furni/auto-interaction.ts @@ -0,0 +1,389 @@ +/** + * Auto-detect furniture interaction capabilities from classname/name. + * Determines: canSit, canLay, canStand, interactionType, clickLimit + */ + +export interface AutoInteractionResult { + canSit: boolean; + canLay: boolean; + canStand: boolean; + interactionType: string; + clickLimit: number; + interactionModesCount: number; + reason: string; +} + +const SIT_KEYWORDS = [ + "bench", + "bank", + "sofa", + "couch", + "chair", + "stool", + "seat", + "armchair", + "loveseat", + "settee", + "ottoman", + "pouf", + "beanbag", + "bean_bag", + "rocking", + "swing", + "hammock", + "throne", + "wheelchair", + "toilet", + "potty", +]; + +const LAY_KEYWORDS = [ + "bed", + "mattress", + "futon", + "bunk", + "cot", + "hammock", + "sleeping", + "bedroll", + "blanket", + "pillow", + "cushion", + "lounger", + "sunbed", + "daybed", + "divan", + "waterbed", +]; + +const STAND_KEYWORDS = [ + "table", + "desk", + "counter", + "bar", + "shelf", + "cabinet", + "wardrobe", + "dresser", + "bookcase", + "rack", + "stand", + "pedestal", + "platform", + "stage", + "floor", + "tile", + "carpet", + "rug", + "path", + "road", + "grass", + "dirt", + "sand", + "snow", + "ice", + "water", + "pool", + "fountain", +]; + +const INTERACTION_TYPE_MAP: Record = { + bench: "bench", + bank: "bench", + sofa: "sofa", + couch: "sofa", + chair: "chair", + stool: "stool", + seat: "chair", + armchair: "armchair", + loveseat: "sofa", + settee: "sofa", + ottoman: "ottoman", + pouf: "ottoman", + beanbag: "beanbag", + rocking: "rocking_chair", + swing: "swing", + hammock: "hammock", + throne: "throne", + wheelchair: "wheelchair", + toilet: "toilet", + potty: "toilet", + bed: "bed", + mattress: "bed", + futon: "bed", + bunk: "bunkbed", + cot: "bed", + sleeping: "bed", + bedroll: "bed", + blanket: "bed", + pillow: "bed", + cushion: "bed", + lounger: "lounger", + sunbed: "sunbed", + daybed: "daybed", + divan: "divan", + waterbed: "waterbed", + table: "table", + desk: "desk", + counter: "counter", + bar: "bar", + shelf: "shelf", + cabinet: "cabinet", + wardrobe: "wardrobe", + dresser: "dresser", + bookcase: "bookcase", + rack: "rack", + stand: "stand", + pedestal: "pedestal", + platform: "platform", + stage: "stage", +}; + +const INTERACTION_MODES: Record = { + bench: 1, + bank: 1, + sofa: 1, + couch: 1, + chair: 1, + stool: 1, + seat: 1, + armchair: 1, + loveseat: 1, + settee: 1, + ottoman: 1, + pouf: 1, + beanbag: 1, + rocking: 1, + swing: 1, + hammock: 1, + throne: 1, + wheelchair: 1, + toilet: 1, + potty: 1, + bed: 1, + mattress: 1, + futon: 1, + bunk: 1, + cot: 1, + sleeping: 1, + bedroll: 1, + blanket: 1, + pillow: 1, + cushion: 1, + lounger: 1, + sunbed: 1, + daybed: 1, + divan: 1, + waterbed: 1, + table: 0, + desk: 0, + counter: 0, + bar: 0, + shelf: 0, + cabinet: 0, + wardrobe: 0, + dresser: 0, + bookcase: 0, + rack: 0, + stand: 0, + pedestal: 0, + platform: 0, + stage: 0, + floor: 0, + tile: 0, + carpet: 0, + rug: 0, + path: 0, + road: 0, + grass: 0, + dirt: 0, + sand: 0, + snow: 0, + ice: 0, + water: 0, + pool: 0, + fountain: 0, +}; + +const CLICK_LIMITS: Record = { + bench: 1, + bank: 1, + sofa: 1, + couch: 1, + chair: 1, + stool: 1, + seat: 1, + armchair: 1, + loveseat: 1, + settee: 1, + ottoman: 1, + pouf: 1, + beanbag: 1, + rocking: 1, + swing: 1, + hammock: 1, + throne: 1, + wheelchair: 1, + toilet: 1, + potty: 1, + bed: 1, + mattress: 1, + futon: 1, + bunk: 1, + cot: 1, + sleeping: 1, + bedroll: 1, + blanket: 1, + pillow: 1, + cushion: 1, + lounger: 1, + sunbed: 1, + daybed: 1, + divan: 1, + waterbed: 1, + table: 0, + desk: 0, + counter: 0, + bar: 0, + shelf: 0, + cabinet: 0, + wardrobe: 0, + dresser: 0, + bookcase: 0, + rack: 0, + stand: 0, + pedestal: 0, + platform: 0, + stage: 0, + floor: 0, + tile: 0, + carpet: 0, + rug: 0, + path: 0, + road: 0, + grass: 0, + dirt: 0, + sand: 0, + snow: 0, + ice: 0, + water: 0, + pool: 0, + fountain: 0, +}; + +function normalizeString(str: string): string { + return str.toLowerCase().replace(/[^a-z0-9_]/g, "_"); +} + +function findMatch(normalized: string, keywords: string[]): string | null { + for (const kw of keywords) { + if (normalized.includes(kw)) { + return kw; + } + } + return null; +} + +export function autoDetectInteraction( + classname: string, + name?: string, +): AutoInteractionResult { + const normalizedClassname = normalizeString(classname); + const normalizedName = name ? normalizeString(name) : ""; + + const searchText = `${normalizedClassname} ${normalizedName}`; + + const sitMatch = findMatch(searchText, SIT_KEYWORDS); + const layMatch = findMatch(searchText, LAY_KEYWORDS); + const standMatch = findMatch(searchText, STAND_KEYWORDS); + + let canSit = false; + let canLay = false; + let canStand = false; + let matchedKeyword: string | null = null; + let interactionType = "default"; + let clickLimit = 0; + let interactionModesCount = 0; + + if (layMatch) { + canLay = true; + canSit = true; + matchedKeyword = layMatch; + interactionType = INTERACTION_TYPE_MAP[layMatch] || "bed"; + clickLimit = CLICK_LIMITS[layMatch] ?? 1; + interactionModesCount = INTERACTION_MODES[layMatch] ?? 1; + } else if (sitMatch) { + canSit = true; + matchedKeyword = sitMatch; + interactionType = INTERACTION_TYPE_MAP[sitMatch] || "chair"; + clickLimit = CLICK_LIMITS[sitMatch] ?? 1; + interactionModesCount = INTERACTION_MODES[sitMatch] ?? 1; + } else if (standMatch) { + canStand = true; + matchedKeyword = standMatch; + interactionType = INTERACTION_TYPE_MAP[standMatch] || "default"; + clickLimit = CLICK_LIMITS[standMatch] ?? 0; + interactionModesCount = INTERACTION_MODES[standMatch] ?? 0; + } + + const reasonParts: string[] = []; + if (matchedKeyword) { + reasonParts.push(`Matched "${matchedKeyword}"`); + } + if (canSit) reasonParts.push("can sit"); + if (canLay) reasonParts.push("can lay"); + if (canStand) reasonParts.push("can stand"); + if (!matchedKeyword) { + reasonParts.push("No keywords matched - default"); + } + + return { + canSit, + canLay, + canStand, + interactionType, + clickLimit, + interactionModesCount, + reason: reasonParts.join(" · "), + }; +} + +export function getInteractionTypeLabel(type: string): string { + const labels: Record = { + bench: "Bench / Bank", + sofa: "Sofa / Couch", + chair: "Chair", + stool: "Stool", + armchair: "Armchair", + ottoman: "Ottoman / Pouf", + beanbag: "Bean Bag", + rocking_chair: "Rocking Chair", + swing: "Swing", + hammock: "Hammock", + throne: "Throne", + wheelchair: "Wheelchair", + toilet: "Toilet", + bed: "Bed", + bunkbed: "Bunk Bed", + lounger: "Lounger / Sunbed", + daybed: "Daybed", + divan: "Divan", + waterbed: "Waterbed", + table: "Table", + desk: "Desk", + counter: "Counter", + bar: "Bar", + shelf: "Shelf", + cabinet: "Cabinet", + wardrobe: "Wardrobe", + dresser: "Dresser", + bookcase: "Bookcase", + rack: "Rack", + stand: "Stand", + pedestal: "Pedestal", + platform: "Platform", + stage: "Stage", + default: "Default (no interaction)", + }; + return labels[type] || type; +} diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts index 280004ae..6bfcf57c 100644 --- a/src/lib/services/clone-import.ts +++ b/src/lib/services/clone-import.ts @@ -2,6 +2,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { eq, inArray, sql } from "drizzle-orm"; import { db, ItemsBase } from "@/lib/db"; +import { autoDetectInteraction } from "@/lib/furni/auto-interaction"; import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel"; import { logger } from "@/lib/logger"; import { extractFurniIconPng } from "@/lib/services/clone-icon"; @@ -324,16 +325,21 @@ export async function cloneSingleFurni(params: { // allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`). // allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon. const allowStack = stackHeight > 0 ? "1" : "0"; + + // Auto-detect interaction modes count from classname/name + const autoInteraction = autoDetectInteraction(classname, entry.name); + const interactionModesCount = autoInteraction.interactionModesCount; + let newId: number; try { newId = await allocateItemsBaseId(async (nextId) => { await db.execute(sql` INSERT INTO items_base (id, sprite_id, public_name, item_name, type, width, length, stack_height, - allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams) + allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count, customparams) VALUES (${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight}, - ${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${entry.customparams})`); + ${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${interactionModesCount}, ${entry.customparams})`); return nextId; }); } catch (err) { diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 1192f8ea..154f3054 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -59,6 +59,7 @@ export const IMPORTED_PAGE_CAPTION = "Imported Furniture"; export { CATEGORY_PAGE, CLASSNAME_RULES } from "@/lib/furni/auto-catalog"; import { CATEGORY_PAGE } from "@/lib/furni/auto-catalog"; +import { autoDetectInteraction } from "@/lib/furni/auto-interaction"; // ── Helpers ────────────────────────────────────────────────────────── @@ -117,7 +118,9 @@ export function buildItemsBaseEnrichmentUpdate(params: { canstandon: boolean; allowStack: string; interactionType: string; + interactionModesCount?: number; }): SQL { + const modesCount = params.interactionModesCount ?? 0; return sql` UPDATE items_base SET width = ${params.dims.x}, length = ${params.dims.y}, stack_height = ${params.dims.z}, @@ -125,7 +128,8 @@ export function buildItemsBaseEnrichmentUpdate(params: { allow_lay = ${params.canlayon ? "1" : "0"}, allow_walk = ${params.canstandon ? "1" : "0"}, allow_stack = ${params.allowStack}, - interaction_type = ${params.interactionType} + interaction_type = ${params.interactionType}, + interaction_modes_count = ${modesCount} WHERE id = ${params.id} `; } @@ -864,6 +868,10 @@ export async function importSingleFurni(params: { // allow_stack: if it has height and you can put things on it const allowStack = dims.z > 0 ? "1" : "0"; + // Auto-detect interaction modes count from classname/name + const autoInteraction = autoDetectInteraction(classname, bestName); + const interactionModesCount = autoInteraction.interactionModesCount; + // Update items_base with ALL enriched data try { const update = buildItemsBaseEnrichmentUpdate({ @@ -874,6 +882,7 @@ export async function importSingleFurni(params: { canstandon, allowStack, interactionType, + interactionModesCount, }); await db.execute(update); } catch (err) { diff --git a/src/lib/services/upload-import.ts b/src/lib/services/upload-import.ts index 708b1a39..d63ece69 100644 --- a/src/lib/services/upload-import.ts +++ b/src/lib/services/upload-import.ts @@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { eq, sql } from "drizzle-orm"; import { db, ItemsBase } from "@/lib/db"; +import { autoDetectInteraction } from "@/lib/furni/auto-interaction"; import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs"; import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data"; import { @@ -158,6 +159,7 @@ async function writeSqlMigration(params: { customparams: string; price: { credits: number; points: number; pointsType: number }; categoryKey: string; + interactionModesCount: number; }): Promise { const num = await findNextMigrationNumber(); const padded = String(num).padStart(4, "0"); @@ -193,12 +195,12 @@ async function writeSqlMigration(params: { "", `INSERT INTO items_base`, ` (id, sprite_id, public_name, item_name, type, width, length, stack_height,`, - ` allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)`, + ` allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count, customparams)`, `VALUES`, ` (@next_id, @next_id, ${escapeSql(params.name)}, ${escapeSql(params.classname)},`, ` ${escapeSql(params.itemType)}, ${params.xdim}, ${params.ydim}, ${params.stackHeight},`, ` ${params.allowStack}, ${params.cansiton ? "1" : "0"}, ${params.canlayon ? "1" : "0"}, ${params.canstandon ? "1" : "0"},`, - ` ${escapeSql(params.interactionType)}, ${escapeSql(params.customparams)});`, + ` ${escapeSql(params.interactionType)}, ${params.interactionModesCount}, ${escapeSql(params.customparams)});`, "", `INSERT INTO catalog_items`, ` (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)`, @@ -328,16 +330,20 @@ export async function uploadSingleFurni(params: { const price = autoPriceFurni(classname); + // Auto-detect interaction modes count from classname/name + const autoInteraction = autoDetectInteraction(classname, name); + const interactionModesCount = autoInteraction.interactionModesCount; + let newId: number; try { newId = await allocateItemsBaseId(async (nextId) => { await db.execute(sql` INSERT INTO items_base (id, sprite_id, public_name, item_name, type, width, length, stack_height, - allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams) + allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count, customparams) VALUES (${nextId}, ${nextId}, ${name}, ${classname}, ${itemType}, ${xdim}, ${ydim}, ${stackHeight}, - ${allowStack}, ${cansiton ? "1" : "0"}, ${canlayon ? "1" : "0"}, ${canstandon ? "1" : "0"}, ${userInteractionType}, ${customparams})`); + ${allowStack}, ${cansiton ? "1" : "0"}, ${canlayon ? "1" : "0"}, ${canstandon ? "1" : "0"}, ${userInteractionType}, ${interactionModesCount}, ${customparams})`); return nextId; }); } catch (err) { @@ -402,6 +408,7 @@ export async function uploadSingleFurni(params: { customparams, price, categoryKey: classifyFurni(classname, itemType), + interactionModesCount, }); warnings.push(`SQL migration written: ${sqlFile}`); } catch (err) { diff --git a/update-Nitrov3.sh b/update-Nitrov3.sh index dce74cc6..744d6915 100755 --- a/update-Nitrov3.sh +++ b/update-Nitrov3.sh @@ -131,6 +131,13 @@ CONFIG_BACKUP_DIR="${SCRIPT_DIR}/storage/config-backups" mkdir -p "$CONFIG_BACKUP_DIR" 2>/dev/null || true mkdir -p "$BACKUP_DIR" 2>/dev/null || true NITRO_LANG="${NITRO_LANG:-en}" +# Nginx site config(s) that serve /gamedata/. After every build/rebuild the +# update script (re)adds an exact-match redirect so /gamedata/ points at +# /gamedata/config/ instead of the raw directory listing. +NGINX_SITE_CONFS=( + "${NITRO_NGINX_SITE_CONF:-/etc/nginx/sites-enabled/cms.conf}" + "${NITRO_NGINX_SITE_CONF_AVAILABLE:-/etc/nginx/sites-available/cms.conf}" +) MYSQL_CRED="-h $DB_HOST -P $DB_PORT -u $DB_USER --ssl-verify-server-cert=${NITRO_SSL_VERIFY}" [ -n "$DB_PASS" ] && MYSQL_CRED="$MYSQL_CRED --password=$DB_PASS" @@ -971,24 +978,53 @@ git_revert_repo() { rm -f "$PARALLEL_STATE_DIR/oldhead-$name" 2>/dev/null || true } +# Run a yarn command inside a repo that pins an unavailable Yarn Berry version. +# Upstream package.json now declares e.g. "packageManager": "yarn@4.18.0", +# which the system Yarn 1 refuses to run ("current global version is 1.22.22") +# and which corepack cannot fetch (yarn 4 is not published on the registry). +# Drop the field for the duration of the command, then restore the pristine +# file so the repo stays clean for the next git pull. Runs inside the repo and +# always returns the caller to SCRIPT_DIR so cwd never leaks between repos. +yarn_run() { + local repo="$1"; shift + cd "$repo" 2>/dev/null || { warn "Cannot cd into %s" "$repo"; return 1; } + local modified=false + if python3 -c " +import json, sys +p = 'package.json' +d = json.load(open(p)) +pm = d.get('packageManager', '') +if pm.startswith('yarn@') and not pm.startswith('yarn@1.'): + d.pop('packageManager', None) + json.dump(d, open(p, 'w'), indent=2) + open(p, 'a').write(chr(10)) + sys.exit(0) +sys.exit(1) +" 2>/dev/null; then + modified=true + fi + yarn "$@" + local rc=$? + if [ "$modified" = true ]; then + git checkout -- package.json 2>/dev/null || true + fi + cd "$SCRIPT_DIR" 2>/dev/null || true + return $rc +} + # yarn install scoped to a per-repo cache folder. Parallel installs (renderer + # client) must never share a cache — yarn 1 corrupts the shared cache, leaving # node_modules incomplete (vite/pixi.js missing) while still exiting 0. -# $1 = repo dir; remaining args forwarded to yarn. Runs inside the repo and -# always returns the caller to SCRIPT_DIR so cwd never leaks between repos. +# $1 = repo dir; remaining args forwarded to yarn. yarn_install_repo() { local repo="$1"; shift local cache_dir="$YARN_CACHE_DIR/$(basename "$repo")" mkdir -p "$cache_dir" 2>/dev/null || true - cd "$repo" 2>/dev/null || { warn "Cannot cd into %s" "$repo"; return 1; } # NODE_ENV may be exported as "production" via .env (the CMS runs in prod), # which makes yarn 1 skip devDependencies — vite is a devDep, so builds # would fail with "vite: not found". Force a non-production env so the full # dependency tree (including dev deps) is always installed. - NODE_ENV=development yarn install --cache-folder "$cache_dir" "$@" >> "$LOG_FILE" 2>&1 - local rc=$? - cd "$SCRIPT_DIR" 2>/dev/null || true - return $rc + NODE_ENV=development yarn_run "$repo" install --cache-folder "$cache_dir" "$@" >> "$LOG_FILE" 2>&1 } # Nuke a repo's own yarn cache and node_modules for a truly clean reinstall. @@ -1314,7 +1350,7 @@ update_client() { fi spinner_stop ok spinner_start "Building frontend..." - if yarn build >> "$LOG_FILE" 2>&1; then + if yarn_run "$NITRO_CLIENT" build >> "$LOG_FILE" 2>&1; then spinner_stop ok else spinner_stop fail @@ -1511,8 +1547,62 @@ flyaway_repair_db() { return 1 } +# After a build/rebuild, ensure nginx redirects /gamedata/ (and /gamedata) +# to /gamedata/config/ instead of serving the raw directory listing. +# Idempotent: injects exact-match location blocks in front of the /gamedata/ +# prefix block, validates with `nginx -t` (restoring the pre-edit copy on +# failure) and keeps a timestamped backup. The actual reload happens at the +# end of do_restart. +ensure_gamedata_redirect() { + local root_cmd="" + [ "$(id -u)" -eq 0 ] || root_cmd="sudo -n" + local changed=false + local conf tmp snippet bak + for conf in "${NGINX_SITE_CONFS[@]}"; do + [ -f "$conf" ] || continue + if grep -qs "location = /gamedata" "$conf" 2>/dev/null; then + ok "Gamedata redirect already present in %s" "$(basename "$conf")" + continue + fi + tmp=$(mktemp) + snippet=" # Redirect /gamedata/ to /gamedata/config/ + location = /gamedata { return 301 /gamedata/config/; } + location = /gamedata/ { return 301 /gamedata/config/; } +" + awk -v s="$snippet" ' + /^[[:space:]]*location \/gamedata\/ \{/ && !done { + printf "%s", s + done = 1 + } + { print } + ' "$conf" > "$tmp" 2>/dev/null + if [ ! -s "$tmp" ] || ! grep -qs "location = /gamedata" "$tmp" 2>/dev/null; then + rm -f "$tmp" 2>/dev/null || true + warn "Could not inject gamedata redirect into %s" "$conf" + continue + fi + bak="$conf.bak-$(date +%s)" + $root_cmd cp "$conf" "$bak" 2>/dev/null || { rm -f "$tmp"; warn "Cannot backup %s" "$conf"; continue; } + if ! $root_cmd install -m 640 "$tmp" "$conf" 2>/dev/null; then + rm -f "$tmp" 2>/dev/null || true + warn "Cannot update %s" "$conf" + continue + fi + rm -f "$tmp" 2>/dev/null || true + if ! sudo nginx -t >/dev/null 2>&1; then + $root_cmd cp "$bak" "$conf" 2>/dev/null || true + warn "nginx -t failed — reverted gamedata redirect in %s" "$conf" + continue + fi + changed=true + ok "Added /gamedata/ → /gamedata/config/ redirect to %s" "$(basename "$conf")" + done + return 0 +} + do_restart() { step 8 8 "Restart Services" + ensure_gamedata_redirect if systemctl cat "$EMULATOR_SERVICE" &>/dev/null; then sudo systemctl restart "$EMULATOR_SERVICE" 2>/dev/null && ok "$EMULATOR_SERVICE restarted" || warn "Restart failed" if ! wait_for_emulator_health; then @@ -1916,7 +2006,7 @@ cmd_flyaway_repair() { step 6 7 "$(_t "Rebuild Frontend")" spinner_start "$(_t "Building frontend...")" cd "$NITRO_CLIENT" - if yarn build >> "$LOG_FILE" 2>&1; then + if yarn_run "$NITRO_CLIENT" build >> "$LOG_FILE" 2>&1; then spinner_stop ok ok "Frontend rebuilt" else