> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shdevelopment.org/llms.txt
> Use this file to discover all available pages before exploring further.

# sh-housing: Complete Housing System for RedM Servers

> Install, configure, customize, and integrate sh-housing, including properties, interiors, hotels, furniture, doors, access rules, contracts, and data-file workflows.

`sh-housing` is a server-authoritative, database-backed housing system for RedM. It supports VORP and RSG from one codebase and provides residential properties, hotel rooms, buy/rent/finance contracts, furniture placement, property storage, access sharing, realtor workflows, and `sh-doorlocks` integration.

This page covers both routine configuration and source-level customization. Back up `config.lua`, the `data/` directory, and all `sh_housing_*` database tables before changing a production installation.

## System overview

| Detail                           | Value                                            |
| -------------------------------- | ------------------------------------------------ |
| Supported frameworks             | VORP, RSG, or automatic detection                |
| Required database library        | `oxmysql`                                        |
| Recommended door provider        | `sh-doorlocks`                                   |
| Default realtor ledger provider  | `sh-society`                                     |
| Included locales                 | English, Spanish, German                         |
| Default property placement limit | 400 furniture objects                            |
| Main configuration               | `config.lua`                                     |
| Content files                    | `data/**/*.json` and `data/doors/door_index.lua` |

<CardGroup cols={2}>
  <Card title="Player housing" icon="house">
    Buy, rent, or finance properties; use storage; furnish rooms; and manage keys.
  </Card>

  <Card title="Realtor tools" icon="briefcase">
    Create property boundaries, edit listings, broker contracts, map doors, and manage the realtor ledger.
  </Card>

  <Card title="Hotels" icon="hotel">
    Configure timed rooms with separate prices, cycles, storage limits, interiors, and doors.
  </Card>

  <Card title="Content packs" icon="folder-open">
    Seed properties, hotels, interiors, furniture, and door mappings from editable data files.
  </Card>
</CardGroup>

## Installation

<Steps>
  <Step title="Install dependencies">
    Install `oxmysql`, your framework core, and the matching inventory. Install `sh-doorlocks` and `sh-society` when using the default integrations.
  </Step>

  <Step title="Keep the resource name unchanged">
    The folder must be named exactly `sh-housing`. The resource stops itself when started under another name.
  </Step>

  <Step title="Import the schema">
    Run `sh-housing/sql/install.sql` against the same database used by your framework and `oxmysql`.
  </Step>

  <Step title="Configure the resource">
    Review `config.lua`, especially the framework, economy, realtor job, storage, door provider, interaction mode, and content synchronization sections.
  </Step>

  <Step title="Configure administrator access">
    Grant the `shhousing.admin` ACE or add the relevant framework groups, jobs, or gangs to `Config.AdminAccess`.
  </Step>

  <Step title="Use a deterministic start order">
    Start dependencies before housing.

    ```cfg server.cfg theme={null}
    ensure oxmysql

    # Start one framework and its inventory before housing.
    ensure rsg-core
    ensure rsg-inventory

    ensure sh-doorlocks
    ensure sh-society
    ensure sh-housing
    ```
  </Step>

  <Step title="Verify startup output">
    Confirm that the console reports successful initialization, the selected framework, content seed totals, and the active door backend.
  </Step>
</Steps>

<Warning>
  Do not run another housing resource against the same properties at the same time. Duplicate interaction points, storage identifiers, ownership logic, and door state writers can conflict.
</Warning>

## Commands

All command names can be changed in `Config.Commands`.

| Default command   | Purpose                                                      | Typical user             |
| ----------------- | ------------------------------------------------------------ | ------------------------ |
| `/house`          | Opens properties available to the owner or an access holder  | Property owner or guest  |
| `/realtorhouse`   | Opens property, hotel, catalog, and realtor-management tools | Realtor or administrator |
| `/houseadmin`     | Opens global administrative tools                            | Administrator            |
| `/furniturestore` | Opens the furniture catalog near a configured store          | Player                   |

```lua config.lua theme={null}
Config.Commands = {
    House = 'house',
    Realtor = 'realtorhouse',
    Admin = 'houseadmin',
    Furniture = 'furniturestore',
}
```

## Configuration guide

<AccordionGroup>
  <Accordion title="Framework, locale, and debugging">
    `Config.Framework` accepts `auto`, `vorp`, or `rsg`. Explicit selection is useful when both framework resources exist in a development server.

    ```lua config.lua theme={null}
    Config.Framework = 'auto'
    Config.Locale = 'en' -- en, es, or de
    Config.Debug = false
    ```

    Enable debugging temporarily while diagnosing interactions, shell teleporting, door state, or target registration. Disable it for normal production use to reduce console noise.
  </Accordion>

  <Accordion title="Security and distance validation">
    `Config.Security` is enforced server-side. These values are not only visual ranges.

    * `EventCooldownMs` throttles repeated event calls.
    * `MaxInteractDistance` is the general property/hotel action range.
    * `MaxPropertyCreateDistance` limits how far a realtor can create or update content.
    * `MaxBuildDistance` and `MaxBuildHeightDelta` constrain furniture placement.
    * `MaxBuildMoveDelta` limits one placement update.
    * `MaxRequestPerMinute` is the per-player RPC budget.

    Increase values only when a large custom MLO or unusual interior layout requires it. Do not use large distance limits to work around incorrectly placed markers.
  </Accordion>

  <Accordion title="Realtor jobs and grades">
    A single realtor job or multiple job names are supported.

    ```lua config.lua theme={null}
    Config.Realtor.JobName = { 'realtor', 'estateagent', 'broker' }

    Config.Realtor.GradePermissions = {
        create = 3,
        publish = 3,
        sell = 2,
        unpublish = 3,
        delete = 4,
    }
    ```

    `BrokeredContractsOnly = true` prevents ordinary players from bypassing the realtor workflow. When `RequireNearPropertyForContracts` is enabled, the realtor and buyer must complete the contract near the property marker.
  </Accordion>

  <Accordion title="Administrator access">
    Access checks include console, ACE, framework group, job grade, and gang.

    ```lua config.lua theme={null}
    Config.AdminAccess = {
        ace = 'shhousing.admin',
        allowConsole = true,
        startupGraceMs = 120000,
        groups = { 'admin', 'god' },
        jobs = { realtor = 4 },
        gangs = {},
    }
    ```

    ```cfg server.cfg theme={null}
    add_ace group.admin shhousing.admin allow
    ```
  </Accordion>

  <Accordion title="Economy, rent, and finance">
    `Config.Economy.DefaultCurrency` supports `cash`, `gold`, `allbanks`, or a valid framework-specific money key.

    Purchase tax applies to buy and finance contracts. Rent tax applies to each rent cycle. Hotel tax applies to the initial hotel charge.

    ```lua config.lua theme={null}
    Config.Economy.Taxes = {
        Enabled = true,
        PropertyBuyPercent = 0,
        PropertyRentPercent = 2,
        HotelPercent = 0,
    }

    Config.Economy.Rent = {
        CycleDays = 7,
        GraceDays = 2,
        LateFeeCash = 25,
        MaxPrepayCycles = 4,
        CheckIntervalMinutes = 15,
        AutoEvict = true,
    }

    Config.Economy.Finance = {
        Enabled = true,
        DownPaymentPercent = 20,
        DefaultPayments = 12,
        MaxPayments = 48,
        CycleDays = 7,
        GraceDays = 2,
        AutoRepossess = true,
    }
    ```

    A buy or finance listing must have a non-zero price. The contract service rejects a zero-total purchase instead of transferring a free property accidentally.
  </Accordion>

  <Accordion title="Offline billing">
    Offline rent and finance collection edits the framework character record directly. Confirm table and column names against your framework version before enabling it.

    ```lua config.lua theme={null}
    Config.Economy.OfflinePayments = {
        Enabled = true,
        Provider = 'auto',
        RSG = {
            Table = 'players',
            MoneyColumn = 'money',
            CitizenIdColumn = 'citizenid',
            CharIdColumns = { 'charid', 'cid' },
        },
        VORP = {
            Table = 'characters',
            CharIdColumn = 'charidentifier',
            CashColumn = 'money',
            GoldColumn = 'gold',
            RolColumn = 'rol',
        },
    }
    ```
  </Accordion>

  <Accordion title="Storage and furniture">
    `Config.Storage` supplies defaults when a property or hotel data entry does not define its own values.

    `Config.Furniture` controls placement limits, movement precision, render batching, furniture-store locations, wallet resale, and animal roaming.

    Keep `RenderSpawnPerTick` conservative on servers with large catalogs or densely furnished properties. Increasing render distance makes more objects eligible to exist on each client and can materially increase client load.
  </Accordion>

  <Accordion title="Door provider">
    ```lua config.lua theme={null}
    Config.Doors = {
        Provider = 'auto', -- auto, sh-doorlocks, or internal
        UseShDoorlocks = true,
        DefaultLockState = true,
        RSGManagedLockMode = false,
        ManagedLockDenyKeyItem = '__sh_housing_denied__',
    }
    ```

    `auto` uses `sh-doorlocks` when the resource is started and otherwise selects the internal backend. Explicitly set `sh-doorlocks` if missing doorlocks should be treated as a deployment error rather than silently falling back.

    Enable `RSGManagedLockMode` when an RSG character identifier cannot be converted to the numeric character ID expected by the door resource. In managed mode, housing permission checks remain authoritative.
  </Accordion>

  <Accordion title="Target and prompt modes">
    `Config.Target.Mode` accepts `target`, `ingame`, `text3d`, or `auto`.

    * `target` uses the configured target resource.
    * `ingame` uses native RedM prompt groups.
    * `text3d` draws world text and listens for configured keys.
    * `auto` prefers the target resource and falls back to `FallbackMode`.

    If you replace `ox_target`, update `Config.Target.Resource` and confirm the target resource exposes compatible zone functions.
  </Accordion>

  <Accordion title="Content synchronization">
    ```lua config.lua theme={null}
    Config.ContentAutoSync = {
        enabled = true,
        intervalMs = 60000,
        syncOnStart = false,
        forceUpdateProperties = true,
        forceUpdateHotels = true,
    }
    ```

    The watcher reloads content only when a managed file signature changes. The minimum interval is 5 seconds.

    `forceUpdateProperties = true` means a changed content file can replace database-backed fields for matching property UIDs. Runtime-created storage marker metadata is preserved, but you should still treat forced synchronization as a write operation and keep a database backup.
  </Accordion>

  <Accordion title="Realtor society ledger">
    The default setup sends housing revenue to `sh-society`.

    ```lua config.lua theme={null}
    Config.Realtor.Management.Ledger.SHSociety = {
        Enabled = true,
        Resource = 'sh-society',
        SocietyId = '',
        WaitForStartup = true,
        StartupTimeoutMs = 60000,
        StartIfMissing = false,
    }
    ```

    Set `SocietyId` when the society identifier differs from the realtor job name. Prefer correct `server.cfg` ordering over `StartIfMissing = true`.
  </Accordion>
</AccordionGroup>

## Content loading and data ownership

The content service loads these files by default:

```lua server/services/content_service.lua theme={null}
local MANAGED_CONTENT_FILES = {
    furniture = { 'data/furniture/furniture_data.json' },
    interiors = { 'data/interiors/interiors_data.json' },
    hotels = { 'data/hotels/hotels_data.json' },
    properties = { 'data/properties/properties_data.json' },
    propertyDoorOverrides = { 'data/properties/property_doors_overrides.json' },
}
```

<Note>
  The wildcard entries in `fxmanifest.lua` make files available to the resource, but they do not automatically make the content service parse every JSON file. Add new file paths to `MANAGED_CONTENT_FILES` when splitting content into multiple files.
</Note>

For example:

```lua server/services/content_service.lua theme={null}
properties = {
    'data/properties/properties_data.json',
    'data/properties/my_server_properties.json',
},
```

Files are merged by stable identifiers. Property and interior-property entries use `uid`; furniture and interior presets use `id`.

### Source files versus database rows

The JSON content files are seed and synchronization sources. Live ownership, contracts, access grants, placed furniture, and payment history are stored in MySQL.

| Data                              | Primary storage                              |
| --------------------------------- | -------------------------------------------- |
| Property templates and boundaries | JSON, then seeded to `sh_housing_properties` |
| Live owner and current contract   | MySQL                                        |
| Property door mapping             | JSON override or MySQL `door_data`           |
| Access grants                     | `sh_housing_access`                          |
| Furniture catalog                 | JSON                                         |
| Purchased furniture wallet        | `sh_housing_furniture_wallet`                |
| Placed furniture                  | `sh_housing_furniture`                       |
| Hotel templates                   | JSON, then seeded to `sh_housing_hotels`     |
| Transactions and realtor actions  | MySQL                                        |

<Warning>
  Do not put an owner character ID or active contract ID in a content JSON file. Ownership is transactional runtime state and should be changed through housing contracts or an administrative transfer.
</Warning>

## Editing property data

Edit `data/properties/properties_data.json` to define repeatable property templates.

```json data/properties/properties_data.json theme={null}
{
  "properties": [
    {
      "uid": "rho_custom_house_01",
      "label": "Rhodes Custom House",
      "town": "Rhodes",
      "coords": {
        "x": 1234.5,
        "y": -1267.8,
        "z": 76.2,
        "heading": 180.0
      },
      "priceCash": 2500,
      "priceGold": 0,
      "rentCash": 100,
      "rentGold": 0,
      "rentCycleDays": 7,
      "storageSlots": 120,
      "storageCapacity": 180000,
      "published": false,
      "storagePoint": {
        "x": 1237.1,
        "y": -1265.4,
        "z": 76.2,
        "heading": 90.0
      },
      "propertyLine": [
        { "x": 1228.0, "y": -1275.0, "z": 75.0 },
        { "x": 1242.0, "y": -1275.0, "z": 75.0 },
        { "x": 1242.0, "y": -1260.0, "z": 75.0 },
        { "x": 1228.0, "y": -1260.0, "z": 75.0 }
      ],
      "propertyMinZ": 73.0,
      "propertyMaxZ": 82.0,
      "interior": {
        "presetId": "rho_house_shell_01",
        "onlyInside": true
      },
      "metadata": {
        "serverCategory": "premium"
      }
    }
  ]
}
```

### Property field reference

| Field                          | Required    | Purpose                                                                 |
| ------------------------------ | ----------- | ----------------------------------------------------------------------- |
| `uid`                          | Yes         | Stable unique key used for seed updates and door overrides              |
| `label`                        | Recommended | Display name; falls back to UID                                         |
| `town`                         | No          | Display grouping or location text                                       |
| `coords`                       | Yes         | Main marker and exterior return point                                   |
| `priceCash`, `priceGold`       | No          | Buy/finance principal                                                   |
| `rentCash`, `rentGold`         | No          | Charge per rent cycle                                                   |
| `rentCycleDays`                | No          | Billing interval                                                        |
| `storageSlots`                 | No          | Inventory slot limit                                                    |
| `storageCapacity`              | No          | Inventory weight/capacity limit                                         |
| `published`                    | No          | Whether the listing is available; defaults to true for property content |
| `propertyLine`                 | Recommended | Exterior polygon used for building and door discovery                   |
| `propertyMinZ`, `propertyMaxZ` | Recommended | Vertical bounds for the polygon                                         |
| `storagePoint`                 | No          | Dedicated stash marker; defaults to the property marker                 |
| `interior`                     | No          | Interior preset, shell, teleport, and interior-only settings            |
| `doorData`                     | No          | One door object or `{ "doors": [...] }`                                 |
| `metadata`                     | No          | Custom and system extension fields                                      |

`buildZone` can be used instead of `propertyLine`. The preferred normalized form is:

```json theme={null}
{
  "buildZone": {
    "points": [
      { "x": 0, "y": 0, "z": 0 },
      { "x": 5, "y": 0, "z": 0 },
      { "x": 5, "y": 5, "z": 0 }
    ],
    "minZ": -1,
    "maxZ": 5
  }
}
```

<Tip>
  Use the in-game property-line editor for the first draft, export or copy the resulting coordinates, and then clean the numbers in JSON. This is less error-prone than estimating a polygon manually.
</Tip>

## Editing doors

Door handling has three distinct layers:

1. `data/doors/door_index.lua` identifies known world doors for automatic discovery.
2. A property's `doorData` stores which doors belong to that property.
3. `sh-doorlocks` or the internal backend owns the active lock state after the property becomes occupied.

### Property door lifecycle

* The in-game property creator attempts to discover up to 64 doors inside the property boundary.
* Discovered mappings are stored with the property.
* An unsold property has no active doorlock records attached.
* When a buy, rent, or finance contract assigns an owner, housing creates or updates active door records and grants the owner access.
* Access changes rebuild the door access payload.
* When ownership is removed, housing removes the active door records but retains reusable mapping data without database door IDs.

### Add a custom MLO door to the index

Add an entry to `data/doors/door_index.lua` using this structure:

```lua data/doors/door_index.lua theme={null}
Doorhashes[1234567890] = {
    1234567890,       -- door hash
    -1896437095,      -- model hash
    "p_doorsgl02x",  -- model name
    1234.567,         -- x
    -1267.890,        -- y
    76.210            -- z
}
```

Use the actual door-system hash, not only the object model hash. Coordinates must point to the physical door object. A correct model with incorrect coordinates can make entity fallback select the wrong nearby object.

### Define a single door mapping

```json theme={null}
{
  "doorData": {
    "doorHash": 1234567890,
    "modelHash": -1896437095,
    "modelName": "p_doorsgl02x",
    "label": "Rhodes Custom House Front Door",
    "coords": {
      "x": 1234.567,
      "y": -1267.890,
      "z": 76.210,
      "heading": 180.0
    },
    "locked": true,
    "autoRelockSeconds": 0
  }
}
```

### Define multiple property doors

```json theme={null}
{
  "doorData": {
    "doors": [
      {
        "doorHash": 1234567890,
        "modelHash": -1896437095,
        "modelName": "p_doorsgl02x",
        "label": "Front Door",
        "coords": { "x": 1234.567, "y": -1267.890, "z": 76.210, "heading": 180 },
        "locked": true,
        "autoRelockSeconds": 0
      },
      {
        "doorHash": 987654321,
        "modelHash": -1896437095,
        "modelName": "p_doorsgl02x",
        "label": "Back Door",
        "coords": { "x": 1240.120, "y": -1262.330, "z": 76.210, "heading": 0 },
        "locked": true,
        "autoRelockSeconds": 30
      }
    ]
  }
}
```

### Use the door override file

`data/properties/property_doors_overrides.json` lets you keep generated door mappings separate from a large property pack. Entries match a property by `uid`.

```json data/properties/property_doors_overrides.json theme={null}
{
  "doors": [
    {
      "uid": "rho_custom_house_01",
      "doorData": {
        "doorHash": 1234567890,
        "modelHash": -1896437095,
        "modelName": "p_doorsgl02x",
        "coords": { "x": 1234.567, "y": -1267.890, "z": 76.210, "heading": 180 },
        "locked": true,
        "autoRelockSeconds": 0
      }
    }
  ]
}
```

The admin panel can map doors and export database mappings into this override file. Commit the exported file with your server content so mappings survive database rebuilds.

<Warning>
  Avoid manually copying `doorId` values between servers. Those IDs refer to environment-specific database rows. Portable content should contain `doorData`; allow housing to create the active door IDs.
</Warning>

## Editing interior and shell data

`data/interiors/interiors_data.json` can contain reusable `presets`, property-style interior entries under `properties`, or both.

### Reusable teleport preset

```json data/interiors/interiors_data.json theme={null}
{
  "presets": [
    {
      "id": "val_room_01",
      "label": "Valentine Room 01",
      "entry": { "x": -325.1, "y": 767.0, "z": 117.5, "h": 90 },
      "exit": { "x": -323.0, "y": 765.0, "z": 117.5, "h": 270 },
      "metadata": {}
    }
  ]
}
```

Use this format for an existing world interior where `entry` and `exit` are absolute teleport points.

### Spawned shell property

```json data/interiors/interiors_data.json theme={null}
{
  "properties": [
    {
      "uid": "interior_shell_int3",
      "label": "Interior Shell 3",
      "town": "Interiors",
      "coords": { "x": -382.3784, "y": 917.0563, "z": 118.5301, "heading": 269.5958 },
      "priceCash": 0,
      "rentCash": 0,
      "storageSlots": 120,
      "storageCapacity": 180000,
      "published": false,
      "interior": {
        "presetId": "shell_int3",
        "onlyInside": true,
        "shell": {
          "model": "int3",
          "spawnDepth": -20,
          "originCoords": { "x": -382.3784, "y": 917.0563, "z": -20, "heading": 0 },
          "entryCoords": { "x": -382.1884, "y": 918.2563, "z": -19.23, "heading": 0 },
          "exitCoords": { "x": -382.1884, "y": 918.2563, "z": -19.23, "heading": 180 }
        }
      }
    }
  ]
}
```

### Shell coordinate rules

* `originCoords` is where the shell object is spawned.
* `entryCoords` is where the player appears inside the shell.
* `exitCoords` is the in-shell interaction point that returns the player to property `coords`.
* `spawnDepth` is a fallback Z offset when no absolute origin is supplied.
* `entryOffset` and `exitOffset` are supported fallbacks relative to the shell origin.
* `headingOffset`, `entryHeadingOffset`, and `exitHeadingOffset` can adjust orientation.
* `onlyInside = true` restricts build validation to the interior zone when one is configured.

<Warning>
  Do not use the exterior property marker as a shell `entryCoords` unless that is intentional. The exterior marker starts the teleport; the shell entry should normally be at the spawned shell's underground or isolated coordinates.
</Warning>

## Editing hotel data

Edit `data/hotels/hotels_data.json`.

```json data/hotels/hotels_data.json theme={null}
{
  "hotels": [
    {
      "uid": "hotel_rho_custom_01",
      "label": "Rhodes Upstairs Room",
      "coords": { "x": 1328.62, "y": -1293.79, "z": 77.02, "heading": 62 },
      "interior": { "presetId": "val_room_01" },
      "price": { "cash": 40, "gold": 0 },
      "cycleHours": 24,
      "storageSlots": 45,
      "storageCapacity": 60000,
      "enabled": true,
      "doorData": {
        "doorHash": 123456789,
        "modelHash": -1896437095,
        "modelName": "p_doorsgl02x",
        "coords": { "x": 1329.1, "y": -1294.2, "z": 77.0, "heading": 62 },
        "locked": true,
        "autoRelockSeconds": 0
      }
    }
  ]
}
```

Hotel entries use `cycleHours`, while residential rent uses `rentCycleDays`. An enabled hotel with mapped door data synchronizes its active door through the hotel contract lifecycle.

## Editing the furniture catalog

Edit `data/furniture/furniture_data.json`.

```json data/furniture/furniture_data.json theme={null}
{
  "items": [
    {
      "id": "Furniture_CustomChair01",
      "model": "p_chaircomfy01x",
      "label": "Custom Comfy Chair",
      "category": "chairs",
      "price": 25,
      "tags": ["chair", "indoor", "wood"],
      "metadata": {
        "imageUrl": "https://cdn.example.com/sh-housing/chairs/custom-chair-01.webp",
        "fallbackImageUrl": "https://cdn.example.com/sh-housing/fallbacks/chair.webp",
        "sourceSection": "custom_furniture",
        "rarity": "common",
        "style": "rustic"
      }
    },
    {
      "id": "Furniture_CustomDog01",
      "model": "A_C_DogAmericanFoxhound_01",
      "label": "American Foxhound",
      "category": "animals",
      "price": 30,
      "metadata": {
		"image": "ui/images/my_custom_image.jpg",
		}
    }
  ],
  "meta": {
    "version": 1
  }
}
```

| Field      | Required | Notes                                                                                    |
| ---------- | -------- | ---------------------------------------------------------------------------------------- |
| `id`       | Yes      | Unique stable catalog ID; wallet entries reference it                                    |
| `model`    | Yes      | Valid RedM object or ped model name                                                      |
| `label`    | No       | Defaults to the ID                                                                       |
| `category` | No       | Defaults to `misc`; `animals` activates ped behavior                                     |
| `price`    | No       | Non-negative whole-number cash price                                                     |
| `tags`     | No       | String list preserved on the catalog item for extensions; not searched by the default UI |
| `metadata` | No       | Arbitrary catalog extension data plus recognized preview/runtime fields                  |

<Warning>
  Do not rename a furniture `id` after players have purchased it. Wallet and placement rows reference the catalog ID. Add a new item and migrate old references deliberately if an ID must change.
</Warning>

## Metadata reference

`metadata` objects let content authors attach structured information without adding database columns or changing the base content schema. Metadata is JSON, so values can be strings, numbers, booleans, arrays, or nested objects.

Use metadata for optional presentation details, grouping, integration identifiers, and runtime hints. Keep required business data such as prices, coordinates, owner IDs, and contract status in their documented fields.

### Furniture metadata and custom images

The furniture catalog UI recognizes these preview keys inside each item's `metadata` object:

| Metadata key         | Behavior                                                     |
| -------------------- | ------------------------------------------------------------ |
| `image`              | Explicit custom catalog preview URL                          |
| `imageUrl`           | Alias for `image`                                            |
| `fallbackImage`      | Used when the primary or automatically generated image fails |
| `fallbackImageUrl`   | Alias for `fallbackImage`                                    |
| `previewFallback`    | Additional fallback alias                                    |
| `previewFallbackUrl` | Additional fallback alias                                    |

Use `metadata.imageUrl` rather than a top-level `imageUrl`. The furniture normalizer preserves the `metadata` object, while its normalized top-level output only includes `id`, `model`, `label`, `category`, `price`, `tags`, and `metadata`.

```json data/furniture/furniture_data.json theme={null}
{
  "id": "Furniture_CustomDesk01",
  "model": "p_desk01x",
  "label": "Walnut Writing Desk",
  "category": "tables",
  "price": 60,
  "tags": ["desk", "table", "office", "wood", "indoor"],
  "metadata": {
    "imageUrl": "https://cdn.example.com/housing/furniture/walnut-writing-desk.webp",
    "fallbackImageUrl": "https://cdn.example.com/housing/furniture/generic-table.webp",
    "sourceSection": "office_collection",
    "rarity": "uncommon",
    "style": "victorian",
    "collection": "saint_denis_1899",
    "dimensions": {
      "width": 1.4,
      "depth": 0.7,
      "height": 0.9
    },
    "customTags": ["writing", "premium", "roleplay"]
  }
}
```

The preview resolution order is:

1. `metadata.image` or `metadata.imageUrl`.
2. The automatic model image generated from the model name.
3. A metadata fallback URL.
4. A hardcoded fallback in `CATALOG_PREVIEW_FALLBACKS` inside `ui/app.js`.
5. The configured default fallback image.

If no image loads, the catalog displays **Preview unavailable for this model** and still allows normal purchasing and placement.

<Tip>
  Use HTTPS image URLs with stable filenames and reasonable dimensions. WebP or optimized JPEG files reduce NUI loading time. Avoid URLs containing quotes or short-lived authentication tokens.
</Tip>

### Hosting furniture images inside the resource

You can ship preview images with `sh-housing` instead of using an external CDN.

1. Create `ui/images/furniture/`.
2. Add the image files to the `files` block in `fxmanifest.lua`.
3. Reference them relative to `ui/index.html`.

```lua fxmanifest.lua theme={null}
files {
    'ui/index.html',
    'ui/style.css',
    'ui/app.js',
    'ui/images/furniture/*.webp',
    -- existing data entries...
}
```

```json theme={null}
{
  "metadata": {
    "imageUrl": "images/furniture/walnut-writing-desk.webp",
    "fallbackImageUrl": "images/furniture/default.webp"
  }
}
```

Restart `sh-housing` after adding new files to `fxmanifest.lua`. Refreshing only the JSON does not add newly introduced image files to the resource manifest.

### Furniture tags and custom grouping

Furniture supports a top-level `tags` array and any custom fields inside `metadata`.

```json theme={null}
{
  "tags": ["chair", "outdoor", "porch"],
  "metadata": {
    "rarity": "common",
    "style": "frontier",
    "collection": "homestead",
    "allowedRooms": ["porch", "kitchen"],
    "staffOnly": false
  }
}
```

The base content service preserves these values, but the default catalog search currently matches only `id`, `label`, `model`, and `category`. Tags and arbitrary metadata do not change behavior until another script or a UI customization consumes them.

To include top-level tags in catalog searches, update the search haystack in `server/services/content_service.lua`:

```lua server/services/content_service.lua theme={null}
local tagText = table.concat(type(item.tags) == 'table' and item.tags or {}, ' ')
local metadata = type(item.metadata) == 'table' and item.metadata or {}
local customTagText = table.concat(
    type(metadata.customTags) == 'table' and metadata.customTags or {},
    ' '
)

local hay = Util.lower((
    '%s %s %s %s %s %s'
):format(
    item.id,
    item.label,
    item.model,
    item.category,
    tagText,
    customTagText
))
```

This is a server-side catalog filter change. Restart `sh-housing` after modifying the Lua service.

### Furniture runtime metadata

Some furniture metadata fields affect placed entities:

| Metadata key      | Consumer                | Purpose                                                                                                                       |
| ----------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `sourceSection`   | Server/client           | Preserves the source catalog section and can identify animal categories                                                       |
| `category`        | Server/client           | Placement category; `animals` causes ped handling                                                                             |
| `entityType`      | Client                  | `ped` or `animal` forces ped creation rather than object creation                                                             |
| `type`            | Client                  | Alias checked when resolving ped/animal placement                                                                             |
| `animalFurniture` | Server-generated marker | Records that an item came from the `animals` category; entity selection primarily uses `entityType`, category, and model type |
| `snappedToGround` | Client                  | Re-applies ground placement when spawning a non-animal object                                                                 |
| `walletConsumed`  | Server-managed          | Records that placement consumed a wallet item                                                                                 |
| `storePriceCash`  | Server-managed          | Records the original store price for wallet placement context                                                                 |

For ordinary catalog animals, set `category` to `animals`; the server automatically applies `entityType = 'ped'` and `animalFurniture = true` to the placement. Do not manually set `walletConsumed` or `storePriceCash` in catalog metadata because those fields represent transaction state.

<Note>
  Arbitrary catalog metadata is retained on the catalog item, but it is not all copied into placed-furniture metadata. The placement service automatically carries `category` and `sourceSection`; transaction and placement fields are then added separately. Copy additional catalog metadata explicitly in `furniture_service.lua` if your placement runtime needs it.
</Note>

### Property metadata

Property metadata contains several recognized fields:

| Metadata key    | Purpose                                                             |
| --------------- | ------------------------------------------------------------------- |
| `buildZone`     | Exterior furniture validation and automatic property-door discovery |
| `storagePoint`  | Dedicated property stash marker                                     |
| `storageCoords` | Alias for `storagePoint`                                            |
| `stashPoint`    | Alias for `storagePoint`                                            |
| `stashCoords`   | Alias for `storagePoint`                                            |
| `contentSource` | Identifies the source content pack                                  |
| `contentType`   | Identifies special content such as `interior_property`              |

You may add your own fields for other resources:

```json theme={null}
{
  "metadata": {
    "district": "scarlett_meadows",
    "propertyClass": "premium",
    "mailboxId": "mail_rho_custom_01",
    "taxZone": "rhodes",
    "discordRoleRequired": null,
    "features": ["stable", "garden", "porch"]
  }
}
```

Custom property metadata does nothing automatically. Read it through `GetProperty` or `GetProperties` from your integration resource and enforce any additional rule server-side.

```lua theme={null}
local property = exports['sh-housing']:GetProperty(propertyId)
local metadata = property and property.metadata or {}

if metadata.mailboxId then
    exports['my-mail-script']:OpenMailbox(source, metadata.mailboxId)
end
```

### Interior metadata

Interior metadata supports shell-related aliases and presentation fields. Recognized keys include:

* `shell.model`, `interiorModel`, `interior_model`, or `Interior_Model`
* `shell.originCoords`, `originCoords`, or `spawnCoords`
* `shell.entryCoords` or `entryCoords`
* `shell.exitCoords` or `exitCoords`
* `shell.entryOffset` or `entryOffset`
* `shell.exitOffset` or `exitOffset`
* `shell.spawnDepth` or `spawnDepth`
* `headingOffset`, `entryHeadingOffset`, and `exitHeadingOffset`
* `Interior_SubModel`
* `page`

Prefer the nested `metadata.shell` structure for new content because it keeps shell-specific fields grouped together.

### Access metadata

Access grants accept arbitrary metadata. Job-based door rules recognize `minGrade`:

```lua theme={null}
exports['sh-housing']:GrantAccess(
    propertyId,
    'manage',
    'job',
    'sheriff',
    nil,
    {
        minGrade = 3,
        reason = 'Sheriff office property access',
        issuedByResource = 'my-law-script'
    }
)
```

Housing uses `minGrade` when building `sh-doorlocks` job-grade rules. Other custom access metadata is stored for integrations and auditing but does not grant additional permissions by itself.

### Contract and door metadata

Contract metadata stores terms and system-generated finance/tax state. Door metadata sent to `sh-doorlocks` identifies the managing system, entity kind, property/hotel ID, and door index.

Treat these as system-owned fields. Extend them only after checking the corresponding contract or door bridge service, and do not replace the complete metadata object during a partial update unless you intend to remove existing system state.

## Localization

Locale files are located under `locales/`.

To add French:

1. Copy `locales/en.lua` to `locales/fr.lua`.
2. Keep every message key unchanged and translate only the string values.
3. Add `locales/fr.lua` to `shared_scripts` in `fxmanifest.lua`.
4. Set `Config.Locale = 'fr'`.

```lua locales/fr.lua theme={null}
Locales.fr = {
    property_created = 'Propriete creee.',
    property_updated = 'Propriete mise a jour.',
    no_permission = "Vous n'avez pas la permission.",
}
```

Some NUI labels are translated in `ui/app.js`. Add matching translations there when introducing a language that must localize the full web interface.

## Access grants

The implemented access types are:

| Access type | Permission granted                       |
| ----------- | ---------------------------------------- |
| `key`       | Door lock/unlock                         |
| `stash`     | Property storage                         |
| `build`     | Furniture placement and editing          |
| `manage`    | Key, stash, build, and management rights |

The implemented subject types are `char`, `job`, `group`, and `gang`.

```lua theme={null}
-- Give one character permanent door access.
local access, err = exports['sh-housing']:GrantAccess(
    propertyId,
    'key',
    'char',
    tostring(characterId),
    nil,
    {}
)

-- Give a job management access with metadata consumed by door rules.
local jobAccess, jobErr = exports['sh-housing']:GrantAccess(
    propertyId,
    'manage',
    'job',
    'sheriff',
    nil,
    { minGrade = 3 }
)
```

`expiresAt` is a Unix timestamp in seconds. Expired grants are ignored and periodically cleaned from the database.

## Server exports

```lua theme={null}
-- Returns property or nil, error.
local property, err = exports['sh-housing']:CreateProperty(payload)

-- Returns updated property or nil, error.
local updated, err = exports['sh-housing']:UpdateProperty(propertyId, patch)

-- Returns true or false, error.
local deleted, err = exports['sh-housing']:DeleteProperty(propertyId)

local property = exports['sh-housing']:GetProperty(propertyId)
local properties = exports['sh-housing']:GetProperties(filter)

local access, err = exports['sh-housing']:GrantAccess(
    propertyId,
    accessType,
    subjectType,
    subjectValue,
    expiresAt,
    metadata
)

local revoked, err = exports['sh-housing']:RevokeAccess(accessId)

local contract, err = exports['sh-housing']:CreateContract(
    propertyId,
    contractType, -- buy, rent, or finance
    holderSource,
    terms
)

local terminated, err = exports['sh-housing']:TerminateContract(contractId, reason)
local opened, err = exports['sh-housing']:OpenHouseStash(source, propertyId)
```

### Programmatic property example

```lua theme={null}
local property, err = exports['sh-housing']:CreateProperty({
    uid = 'script_created_house_01',
    label = 'Script Created House',
    town = 'Blackwater',
    coords = { x = -875.0, y = -1330.5, z = 43.96, heading = 177.0 },
    priceCash = 1500,
    rentCash = 75,
    rentCycleDays = 7,
    storageSlots = 100,
    storageCapacity = 150000,
    published = false,
    propertyLine = {
        { x = -882.0, y = -1337.0, z = 43.0 },
        { x = -868.0, y = -1337.0, z = 43.0 },
        { x = -868.0, y = -1323.0, z = 43.0 },
        { x = -882.0, y = -1323.0, z = 43.0 },
    },
    propertyMinZ = 41.0,
    propertyMaxZ = 50.0,
})

if not property then
    print(('Housing property creation failed: %s'):format(tostring(err)))
end
```

## Database tables

| Table                         | Stores                                                              |
| ----------------------------- | ------------------------------------------------------------------- |
| `sh_housing_properties`       | Property definition, owner link, door/interior JSON, storage limits |
| `sh_housing_contracts`        | Property and hotel agreements, billing schedule, status             |
| `sh_housing_access`           | Character/job/group/gang grants                                     |
| `sh_housing_furniture`        | Placed objects and animals                                          |
| `sh_housing_furniture_wallet` | Purchased catalog quantities per character                          |
| `sh_housing_hotels`           | Hotel definitions and door/interior data                            |
| `sh_housing_realtor_actions`  | Realtor audit records                                               |
| `sh_housing_realtor_ledger`   | Internal fallback ledger                                            |
| `sh_housing_transactions`     | Housing payment ledger                                              |

<Warning>
  Avoid editing owner, contract, access, door ID, or wallet columns directly while the resource is running. Services cache these records in memory. Direct SQL can leave the cache inconsistent until a resource restart or administrative rebuild.
</Warning>

<Steps>
  <Step title="Use a staging database">
    Test schema and content synchronization against a copy of production data.
  </Step>

  <Step title="Create an unpublished property">
    Verify marker position, polygon bounds, storage point, and interior teleporting.
  </Step>

  <Step title="Check automatic door mappings">
    Confirm the property panel shows the expected door count. Remove unrelated gates or neighboring doors before publishing.
  </Step>

  <Step title="Test every contract path">
    Test buy, rent, finance, termination, missed payment, eviction, and repossession with non-administrator characters.
  </Step>

  <Step title="Verify permissions">
    Test owner, key-only, stash-only, build-only, manage, realtor, and administrator behavior separately.
  </Step>

  <Step title="Restart all relevant resources">
    A full dependency-order restart catches issues hidden by hot reloads or stale client state.
  </Step>
</Steps>

## Troubleshooting

<AccordionGroup>
  <Accordion title="A property appears in JSON but not in game">
    * Validate the JSON syntax.
    * Confirm the file is listed in `MANAGED_CONTENT_FILES`.
    * Confirm the property has a non-empty unique `uid`.
    * Check console output for content parsing or database errors.
    * Run **Sync Content Packs** from the admin panel or restart `sh-housing`.
    * If the UID already exists, review `forceUpdateProperties` behavior.
  </Accordion>

  <Accordion title="A custom MLO door is not detected">
    * Add the door to `data/doors/door_index.lua` or the corresponding `sh-doorlocks` index.
    * Verify door hash, model hash, model name, and world coordinates.
    * Confirm the door lies inside the property's polygon and vertical range.
    * Use **Auto Door (Nearest)** while standing at the door when polygon discovery is unsuitable.
    * Use **Set Door Manual** when the MLO does not expose a discoverable indexed door.
  </Accordion>

  <Accordion title="The door maps but does not lock after sale">
    * Confirm `Config.Doors.Provider` resolves to the intended backend.
    * Confirm `sh-doorlocks` starts before `sh-housing`.
    * Press **Sync Door** and record the exact error.
    * Check for an existing `sh-doorlocks` row with the same hash and coordinates.
    * Confirm the housing property has `doorData` and receives a persisted `doorId` after sale.
    * If the lock state changes in the panel but the object remains movable, re-check the MLO door hash/model/coordinates.
  </Accordion>

  <Accordion title="The owner cannot operate a door">
    * Confirm the property owner character ID matches the current selected character.
    * On RSG, verify the numeric door character ID mapping.
    * Enable `RSGManagedLockMode` if identifiers cannot be converted safely.
    * Re-sync the property door after ownership or access changes.
  </Accordion>

  <Accordion title="Furniture cannot be placed">
    * Confirm build mode is active for the selected property.
    * Check the exterior or interior build polygon and its Z limits.
    * Check `MaxBuildDistance`, `MaxBuildHeightDelta`, and `MaxBuildMoveDelta`.
    * Confirm the player has owner, manage, or build access.
    * Verify the catalog model exists and streams correctly.
  </Accordion>

  <Accordion title="A shell is black or teleports incorrectly">
    * Confirm the model name is valid and streamed.
    * Verify `originCoords`, `entryCoords`, and `exitCoords` are in the same shell coordinate space.
    * Test the shell without offsets before adding heading offsets.
    * Adjust `Config.Interiors.Lighting` only after the shell position is correct.
    * Do not use lighting intensity to hide a shell that spawned at the wrong coordinates.
  </Accordion>

  <Accordion title="Content changes overwrite in-game edits">
    Runtime content synchronization may force JSON values back into matching database rows. Disable the relevant `forceUpdateProperties` or `forceUpdateHotels` option, or make the JSON source authoritative and apply the change there as well.

    Door mappings edited in-game can be exported to `property_doors_overrides.json` so they become part of authoritative content.
  </Accordion>
</AccordionGroup>

## Recommended production workflow

1. Define properties, hotels, and interiors in source-controlled JSON.
2. Use unpublished listings while measuring markers, polygons, shell points, and doors.
3. Use in-game auto mapping for doors, then export mappings to the override file.
4. Validate JSON and JavaScript before deployment.
5. Seed into a staging database and complete contract/access tests.
6. Back up production tables.
7. Deploy data and scripts together, then restart dependencies in order.
8. Monitor initialization, content-sync, contract, and door-sync logs.

<Tip>
  Treat stable UIDs and catalog IDs as public API. Coordinates, labels, prices, and metadata can evolve; identifiers should remain stable after release.
</Tip>
