Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
570c3a883e | ||
|
|
ab43f5d63c | ||
|
|
c78f8812ad | ||
|
|
aed70db81f | ||
|
|
1126a70d55 |
No files matched your search
@@ -0,0 +1,111 @@
|
|||||||
|
# Furni Import Hotel Source Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make the furni import visibly and consistently use the CMS `habbo_gamedata_hotel` setting without Italy-specific names or messages.
|
||||||
|
|
||||||
|
**Architecture:** Keep locale selection centralized in `getHabboGamedataHotel()` and expose normalized display metadata to the server-rendered import page. Rename the furnidata cache contract to generic official-Habbo terminology, and keep asset downloads on the global `images.habbo.com` CDN.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript 7, React 19, Next.js 16, Vitest 4.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `habbo_gamedata_hotel` remains the single source of truth.
|
||||||
|
- Furnidata and external texts use `www.habbo.<hotel>`.
|
||||||
|
- SWF and icon downloads remain on `images.habbo.com`.
|
||||||
|
- Cache entries must never leak across two configured hotels.
|
||||||
|
- Existing import behavior and permissions remain unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Generic official-Habbo furnidata contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/services/habbo-furnidata-cache.test.ts`
|
||||||
|
- Modify: `src/types/furni.ts`
|
||||||
|
- Modify: `src/lib/services/habbo-furnidata-cache.ts`
|
||||||
|
- Modify: `src/lib/services/habbofurni.ts`
|
||||||
|
- Modify: `src/lib/services/furni-data.ts`
|
||||||
|
- Modify: `src/lib/services/furni-import.ts`
|
||||||
|
- Modify: `src/actions/admin-settings.ts`
|
||||||
|
- Modify: `src/app/api/admin/import/furni/route.ts`
|
||||||
|
- Modify: `src/app/api/admin/import/furni/resync/route.ts`
|
||||||
|
- Modify: `src/app/api/admin/import/furni/batch-regen/route.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `OfficialHabboFurniEntry`.
|
||||||
|
- Produces: `getOfficialHabboFurnidata()`, `lookupOfficialHabboFurni()`, and `clearOfficialHabboFurnidataCache()`.
|
||||||
|
- Consumes: `getHabboGamedataHotel()` and `habboFurnidataUrl(hotel)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a failing cache-isolation test**
|
||||||
|
|
||||||
|
Mock the selected hotel as `it` for the first request and `nl` for the second. Return distinct fixtures from `fetch` and assert that the second call returns the Dutch fixture and requests `https://www.habbo.nl/gamedata/furnidata_json/1`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the cache test and verify RED**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because the generic official-Habbo API is not exported yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rename the cache contract and consumers**
|
||||||
|
|
||||||
|
Rename the Italy-specific type and functions throughout the import pipeline. Keep the existing hotel-keyed cache behavior and update comments to say “configured official Habbo hotel”.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the cache test and verify GREEN**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS with separate `it` and `nl` fetches.
|
||||||
|
|
||||||
|
### Task 2: Display and report the configured source
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/app/admin/import/furni/import-source.test.ts`
|
||||||
|
- Create: `src/app/admin/import/furni/import-source.ts`
|
||||||
|
- Modify: `src/app/admin/import/furni/page.tsx`
|
||||||
|
- Modify: `src/app/admin/import/furni/import-furni-client.tsx`
|
||||||
|
- Modify: `src/lib/services/furni-import.ts`
|
||||||
|
- Modify: `src/lib/services/furni-import.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `FurniImportSource { hotel, label, host, furnidataUrl }`.
|
||||||
|
- Produces: `getFurniImportSource()` for the server page.
|
||||||
|
- Produces: `formatOfficialHabboEnrichmentWarning(hotel, name)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing source and message tests**
|
||||||
|
|
||||||
|
Assert that an `nl` setting becomes `{ hotel: "nl", label: "Netherlands (habbo.nl)", host: "habbo.nl", furnidataUrl: "https://www.habbo.nl/gamedata/furnidata_json/1" }` and that enrichment reports `Enriched from habbo.nl`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests and verify RED**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run --coverage=false src/app/admin/import/furni/import-source.test.ts src/lib/services/furni-import.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL because source metadata and the dynamic warning formatter do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement source metadata and UI**
|
||||||
|
|
||||||
|
Read the normalized hotel on the server page, pass source metadata into `ImportFurniClient`, and render a compact source badge/link above the furni controls. Replace literal `habbo.it` enrichment wording with the resolved host.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused verification**
|
||||||
|
|
||||||
|
Run: `pnpm exec vitest run --coverage=false src/lib/services/habbo-furnidata-cache.test.ts src/app/admin/import/furni/import-source.test.ts src/lib/services/furni-import.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run repository verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all commands exit with status 0 using the same non-secret production validation environment as CI where required.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the implementation**
|
||||||
|
|
||||||
|
```text
|
||||||
|
fix: use configured Habbo hotel in furni import
|
||||||
|
```
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Furni import hotel source
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The furni import already fetches furnidata through the CMS setting
|
||||||
|
`habbo_gamedata_hotel`, but its public and internal language still refers to
|
||||||
|
Habbo Italy. This makes the active source unclear and gives the impression
|
||||||
|
that the importer is hardcoded to `habbo.it`.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
The import page will read `habbo_gamedata_hotel` on the server and pass the
|
||||||
|
normalized hotel value and label to the client. The page will display the
|
||||||
|
active official furnidata source near the import controls, including the
|
||||||
|
resolved `habbo.<hotel>` host.
|
||||||
|
|
||||||
|
Import enrichment warnings will use the resolved hotel label rather than the
|
||||||
|
literal `habbo.it`. Internal furnidata cache APIs and types will be renamed
|
||||||
|
from Italy-specific names to generic official-Habbo names while retaining
|
||||||
|
temporary aliases only where needed to avoid an unsafe all-at-once migration.
|
||||||
|
|
||||||
|
Furniture SWF and icon downloads remain on `images.habbo.com`. That host is
|
||||||
|
Habbo's global asset CDN and must not be derived from the gamedata locale.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
1. Admin settings stores `habbo_gamedata_hotel`.
|
||||||
|
2. Server-side import code normalizes the value through
|
||||||
|
`getHabboGamedataHotel()`.
|
||||||
|
3. Furnidata and external texts use `www.habbo.<hotel>`.
|
||||||
|
4. The import UI and enrichment messages display the same resolved hotel.
|
||||||
|
5. The in-memory cache remains keyed by hotel, so changing the setting loads
|
||||||
|
the selected locale rather than reusing another locale's data.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Tests will cover normalized locale URL generation, cache separation after a
|
||||||
|
hotel change, dynamic enrichment text, and the source information passed to
|
||||||
|
the import UI. Existing furni import tests, typecheck, full tests, and the
|
||||||
|
production build must pass.
|
||||||
+1
-1
@@ -93,7 +93,7 @@
|
|||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.25",
|
"postcss": "^8.5.25",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "^4.23.2",
|
"tsx": "^4.23.4",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "8.2.0",
|
"vite": "8.2.0",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
|
|||||||
Generated
+16
-16
@@ -190,17 +190,17 @@ importers:
|
|||||||
specifier: ^4.3.3
|
specifier: ^4.3.3
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.23.2
|
specifier: ^4.23.4
|
||||||
version: 4.23.2
|
version: 4.23.4
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^7.0.2
|
specifier: ^7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vite:
|
vite:
|
||||||
specifier: 8.2.0
|
specifier: 8.2.0
|
||||||
version: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected])
|
version: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected])
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.10
|
specifier: 4.1.10
|
||||||
version: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected]))
|
version: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected]))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -3924,8 +3924,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
[email protected].2:
|
[email protected].4:
|
||||||
resolution: {integrity: sha512-Cj3EO8dIn8hXjlSFh/dnJOKAFNE52YMw4rZX0HqlnIdcBoombYNd1Z0ALUpbaLilH6fdDpYqs9GZtsyfkfBjPg==}
|
resolution: {integrity: sha512-ZiUQ8oT/KzN51mJUWPqARYqwFLFJZtGZipRkw1ynHMr9vy3eU77m5yfF3Gzm6meEg/beW+lUu3fHYgskTN2oVQ==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -5806,7 +5806,7 @@ snapshots:
|
|||||||
obug: 2.1.4
|
obug: 2.1.4
|
||||||
std-env: 4.2.0
|
std-env: 4.2.0
|
||||||
tinyrainbow: 3.1.1
|
tinyrainbow: 3.1.1
|
||||||
vitest: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected]))
|
vitest: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected]))
|
||||||
|
|
||||||
'@vitest/[email protected]':
|
'@vitest/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5817,13 +5817,13 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.1
|
tinyrainbow: 3.1.1
|
||||||
|
|
||||||
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected]))':
|
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected]))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.10
|
'@vitest/spy': 4.1.10
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected])
|
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected])
|
||||||
|
|
||||||
'@vitest/[email protected]':
|
'@vitest/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6091,7 +6091,7 @@ snapshots:
|
|||||||
'@drizzle-team/brocli': 0.10.2
|
'@drizzle-team/brocli': 0.10.2
|
||||||
'@esbuild-kit/esm-loader': 2.6.5
|
'@esbuild-kit/esm-loader': 2.6.5
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
tsx: 4.23.2
|
tsx: 4.23.4
|
||||||
|
|
||||||
[email protected](@electric-sql/[email protected])(@opentelemetry/[email protected])(@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected]))([email protected])([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected])):
|
[email protected](@electric-sql/[email protected])(@opentelemetry/[email protected])(@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected]))([email protected])([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected])):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -7274,7 +7274,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected].2:
|
[email protected].4:
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.28.1
|
esbuild: 0.28.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -7345,7 +7345,7 @@ snapshots:
|
|||||||
typescript: 7.0.2
|
typescript: 7.0.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
[email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected]):
|
[email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.33.0
|
lightningcss: 1.33.0
|
||||||
picomatch: 4.0.5
|
picomatch: 4.0.5
|
||||||
@@ -7358,13 +7358,13 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
terser: 5.49.0
|
terser: 5.49.0
|
||||||
tsx: 4.23.2
|
tsx: 4.23.4
|
||||||
yaml: 2.9.0
|
yaml: 2.9.0
|
||||||
|
|
||||||
[email protected](@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected])):
|
[email protected](@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected])):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.10
|
'@vitest/expect': 4.1.10
|
||||||
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected]))
|
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected]))
|
||||||
'@vitest/pretty-format': 4.1.10
|
'@vitest/pretty-format': 4.1.10
|
||||||
'@vitest/runner': 4.1.10
|
'@vitest/runner': 4.1.10
|
||||||
'@vitest/snapshot': 4.1.10
|
'@vitest/snapshot': 4.1.10
|
||||||
@@ -7381,7 +7381,7 @@ snapshots:
|
|||||||
tinyexec: 1.2.4
|
tinyexec: 1.2.4
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
tinyrainbow: 3.1.1
|
tinyrainbow: 3.1.1
|
||||||
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].2)([email protected])
|
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected])
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.1
|
'@opentelemetry/api': 1.9.1
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
normalizeHabboGamedataHotel,
|
normalizeHabboGamedataHotel,
|
||||||
} from "@/lib/habbo-gamedata-hotel";
|
} from "@/lib/habbo-gamedata-hotel";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
|
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
|
||||||
import { clearBadgeCache } from "@/lib/services/habboassets";
|
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ function normalizeSettingValue(key: string, value: string): string {
|
|||||||
|
|
||||||
function bustGamedataCachesIfNeeded(key: string): void {
|
function bustGamedataCachesIfNeeded(key: string): void {
|
||||||
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
|
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
|
||||||
clearHabboItCache();
|
clearOfficialHabboFurnidataCache();
|
||||||
clearBadgeCache();
|
clearBadgeCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ export const saveManagedSettings = adminAction(
|
|||||||
);
|
);
|
||||||
await siteSettings.reload();
|
await siteSettings.reload();
|
||||||
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
|
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
|
||||||
clearHabboItCache();
|
clearOfficialHabboFurnidataCache();
|
||||||
clearBadgeCache();
|
clearBadgeCache();
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/settings");
|
revalidatePath("/admin/settings");
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
Download,
|
Download,
|
||||||
|
ExternalLink,
|
||||||
FileWarning,
|
FileWarning,
|
||||||
FolderSync,
|
FolderSync,
|
||||||
|
Globe2,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -52,6 +54,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
|
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { NitroEditorDialog } from "./nitro-editor-dialog";
|
import { NitroEditorDialog } from "./nitro-editor-dialog";
|
||||||
|
|
||||||
interface FurniItem {
|
interface FurniItem {
|
||||||
@@ -146,7 +149,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
failed: "Failed",
|
failed: "Failed",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ImportFurniClient() {
|
export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [activeSearch, setActiveSearch] = useState("");
|
const [activeSearch, setActiveSearch] = useState("");
|
||||||
const [items, setItems] = useState<FurniItem[]>([]);
|
const [items, setItems] = useState<FurniItem[]>([]);
|
||||||
@@ -936,6 +939,31 @@ export function ImportFurniClient() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-md bg-primary/10 p-2 text-primary">
|
||||||
|
<Globe2 className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Official furnidata source
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium">{source.label}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Furniture assets: {source.assetCdnHost}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={source.furnidataUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{source.host}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{/* Dashboard Statistics */}
|
{/* Dashboard Statistics */}
|
||||||
{!stats && (
|
{!stats && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import * as hotelHelpers from "@/lib/habbo-gamedata-hotel";
|
||||||
|
|
||||||
|
describe("furni import source metadata", () => {
|
||||||
|
it("describes the configured hotel without changing the global asset CDN", () => {
|
||||||
|
const helpers = hotelHelpers as unknown as {
|
||||||
|
buildFurniImportSource?: (hotel: string) => {
|
||||||
|
hotel: string;
|
||||||
|
label: string;
|
||||||
|
host: string;
|
||||||
|
furnidataUrl: string;
|
||||||
|
assetCdnHost: string;
|
||||||
|
};
|
||||||
|
officialHabboEnrichmentWarning?: (hotel: string, name: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(helpers.buildFurniImportSource).toBeTypeOf("function");
|
||||||
|
expect(helpers.officialHabboEnrichmentWarning).toBeTypeOf("function");
|
||||||
|
if (
|
||||||
|
!helpers.buildFurniImportSource ||
|
||||||
|
!helpers.officialHabboEnrichmentWarning
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(helpers.buildFurniImportSource("nl")).toEqual({
|
||||||
|
hotel: "nl",
|
||||||
|
label: "Netherlands (habbo.nl)",
|
||||||
|
host: "habbo.nl",
|
||||||
|
furnidataUrl: "https://www.habbo.nl/gamedata/furnidata_json/1",
|
||||||
|
assetCdnHost: "images.habbo.com",
|
||||||
|
});
|
||||||
|
expect(helpers.officialHabboEnrichmentWarning("nl", "Stoel")).toBe(
|
||||||
|
'Enriched from habbo.nl: "Stoel"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||||
import { ImportFurniClient } from "./import-furni-client";
|
import { ImportFurniClient } from "./import-furni-client";
|
||||||
|
|
||||||
export default async function ImportFurniPage(_props: {
|
export default async function ImportFurniPage(_props: {
|
||||||
@@ -10,5 +12,6 @@ export default async function ImportFurniPage(_props: {
|
|||||||
redirect("/admin");
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ImportFurniClient />;
|
const hotel = await getHabboGamedataHotel();
|
||||||
|
return <ImportFurniClient source={buildFurniImportSource(hotel)} />;
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import { promises as fs } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { withAdmin } from "@/lib/api-handler";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
import { apiOk } from "@/lib/api-response";
|
import { apiOk } from "@/lib/api-response";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
const CATALOGUE_DIR = path.join(
|
catalogueAssetDirectories,
|
||||||
/*turbopackIgnore: true*/ process.cwd(),
|
readCatalogueAssetFiles,
|
||||||
"public",
|
} from "@/lib/services/catalog-assets";
|
||||||
"swf",
|
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
||||||
"c_images",
|
|
||||||
"catalogue",
|
|
||||||
);
|
|
||||||
|
|
||||||
export const GET = withAdmin(
|
export const GET = withAdmin(
|
||||||
{ permission: PERMS.CATALOG_VIEW },
|
{ permission: PERMS.CATALOG_VIEW },
|
||||||
@@ -23,18 +18,14 @@ export const GET = withAdmin(
|
|||||||
const offset = Math.max(Number(sp.get("offset") || 0) || 0, 0);
|
const offset = Math.max(Number(sp.get("offset") || 0) || 0, 0);
|
||||||
const search = (sp.get("search") || "").trim().toLowerCase();
|
const search = (sp.get("search") || "").trim().toLowerCase();
|
||||||
|
|
||||||
let icons: number[] = [];
|
const directories = catalogueAssetDirectories(await getGamedataRoot());
|
||||||
try {
|
const files = await readCatalogueAssetFiles(directories);
|
||||||
const files = await fs.readdir(CATALOGUE_DIR);
|
let icons = files
|
||||||
icons = files
|
.map((f) => /^icon_(\d+)\.(?:png|gif)$/i.exec(f))
|
||||||
.map((f) => /^icon_(\d+)\.(?:png|gif)$/i.exec(f))
|
.filter((m): m is RegExpExecArray => Boolean(m))
|
||||||
.filter((m): m is RegExpExecArray => Boolean(m))
|
.map((m) => Number(m[1]))
|
||||||
.map((m) => Number(m[1]))
|
.filter((n) => Number.isFinite(n))
|
||||||
.filter((n) => Number.isFinite(n))
|
.sort((a, b) => a - b);
|
||||||
.sort((a, b) => a - b);
|
|
||||||
} catch {
|
|
||||||
icons = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
icons = icons.filter((id) => String(id).includes(search));
|
icons = icons.filter((id) => String(id).includes(search));
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { promises as fs } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { withAdmin } from "@/lib/api-handler";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
import { apiOk } from "@/lib/api-response";
|
import { apiOk } from "@/lib/api-response";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
const CATALOGUE_DIR = path.join(
|
catalogueAssetDirectories,
|
||||||
/*turbopackIgnore: true*/ process.cwd(),
|
readCatalogueAssetFiles,
|
||||||
"public",
|
} from "@/lib/services/catalog-assets";
|
||||||
"swf",
|
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
||||||
"c_images",
|
|
||||||
"catalogue",
|
|
||||||
);
|
|
||||||
|
|
||||||
export const GET = withAdmin(
|
export const GET = withAdmin(
|
||||||
{ permission: PERMS.CATALOG_VIEW },
|
{ permission: PERMS.CATALOG_VIEW },
|
||||||
@@ -25,20 +20,17 @@ export const GET = withAdmin(
|
|||||||
const type = sp.get("type") || "all";
|
const type = sp.get("type") || "all";
|
||||||
|
|
||||||
let names: string[] = [];
|
let names: string[] = [];
|
||||||
try {
|
const directories = catalogueAssetDirectories(await getGamedataRoot());
|
||||||
const files = await fs.readdir(CATALOGUE_DIR);
|
const files = await readCatalogueAssetFiles(directories);
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (/^icon_\d+\./i.test(file)) continue;
|
if (/^icon_\d+\./i.test(file)) continue;
|
||||||
const base = file.replace(/\.(png|gif|jpg|jpeg|webp)$/i, "");
|
const base = file.replace(/\.(png|gif|jpg|jpeg|webp)$/i, "");
|
||||||
if (!base || seen.has(base)) continue;
|
if (!base || seen.has(base)) continue;
|
||||||
seen.add(base);
|
seen.add(base);
|
||||||
names.push(base);
|
names.push(base);
|
||||||
}
|
|
||||||
names.sort((a, b) => a.localeCompare(b));
|
|
||||||
} catch {
|
|
||||||
names = [];
|
|
||||||
}
|
}
|
||||||
|
names.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
if (type === "header") {
|
if (type === "header") {
|
||||||
names = names.filter((n) => /header/i.test(n));
|
names = names.filter((n) => /header/i.test(n));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
readFurniData,
|
readFurniData,
|
||||||
} from "@/lib/services/furni-data";
|
} from "@/lib/services/furni-data";
|
||||||
import { downloadFile, ensureDirectories } from "@/lib/services/furni-import";
|
import { downloadFile, ensureDirectories } from "@/lib/services/furni-import";
|
||||||
import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export const POST = withAdmin(
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
// Load furnidata once for all items
|
// Load furnidata once for all items
|
||||||
const furnidata = await getHabboItFurnidata();
|
const furnidata = await getOfficialHabboFurnidata();
|
||||||
|
|
||||||
// Load existing FurnitureData.json classnames for dedup
|
// Load existing FurnitureData.json classnames for dedup
|
||||||
const existingFD = (await readFurniData()) as {
|
const existingFD = (await readFurniData()) as {
|
||||||
@@ -204,7 +204,7 @@ export const POST = withAdmin(
|
|||||||
spriteId: dbItem?.spriteId || 0,
|
spriteId: dbItem?.spriteId || 0,
|
||||||
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
|
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
|
||||||
metadata: conversion.metadata || null,
|
metadata: conversion.metadata || null,
|
||||||
habboIt: fd || null,
|
officialHabbo: fd || null,
|
||||||
});
|
});
|
||||||
furniDataEntries.push({ entry, itemType });
|
furniDataEntries.push({ entry, itemType });
|
||||||
fdClassnames.add(baseClassname);
|
fdClassnames.add(baseClassname);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
getFurnitureDataPath,
|
getFurnitureDataPath,
|
||||||
readFurniData,
|
readFurniData,
|
||||||
} from "@/lib/services/furni-data";
|
} from "@/lib/services/furni-data";
|
||||||
import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache";
|
import { lookupOfficialHabboFurni } from "@/lib/services/habbo-furnidata-cache";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,9 +86,9 @@ export const POST = withAdmin(
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
try {
|
try {
|
||||||
const classname = row.itemName;
|
const classname = row.itemName;
|
||||||
let habboIt = null;
|
let officialHabbo = null;
|
||||||
try {
|
try {
|
||||||
habboIt = await lookupHabboIt(classname);
|
officialHabbo = await lookupOfficialHabboFurni(classname);
|
||||||
} catch {
|
} catch {
|
||||||
/* optional */
|
/* optional */
|
||||||
}
|
}
|
||||||
@@ -96,10 +96,10 @@ export const POST = withAdmin(
|
|||||||
const entry = buildFurniEntry({
|
const entry = buildFurniEntry({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
classname,
|
classname,
|
||||||
revision: habboIt?.revision ?? 0,
|
revision: officialHabbo?.revision ?? 0,
|
||||||
category: habboIt?.category || "unknown",
|
category: officialHabbo?.category || "unknown",
|
||||||
name: row.publicName || habboIt?.name || classname,
|
name: row.publicName || officialHabbo?.name || classname,
|
||||||
description: habboIt?.description || "",
|
description: officialHabbo?.description || "",
|
||||||
spriteId: row.spriteId,
|
spriteId: row.spriteId,
|
||||||
dims: {
|
dims: {
|
||||||
x: row.width,
|
x: row.width,
|
||||||
@@ -107,7 +107,7 @@ export const POST = withAdmin(
|
|||||||
z: Math.round(Number(row.stackHeight)),
|
z: Math.round(Number(row.stackHeight)),
|
||||||
},
|
},
|
||||||
metadata: null,
|
metadata: null,
|
||||||
habboIt,
|
officialHabbo,
|
||||||
});
|
});
|
||||||
|
|
||||||
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
|
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
IMPORTED_PAGE_CAPTION_SAVE,
|
IMPORTED_PAGE_CAPTION_SAVE,
|
||||||
importSingleFurni,
|
importSingleFurni,
|
||||||
} from "@/lib/services/furni-import";
|
} from "@/lib/services/furni-import";
|
||||||
import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||||
import { listFurniture } from "@/lib/services/habbofurni";
|
import { listFurniture } from "@/lib/services/habbofurni";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
||||||
@@ -183,7 +183,7 @@ export const GET = withAdmin(
|
|||||||
const pageItems = missing.slice(start, start + perPage);
|
const pageItems = missing.slice(start, start + perPage);
|
||||||
|
|
||||||
// Enrich with revision from official furnidata
|
// Enrich with revision from official furnidata
|
||||||
const furnidata = await getHabboItFurnidata();
|
const furnidata = await getOfficialHabboFurnidata();
|
||||||
const items = pageItems.map((item) => {
|
const items = pageItems.map((item) => {
|
||||||
const fd =
|
const fd =
|
||||||
furnidata.get(item.item_name) ||
|
furnidata.get(item.item_name) ||
|
||||||
@@ -434,7 +434,7 @@ export const PATCH = withAdmin(
|
|||||||
// Resolve revision: try furnidata first, then spriteId as fallback
|
// Resolve revision: try furnidata first, then spriteId as fallback
|
||||||
let rev = item.revision || 0;
|
let rev = item.revision || 0;
|
||||||
if (rev === 0) {
|
if (rev === 0) {
|
||||||
const furnidata = await getHabboItFurnidata();
|
const furnidata = await getOfficialHabboFurnidata();
|
||||||
const fd =
|
const fd =
|
||||||
furnidata.get(item.classname) ||
|
furnidata.get(item.classname) ||
|
||||||
furnidata.get(baseClassname) ||
|
furnidata.get(baseClassname) ||
|
||||||
@@ -516,7 +516,7 @@ export const PATCH = withAdmin(
|
|||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(eq(ItemsBase.itemName, item.classname))
|
.where(eq(ItemsBase.itemName, item.classname))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const furnidata = await getHabboItFurnidata();
|
const furnidata = await getOfficialHabboFurnidata();
|
||||||
const fd =
|
const fd =
|
||||||
furnidata.get(item.classname) || furnidata.get(baseClassname);
|
furnidata.get(item.classname) || furnidata.get(baseClassname);
|
||||||
const itemType = dbItem?.type === "i" ? "i" : "s";
|
const itemType = dbItem?.type === "i" ? "i" : "s";
|
||||||
@@ -531,7 +531,7 @@ export const PATCH = withAdmin(
|
|||||||
spriteId: dbItem?.spriteId || 0,
|
spriteId: dbItem?.spriteId || 0,
|
||||||
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
|
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
|
||||||
metadata: conversion.metadata || null,
|
metadata: conversion.metadata || null,
|
||||||
habboIt: fd || null,
|
officialHabbo: fd || null,
|
||||||
});
|
});
|
||||||
await appendFurniEntry(entry, itemType);
|
await appendFurniEntry(entry, itemType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { ImageOff, Loader2 } from "lucide-react";
|
import { ImageOff, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { CATALOGUE_ASSET_BASE_PATH } from "@/lib/catalog-assets";
|
||||||
|
|
||||||
interface ImagePreviewProps {
|
interface ImagePreviewProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -12,7 +13,7 @@ interface ImagePreviewProps {
|
|||||||
|
|
||||||
export function ImagePreview({
|
export function ImagePreview({
|
||||||
value,
|
value,
|
||||||
basePath = "/swf/c_images/catalogue",
|
basePath = CATALOGUE_ASSET_BASE_PATH,
|
||||||
height = 60,
|
height = 60,
|
||||||
className,
|
className,
|
||||||
}: ImagePreviewProps) {
|
}: ImagePreviewProps) {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
|
import { catalogueIconUrl } from "@/lib/catalog-assets";
|
||||||
import { translateCaption } from "@/lib/catalog-translations";
|
import { translateCaption } from "@/lib/catalog-translations";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ export function CatalogIcon({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={`/swf/c_images/catalogue/icon_${iconImage}.png`}
|
src={catalogueIconUrl(iconImage)}
|
||||||
alt=""
|
alt=""
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { catalogueAssetUrl } from "@/lib/catalog-assets";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface CatalogImagePickerProps {
|
interface CatalogImagePickerProps {
|
||||||
@@ -248,10 +249,7 @@ function CatalogImageThumb({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try .png first, fallback to .gif
|
// Try .png first, fallback to .gif
|
||||||
const src =
|
const src = catalogueAssetUrl(name, error === 0 ? "png" : "gif");
|
||||||
error === 0
|
|
||||||
? `/swf/c_images/catalogue/${name}.png`
|
|
||||||
: `/swf/c_images/catalogue/${name}.gif`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { catalogueIconUrl } from "@/lib/catalog-assets";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface IconPickerProps {
|
interface IconPickerProps {
|
||||||
@@ -228,7 +229,7 @@ function IconPreview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={`/swf/c_images/catalogue/icon_${iconImage}.png`}
|
src={catalogueIconUrl(iconImage)}
|
||||||
alt=""
|
alt=""
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { catalogueAssetUrl, catalogueIconUrl } from "@/lib/catalog-assets";
|
||||||
|
|
||||||
|
describe("catalogue asset URLs", () => {
|
||||||
|
it("uses the production gamedata mount for category icons", () => {
|
||||||
|
expect(catalogueIconUrl(7)).toBe("/gamedata/c_images/catalogue/icon_7.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes catalogue image names and supports both image formats", () => {
|
||||||
|
expect(catalogueAssetUrl("front page", "png")).toBe(
|
||||||
|
"/gamedata/c_images/catalogue/front%20page.png",
|
||||||
|
);
|
||||||
|
expect(catalogueAssetUrl("front page", "gif")).toBe(
|
||||||
|
"/gamedata/c_images/catalogue/front%20page.gif",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const CATALOGUE_ASSET_BASE_PATH = "/gamedata/c_images/catalogue";
|
||||||
|
|
||||||
|
export function catalogueIconUrl(iconImage: number): string {
|
||||||
|
return `${CATALOGUE_ASSET_BASE_PATH}/icon_${iconImage}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogueAssetUrl(
|
||||||
|
name: string,
|
||||||
|
extension: "png" | "gif",
|
||||||
|
): string {
|
||||||
|
return `${CATALOGUE_ASSET_BASE_PATH}/${encodeURIComponent(name)}.${extension}`;
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@ export const HABBO_GAMEDATA_HOTELS = [
|
|||||||
export type HabboGamedataHotel =
|
export type HabboGamedataHotel =
|
||||||
(typeof HABBO_GAMEDATA_HOTELS)[number]["value"];
|
(typeof HABBO_GAMEDATA_HOTELS)[number]["value"];
|
||||||
|
|
||||||
|
export interface FurniImportSource {
|
||||||
|
hotel: HabboGamedataHotel;
|
||||||
|
label: string;
|
||||||
|
host: string;
|
||||||
|
furnidataUrl: string;
|
||||||
|
assetCdnHost: "images.habbo.com";
|
||||||
|
}
|
||||||
|
|
||||||
export const HABBO_GAMEDATA_HOTEL_SETTING_KEY = "habbo_gamedata_hotel";
|
export const HABBO_GAMEDATA_HOTEL_SETTING_KEY = "habbo_gamedata_hotel";
|
||||||
export const DEFAULT_HABBO_GAMEDATA_HOTEL: HabboGamedataHotel = "it";
|
export const DEFAULT_HABBO_GAMEDATA_HOTEL: HabboGamedataHotel = "it";
|
||||||
|
|
||||||
@@ -47,6 +55,25 @@ export function habboFurnidataUrl(hotel: string): string {
|
|||||||
return `https://www.habbo.${h}/gamedata/furnidata_json/1`;
|
return `https://www.habbo.${h}/gamedata/furnidata_json/1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildFurniImportSource(hotel: string): FurniImportSource {
|
||||||
|
const normalized = normalizeHabboGamedataHotel(hotel);
|
||||||
|
return {
|
||||||
|
hotel: normalized,
|
||||||
|
label: habboGamedataHotelLabel(normalized),
|
||||||
|
host: `habbo.${normalized}`,
|
||||||
|
furnidataUrl: habboFurnidataUrl(normalized),
|
||||||
|
assetCdnHost: "images.habbo.com",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function officialHabboEnrichmentWarning(
|
||||||
|
hotel: string,
|
||||||
|
name: string,
|
||||||
|
): string {
|
||||||
|
const normalized = normalizeHabboGamedataHotel(hotel);
|
||||||
|
return `Enriched from habbo.${normalized}: "${name}"`;
|
||||||
|
}
|
||||||
|
|
||||||
export function habboExternalTextsUrl(hotel: string): string {
|
export function habboExternalTextsUrl(hotel: string): string {
|
||||||
const h = normalizeHabboGamedataHotel(hotel);
|
const h = normalizeHabboGamedataHotel(hotel);
|
||||||
return `https://www.habbo.${h}/gamedata/external_flash_texts/1`;
|
return `https://www.habbo.${h}/gamedata/external_flash_texts/1`;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { catalogueAssetDirectories } from "@/lib/services/catalog-assets";
|
||||||
|
|
||||||
|
describe("catalogue asset directories", () => {
|
||||||
|
it("prefers the configured Gamedata catalogue and retains the bundled fallback", () => {
|
||||||
|
const cwd = path.join("test", "cms");
|
||||||
|
const gamedataRoot = path.join("test", "Gamedata");
|
||||||
|
|
||||||
|
expect(catalogueAssetDirectories(gamedataRoot, cwd)).toEqual([
|
||||||
|
path.join(gamedataRoot, "c_images", "catalogue"),
|
||||||
|
path.join(cwd, "public", "swf", "c_images", "catalogue"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses only the bundled catalogue when no Gamedata root is configured", () => {
|
||||||
|
const cwd = path.join("test", "cms");
|
||||||
|
|
||||||
|
expect(catalogueAssetDirectories("", cwd)).toEqual([
|
||||||
|
path.join(cwd, "public", "swf", "c_images", "catalogue"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function catalogueAssetDirectories(
|
||||||
|
gamedataRoot: string,
|
||||||
|
cwd = process.cwd(),
|
||||||
|
): string[] {
|
||||||
|
const bundled = path.join(cwd, "public", "swf", "c_images", "catalogue");
|
||||||
|
if (!gamedataRoot.trim()) return [bundled];
|
||||||
|
|
||||||
|
return [path.join(gamedataRoot, "c_images", "catalogue"), bundled];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readCatalogueAssetFiles(
|
||||||
|
directories: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
const files = new Set<string>();
|
||||||
|
for (const directory of directories) {
|
||||||
|
try {
|
||||||
|
for (const file of await fs.readdir(directory)) files.add(file);
|
||||||
|
} catch {
|
||||||
|
// A missing optional source must not hide assets from the other source.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...files];
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ vi.mock("@/lib/services/site-settings", () => ({
|
|||||||
siteSettings: { get: settingGetMock },
|
siteSettings: { get: settingGetMock },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { writeFurniData } from "./furni-data";
|
import { getFurnitureDataPath, writeFurniData } from "./furni-data";
|
||||||
|
|
||||||
describe("writeFurniData production mirror", () => {
|
describe("writeFurniData production mirror", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -60,4 +60,14 @@ describe("writeFurniData production mirror", () => {
|
|||||||
JSON.stringify(data),
|
JSON.stringify(data),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves the default JSON file from the production Gamedata root", async () => {
|
||||||
|
settingGetMock.mockImplementation(
|
||||||
|
async (_key: string, fallback: string | null = null) => fallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(getFurnitureDataPath()).resolves.toBe(
|
||||||
|
path.join(tempDir, "Gamedata", "config", "FurnitureData.json"),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,25 +4,30 @@ import {
|
|||||||
getGamedataRoot,
|
getGamedataRoot,
|
||||||
getNitroFilesRoot,
|
getNitroFilesRoot,
|
||||||
} from "@/lib/services/furni-asset-dirs";
|
} from "@/lib/services/furni-asset-dirs";
|
||||||
import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache";
|
import type { OfficialHabboFurniEntry } from "@/lib/services/habbo-furnidata-cache";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import type { FurniMetadata } from "@/lib/services/swf-to-nitro";
|
import type { FurniMetadata } from "@/lib/services/swf-to-nitro";
|
||||||
|
|
||||||
const LOCK_STALE_MS = 60_000;
|
const LOCK_STALE_MS = 60_000;
|
||||||
|
const DEFAULT_FURNI_DATA_URL = "/gamedata/config/FurnitureData.json";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the FurnitureData file path from the admin setting
|
* Resolve the FurnitureData file path from the admin setting
|
||||||
* `furni_data_url` (e.g. `/nitro-assets/config/FurnitureData.json5`) mapped
|
* `furni_data_url`. The production `/gamedata` mount maps to the configured
|
||||||
* under the public/ directory. Falls back to the json5 path if the setting
|
* Gamedata filesystem root; other relative URLs map under public/.
|
||||||
* is unset.
|
|
||||||
*/
|
*/
|
||||||
export async function getFurnitureDataPath(): Promise<string> {
|
export async function getFurnitureDataPath(): Promise<string> {
|
||||||
const url =
|
const url =
|
||||||
(await siteSettings.get(
|
(await siteSettings.get("furni_data_url", DEFAULT_FURNI_DATA_URL)) ??
|
||||||
"furni_data_url",
|
DEFAULT_FURNI_DATA_URL;
|
||||||
"/nitro-assets/config/FurnitureData.json5",
|
|
||||||
)) ?? "/nitro-assets/config/FurnitureData.json5";
|
|
||||||
if (path.isAbsolute(url) && !url.startsWith("/")) return url;
|
if (path.isAbsolute(url) && !url.startsWith("/")) return url;
|
||||||
|
if (url === "/gamedata" || url.startsWith("/gamedata/")) {
|
||||||
|
const gamedataRoot = await getGamedataRoot();
|
||||||
|
if (gamedataRoot) {
|
||||||
|
const relative = url.replace(/^\/gamedata\/?/, "");
|
||||||
|
return path.join(/*turbopackIgnore: true*/ gamedataRoot, relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
const relative = url.replace(/^\/+/, "");
|
const relative = url.replace(/^\/+/, "");
|
||||||
// The `relative` segment is admin-configurable so its concrete value is
|
// The `relative` segment is admin-configurable so its concrete value is
|
||||||
// unknown at build time; without the hint Turbopack traces the entire
|
// unknown at build time; without the hint Turbopack traces the entire
|
||||||
@@ -150,7 +155,7 @@ export function buildFurniEntry(params: {
|
|||||||
spriteId: number;
|
spriteId: number;
|
||||||
dims: { x: number; y: number; z: number };
|
dims: { x: number; y: number; z: number };
|
||||||
metadata: FurniMetadata | null;
|
metadata: FurniMetadata | null;
|
||||||
habboIt?: HabboItFurniEntry | null;
|
officialHabbo?: OfficialHabboFurniEntry | null;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
@@ -162,20 +167,20 @@ export function buildFurniEntry(params: {
|
|||||||
spriteId,
|
spriteId,
|
||||||
dims,
|
dims,
|
||||||
metadata,
|
metadata,
|
||||||
habboIt,
|
officialHabbo,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
// Merge partcolors: prefer habbo.it (has real hex colors), fallback to .nitro extraction
|
// Prefer the configured official hotel's real hex colors, then .nitro metadata.
|
||||||
const partcolors = habboIt?.partcolors?.color?.length
|
const partcolors = officialHabbo?.partcolors?.color?.length
|
||||||
? habboIt.partcolors
|
? officialHabbo.partcolors
|
||||||
: (metadata?.partcolors ?? { color: [] });
|
: (metadata?.partcolors ?? { color: [] });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
classname,
|
classname,
|
||||||
revision: revision || habboIt?.revision || 0,
|
revision: revision || officialHabbo?.revision || 0,
|
||||||
category: category || habboIt?.category || "unknown",
|
category: category || officialHabbo?.category || "unknown",
|
||||||
defaultdir: metadata?.defaultdir ?? habboIt?.defaultdir ?? 0,
|
defaultdir: metadata?.defaultdir ?? officialHabbo?.defaultdir ?? 0,
|
||||||
xdim: dims.x,
|
xdim: dims.x,
|
||||||
ydim: dims.y,
|
ydim: dims.y,
|
||||||
partcolors,
|
partcolors,
|
||||||
@@ -183,19 +188,19 @@ export function buildFurniEntry(params: {
|
|||||||
description: description || "",
|
description: description || "",
|
||||||
adurl: "",
|
adurl: "",
|
||||||
offerid: spriteId,
|
offerid: spriteId,
|
||||||
buyout: !!habboIt?.bc,
|
buyout: !!officialHabbo?.bc,
|
||||||
rentofferid: -1,
|
rentofferid: -1,
|
||||||
rentbuyout: false,
|
rentbuyout: false,
|
||||||
bc: habboIt?.bc ?? false,
|
bc: officialHabbo?.bc ?? false,
|
||||||
excludeddynamic: false,
|
excludeddynamic: false,
|
||||||
customparams: habboIt?.customparams ?? "",
|
customparams: officialHabbo?.customparams ?? "",
|
||||||
specialtype: metadata?.specialtype ?? habboIt?.specialtype ?? 0,
|
specialtype: metadata?.specialtype ?? officialHabbo?.specialtype ?? 0,
|
||||||
canstandon: metadata?.canstandon ?? habboIt?.canstandon ?? false,
|
canstandon: metadata?.canstandon ?? officialHabbo?.canstandon ?? false,
|
||||||
cansiton: metadata?.cansiton ?? habboIt?.cansiton ?? false,
|
cansiton: metadata?.cansiton ?? officialHabbo?.cansiton ?? false,
|
||||||
canlayon: metadata?.canlayon ?? habboIt?.canlayon ?? false,
|
canlayon: metadata?.canlayon ?? officialHabbo?.canlayon ?? false,
|
||||||
furniline: habboIt?.furniline ?? "",
|
furniline: officialHabbo?.furniline ?? "",
|
||||||
environment: habboIt?.environment ?? "",
|
environment: officialHabbo?.environment ?? "",
|
||||||
rare: habboIt?.rare ?? false,
|
rare: officialHabbo?.rare ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ vi.mock("@/lib/services/furni-data", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
|
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
|
||||||
lookupHabboIt: vi.fn(),
|
lookupOfficialHabboFurni: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/services/import/core/download", () => ({
|
vi.mock("@/lib/services/import/core/download", () => ({
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import { existsSync, promises as fs } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { and, eq, type SQL, sql } from "drizzle-orm";
|
import { and, eq, type SQL, sql } from "drizzle-orm";
|
||||||
import { CatalogPages, db, ItemsBase } from "@/lib/db";
|
import { CatalogPages, db, ItemsBase } from "@/lib/db";
|
||||||
|
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import {
|
import {
|
||||||
appendFurniEntry,
|
appendFurniEntry,
|
||||||
buildFurniEntry,
|
buildFurniEntry,
|
||||||
removeFurniEntry,
|
removeFurniEntry,
|
||||||
} from "@/lib/services/furni-data";
|
} from "@/lib/services/furni-data";
|
||||||
import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache";
|
import type { OfficialHabboFurniEntry } from "@/lib/services/habbo-furnidata-cache";
|
||||||
import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache";
|
import { lookupOfficialHabboFurni } from "@/lib/services/habbo-furnidata-cache";
|
||||||
|
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||||
|
|
||||||
// Re-export the download helpers now owned by the shared import core.
|
// Re-export the download helpers now owned by the shared import core.
|
||||||
// Existing furni callers keep importing them from here.
|
// Existing furni callers keep importing them from here.
|
||||||
@@ -396,22 +398,23 @@ export async function importSingleFurni(params: {
|
|||||||
const itemType = type === "wallitem" ? "i" : "s";
|
const itemType = type === "wallitem" ? "i" : "s";
|
||||||
const spriteId = originalId;
|
const spriteId = originalId;
|
||||||
|
|
||||||
// ── Cross-reference with habbo.it furnidata for enrichment ────────
|
// ── Cross-reference with configured official Habbo furnidata ──────
|
||||||
let habboIt: HabboItFurniEntry | null = null;
|
let officialHabbo: OfficialHabboFurniEntry | null = null;
|
||||||
try {
|
try {
|
||||||
habboIt = await lookupHabboIt(classname);
|
const hotel = await getHabboGamedataHotel();
|
||||||
if (habboIt) {
|
officialHabbo = await lookupOfficialHabboFurni(classname);
|
||||||
warnings.push(`Enriched from habbo.it: "${habboIt.name}"`);
|
if (officialHabbo) {
|
||||||
|
warnings.push(officialHabboEnrichmentWarning(hotel, officialHabbo.name));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-blocking: habbo.it enrichment is optional
|
// Non-blocking: official Habbo enrichment is optional
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best values: use habbo.it furnidata for enrichment
|
// Best values: use configured official Habbo furnidata for enrichment.
|
||||||
const bestName = name || habboIt?.name || classname;
|
const bestName = name || officialHabbo?.name || classname;
|
||||||
const bestDescription = description || habboIt?.description || "";
|
const bestDescription = description || officialHabbo?.description || "";
|
||||||
const bestRevision = revision || habboIt?.revision || 0;
|
const bestRevision = revision || officialHabbo?.revision || 0;
|
||||||
const bestCategory = category || habboIt?.category || "unknown";
|
const bestCategory = category || officialHabbo?.category || "unknown";
|
||||||
|
|
||||||
// ── Insert into DB with enriched data ──────────────────────────────
|
// ── Insert into DB with enriched data ──────────────────────────────
|
||||||
onProgress?.("writing_db");
|
onProgress?.("writing_db");
|
||||||
@@ -581,15 +584,15 @@ export async function importSingleFurni(params: {
|
|||||||
// ── Build enriched FurnitureData entry ────────────────────────────
|
// ── Build enriched FurnitureData entry ────────────────────────────
|
||||||
const dims =
|
const dims =
|
||||||
conversionResult?.dimensions ||
|
conversionResult?.dimensions ||
|
||||||
(habboIt
|
(officialHabbo
|
||||||
? { x: habboIt.xdim, y: habboIt.ydim, z: 0 }
|
? { x: officialHabbo.xdim, y: officialHabbo.ydim, z: 0 }
|
||||||
: { x: 1, y: 1, z: 0 });
|
: { x: 1, y: 1, z: 0 });
|
||||||
const meta = conversionResult?.metadata ?? null;
|
const meta = conversionResult?.metadata ?? null;
|
||||||
|
|
||||||
// Merge interaction flags: prefer .nitro extraction, fallback to habbo.it
|
// Prefer .nitro flags, then the configured official Habbo furnidata.
|
||||||
const cansiton = meta?.cansiton ?? habboIt?.cansiton ?? false;
|
const cansiton = meta?.cansiton ?? officialHabbo?.cansiton ?? false;
|
||||||
const canlayon = meta?.canlayon ?? habboIt?.canlayon ?? false;
|
const canlayon = meta?.canlayon ?? officialHabbo?.canlayon ?? false;
|
||||||
const canstandon = meta?.canstandon ?? habboIt?.canstandon ?? false;
|
const canstandon = meta?.canstandon ?? officialHabbo?.canstandon ?? false;
|
||||||
|
|
||||||
// Derive interaction_type. Priority:
|
// Derive interaction_type. Priority:
|
||||||
// 1. Wired LOGIC items (wf_act_/wf_trg_/wf_cnd_/wf_slc_/wf_xtra_/wf_var_)
|
// 1. Wired LOGIC items (wf_act_/wf_trg_/wf_cnd_/wf_slc_/wf_xtra_/wf_var_)
|
||||||
@@ -657,7 +660,7 @@ export async function importSingleFurni(params: {
|
|||||||
spriteId,
|
spriteId,
|
||||||
dims,
|
dims,
|
||||||
metadata: meta,
|
metadata: meta,
|
||||||
habboIt,
|
officialHabbo,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Add to FurnitureData.json (or defer for batch) ────────────────
|
// ── Add to FurnitureData.json (or defer for batch) ────────────────
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { getHabboGamedataHotelMock } = vi.hoisted(() => ({
|
||||||
|
getHabboGamedataHotelMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/habbo-gamedata-hotel", () => ({
|
||||||
|
getHabboGamedataHotel: getHabboGamedataHotelMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/logger", () => ({
|
||||||
|
logger: { warn: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as cacheModule from "./habbo-furnidata-cache";
|
||||||
|
|
||||||
|
function fixture(classname: string, name: string) {
|
||||||
|
return {
|
||||||
|
roomitemtypes: {
|
||||||
|
furnitype: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
classname,
|
||||||
|
name,
|
||||||
|
description: "",
|
||||||
|
revision: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
wallitemtypes: { furnitype: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("official Habbo furnidata cache", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
const cacheApi = cacheModule as unknown as {
|
||||||
|
clearOfficialHabboFurnidataCache?: () => void;
|
||||||
|
clearHabboItCache?: () => void;
|
||||||
|
};
|
||||||
|
(
|
||||||
|
cacheApi.clearOfficialHabboFurnidataCache ?? cacheApi.clearHabboItCache
|
||||||
|
)?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports a generic API and isolates cached data by configured hotel", async () => {
|
||||||
|
const generic = cacheModule as unknown as {
|
||||||
|
getOfficialHabboFurnidata?: () => Promise<Map<string, { name: string }>>;
|
||||||
|
};
|
||||||
|
expect(generic.getOfficialHabboFurnidata).toBeTypeOf("function");
|
||||||
|
if (!generic.getOfficialHabboFurnidata) return;
|
||||||
|
|
||||||
|
getHabboGamedataHotelMock
|
||||||
|
.mockResolvedValueOnce("it")
|
||||||
|
.mockResolvedValueOnce("nl");
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(fixture("italian_chair", "Sedia"))),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(fixture("dutch_chair", "Stoel"))),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const italian = await generic.getOfficialHabboFurnidata();
|
||||||
|
const dutch = await generic.getOfficialHabboFurnidata();
|
||||||
|
|
||||||
|
expect(italian.get("italian_chair")?.name).toBe("Sedia");
|
||||||
|
expect(dutch.get("dutch_chair")?.name).toBe("Stoel");
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"https://www.habbo.nl/gamedata/furnidata_json/1",
|
||||||
|
expect.objectContaining({ headers: { Accept: "application/json" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* (`habbo_gamedata_hotel`). Downloads once per hotel, keeps in memory.
|
* (`habbo_gamedata_hotel`). Downloads once per hotel, keeps in memory.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type { HabboItFurniEntry } from "@/types/furni";
|
export type { OfficialHabboFurniEntry } from "@/types/furni";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type HabboGamedataHotel,
|
type HabboGamedataHotel,
|
||||||
@@ -11,21 +11,21 @@ import {
|
|||||||
} from "@/lib/habbo-gamedata-hotel";
|
} from "@/lib/habbo-gamedata-hotel";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||||
import type { HabboItFurniEntry } from "@/types/furni";
|
import type { OfficialHabboFurniEntry } from "@/types/furni";
|
||||||
|
|
||||||
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
let cache: Map<string, HabboItFurniEntry> | null = null;
|
let cache: Map<string, OfficialHabboFurniEntry> | null = null;
|
||||||
let cacheHotel: HabboGamedataHotel | null = null;
|
let cacheHotel: HabboGamedataHotel | null = null;
|
||||||
let cacheTimestamp = 0;
|
let cacheTimestamp = 0;
|
||||||
let loading: Promise<Map<string, HabboItFurniEntry>> | null = null;
|
let loading: Promise<Map<string, OfficialHabboFurniEntry>> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and parses the full official Habbo furnidata JSON for the
|
* Fetches and parses the full official Habbo furnidata JSON for the
|
||||||
* configured hotel. Results are cached in memory for 30 minutes.
|
* configured hotel. Results are cached in memory for 30 minutes.
|
||||||
*/
|
*/
|
||||||
export async function getHabboItFurnidata(): Promise<
|
export async function getOfficialHabboFurnidata(): Promise<
|
||||||
Map<string, HabboItFurniEntry>
|
Map<string, OfficialHabboFurniEntry>
|
||||||
> {
|
> {
|
||||||
const hotel = await getHabboGamedataHotel();
|
const hotel = await getHabboGamedataHotel();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -51,7 +51,7 @@ export async function getHabboItFurnidata(): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const map = new Map<string, HabboItFurniEntry>();
|
const map = new Map<string, OfficialHabboFurniEntry>();
|
||||||
|
|
||||||
const roomTypes = data?.roomitemtypes?.furnitype;
|
const roomTypes = data?.roomitemtypes?.furnitype;
|
||||||
if (Array.isArray(roomTypes)) {
|
if (Array.isArray(roomTypes)) {
|
||||||
@@ -137,10 +137,10 @@ export async function getHabboItFurnidata(): Promise<
|
|||||||
* Lookup a single item by classname from official Habbo furnidata.
|
* Lookup a single item by classname from official Habbo furnidata.
|
||||||
* Tries exact match first, then base classname (without color variant).
|
* Tries exact match first, then base classname (without color variant).
|
||||||
*/
|
*/
|
||||||
export async function lookupHabboIt(
|
export async function lookupOfficialHabboFurni(
|
||||||
classname: string,
|
classname: string,
|
||||||
): Promise<HabboItFurniEntry | null> {
|
): Promise<OfficialHabboFurniEntry | null> {
|
||||||
const map = await getHabboItFurnidata();
|
const map = await getOfficialHabboFurnidata();
|
||||||
|
|
||||||
const exact = map.get(classname);
|
const exact = map.get(classname);
|
||||||
if (exact) return exact;
|
if (exact) return exact;
|
||||||
@@ -162,7 +162,7 @@ export async function lookupHabboIt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the cache (e.g. after changing habbo_gamedata_hotel). */
|
/** Clear the cache (e.g. after changing habbo_gamedata_hotel). */
|
||||||
export function clearHabboItCache(): void {
|
export function clearOfficialHabboFurnidataCache(): void {
|
||||||
cache = null;
|
cache = null;
|
||||||
cacheHotel = null;
|
cacheHotel = null;
|
||||||
cacheTimestamp = 0;
|
cacheTimestamp = 0;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import type { FurniListResult, HabboFurniItem } from "@/types/furni";
|
import type { FurniListResult, HabboFurniItem } from "@/types/furni";
|
||||||
import { getHabboItFurnidata } from "./habbo-furnidata-cache";
|
import { getOfficialHabboFurnidata } from "./habbo-furnidata-cache";
|
||||||
|
|
||||||
export type { FurniListResult, HabboFurniItem };
|
export type { FurniListResult, HabboFurniItem };
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export async function listFurniture(
|
|||||||
const importedSet = options.importedSet;
|
const importedSet = options.importedSet;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const map = await getHabboItFurnidata();
|
const map = await getOfficialHabboFurnidata();
|
||||||
if (map.size === 0) return empty;
|
if (map.size === 0) return empty;
|
||||||
|
|
||||||
// Convert to array and optionally filter by search
|
// Convert to array and optionally filter by search
|
||||||
@@ -108,12 +108,12 @@ export async function searchFurniture(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single furniture item by classname.
|
* Get a single furniture item by classname.
|
||||||
* Uses the cached habbo.it furnidata.
|
* Uses cached furnidata from the configured official Habbo hotel.
|
||||||
*/
|
*/
|
||||||
export async function getFurnitureByClassname(
|
export async function getFurnitureByClassname(
|
||||||
classname: string,
|
classname: string,
|
||||||
): Promise<HabboFurniItem | null> {
|
): Promise<HabboFurniItem | null> {
|
||||||
const map = await getHabboItFurnidata();
|
const map = await getOfficialHabboFurnidata();
|
||||||
|
|
||||||
// Try exact match
|
// Try exact match
|
||||||
let entry = map.get(classname);
|
let entry = map.get(classname);
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ export interface BatchConversionResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HabboItFurniEntry {
|
export interface OfficialHabboFurniEntry {
|
||||||
id: number;
|
id: number;
|
||||||
classname: string;
|
classname: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
|
|||||||
Reference in new issue
Block a user