Compare commits
23 Commits
a50383021f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f17cf9604e | |||
|
|
2b72f23ea9 | ||
| 6c9c48dd19 | |||
| 7652289260 | |||
| b406664da4 | |||
| 42ad3a9f95 | |||
| e5ee043a07 | |||
| 8e281a2391 | |||
| 5e08f5bba1 | |||
| 5c2c65850e | |||
| 2e4ce548c1 | |||
| 90058723a2 | |||
| 5694395e6b | |||
| 311ba9c2a2 | |||
| 86a5dc866e | |||
| 43852d864a | |||
| 3c2e517766 | |||
| 1e7e05bf26 | |||
| 5879f24f9d | |||
| a24da5dfa4 | |||
| d429053aa4 | |||
| 1f7f25a750 | |||
| 375af109b0 |
@@ -12,8 +12,8 @@ Each role is tagged with its name — run one or more with `--tags`:
|
||||
|
||||
```bash
|
||||
# Run one role
|
||||
ansible-playbook -i inventory.ini ansible-playbook.yml --tags minecraft --ask-become-pass
|
||||
|
||||
ansible-playbook -i inventory.ini ansible-playbook.yml --tags minecraft --ask-become-pass
|
||||
# Run several
|
||||
ansible-playbook -i inventory.ini ansible-playbook.yml --tags "jellyfin,gitea" --ask-become-pass
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
tags: [jellyfin]
|
||||
- role: wazuh
|
||||
tags: [wazuh]
|
||||
- role: minecraft
|
||||
tags: [minecraft]
|
||||
- role: 7dtd
|
||||
tags: [7dtd]
|
||||
- role: trade_hub
|
||||
tags: [trade_hub]
|
||||
|
||||
# - role: minecraft
|
||||
# tags: [minecraft]
|
||||
# - role: 7dtd
|
||||
# tags: [7dtd]
|
||||
# - role: zomboid
|
||||
# tags: [zomboid]
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[internal-apps]
|
||||
internal-apps
|
||||
internal-apps ansible_user=boxswapper ansible_become_password="bQ4Sxf5Ycrw#7ag4j$"
|
||||
|
||||
BIN
roles/7dtd/files/mods/MoreAnimals.zip
Normal file
BIN
roles/7dtd/files/mods/MoreAnimals.zip
Normal file
Binary file not shown.
33
roles/7dtd/files/mods/MoreAnimals/Config/spawning.xml
Normal file
33
roles/7dtd/files/mods/MoreAnimals/Config/spawning.xml
Normal file
@@ -0,0 +1,33 @@
|
||||
<configs>
|
||||
<!--
|
||||
More Animals per Area - v3.x spawn-density patch for the dedicated server.
|
||||
|
||||
Written for the v3.0+ spawning.xml schema (validated against the v3.0 shipped file):
|
||||
- maxcount is now per 80x80m "biome chunk" (vanilla = 1 for every animal group).
|
||||
Friendly groups: WildGameForest (day/Any, all biomes), WildGameForestNight
|
||||
(pine/burnt), WildGameStagNight (snow/wasteland). Predators: EnemyAnimalsForest,
|
||||
EnemyAnimalsBurntForestAny/Night, EnemyAnimalsDesert/Night, EnemyAnimalsSnow/Night,
|
||||
EnemyAnimalsWasteland/Night. The starts-with() selectors cover ALL current and future
|
||||
variants and cannot hit non-animal spawns (EnemyVulture*, ZombiesAll do not match).
|
||||
- respawndelay is SIX comma-separated game-day values, one per sandbox Respawn speed:
|
||||
Default,VerySlow,Slow,Normal,Fast,VeryFast (multipliers 1.75,1.35,1,0.65,0.35).
|
||||
A single scalar corrupts the entry on v3.x. Base values: 0.25 for friendly game
|
||||
(~6x faster than the 1.5 vanilla), 0.6 for night/predator groups.
|
||||
- GATE: XML mods are only honored while the sandbox "Density" options (Day/Night Animal
|
||||
+ Enemy) are set to Default. If a SandboxCode sets any density != Default, THIS PATCH
|
||||
IS IGNORED and the sandbox value wins - use SandboxCode density in that case.
|
||||
- EnemyAnimalsSnowNight / EnemyAnimalsWasteland* are zombie animals (undead), included
|
||||
by the selector but worth knowing about.
|
||||
|
||||
Effect vs vanilla (pine forest): WildGameForest 1->4 alive per 80x80m chunk, respawn
|
||||
1.5d->0.25d; predators 1->3, respawn 2.9d->0.6d - roughly a 3-4x wildlife increase.
|
||||
-->
|
||||
|
||||
<!-- Friendly animals (day + night, all biomes): 4 alive per chunk, base respawn 0.25d -->
|
||||
<set xpath="/spawning/biome/spawn[starts-with(@entitygroup,'WildGame')]/@maxcount">4</set>
|
||||
<set xpath="/spawning/biome/spawn[starts-with(@entitygroup,'WildGame')]/@respawndelay">0.25,0.438,0.338,0.25,0.163,0.088</set>
|
||||
|
||||
<!-- Hostile animals (all biome variants incl. zombie-animal night groups): 3 alive per chunk, base respawn 0.6d -->
|
||||
<set xpath="/spawning/biome/spawn[starts-with(@entitygroup,'EnemyAnimals')]/@maxcount">3</set>
|
||||
<set xpath="/spawning/biome/spawn[starts-with(@entitygroup,'EnemyAnimals')]/@respawndelay">0.6,1.05,0.81,0.6,0.39,0.21</set>
|
||||
</configs>
|
||||
7
roles/7dtd/files/mods/MoreAnimals/ModInfo.xml
Normal file
7
roles/7dtd/files/mods/MoreAnimals/ModInfo.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<xml>
|
||||
<Name value="MoreAnimals"/>
|
||||
<DisplayName value="More Animals per Area"/>
|
||||
<Version value="1.1"/>
|
||||
<Description value="Raises wild-game and predator spawn density (maxcount) and respawn rate in every biome. Pure XML data patch - no DLLs, no client install, EAC-safe."/>
|
||||
<Author value="boxswapper"/>
|
||||
</xml>
|
||||
Binary file not shown.
Binary file not shown.
@@ -35,8 +35,24 @@
|
||||
|
||||
# Mods live in the bind-mounted serverfiles; the container loads them on its next start
|
||||
# (recreate on every deploy re-reads them). zips come from roles/7dtd/files/mods (sdtd_mods
|
||||
# in vars.yml), extracted one level deep (the zips already carry their own _Singularity/
|
||||
# and zByteblazarsNoRespawnNearBackpack/ subfolders, which fix load order by name).
|
||||
# in vars.yml), extracted one level deep (each zip carries its own mod subfolder; prefix
|
||||
# folders with z- if they must sort LAST for load order).
|
||||
# Deployed now: MoreAnimals — a PURE XML data patch (no DLL/Harmony) raising wild-game/predator
|
||||
# spawn density (v3.x schema: maxcount per 80x80m chunk + 6-value respawndelay tuples). Works
|
||||
# with EAC ON, needs NO client install (spawning is server-authoritative; the server pushes its
|
||||
# XML state to clients on connect). GATE: XML values apply only while the sandbox Density options
|
||||
# are Default — once sdtd_sandbox_code sets any density != Default, the sandbox value wins and
|
||||
# this patch is inert; use SandboxCode Day/Night Animal Density + Respawn instead.
|
||||
# unarchive only ADDS — it never removes, so a mod dropped from sdtd_mods would keep loading:
|
||||
# with an empty list we wipe the whole dir and the create-task below rebuilds it empty.
|
||||
# Partial mod swaps are not auto-cleaned (the role only knows zip names, not the folders zips
|
||||
# unpack into) — remove those retired folders from the box by hand.
|
||||
- name: Remove 7 Days to Die Mods directory when no mods are deployed
|
||||
ansible.builtin.file:
|
||||
path: /opt/apps/7dtd/serverfiles/Mods
|
||||
state: absent
|
||||
when: sdtd_mods | length == 0
|
||||
|
||||
- name: Create 7 Days to Die Mods directory (container user)
|
||||
ansible.builtin.file:
|
||||
path: /opt/apps/7dtd/serverfiles/Mods
|
||||
@@ -47,7 +63,7 @@
|
||||
|
||||
- name: Deploy 7 Days to Die mods from vendored zips
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ item }}"
|
||||
src: "mods/{{ item }}"
|
||||
dest: /opt/apps/7dtd/serverfiles/Mods
|
||||
owner: '1000'
|
||||
group: '1000'
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<property name="ServerDescription" value="{{ sdtd_server_description }}" />
|
||||
<property name="ServerWebsiteURL" value="{{ sdtd_website_url }}" />
|
||||
<property name="ServerPassword" value="{{ sdtd_server_password }}" />
|
||||
<property name="ServerLoginConfirmationText" value="" />
|
||||
<property name="ServerLoginConfirmationText" value="{{ sdtd_server_confirmation }}" />
|
||||
<property name="Region" value="{{ sdtd_region }}" />
|
||||
<property name="Language" value="{{ sdtd_language }}" />
|
||||
|
||||
@@ -62,52 +62,35 @@
|
||||
<property name="GameName" value="{{ sdtd_game_name }}" />
|
||||
<property name="GameMode" value="{{ sdtd_game_mode }}" />
|
||||
|
||||
<!-- Difficulty: 0 = Scavenger, 1 = Adventurer, 2 = Nomad, 3 = Warrior, 4 = Survivalist, 5 = Insane -->
|
||||
<property name="GameDifficulty" value="{{ sdtd_difficulty }}" />
|
||||
<property name="BlockDamagePlayer" value="100" />
|
||||
<property name="BlockDamageAI" value="100" />
|
||||
<property name="BlockDamageAIBM" value="100" />
|
||||
<property name="XPMultiplier" value="{{ sdtd_xp_multiplier }}" />
|
||||
<!-- v3.x GAMEPLAY TUNING MOVED TO SANDBOXCODE: GameDifficulty, XPMultiplier, DayNightLength,
|
||||
DayLightLength, DeathPenalty, DropOnDeath/DropOnQuit, zombie speeds (ZombieMove etc.),
|
||||
BloodMoon*, LootAbundance, LootRespawnDays, AirDropFrequency, BlockDamage*, EnemySpawnMode
|
||||
were REMOVED from serverconfig.xml in v3.0 and are silently IGNORED - the game reads only
|
||||
the SandboxCode. Empty code = Adventurer defaults. To restore the server's intended tuning:
|
||||
client -> New Game -> Sandbox Options -> mirror the sdtd_* reference vars in vars.yml ->
|
||||
copy code -> set sdtd_sandbox_code in vars.yml. MaxSpawnedZombies/MaxSpawnedAnimals remain
|
||||
live serverconfig properties (see below). -->
|
||||
{% if sdtd_sandbox_code %}
|
||||
<property name="SandboxCode" value="{{ sdtd_sandbox_code }}" />
|
||||
{% endif %}
|
||||
|
||||
<!-- Still-live v3.x gameplay properties (not moved to sandbox) -->
|
||||
<property name="PlayerSafeZoneLevel" value="5" />
|
||||
<property name="PlayerSafeZoneHours" value="5" />
|
||||
|
||||
<!-- Day/night: DayNightLength = real minutes per in-game day, DayLightLength = in-game hours of daylight -->
|
||||
<property name="BuildCreate" value="false" />
|
||||
<property name="DayNightLength" value="{{ sdtd_day_night_length }}" />
|
||||
<property name="DayLightLength" value="{{ sdtd_day_light_length }}" />
|
||||
<property name="DeathPenalty" value="1" />
|
||||
<!-- DropOnDeath: 0 = nothing, 1 = everything, 2 = toolbelt only, 3 = backpack only, 4 = delete all -->
|
||||
<property name="DropOnDeath" value="{{ sdtd_drop_on_death }}" />
|
||||
<property name="DropOnQuit" value="0" />
|
||||
<property name="BedrollDeadZoneSize" value="15" />
|
||||
<property name="BedrollExpiryTime" value="45" />
|
||||
|
||||
<!-- Performance related -->
|
||||
<!-- Performance / spawn caps (live properties; animal density itself is patched by the
|
||||
MoreAnimals modlet on Data/Config/spawning.xml - XML values apply only while the sandbox
|
||||
Density options are Default) -->
|
||||
<property name="MaxSpawnedZombies" value="{{ sdtd_max_spawned_zombies }}" />
|
||||
<property name="MaxSpawnedAnimals" value="50" />
|
||||
<property name="MaxSpawnedAnimals" value="{{ sdtd_max_spawn_animals }}" />
|
||||
<property name="ServerMaxAllowedViewDistance" value="12" />
|
||||
<property name="MaxQueuedMeshLayers" value="1000" />
|
||||
|
||||
<!-- Zombie settings. ZombieMove: 0 = walk, 1 = jog, 2 = run, 3 = sprint, 4 = nightmare -->
|
||||
<property name="EnemySpawnMode" value="true" />
|
||||
<property name="EnemyDifficulty" value="0" />
|
||||
<property name="ZombieFeralSense" value="0" />
|
||||
<property name="ZombieMove" value="{{ sdtd_zombie_move }}" />
|
||||
<property name="ZombieMoveNight" value="{{ sdtd_zombie_move_night }}" />
|
||||
<property name="ZombieFeralMove" value="3" />
|
||||
<property name="ZombieBMMove" value="3" />
|
||||
<property name="BloodMoonFrequency" value="{{ sdtd_bloodmoon_frequency }}" />
|
||||
<property name="BloodMoonRange" value="0" />
|
||||
<property name="BloodMoonWarning" value="8" />
|
||||
<property name="BloodMoonEnemyCount" value="{{ sdtd_bloodmoon_enemy_count }}" />
|
||||
|
||||
<!-- Loot -->
|
||||
<property name="LootAbundance" value="{{ sdtd_loot_abundance }}" />
|
||||
<property name="LootRespawnDays" value="7" />
|
||||
<property name="AirDropFrequency" value="{{ sdtd_airdrop_frequency }}" />
|
||||
<property name="AirDropMarker" value="true" />
|
||||
|
||||
<!-- Multiplayer. PlayerKillingMode: 0 = no killing, 1 = kill allies only, 2 = kill strangers only, 3 = kill everyone -->
|
||||
<property name="AirDropMarker" value="true" />
|
||||
<property name="PartySharedKillRange" value="100" />
|
||||
<property name="PlayerKillingMode" value="{{ sdtd_player_killing_mode }}" />
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
env:
|
||||
USER_UID: 1000
|
||||
USER_GID: 1000
|
||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||
restart_policy: always
|
||||
recreate: true
|
||||
state: started
|
||||
|
||||
95
roles/trade_hub/README.md
Normal file
95
roles/trade_hub/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# trade_hub
|
||||
|
||||
Deploys the Free People of the Verse trade hub: a FastAPI backend, an nginx
|
||||
frontend and PostgreSQL, all run by podman on the target host. The role stages
|
||||
its own copy of the application source from `files/trade-hub/` and builds the
|
||||
backend and frontend images locally, so it does not need a registry.
|
||||
|
||||
## Usage
|
||||
|
||||
Add the role to an existing play. It is safe to run after a `packages` role that
|
||||
already installs podman:
|
||||
|
||||
```yaml
|
||||
- hosts: internal-apps
|
||||
become: true
|
||||
vars_files:
|
||||
- ./vars.yml
|
||||
roles:
|
||||
- role: packages
|
||||
tags: [packages]
|
||||
# ...
|
||||
- role: trade_hub
|
||||
tags: [trade_hub]
|
||||
```
|
||||
|
||||
The play-level `become: true` makes podman run as root. If you run the role
|
||||
without become, podman runs rootless and the containers belong to the connecting
|
||||
user.
|
||||
|
||||
The playbook's `Pull images` task is unrelated to this role: the role builds its
|
||||
own backend/frontend images from the staged source. The PostgreSQL image is
|
||||
pulled by the role if it is not already present.
|
||||
|
||||
## Required variables
|
||||
|
||||
Set a real database password in `vars.yml` (or the environment of the control
|
||||
node):
|
||||
|
||||
```yaml
|
||||
trade_hub_postgres_password: "choose-something-strong"
|
||||
```
|
||||
|
||||
If it is omitted, the role falls back to `POSTGRES_PASSWORD` from the control
|
||||
node environment, then to the development default `sc_inventory_dev`.
|
||||
|
||||
## Common variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `trade_hub_app_dir` | `/opt/trade-hub` | Where the build context is staged |
|
||||
| `trade_hub_install_podman` | `false` | Install podman if it is missing |
|
||||
| `trade_hub_build_images` | `true` | Build backend/frontend images on the target |
|
||||
| `trade_hub_force_rebuild` | `false` | Rebuild images and restart containers |
|
||||
| `trade_hub_image_tag` | `latest` | Tag applied to built images |
|
||||
| `trade_hub_backend_image` | `localhost/sc-inventory-backend` | Backend image name |
|
||||
| `trade_hub_frontend_image` | `localhost/sc-inventory-frontend` | Frontend image name |
|
||||
| `trade_hub_postgres_image` | `docker.io/library/postgres:16-alpine` | Database image |
|
||||
| `trade_hub_postgres_user` | `sc_inventory` | Database user |
|
||||
| `trade_hub_postgres_db` | `sc_inventory` | Database name |
|
||||
| `trade_hub_postgres_password` | env `POSTGRES_PASSWORD` or `sc_inventory_dev` | Database password |
|
||||
| `trade_hub_network` | `sc-inventory` | Podman network name |
|
||||
| `trade_hub_volume` | `sc-inventory-data` | PostgreSQL data volume |
|
||||
| `trade_hub_frontend_bind` | `0.0.0.0` | Address the frontend publishes on |
|
||||
| `trade_hub_frontend_port` | `8071` | Host publish and container listen port for the frontend |
|
||||
| `trade_hub_frontend_ip` | `10.10.0.5` | Static address on the shared `npm_internal` proxy network |
|
||||
| `trade_hub_cookie_secure` | `false` | Mark session cookies `Secure` (enable behind TLS) |
|
||||
| `trade_hub_healthcheck_url` | `http://127.0.0.1:<port>/healthz` | Post-deploy check |
|
||||
| `trade_hub_remove_legacy_apache` | `false` | Remove a container named `my-apache-server` |
|
||||
| `trade_hub_enable_restart_service` | `false` | Enable `podman-restart.service` |
|
||||
|
||||
The container names also default to `sc-inventory-db`, `sc-inventory-backend`
|
||||
and `sc-inventory-frontend`, matching the previous standalone playbook so an
|
||||
existing installation is upgraded in place.
|
||||
|
||||
## Example deployment
|
||||
|
||||
```bash
|
||||
ansible-playbook site.yml --tags trade_hub -e trade_hub_postgres_password='...'
|
||||
```
|
||||
|
||||
The role verifies `http://127.0.0.1:8071/healthz` after starting the containers.
|
||||
Only the frontend publishes a port; the database and backend stay on the
|
||||
internal podman network.
|
||||
|
||||
## Proxy manager
|
||||
|
||||
The frontend is pinned to `10.10.0.5` on the shared `npm_internal` podman
|
||||
network, which also hosts Nginx Proxy Manager (10.10.0.19). Configure the proxy
|
||||
host upstream as `http://10.10.0.5:8071`.
|
||||
|
||||
The `network` role must run before this one so `npm_internal` exists:
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inventory.ini ansible-playbook.yml --tags "network,trade_hub"
|
||||
```
|
||||
66
roles/trade_hub/defaults/main.yml
Normal file
66
roles/trade_hub/defaults/main.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
# Where the application build context is staged on the target host.
|
||||
trade_hub_app_dir: /opt/trade-hub
|
||||
|
||||
# Install podman as part of this role. Leave false when another role (for
|
||||
# example "packages") already provides podman.
|
||||
trade_hub_install_podman: false
|
||||
|
||||
# Images. These are built locally by default; point them at a registry if you
|
||||
# prefer to pull prebuilt images.
|
||||
trade_hub_image_tag: latest
|
||||
trade_hub_backend_image: localhost/sc-inventory-backend
|
||||
trade_hub_frontend_image: localhost/sc-inventory-frontend
|
||||
trade_hub_postgres_image: docker.io/library/postgres:16-alpine
|
||||
trade_hub_build_images: true
|
||||
trade_hub_force_rebuild: false
|
||||
|
||||
# Directories created under trade_hub_app_dir before the source is staged.
|
||||
# ansible.builtin.copy does NOT create parent directories for single-file
|
||||
# sources, so every directory a staged file lands in must exist first.
|
||||
trade_hub_app_subdirs:
|
||||
- backend
|
||||
- backend/app
|
||||
- backend/scripts
|
||||
- frontend
|
||||
- frontend/src
|
||||
|
||||
# Container names. Kept compatible with the previous standalone playbook so an
|
||||
# existing installation is upgraded in place.
|
||||
trade_hub_db_container: sc-inventory-db
|
||||
trade_hub_backend_container: sc-inventory-backend
|
||||
trade_hub_frontend_container: sc-inventory-frontend
|
||||
|
||||
# Database.
|
||||
trade_hub_postgres_user: sc_inventory
|
||||
trade_hub_postgres_db: sc_inventory
|
||||
trade_hub_postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') | default(trade_hub_postgres_pw, true) }}"
|
||||
trade_hub_database_url: "postgresql+psycopg://{{ trade_hub_postgres_user }}:{{ trade_hub_postgres_password }}@db:5432/{{ trade_hub_postgres_db }}"
|
||||
trade_hub_db_wait_seconds: 60
|
||||
|
||||
# Networking.
|
||||
trade_hub_network: sc-inventory
|
||||
trade_hub_volume: sc-inventory-data
|
||||
trade_hub_frontend_bind: 0.0.0.0
|
||||
# Published on the host and used as the container's nginx listen port. Nginx
|
||||
# Proxy Manager proxies to http://<trade_hub_frontend_ip>:<trade_hub_frontend_port>.
|
||||
trade_hub_frontend_port: 8071
|
||||
# Static address for the frontend on the shared npm_internal proxy network
|
||||
# (created by the "network" role), so the proxy-manager upstream never changes.
|
||||
trade_hub_frontend_ip: "10.10.0.5"
|
||||
# Set to true (or "true") once the site is served over TLS.
|
||||
trade_hub_cookie_secure: "false"
|
||||
|
||||
# Post-deploy health check.
|
||||
trade_hub_healthcheck_url: "http://127.0.0.1:{{ trade_hub_frontend_port }}/healthz"
|
||||
trade_hub_healthcheck_retries: 12
|
||||
trade_hub_healthcheck_delay: 5
|
||||
|
||||
# The original standalone playbook removed an ad-hoc Apache container that
|
||||
# used the same host. Disabled by default so the role never touches unrelated
|
||||
# containers.
|
||||
trade_hub_remove_legacy_apache: false
|
||||
trade_hub_legacy_apache_container: my-apache-server
|
||||
|
||||
# Enable podman-restart.service so the containers come back after a reboot.
|
||||
trade_hub_enable_restart_service: false
|
||||
25
roles/trade_hub/files/trade-hub/backend/Dockerfile
Normal file
25
roles/trade_hub/files/trade-hub/backend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM docker.io/library/python:3.12-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Download the full scunpacked dataset at build time so the image is
|
||||
# self-contained; fetch_data.py reduces items.json to commodities.json.
|
||||
RUN python scripts/fetch_data.py --output-dir /app/data/full
|
||||
ENV DATA_DIR=/app/data/full
|
||||
|
||||
RUN useradd --create-home appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
1
roles/trade_hub/files/trade-hub/backend/app/__init__.py
Normal file
1
roles/trade_hub/files/trade-hub/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Star Citizen Inventory backend package (scunpacked data)."""
|
||||
127
roles/trade_hub/files/trade-hub/backend/app/api.py
Normal file
127
roles/trade_hub/files/trade-hub/backend/app/api.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""FastAPI routes for the Star Citizen inventory (scunpacked data).
|
||||
|
||||
The catalog endpoints below are read-only. Accounts and personal inventories
|
||||
are served by the auth and inventory routers and persisted in PostgreSQL.
|
||||
"""
|
||||
import math
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app import config
|
||||
from app.auth import router as auth_router
|
||||
from app.available import router as available_router
|
||||
from app.catalog import store
|
||||
from app.db import db_healthy, init_db
|
||||
from app.inventory import router as inventory_router
|
||||
from app.orders import router as orders_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
if config.AUTO_INIT_DB:
|
||||
init_db(wait_seconds=config.DB_WAIT_SECONDS)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Star Citizen Inventory API",
|
||||
description="Read-only item catalog built from scunpacked game data, plus "
|
||||
"per-user accounts and personal inventories.",
|
||||
version="1.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Same-origin deployment by default. Cross-origin access must be opted into
|
||||
# explicitly, because credentialed cookies and a wildcard origin are
|
||||
# incompatible (and unsafe).
|
||||
if config.CORS_ORIGINS:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=config.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(available_router)
|
||||
app.include_router(orders_router)
|
||||
|
||||
VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"}
|
||||
|
||||
|
||||
def _filter_items(category, search, manufacturer, item_type, size):
|
||||
result = store.items
|
||||
if category is not None:
|
||||
if category not in VALID_CATEGORIES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Invalid category '{category}'. Valid: {sorted(VALID_CATEGORIES)}",
|
||||
)
|
||||
result = [i for i in result if i["category"] == category]
|
||||
if search is not None and search.strip():
|
||||
needle = search.strip().lower()
|
||||
result = [
|
||||
i
|
||||
for i in result
|
||||
if needle in i["name"].lower() or needle in i["class_name"].lower()
|
||||
]
|
||||
if manufacturer is not None and manufacturer.strip():
|
||||
code = manufacturer.strip().lower()
|
||||
result = [i for i in result if i["manufacturer"]["code"].lower() == code]
|
||||
if item_type is not None and item_type.strip():
|
||||
wanted = item_type.strip().lower()
|
||||
result = [i for i in result if (i["type"] or "").lower() == wanted]
|
||||
if size is not None:
|
||||
result = [i for i in result if i["size"] == size]
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"status": "ok", "counts": store.counts, "db": db_healthy()}
|
||||
|
||||
|
||||
@app.get("/api/v1/stats")
|
||||
def stats():
|
||||
return {"categories": store.counts, "manufacturers": len(store.manufacturers)}
|
||||
|
||||
|
||||
@app.get("/api/v1/manufacturers")
|
||||
def manufacturers():
|
||||
return store.manufacturers
|
||||
|
||||
|
||||
@app.get("/api/v1/items")
|
||||
def list_items(
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
manufacturer: str | None = None,
|
||||
item_type: str | None = Query(default=None, alias="type"),
|
||||
size: int | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=25, ge=1),
|
||||
):
|
||||
page_size = min(page_size, 100) # cap, don't reject
|
||||
filtered = _filter_items(category, search, manufacturer, item_type, size)
|
||||
total = len(filtered)
|
||||
total_pages = max(1, math.ceil(total / page_size))
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"items": filtered[start : start + page_size],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/items/{class_name}")
|
||||
def item_detail(class_name: str):
|
||||
summary = store.by_class_name.get(class_name)
|
||||
if summary is None:
|
||||
raise HTTPException(status_code=404, detail=f"Item '{class_name}' not found")
|
||||
return {**summary, "details": store.details.get(class_name, {})}
|
||||
160
roles/trade_hub/files/trade-hub/backend/app/auth.py
Normal file
160
roles/trade_hub/files/trade-hub/backend/app/auth.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Authentication routes: register, login, logout and current-user lookup.
|
||||
|
||||
Identity is a self-hosted username/password pair. Passwords are hashed with
|
||||
Argon2; sessions are opaque random tokens whose SHA-256 is persisted, delivered
|
||||
in an httpOnly cookie.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import config, security
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user, require_same_origin
|
||||
from app.models import User, UserSession, utcnow
|
||||
from app.schemas import AuthResponse, LoginRequest, RegisterRequest, UserOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
throttle = security.LoginThrottle(
|
||||
config.LOGIN_MAX_ATTEMPTS, config.LOGIN_WINDOW_SECONDS
|
||||
)
|
||||
|
||||
_COOKIE_MAX_AGE = config.SESSION_TTL_DAYS * 24 * 60 * 60
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=config.COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=config.COOKIE_SECURE,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _issue_session(db: DbSession, user: User) -> str:
|
||||
token = security.new_session_token()
|
||||
now = utcnow()
|
||||
# Drop this user's expired sessions while we are here.
|
||||
db.execute(
|
||||
delete(UserSession).where(
|
||||
UserSession.user_id == user.id, UserSession.expires_at <= now
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
UserSession(
|
||||
token_hash=security.hash_token(token),
|
||||
user_id=user.id,
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(days=config.SESSION_TTL_DAYS),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
status_code=201,
|
||||
response_model=AuthResponse,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def register(payload: RegisterRequest, response: Response, db: DbSession = Depends(get_db)):
|
||||
username = payload.username.strip()
|
||||
if not security.valid_username(username):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Username must be 3-32 characters: letters, digits, '.', '_' or '-'",
|
||||
)
|
||||
|
||||
username_key = security.normalize_username(username)
|
||||
existing = db.execute(
|
||||
select(User).where(User.username_key == username_key)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="Username is already taken")
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
username_key=username_key,
|
||||
password_hash=security.hash_password(payload.password),
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Username is already taken")
|
||||
|
||||
db.refresh(user)
|
||||
_set_session_cookie(response, _issue_session(db, user))
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=AuthResponse,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def login(payload: LoginRequest, request: Request, response: Response, db: DbSession = Depends(get_db)):
|
||||
username_key = security.normalize_username(payload.username)
|
||||
ip = security.client_ip(request)
|
||||
|
||||
wait = throttle.retry_after(username_key, ip)
|
||||
if wait:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many failed login attempts. Try again later.",
|
||||
headers={"Retry-After": str(wait)},
|
||||
)
|
||||
|
||||
user = db.execute(
|
||||
select(User).where(User.username_key == username_key)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
security.burn_password_check(payload.password)
|
||||
throttle.record_failure(username_key, ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
if not security.verify_password(payload.password, user.password_hash):
|
||||
throttle.record_failure(username_key, ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
throttle.reset(username_key, ip)
|
||||
if security.needs_rehash(user.password_hash):
|
||||
user.password_hash = security.hash_password(payload.password)
|
||||
db.commit()
|
||||
|
||||
_set_session_cookie(response, _issue_session(db, user))
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def logout(request: Request, db: DbSession = Depends(get_db)):
|
||||
token = request.cookies.get(config.COOKIE_NAME)
|
||||
if token:
|
||||
db.execute(
|
||||
delete(UserSession).where(
|
||||
UserSession.token_hash == security.hash_token(token)
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
response = Response(status_code=204)
|
||||
response.delete_cookie(config.COOKIE_NAME, path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=AuthResponse)
|
||||
def me(user: User = Depends(get_current_user)):
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
69
roles/trade_hub/files/trade-hub/backend/app/available.py
Normal file
69
roles/trade_hub/files/trade-hub/backend/app/available.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Public listing of inventory items owners marked as `available`.
|
||||
|
||||
Read-only and unauthenticated, like the catalog. Notes are never included: the
|
||||
listing only reveals the owner's username, quantity and catalog details.
|
||||
"""
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.inventory import SORT_KEYS, enrich_entry
|
||||
from app.models import InventoryEntry, User
|
||||
from app.schemas import AvailablePage
|
||||
|
||||
router = APIRouter(prefix="/api/v1/available", tags=["available"])
|
||||
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
@router.get("", response_model=AvailablePage)
|
||||
def list_available(
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
sort: str = Query(default="updated", pattern="^(name|category|quantity|updated)$"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=25, ge=1),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
page_size = min(page_size, MAX_PAGE_SIZE)
|
||||
rows = db.execute(
|
||||
select(InventoryEntry, User.username)
|
||||
.join(User, User.id == InventoryEntry.user_id)
|
||||
.where(InventoryEntry.visibility == "available")
|
||||
).all()
|
||||
|
||||
items = []
|
||||
for entry, username in rows:
|
||||
enriched = enrich_entry(entry)
|
||||
if not enriched["available"]:
|
||||
continue
|
||||
enriched["username"] = username
|
||||
items.append(enriched)
|
||||
|
||||
if category is not None and category.strip():
|
||||
wanted = category.strip()
|
||||
items = [i for i in items if i["category"] == wanted]
|
||||
if search is not None and search.strip():
|
||||
needle = search.strip().lower()
|
||||
items = [
|
||||
i
|
||||
for i in items
|
||||
if needle in i["name"].lower()
|
||||
or needle in i["class_name"].lower()
|
||||
or needle in i["username"].lower()
|
||||
]
|
||||
|
||||
items.sort(key=SORT_KEYS[sort])
|
||||
total = len(items)
|
||||
total_pages = max(1, math.ceil(total / page_size))
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"items": items[start : start + page_size],
|
||||
}
|
||||
8
roles/trade_hub/files/trade-hub/backend/app/catalog.py
Normal file
8
roles/trade_hub/files/trade-hub/backend/app/catalog.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Single owner of the immutable, read-only scunpacked catalog.
|
||||
|
||||
Keeping the store here (instead of in app/api.py) lets the auth and inventory
|
||||
routers import it without importing the FastAPI app itself.
|
||||
"""
|
||||
from app import loader
|
||||
|
||||
store = loader.load_store()
|
||||
58
roles/trade_hub/files/trade-hub/backend/app/config.py
Normal file
58
roles/trade_hub/files/trade-hub/backend/app/config.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Configuration for the inventory backend.
|
||||
|
||||
DATA_DIR selects which dataset the API serves:
|
||||
- local dev / tests: small real-schema samples (default) or a fixture dir
|
||||
- container image: the full scunpacked dataset downloaded at build time
|
||||
|
||||
The catalog is read-only. Everything mutable (accounts, sessions, per-user
|
||||
inventories) lives in the database configured by DATABASE_URL.
|
||||
"""
|
||||
import os
|
||||
|
||||
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_BACKEND_DIR = os.path.dirname(_APP_DIR)
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(_BACKEND_DIR, "data", "samples"))
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# --- Persistence ----------------------------------------------------------
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL", "postgresql+psycopg://sc_inventory:sc_inventory_dev@db:5432/sc_inventory"
|
||||
)
|
||||
# Create missing tables on startup. Disabled by the test suite, which builds
|
||||
# its own schema against an in-memory SQLite database.
|
||||
AUTO_INIT_DB = _env_bool("AUTO_INIT_DB", True)
|
||||
# How long startup waits for PostgreSQL to accept connections (container boot).
|
||||
DB_WAIT_SECONDS = int(os.environ.get("DB_WAIT_SECONDS", "60"))
|
||||
|
||||
# --- Session cookies ------------------------------------------------------
|
||||
# Enable COOKIE_SECURE once the site is served over TLS. Defaults to false so
|
||||
# plain http://localhost:8080 keeps working out of the box.
|
||||
COOKIE_SECURE = _env_bool("COOKIE_SECURE", False)
|
||||
COOKIE_NAME = os.environ.get("COOKIE_NAME", "sc_session")
|
||||
SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30"))
|
||||
|
||||
# --- CORS -----------------------------------------------------------------
|
||||
# Empty by default: the frontend is served same-origin through nginx, so no
|
||||
# cross-origin access is needed. Comma-separated origins opt in explicitly.
|
||||
CORS_ORIGINS = [
|
||||
origin.strip()
|
||||
for origin in os.environ.get("CORS_ORIGINS", "").split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
# --- Login hardening ------------------------------------------------------
|
||||
LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", "5"))
|
||||
LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "900"))
|
||||
|
||||
# --- Input limits ---------------------------------------------------------
|
||||
MAX_NOTES_LENGTH = 1000
|
||||
MAX_QUANTITY = 1_000_000
|
||||
MAX_QUALITY = 1000
|
||||
147
roles/trade_hub/files/trade-hub/backend/app/db.py
Normal file
147
roles/trade_hub/files/trade-hub/backend/app/db.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Database engine, session factory and schema bootstrap.
|
||||
|
||||
The engine is created lazily so importing the app (and loading the read-only
|
||||
catalog) never requires a reachable database.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app import config
|
||||
from app.models import Base
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
"""Return the process-wide engine, creating it on first use."""
|
||||
global _engine, _session_factory
|
||||
if _engine is None:
|
||||
url = config.DATABASE_URL
|
||||
kwargs = {"pool_pre_ping": True, "future": True}
|
||||
if url.startswith("sqlite"):
|
||||
kwargs["connect_args"] = {"check_same_thread": False}
|
||||
if ":memory:" in url or url in {"sqlite://", "sqlite+pysqlite://"}:
|
||||
kwargs["poolclass"] = StaticPool
|
||||
_engine = create_engine(url, **kwargs)
|
||||
_session_factory = sessionmaker(
|
||||
bind=_engine, autoflush=False, autocommit=False, expire_on_commit=False
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory():
|
||||
get_engine()
|
||||
return _session_factory
|
||||
|
||||
|
||||
def get_db():
|
||||
"""FastAPI dependency yielding a request-scoped SQLAlchemy session."""
|
||||
db = get_session_factory()()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _upgrade_schema(engine) -> None:
|
||||
"""Add columns that `create_all` cannot add to an existing table.
|
||||
|
||||
PostgreSQL only: the SQLite database used by the tests is created fresh
|
||||
from the models on every run, so it already has every column.
|
||||
"""
|
||||
if engine.dialect.name != "postgresql":
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
|
||||
"visibility VARCHAR(16) NOT NULL DEFAULT 'private'"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_inventory_visibility'
|
||||
) THEN
|
||||
ALTER TABLE inventory_entries
|
||||
ADD CONSTRAINT ck_inventory_visibility
|
||||
CHECK (visibility IN ('private', 'available'));
|
||||
END IF;
|
||||
END $$
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
|
||||
"quality INTEGER"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_inventory_quality_range'
|
||||
) THEN
|
||||
ALTER TABLE inventory_entries
|
||||
ADD CONSTRAINT ck_inventory_quality_range
|
||||
CHECK (quality IS NULL OR (quality >= 1 AND quality <= 1000));
|
||||
END IF;
|
||||
END $$
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
|
||||
"""Create missing tables, retrying while PostgreSQL finishes booting.
|
||||
|
||||
`create_all(checkfirst=True)` is idempotent and safe to run on every start.
|
||||
Additive columns for existing deployments are applied afterwards; changing
|
||||
or dropping an existing column still requires Alembic.
|
||||
"""
|
||||
deadline = time.monotonic() + max(wait_seconds, 0)
|
||||
while True:
|
||||
try:
|
||||
engine = get_engine()
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
Base.metadata.create_all(bind=engine, checkfirst=True)
|
||||
_upgrade_schema(engine)
|
||||
return
|
||||
except (OperationalError, IntegrityError, ProgrammingError) as exc:
|
||||
# A concurrent startup may have created the tables first.
|
||||
if "already exists" in str(exc).lower():
|
||||
return
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
log.warning(
|
||||
"database not ready (%s); retrying in %.1fs",
|
||||
exc.__class__.__name__,
|
||||
interval,
|
||||
)
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def db_healthy() -> bool:
|
||||
try:
|
||||
with get_engine().connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
39
roles/trade_hub/files/trade-hub/backend/app/deps.py
Normal file
39
roles/trade_hub/files/trade-hub/backend/app/deps.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Shared FastAPI dependencies: origin check and current-user resolution."""
|
||||
from datetime import timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import config, security
|
||||
from app.db import get_db
|
||||
from app.models import User, UserSession, utcnow
|
||||
|
||||
|
||||
def require_same_origin(request: Request) -> None:
|
||||
if not security.same_origin(request):
|
||||
raise HTTPException(status_code=403, detail="Cross-origin request rejected")
|
||||
|
||||
|
||||
def _as_utc(value):
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request, db: DbSession = Depends(get_db)
|
||||
) -> User:
|
||||
token = request.cookies.get(config.COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
session = db.get(UserSession, security.hash_token(token))
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
if _as_utc(session.expires_at) <= utcnow():
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=401, detail="Session expired")
|
||||
|
||||
return session.user
|
||||
286
roles/trade_hub/files/trade-hub/backend/app/inventory.py
Normal file
286
roles/trade_hub/files/trade-hub/backend/app/inventory.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Per-user inventory routes.
|
||||
|
||||
Rows reference immutable catalog items by `class_name`; responses are enriched
|
||||
live from the in-memory catalog. A missing catalog item is reported as
|
||||
`available: false` instead of deleting the user's data.
|
||||
"""
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import catalog
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user, require_same_origin
|
||||
from app.models import InventoryEntry, User
|
||||
from app.schemas import (
|
||||
InventoryAddRequest,
|
||||
InventoryAddResponse,
|
||||
InventoryEntryOut,
|
||||
InventoryPage,
|
||||
InventoryPatchRequest,
|
||||
InventorySummary,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/me/inventory",
|
||||
tags=["inventory"],
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def enrich_entry(entry: InventoryEntry) -> dict:
|
||||
item = catalog.store.by_class_name.get(entry.class_name)
|
||||
if item is None:
|
||||
return {
|
||||
"class_name": entry.class_name,
|
||||
"name": entry.class_name.replace("_", " "),
|
||||
"category": None,
|
||||
"type": None,
|
||||
"size": None,
|
||||
"grade": None,
|
||||
"manufacturer": None,
|
||||
"quantity": entry.quantity,
|
||||
"notes": entry.notes,
|
||||
"quality": entry.quality,
|
||||
"quality_applicable": False,
|
||||
"visibility": entry.visibility,
|
||||
"available": False,
|
||||
"created_at": entry.created_at,
|
||||
"updated_at": entry.updated_at,
|
||||
}
|
||||
return {
|
||||
"class_name": entry.class_name,
|
||||
"name": item["name"],
|
||||
"category": item["category"],
|
||||
"type": item["type"],
|
||||
"size": item["size"],
|
||||
"grade": item["grade"],
|
||||
"manufacturer": item["manufacturer"],
|
||||
"quantity": entry.quantity,
|
||||
"notes": entry.notes,
|
||||
"quality": entry.quality,
|
||||
"quality_applicable": item.get("quality_applicable", False),
|
||||
"visibility": entry.visibility,
|
||||
"available": True,
|
||||
"created_at": entry.created_at,
|
||||
"updated_at": entry.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _validate_quality(item: dict, quality: int | None) -> None:
|
||||
if quality is not None and not item.get("quality_applicable", False):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{item['name']}'",
|
||||
)
|
||||
|
||||
|
||||
def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
|
||||
return db.execute(
|
||||
select(InventoryEntry).where(
|
||||
InventoryEntry.user_id == user.id,
|
||||
InventoryEntry.class_name == class_name,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _sort_key_name(item):
|
||||
return (item["name"].lower(), item["class_name"])
|
||||
|
||||
|
||||
def _sort_key_category(item):
|
||||
return ((item["category"] or "").lower(), item["name"].lower(), item["class_name"])
|
||||
|
||||
|
||||
def _sort_key_quantity(item):
|
||||
return (-item["quantity"], item["name"].lower(), item["class_name"])
|
||||
|
||||
|
||||
def _sort_key_updated(item):
|
||||
return (-item["updated_at"].timestamp(), item["name"].lower())
|
||||
|
||||
|
||||
SORT_KEYS = {
|
||||
"name": _sort_key_name,
|
||||
"category": _sort_key_category,
|
||||
"quantity": _sort_key_quantity,
|
||||
"updated": _sort_key_updated,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=InventoryPage)
|
||||
def list_inventory(
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
sort: str = Query(default="name", pattern="^(name|category|quantity|updated)$"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=25, ge=1),
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
page_size = min(page_size, MAX_PAGE_SIZE)
|
||||
rows = db.execute(
|
||||
select(InventoryEntry).where(InventoryEntry.user_id == user.id)
|
||||
).scalars().all()
|
||||
|
||||
items = [enrich_entry(row) for row in rows]
|
||||
|
||||
if category is not None and category.strip():
|
||||
wanted = category.strip()
|
||||
items = [i for i in items if i["category"] == wanted]
|
||||
if search is not None and search.strip():
|
||||
needle = search.strip().lower()
|
||||
items = [
|
||||
i
|
||||
for i in items
|
||||
if needle in i["name"].lower() or needle in i["class_name"].lower()
|
||||
]
|
||||
|
||||
items.sort(key=SORT_KEYS[sort])
|
||||
total = len(items)
|
||||
total_pages = max(1, math.ceil(total / page_size))
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"items": items[start : start + page_size],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary", response_model=InventorySummary)
|
||||
def inventory_summary(
|
||||
user: User = Depends(get_current_user), db: DbSession = Depends(get_db)
|
||||
):
|
||||
rows = db.execute(
|
||||
select(InventoryEntry).where(InventoryEntry.user_id == user.id)
|
||||
).scalars().all()
|
||||
|
||||
categories: dict[str, int] = {}
|
||||
unavailable = 0
|
||||
total_quantity = 0
|
||||
for row in rows:
|
||||
item = catalog.store.by_class_name.get(row.class_name)
|
||||
if item is None:
|
||||
unavailable += 1
|
||||
else:
|
||||
key = item["category"]
|
||||
categories[key] = categories.get(key, 0) + row.quantity
|
||||
total_quantity += row.quantity
|
||||
|
||||
return {
|
||||
"items": len(rows),
|
||||
"quantity": total_quantity,
|
||||
"categories": categories,
|
||||
"unavailable": unavailable,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=InventoryAddResponse)
|
||||
def add_to_inventory(
|
||||
payload: InventoryAddRequest,
|
||||
response: Response,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
class_name = payload.class_name.strip()
|
||||
item = catalog.store.by_class_name.get(class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Catalog item '{class_name}' not found"
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
|
||||
entry = _find(db, user, class_name)
|
||||
created = entry is None
|
||||
|
||||
if created:
|
||||
entry = InventoryEntry(
|
||||
user_id=user.id,
|
||||
class_name=class_name,
|
||||
quantity=payload.quantity,
|
||||
notes=payload.notes,
|
||||
quality=payload.quality,
|
||||
)
|
||||
db.add(entry)
|
||||
else:
|
||||
entry.quantity += payload.quantity
|
||||
if payload.notes:
|
||||
entry.notes = payload.notes
|
||||
if payload.quality is not None:
|
||||
entry.quality = payload.quality
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Lost a race against a concurrent add: re-read and increment instead.
|
||||
db.rollback()
|
||||
entry = _find(db, user, class_name)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=409, detail="Could not add item; retry")
|
||||
entry.quantity += payload.quantity
|
||||
if payload.notes:
|
||||
entry.notes = payload.notes
|
||||
if payload.quality is not None:
|
||||
entry.quality = payload.quality
|
||||
db.commit()
|
||||
created = False
|
||||
|
||||
db.refresh(entry)
|
||||
response.status_code = 201 if created else 200
|
||||
return {"entry": InventoryEntryOut.model_validate(enrich_entry(entry)), "created": created}
|
||||
|
||||
|
||||
@router.patch("/{class_name}", response_model=InventoryEntryOut)
|
||||
def update_inventory_entry(
|
||||
class_name: str,
|
||||
payload: InventoryPatchRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
entry = _find(db, user, class_name)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Item is not in your inventory")
|
||||
|
||||
if payload.quantity is not None:
|
||||
entry.quantity = payload.quantity
|
||||
if payload.notes is not None:
|
||||
entry.notes = payload.notes
|
||||
if payload.visibility is not None:
|
||||
entry.visibility = payload.visibility
|
||||
if "quality" in payload.model_fields_set:
|
||||
# Explicit null clears the value; omitted keeps the existing one.
|
||||
if payload.quality is not None:
|
||||
item = catalog.store.by_class_name.get(entry.class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{entry.class_name}'",
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
entry.quality = payload.quality
|
||||
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return InventoryEntryOut.model_validate(enrich_entry(entry))
|
||||
|
||||
|
||||
@router.delete("/{class_name}", status_code=204)
|
||||
def remove_inventory_entry(
|
||||
class_name: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
entry = _find(db, user, class_name)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Item is not in your inventory")
|
||||
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
254
roles/trade_hub/files/trade-hub/backend/app/loader.py
Normal file
254
roles/trade_hub/files/trade-hub/backend/app/loader.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Load and normalize scunpacked JSON data into the inventory store.
|
||||
|
||||
Source data: https://github.com/richardthombs/scunpacked
|
||||
(mirrored at https://github.com/StarCitizenWiki/scunpacked-data)
|
||||
|
||||
Real schemas this loader consumes:
|
||||
- ships.json: {"UUID","ClassName","Name","Description","Career","Role",
|
||||
"Manufacturer":{"UUID","Code","Name"},"Size","Length","Width",
|
||||
"Height","Crew","Mass","IsVehicle","IsGravlev","IsSpaceship",...}
|
||||
- ship-items.json / fps-items.json:
|
||||
{"className","reference","itemName","type","subType","size",
|
||||
"grade","name","tags","classification",
|
||||
"stdItem":{"UUID","ClassName","Size","Grade","Width","Height",
|
||||
"Length","Mass","Type","Name","Description","DescriptionText",
|
||||
"Manufacturer":{"Name","Code","UUID"},"Tags":[...],"Ports":[...]}}
|
||||
- manufacturers.json:{"Code","Name","Reference","Description"}
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from app import config
|
||||
|
||||
PLACEHOLDER = "<= PLACEHOLDER =>"
|
||||
|
||||
CATEGORY_SHIPS = "ships"
|
||||
CATEGORY_SHIP_ITEMS = "ship-items"
|
||||
CATEGORY_FPS_ITEMS = "fps-items"
|
||||
CATEGORY_COMMODITIES = "commodities"
|
||||
|
||||
# Commodities that can carry a mining/refining quality value (1-1000). These are
|
||||
# the ores, minerals and gems that players actually mine (and their refined
|
||||
# counterparts); crafted alloys, gases, organics, processed goods and junk
|
||||
# by-products are deliberately excluded. Keep in sync with the Ore quality table
|
||||
# at https://starcitizen.tools/Ore_quality.
|
||||
QUALITY_MATERIALS = frozenset(
|
||||
name.lower()
|
||||
for name in (
|
||||
"Agricium",
|
||||
"Aluminum",
|
||||
"Aphorite",
|
||||
"Aslarite",
|
||||
"Beradom",
|
||||
"Beryl",
|
||||
"Bexalite",
|
||||
"Borase",
|
||||
"Carinite",
|
||||
"Copper",
|
||||
"Corundum",
|
||||
"Diamond",
|
||||
"Dolivine",
|
||||
"Feynmaline",
|
||||
"Glacosite",
|
||||
"Gold",
|
||||
"Hadanite",
|
||||
"Hephaestanite",
|
||||
"Iron",
|
||||
"Jaclium",
|
||||
"Janalite",
|
||||
"Laranite",
|
||||
"Lindinium",
|
||||
"Ouratite",
|
||||
"Pressurized Ice",
|
||||
"Quantainium",
|
||||
"Quartz",
|
||||
"Riccite",
|
||||
"Sadaryx",
|
||||
"Saldynium",
|
||||
"Savrilium",
|
||||
"Silicon",
|
||||
"Stileron",
|
||||
"Taranite",
|
||||
"Tin",
|
||||
"Titanium",
|
||||
"Torite",
|
||||
"Tungsten",
|
||||
)
|
||||
)
|
||||
|
||||
# Variant suffixes are presentation labels, not a different material: "Iron
|
||||
# (Ore)" and "Iron" share a quality value, as do "(Raw)", "(Pure)" and "(R)".
|
||||
_QUALITY_VARIANT = re.compile(r"\s*\((?:Ore|Raw|Pure|R|\d+\s*SCU)\)\s*$", re.IGNORECASE)
|
||||
_RAW_PREFIX = re.compile(r"^Raw\s+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _quality_applicable(name, category):
|
||||
if category != CATEGORY_COMMODITIES:
|
||||
return False
|
||||
base_name = _QUALITY_VARIANT.sub("", name or "").strip()
|
||||
base_name = _RAW_PREFIX.sub("", base_name).strip().lower()
|
||||
return base_name in QUALITY_MATERIALS
|
||||
|
||||
|
||||
def _load_json(data_dir, filename):
|
||||
path = os.path.join(data_dir, filename)
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _resolve_name(*candidates, class_name=None):
|
||||
"""Pick the first real display name; fall back to a prettified ClassName.
|
||||
|
||||
Many scunpacked entries have names literally set to "<= PLACEHOLDER =>"
|
||||
(untranslated labels), so the fallback rule is mandatory.
|
||||
"""
|
||||
for candidate in candidates:
|
||||
if candidate and str(candidate).strip() and str(candidate).strip() != PLACEHOLDER:
|
||||
return str(candidate).strip()
|
||||
if class_name:
|
||||
return str(class_name).replace("_", " ")
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def _manufacturer(raw):
|
||||
if not raw:
|
||||
return {"code": "UNKN", "name": "Unknown Manufacturer"}
|
||||
code = raw.get("Code") or raw.get("code") or "UNKN"
|
||||
name = _resolve_name(raw.get("Name"), raw.get("name"), class_name=code)
|
||||
return {"code": code, "name": name}
|
||||
|
||||
|
||||
def _normalize_ship(ship):
|
||||
class_name = ship.get("ClassName") or ""
|
||||
manufacturer = ship.get("Manufacturer") or {}
|
||||
flags = {
|
||||
"is_spaceship": bool(ship.get("IsSpaceship")),
|
||||
"is_vehicle": bool(ship.get("IsVehicle")),
|
||||
"is_gravlev": bool(ship.get("IsGravlev")),
|
||||
}
|
||||
tags = [flag for flag, value in flags.items() if value]
|
||||
return {
|
||||
"uuid": ship.get("UUID") or "",
|
||||
"class_name": class_name,
|
||||
"name": _resolve_name(ship.get("Name"), class_name=class_name),
|
||||
"category": CATEGORY_SHIPS,
|
||||
"type": ship.get("Career") or "Ship",
|
||||
"sub_type": ship.get("Role") or "",
|
||||
"size": ship.get("Size") or 0,
|
||||
"grade": None,
|
||||
"manufacturer": _manufacturer(manufacturer),
|
||||
"classification": "Ship",
|
||||
"description": ship.get("Description") or ship.get("DescriptionText") or "",
|
||||
"tags": tags,
|
||||
"quality_applicable": False,
|
||||
}, {
|
||||
"career": ship.get("Career") or "",
|
||||
"role": ship.get("Role") or "",
|
||||
"length": ship.get("Length"),
|
||||
"width": ship.get("Width"),
|
||||
"height": ship.get("Height"),
|
||||
"crew": ship.get("Crew"),
|
||||
"mass": ship.get("Mass"),
|
||||
**flags,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_item(item, category):
|
||||
class_name = item.get("className") or ""
|
||||
std = item.get("stdItem") or {}
|
||||
manufacturer = std.get("Manufacturer") or {}
|
||||
raw_tags = std.get("Tags")
|
||||
tags = list(raw_tags) if isinstance(raw_tags, list) else []
|
||||
summary = {
|
||||
"uuid": std.get("UUID") or item.get("reference") or "",
|
||||
"class_name": class_name,
|
||||
"name": _resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
|
||||
"category": category,
|
||||
"type": item.get("type") or std.get("Type") or "",
|
||||
"sub_type": item.get("subType") or "",
|
||||
"size": item.get("size") if item.get("size") is not None else std.get("Size"),
|
||||
"grade": item.get("grade") if item.get("grade") is not None else std.get("Grade"),
|
||||
"manufacturer": _manufacturer(manufacturer),
|
||||
"classification": item.get("classification") or "",
|
||||
"description": std.get("Description") or std.get("DescriptionText") or "",
|
||||
"tags": tags,
|
||||
"quality_applicable": _quality_applicable(
|
||||
_resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
|
||||
category,
|
||||
),
|
||||
}
|
||||
if category == CATEGORY_COMMODITIES:
|
||||
summary["size"] = None
|
||||
details = {
|
||||
"ports": std.get("Ports") or [],
|
||||
"mass": std.get("Mass"),
|
||||
"width": std.get("Width"),
|
||||
"height": std.get("Height"),
|
||||
"length": std.get("Length"),
|
||||
}
|
||||
return summary, details
|
||||
|
||||
|
||||
class Store:
|
||||
"""In-memory, read-only inventory store built from the scunpacked JSON."""
|
||||
|
||||
def __init__(self, items, details, manufacturers):
|
||||
self.items = items
|
||||
self.details = details
|
||||
self.manufacturers = manufacturers
|
||||
self.by_class_name = {item["class_name"]: item for item in items}
|
||||
self.counts = {
|
||||
CATEGORY_SHIPS: sum(1 for i in items if i["category"] == CATEGORY_SHIPS),
|
||||
CATEGORY_SHIP_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_SHIP_ITEMS),
|
||||
CATEGORY_FPS_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_FPS_ITEMS),
|
||||
CATEGORY_COMMODITIES: sum(
|
||||
1 for i in items if i["category"] == CATEGORY_COMMODITIES
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def load_store(data_dir=None):
|
||||
data_dir = data_dir or config.DATA_DIR
|
||||
|
||||
items = []
|
||||
details = {}
|
||||
|
||||
for ship in _load_json(data_dir, "ships.json"):
|
||||
summary, detail = _normalize_ship(ship)
|
||||
items.append(summary)
|
||||
details[summary["class_name"]] = detail
|
||||
|
||||
for entry in _load_json(data_dir, "ship-items.json"):
|
||||
summary, detail = _normalize_item(entry, CATEGORY_SHIP_ITEMS)
|
||||
items.append(summary)
|
||||
details[summary["class_name"]] = detail
|
||||
|
||||
for entry in _load_json(data_dir, "fps-items.json"):
|
||||
summary, detail = _normalize_item(entry, CATEGORY_FPS_ITEMS)
|
||||
items.append(summary)
|
||||
details[summary["class_name"]] = detail
|
||||
|
||||
for entry in _load_json(data_dir, "commodities.json"):
|
||||
summary, detail = _normalize_item(entry, CATEGORY_COMMODITIES)
|
||||
items.append(summary)
|
||||
details[summary["class_name"]] = detail
|
||||
|
||||
# Deterministic order so pagination is stable.
|
||||
items.sort(key=lambda i: (i["category"], i["name"].lower(), i["class_name"]))
|
||||
|
||||
manufacturers = sorted(
|
||||
(
|
||||
{
|
||||
"code": m.get("Code") or "",
|
||||
"name": _resolve_name(m.get("Name"), class_name=m.get("Code") or ""),
|
||||
"description": m.get("Description") or "",
|
||||
}
|
||||
for m in _load_json(data_dir, "manufacturers.json")
|
||||
),
|
||||
key=lambda m: m["name"].lower(),
|
||||
)
|
||||
|
||||
return Store(items, details, manufacturers)
|
||||
157
roles/trade_hub/files/trade-hub/backend/app/models.py
Normal file
157
roles/trade_hub/files/trade-hub/backend/app/models.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""SQLAlchemy models for mutable user data (accounts + personal inventories).
|
||||
|
||||
The scunpacked catalog is deliberately NOT modeled here. It stays read-only in
|
||||
memory (see app/catalog.py); inventory rows reference catalog items only by
|
||||
`class_name`, so the catalog can be replaced wholesale without touching user
|
||||
data.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Timezone-aware current time (portable across SQLite and PostgreSQL)."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# Display name as typed at registration.
|
||||
username: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# Lowercased form, unique: gives case-insensitive uniqueness without citext.
|
||||
username_key: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, unique=True, index=True
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
|
||||
sessions: Mapped[list["UserSession"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
inventory: Mapped[list["InventoryEntry"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
orders: Mapped[list["Order"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
# Only the SHA-256 of the cookie token is persisted.
|
||||
token_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="sessions")
|
||||
|
||||
|
||||
class InventoryEntry(Base):
|
||||
__tablename__ = "inventory_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"),
|
||||
CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"),
|
||||
CheckConstraint(
|
||||
"visibility IN ('private', 'available')",
|
||||
name="ck_inventory_visibility",
|
||||
),
|
||||
CheckConstraint(
|
||||
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
|
||||
name="ck_inventory_quality_range",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# Mining/refining quality (1-1000) for commodities where it applies. NULL
|
||||
# means "not specified"; non-quality commodities must keep this NULL. The
|
||||
# applicability itself is catalog data, not stored here.
|
||||
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# "private" (default): only the owner sees the row. "available": the owner
|
||||
# is offering the item. Unrelated to the response flag `available`, which
|
||||
# only reports whether the catalog still contains the referenced item.
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="private", server_default="private"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="inventory")
|
||||
|
||||
|
||||
class Order(Base):
|
||||
"""A public request for a catalog item placed by a logged-in user.
|
||||
|
||||
Orders are intentionally visible to everyone (see app/orders.py). They
|
||||
reference catalog items by `class_name` only, exactly like inventory rows,
|
||||
so a catalog refresh cannot break them.
|
||||
"""
|
||||
|
||||
__tablename__ = "orders"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity >= 1", name="ck_orders_quantity_positive"),
|
||||
CheckConstraint(
|
||||
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
|
||||
name="ck_orders_quality_range",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('open', 'fulfilled')",
|
||||
name="ck_orders_status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
# Mining/refining quality (1-1000); only valid for quality-applicable ores.
|
||||
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="open", server_default="open"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="orders")
|
||||
209
roles/trade_hub/files/trade-hub/backend/app/orders.py
Normal file
209
roles/trade_hub/files/trade-hub/backend/app/orders.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Public order board.
|
||||
|
||||
Any logged-in user can place an order (a request) for a catalog item. Every
|
||||
order is visible to everyone, with the requester's display name, quantity,
|
||||
notes, and — for quality-applicable ores — the requested quality. Orders are
|
||||
read-only for the public; only the owner can edit or remove their own order.
|
||||
|
||||
Like inventory rows, orders reference catalog items by `class_name` only, so a
|
||||
dataset refresh cannot break them.
|
||||
"""
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import catalog
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user, require_same_origin
|
||||
from app.inventory import SORT_KEYS
|
||||
from app.models import Order, User
|
||||
from app.schemas import OrderCreateRequest, OrderOut, OrderPage, OrderPatchRequest
|
||||
|
||||
router = APIRouter(prefix="/api/v1/orders", tags=["orders"])
|
||||
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def enrich_order(order: Order, username: str) -> dict:
|
||||
item = catalog.store.by_class_name.get(order.class_name)
|
||||
if item is None:
|
||||
item_data = {
|
||||
"name": order.class_name.replace("_", " "),
|
||||
"category": None,
|
||||
"type": None,
|
||||
"size": None,
|
||||
"grade": None,
|
||||
"manufacturer": None,
|
||||
"quality_applicable": False,
|
||||
"available": False,
|
||||
}
|
||||
else:
|
||||
item_data = {
|
||||
"name": item["name"],
|
||||
"category": item["category"],
|
||||
"type": item["type"],
|
||||
"size": item["size"],
|
||||
"grade": item["grade"],
|
||||
"manufacturer": item["manufacturer"],
|
||||
"quality_applicable": item.get("quality_applicable", False),
|
||||
"available": True,
|
||||
}
|
||||
return {
|
||||
"id": order.id,
|
||||
"class_name": order.class_name,
|
||||
**item_data,
|
||||
"quantity": order.quantity,
|
||||
"notes": order.notes,
|
||||
"quality": order.quality,
|
||||
"username": username,
|
||||
"status": order.status,
|
||||
"created_at": order.created_at,
|
||||
"updated_at": order.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _validate_quality(item: dict, quality: int | None) -> None:
|
||||
if quality is not None and not item.get("quality_applicable", False):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{item['name']}'",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=OrderPage)
|
||||
def list_orders(
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
status: str | None = Query(default=None, pattern="^(open|fulfilled)$"),
|
||||
sort: str = Query(default="updated", pattern="^(name|category|quantity|updated)$"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=25, ge=1),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
page_size = min(page_size, MAX_PAGE_SIZE)
|
||||
rows = db.execute(
|
||||
select(Order, User.username).join(User, User.id == Order.user_id)
|
||||
).all()
|
||||
|
||||
items = [enrich_order(order, username) for order, username in rows]
|
||||
|
||||
if status is not None:
|
||||
items = [i for i in items if i["status"] == status]
|
||||
if category is not None and category.strip():
|
||||
wanted = category.strip()
|
||||
items = [i for i in items if i["category"] == wanted]
|
||||
if search is not None and search.strip():
|
||||
needle = search.strip().lower()
|
||||
items = [
|
||||
i
|
||||
for i in items
|
||||
if needle in i["name"].lower()
|
||||
or needle in i["class_name"].lower()
|
||||
or needle in i["username"].lower()
|
||||
]
|
||||
|
||||
items.sort(key=SORT_KEYS[sort])
|
||||
total = len(items)
|
||||
total_pages = max(1, math.ceil(total / page_size))
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"items": items[start : start + page_size],
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=OrderOut,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def create_order(
|
||||
payload: OrderCreateRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
class_name = payload.class_name.strip()
|
||||
item = catalog.store.by_class_name.get(class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Catalog item '{class_name}' not found"
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
|
||||
order = Order(
|
||||
user_id=user.id,
|
||||
class_name=class_name,
|
||||
quantity=payload.quantity,
|
||||
notes=payload.notes,
|
||||
quality=payload.quality,
|
||||
)
|
||||
db.add(order)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return enrich_order(order, user.username)
|
||||
|
||||
|
||||
def _own_order(db: DbSession, user: User, order_id: int) -> Order:
|
||||
order = db.get(Order, order_id)
|
||||
if order is None or order.user_id != user.id:
|
||||
# Do not reveal whether somebody else's order exists.
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
return order
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{order_id}",
|
||||
response_model=OrderOut,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def update_order(
|
||||
order_id: int,
|
||||
payload: OrderPatchRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
order = _own_order(db, user, order_id)
|
||||
|
||||
if payload.quantity is not None:
|
||||
order.quantity = payload.quantity
|
||||
if payload.notes is not None:
|
||||
order.notes = payload.notes
|
||||
if payload.status is not None:
|
||||
order.status = payload.status
|
||||
if "quality" in payload.model_fields_set:
|
||||
# Explicit null clears the value; omitted keeps the existing one.
|
||||
if payload.quality is not None:
|
||||
item = catalog.store.by_class_name.get(order.class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{order.class_name}'",
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
order.quality = payload.quality
|
||||
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return enrich_order(order, user.username)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{order_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def delete_order(
|
||||
order_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
order = _own_order(db, user, order_id)
|
||||
db.delete(order)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
156
roles/trade_hub/files/trade-hub/backend/app/schemas.py
Normal file
156
roles/trade_hub/files/trade-hub/backend/app/schemas.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Pydantic request/response models for auth and personal inventory."""
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app import config
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=32)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=32)
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
user: UserOut
|
||||
|
||||
|
||||
class InventoryAddRequest(BaseModel):
|
||||
class_name: str = Field(min_length=1, max_length=255)
|
||||
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
|
||||
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
|
||||
|
||||
|
||||
class InventoryPatchRequest(BaseModel):
|
||||
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
|
||||
visibility: Literal["private", "available"] | None = None
|
||||
# Explicit null clears the quality; omitting the field leaves it unchanged.
|
||||
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
|
||||
|
||||
|
||||
class InventoryEntryOut(BaseModel):
|
||||
class_name: str
|
||||
name: str
|
||||
category: str | None
|
||||
type: str | None
|
||||
size: int | None
|
||||
grade: Any | None = None
|
||||
manufacturer: dict | None
|
||||
quantity: int
|
||||
notes: str
|
||||
# Mining/refining quality (1-1000); only meaningful when
|
||||
# `quality_applicable` is true. None means the owner has not set it.
|
||||
quality: int | None = None
|
||||
quality_applicable: bool = False
|
||||
# Owner-chosen classification. Unrelated to `available` below.
|
||||
visibility: Literal["private", "available"]
|
||||
# False when the catalog no longer contains the referenced item (e.g. after
|
||||
# a dataset refresh). The row is preserved rather than silently deleted.
|
||||
available: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class InventoryPage(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
items: list[InventoryEntryOut]
|
||||
|
||||
|
||||
class InventorySummary(BaseModel):
|
||||
items: int
|
||||
quantity: int
|
||||
categories: dict[str, int]
|
||||
unavailable: int
|
||||
|
||||
|
||||
class AvailableItemOut(BaseModel):
|
||||
class_name: str
|
||||
name: str
|
||||
category: str | None
|
||||
type: str | None
|
||||
size: int | None
|
||||
grade: Any | None = None
|
||||
manufacturer: dict | None
|
||||
quantity: int
|
||||
quality: int | None = None
|
||||
quality_applicable: bool = False
|
||||
# Public listing: only the owner's display name, never notes.
|
||||
username: str
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AvailablePage(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
items: list[AvailableItemOut]
|
||||
|
||||
|
||||
class InventoryAddResponse(BaseModel):
|
||||
entry: InventoryEntryOut
|
||||
created: bool
|
||||
|
||||
|
||||
class OrderCreateRequest(BaseModel):
|
||||
class_name: str = Field(min_length=1, max_length=255)
|
||||
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
|
||||
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
|
||||
|
||||
|
||||
class OrderPatchRequest(BaseModel):
|
||||
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
|
||||
status: Literal["open", "fulfilled"] | None = None
|
||||
# Explicit null clears the quality; omitting the field leaves it unchanged.
|
||||
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
|
||||
|
||||
|
||||
class OrderOut(BaseModel):
|
||||
id: int
|
||||
class_name: str
|
||||
name: str
|
||||
category: str | None
|
||||
type: str | None
|
||||
size: int | None
|
||||
grade: Any | None = None
|
||||
manufacturer: dict | None
|
||||
quantity: int
|
||||
notes: str
|
||||
quality: int | None = None
|
||||
quality_applicable: bool = False
|
||||
# Public listing: the requesting user's display name.
|
||||
username: str
|
||||
status: Literal["open", "fulfilled"]
|
||||
# False when the catalog no longer contains the referenced item.
|
||||
available: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrderPage(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
items: list[OrderOut]
|
||||
140
roles/trade_hub/files/trade-hub/backend/app/security.py
Normal file
140
roles/trade_hub/files/trade-hub/backend/app/security.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Password hashing, session tokens, origin checks and login throttling."""
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
# Hash of a throwaway secret, verified when a username does not exist so that
|
||||
# "unknown user" and "wrong password" take comparable time.
|
||||
_DUMMY_HASH = _hasher.hash(secrets.token_urlsafe(16))
|
||||
|
||||
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_LENGTH = 128
|
||||
|
||||
|
||||
def normalize_username(username: str) -> str:
|
||||
return username.strip().lower()
|
||||
|
||||
|
||||
def valid_username(username: str) -> bool:
|
||||
return bool(USERNAME_RE.match(username.strip()))
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
return False
|
||||
|
||||
|
||||
def burn_password_check(password: str) -> None:
|
||||
"""Constant-ish work for unknown users, to blunt username enumeration."""
|
||||
try:
|
||||
_hasher.verify(_DUMMY_HASH, password)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
pass
|
||||
|
||||
|
||||
def needs_rehash(password_hash: str) -> bool:
|
||||
return _hasher.check_needs_rehash(password_hash)
|
||||
|
||||
|
||||
def new_session_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def client_ip(request) -> str:
|
||||
"""Real client address as seen by the reverse proxy.
|
||||
|
||||
nginx overwrites X-Real-IP with the actual peer address, so it is safe to
|
||||
trust here. X-Forwarded-For is client-appendable and is never used.
|
||||
"""
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def same_origin(request) -> bool:
|
||||
"""Reject cross-origin state-changing requests (CSRF defense in depth)."""
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
return True # non-browser client; the session cookie is still required
|
||||
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or ""
|
||||
return urlsplit(origin).netloc.lower() == host.lower()
|
||||
|
||||
class LoginThrottle:
|
||||
"""In-process sliding-window limiter for failed logins.
|
||||
|
||||
Tracks failures per (username, ip) and, with a higher ceiling, per ip so a
|
||||
single address cannot spray passwords across many usernames.
|
||||
"""
|
||||
|
||||
def __init__(self, max_attempts: int, window_seconds: int):
|
||||
self.max_attempts = max_attempts
|
||||
self.window = window_seconds
|
||||
self._events: dict[tuple[str, str], deque[float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _keys(self, username_key: str, ip: str) -> list[tuple[tuple[str, str], int]]:
|
||||
return [
|
||||
((username_key, ip), self.max_attempts),
|
||||
(("*", ip), self.max_attempts * 4),
|
||||
]
|
||||
|
||||
def _recent(self, key: tuple[str, str], now: float) -> deque[float] | None:
|
||||
events = self._events.get(key)
|
||||
if not events:
|
||||
return None
|
||||
cutoff = now - self.window
|
||||
while events and events[0] < cutoff:
|
||||
events.popleft()
|
||||
if not events:
|
||||
self._events.pop(key, None)
|
||||
return None
|
||||
return events
|
||||
|
||||
def retry_after(self, username_key: str, ip: str) -> int:
|
||||
"""Seconds to wait before another attempt is allowed (0 if allowed)."""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
waits = []
|
||||
for key, limit in self._keys(username_key, ip):
|
||||
events = self._recent(key, now)
|
||||
if events and len(events) >= limit:
|
||||
waits.append(int(self.window - (now - events[0])) + 1)
|
||||
return max(waits) if waits else 0
|
||||
|
||||
def record_failure(self, username_key: str, ip: str) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
if len(self._events) > 5000: # opportunistic cleanup
|
||||
for key in list(self._events):
|
||||
self._recent(key, now)
|
||||
for key, _limit in self._keys(username_key, ip):
|
||||
self._events.setdefault(key, deque()).append(now)
|
||||
|
||||
def reset(self, username_key: str, ip: str) -> None:
|
||||
with self._lock:
|
||||
for key, _limit in self._keys(username_key, ip):
|
||||
self._events.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._events.clear()
|
||||
File diff suppressed because it is too large
Load Diff
18185
roles/trade_hub/files/trade-hub/backend/data/samples/fps-items.json
Normal file
18185
roles/trade_hub/files/trade-hub/backend/data/samples/fps-items.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,838 @@
|
||||
[
|
||||
{
|
||||
"Code": "987",
|
||||
"Name": "987",
|
||||
"Reference": "3b54a8cc-5700-45f1-940a-2436b213926f",
|
||||
"Description": "Known for their more aggressive urban styling. Asymmetrical. Bold graphic prints."
|
||||
},
|
||||
{
|
||||
"Code": "ACAS",
|
||||
"Name": "Ace Astrogation",
|
||||
"Reference": "cfc0122d-5275-415a-a656-3dcb3346feb5",
|
||||
"Description": "Corp focusing on the structural (non-combat) aspects of the ship. Builds sleek, elegant but powerful power plants."
|
||||
},
|
||||
{
|
||||
"Code": "ACDO",
|
||||
"Name": "Aciedo Communications",
|
||||
"Reference": "b99ddb18-b325-44de-abfa-ce2e978424e4",
|
||||
"Description": "Responsible for building the CommRelay infrastructure around the UEE, Aciedo also developed the technology to allow for system to system communications."
|
||||
},
|
||||
{
|
||||
"Code": "ACOM",
|
||||
"Name": "ACOM",
|
||||
"Reference": "5d616e6f-3257-4d19-90c3-11ab974f12e7",
|
||||
"Description": "In racing circles, the term ACOM has come to have two meanings. The first is an abbreviation of the phrase ‘a commanding lead’ – meaning to pull so far ahead that the rest of the pack doesn’t stand a chance of catching up. The second is a more recent usage in that it refers to the start-up manufacturing company who took the phrase as their epithet. ACOM focuses on creating ship components that handle as much power as possible as quickly as possible in order to give pilots the all-important extra edge during a race. Founded by the children of famed pit crew chief Garrit Bijarani, siblings Jayce and Zan took four generations of racing knowledge and gave it form in ACOM’s full line of power plants and coolers."
|
||||
},
|
||||
{
|
||||
"Code": "AEGS",
|
||||
"Name": "Aegis Dynamics",
|
||||
"Reference": "cf4a74bf-eb2c-462a-9b78-f7f2724c31d2",
|
||||
"Description": "Aegis grew to prominence as a manufacturer of military spacecraft during the First Tevarin War. Favored by Ivar Messer, their orbital bombers grew to public prominence after his infamous victory on Idris IV and became synonymous with the ruthless Messer regime that followed. After the Fall of the Imperator, Aegis' contracts were stripped, their ships fell out of favor with the public, and the company went into a tailspin. They downsized their craft production and turned to manufacturing parts. However, time heals all wounds and today Aegis has risen once again to be a top military contractor of ships and components."
|
||||
},
|
||||
{
|
||||
"Code": "AEHA",
|
||||
"Name": "Aeroview Hangars",
|
||||
"Reference": "c7865487-2114-4863-b691-2d7d29ce1bfa"
|
||||
},
|
||||
{
|
||||
"Code": "ALB",
|
||||
"Name": "Alejo Brothers",
|
||||
"Reference": "dd14fd7f-eead-4f7d-8057-2d95405a0183",
|
||||
"Description": "Reflect your rugged independence and attitude with Alejo Brothers gear."
|
||||
},
|
||||
{
|
||||
"Code": "AMRS",
|
||||
"Name": "Amon & Reese Co.",
|
||||
"Reference": "eccba58a-f6d9-42e8-95c6-d0a65763e583",
|
||||
"Description": "Formed early in the Messer era by recent veterans Marcelo Amon and Travis Reese, A&R Co. is one of the oldest energy weapon manufacturers in the UEE. Their designs have always been user focused and they have a well-earned reputation for dependability and ease of use, something the company claims derives from a dedication to field testing that well exceeds what other companies do."
|
||||
},
|
||||
{
|
||||
"Code": "ANVL",
|
||||
"Name": "Anvil Aerospace",
|
||||
"Reference": "b922abdb-0634-4358-9eee-60118f3cf3a7",
|
||||
"Description": "For civilians, Anvil Aerospace produces small fighting craft but with less of the pirate stigma. These ships are more expensive, less spit-and-glue, and built to take more hits than a comparable Drake model. The bulk of Anvil's income is from its military contracts with the UEE to design and build their warships, especially the iconic Hornet."
|
||||
},
|
||||
{
|
||||
"Code": "APAR",
|
||||
"Name": "Apocalypse Arms",
|
||||
"Reference": "77f7999a-b23b-4812-94c2-09b5e15e1b0e",
|
||||
"Description": "Apocalypse Arms produces aggressive, devastating weapons inspired by Messer-Era technology."
|
||||
},
|
||||
{
|
||||
"Code": "APLL",
|
||||
"Name": "Aparelli",
|
||||
"Reference": "27e371d2-2dcb-428c-a32a-a17e246f02e8"
|
||||
},
|
||||
{
|
||||
"Code": "ARCC",
|
||||
"Name": "ArcCorp",
|
||||
"Reference": "0ade6e45-ecdd-42a9-9046-3e96dbe4f5d0",
|
||||
"Description": "This massive conglomerate owns one of the planets in the Stanton system, but got its start building fusion engines for industrial craft. They also have a division for terraforming that's diverged into more of a mining consortium."
|
||||
},
|
||||
{
|
||||
"Code": "ARGO",
|
||||
"Name": "Argo Astronautics",
|
||||
"Reference": "9e1f5a35-edcc-4d19-bede-08e2730043c5",
|
||||
"Description": "This commercial ship manufacturer focuses on building shuttles, transport vessels and utility vehicles that can be seen around the universe."
|
||||
},
|
||||
{
|
||||
"Code": "ARMA",
|
||||
"Name": "ArmaMod",
|
||||
"Reference": "f2ca879c-deb6-49bf-a4b1-dc3a9cf91e7a",
|
||||
"Description": "ArmaMod"
|
||||
},
|
||||
{
|
||||
"Code": "ASAD",
|
||||
"Name": "Associated Sciences & Development",
|
||||
"Reference": "055fe7c5-2e02-4e3a-aacb-f289563fabbd",
|
||||
"Description": "PH Associated Science and Development Description"
|
||||
},
|
||||
{
|
||||
"Code": "ASAS",
|
||||
"Name": "Ascension Astro",
|
||||
"Reference": "abca0ae0-a819-4aeb-a1a7-f8f1d7e277cf",
|
||||
"Description": "Ascension Astro started off as a boutique specialty company; selling high-end, low IR thrusters to wealthy clients who wanted to fly their luxury ships while avoiding the eyes of greedy pirates. As good as their thrusters were at being low-sig, the effect was often ruined when the engines or shielding made by other manufacturers would be screaming the ship’s presence. Slowly, Ascension began expanding their line to include a whole suite of components that, when working in conjunction, would provide an extremely low profile and a safer journey."
|
||||
},
|
||||
{
|
||||
"Code": "ATRO",
|
||||
"Name": "Astro Armada",
|
||||
"Reference": "db2fdcd3-aa45-4878-bdba-8d6062885f45"
|
||||
},
|
||||
{
|
||||
"Code": "BANU",
|
||||
"Name": "Banu Souli",
|
||||
"Reference": "2b5ba252-e826-4356-ac0d-8d88007ea386",
|
||||
"Description": "Banu society combines the Human concepts of family and corporations into a single social unit know as a Souli, a cohabitation focused centered around a specific skillset. These Banu guilds do not control an entire market, rather there are always numerous Souli competing for the same market as the guilds are constantly in flux. \n\nBecause of this, the Banu do not market specific brand Soulis. Instead they simply market all their wares to Humans as Banu built."
|
||||
},
|
||||
{
|
||||
"Code": "BASL",
|
||||
"Name": "Basilisk",
|
||||
"Reference": "2948496b-45d2-41f8-bdff-97587f1e6981",
|
||||
"Description": "Defensive system manufacturer who produces shield systems, ship armor and personal armor for both the military and private sector."
|
||||
},
|
||||
{
|
||||
"Code": "BEHR",
|
||||
"Name": "Behring Applied Technology",
|
||||
"Reference": "65a5d887-3b21-4046-a718-6912c0c7c3be",
|
||||
"Description": "The Behring Research Consortium was founded in 2554 on Terra as the end result of several years of buyouts and consolidation of dozens of small research and development firms by Behring Equity Investments. By having so many different research units focusing on a wide variety of developments, the Behring group hoped to increase the likelihood of expensive research resulting in a marketable product. Their history speaks for itself as the famed laboratory is accredited for the standardization of laser weaponry and dozens of other innovations. Today, Behring is still going strong with their technological forward products featuring straight forward and simple designs, making everything from guns, missiles, sentry drones, to shields and armor."
|
||||
},
|
||||
{
|
||||
"Code": "BLTR",
|
||||
"Name": "Blue Triangle Inc.",
|
||||
"Reference": "bcea197e-ac9d-49f4-a692-d77c6927077f",
|
||||
"Description": "Makers of advanced computer and analytic systems, Blue Triangle prides itself on hiring only the best engineers and programmers, many of whom started their careers as black hat hackers."
|
||||
},
|
||||
{
|
||||
"Code": "BRRA",
|
||||
"Name": "Broad & Rabiee",
|
||||
"Reference": "ad865899-f5ba-4818-ae8c-fe80a55108b7",
|
||||
"Description": "Founded by a pair of aerospace engineers, Broad & Rabiee began with a line of computers and components before branching out to turrets."
|
||||
},
|
||||
{
|
||||
"Code": "CBA",
|
||||
"Name": "Code Blue Apparel",
|
||||
"Reference": "d9179d05-55be-4eed-8b1b-93bb8450ff04",
|
||||
"Description": "Code Blue Apparel produces a wide variety of longwear professional uniforms and jumpsuits that focus on comfort and personality without sacrificing functionality."
|
||||
},
|
||||
{
|
||||
"Code": "CBD",
|
||||
"Name": "Custom Built Duds",
|
||||
"Reference": "64eef2d9-5d3d-431d-a83b-ae077a16b4e2",
|
||||
"Description": "CBD. They're clothes. Either buy them or don't."
|
||||
},
|
||||
{
|
||||
"Code": "CCC",
|
||||
"Name": "CC's Conversions",
|
||||
"Reference": "be4a26fd-0585-4aaf-bad9-d234091bd02e",
|
||||
"Description": "CC's Conversions Description"
|
||||
},
|
||||
{
|
||||
"Code": "CDS",
|
||||
"Name": "Clark Defense Systems",
|
||||
"Reference": "8a7bd715-5b97-404f-b02e-0d0cc593f608",
|
||||
"Description": "Ground-combat defensive systems, covers a lot of commercial territory from personal armor to defense systems. Naming convention tend to be pretty straight-forward and logical.\n\nModels: PAB-4 (Personal Ablative/Ballistic Light Armor)\n\nQDB-12 QuiKCade (Quick Deployment Barricade)"
|
||||
},
|
||||
{
|
||||
"Code": "CHCO",
|
||||
"Name": "Chimera Communications",
|
||||
"Reference": "8af71a8a-7da0-4e31-afc7-648fc916e387",
|
||||
"Description": "A dedicated manufacturer of communication, radar and scan systems. They are the rugged go-to company for miners and explorers alike with their sturdy, well-tested and dependable products."
|
||||
},
|
||||
{
|
||||
"Code": "CLDA",
|
||||
"Name": "Caldera",
|
||||
"Reference": "eddc1a94-7840-4855-9889-544821d4d739",
|
||||
"Description": "Caldera Description."
|
||||
},
|
||||
{
|
||||
"Code": "CNOU",
|
||||
"Name": "Consolidated Outland",
|
||||
"Reference": "ec227ef3-fb08-4897-abdb-579eb4e87c25",
|
||||
"Description": "Founded by maverick trillionaire Silas Koerner, Consolidated Outland is an up-and-coming spacecraft concern, the first ever headquartered on a frontier world. Koerner, who made his sizable fortune in jump-communications, founded the company as an attempt to strike back at what he sees as the overly-regulated spacecraft industry. With facilities established as far from possible from the prying eyes of competing corporations, Outland is quickly becoming one to watch."
|
||||
},
|
||||
{
|
||||
"Code": "CNTR",
|
||||
"Name": "CenterMass",
|
||||
"Reference": "e441348b-6b41-4d00-a381-5ff20e014c2c"
|
||||
},
|
||||
{
|
||||
"Code": "CONO",
|
||||
"Name": "Conscientious Objects",
|
||||
"Reference": "10d3571e-dd95-4e5d-a644-ccaee0f1fdb9"
|
||||
},
|
||||
{
|
||||
"Code": "CORD",
|
||||
"Name": "Cordry's",
|
||||
"Reference": "918384f8-9a35-465a-a515-ba256e96d6e8"
|
||||
},
|
||||
{
|
||||
"Code": "CRLF",
|
||||
"Name": "CureLife",
|
||||
"Reference": "dd55bc19-5212-4365-bbc8-73474f0257ed",
|
||||
"Description": "Makes medical packs for commercial purposes. Formed by a group of doctors, CureLife was the first company to offer heavy medical solutions to the general public."
|
||||
},
|
||||
{
|
||||
"Code": "CRUS",
|
||||
"Name": "Crusader Industries",
|
||||
"Reference": "63685f47-b0cc-4ebe-8f16-72793a5cd6e0",
|
||||
"Description": "Makers of an extensive fleet of cargo, vehicle and data transport ships, and cutting edge shuttles, Crusader Industries is a titan of the aerospace industry. It is a company so powerful and prominent that it purchased a gas giant in the Stanton system to house its current headquarters."
|
||||
},
|
||||
{
|
||||
"Code": "CTL",
|
||||
"Name": "City Lights",
|
||||
"Reference": "5ca8a669-44c2-40e4-89b2-7a5da790b832",
|
||||
"Description": "City Lights makes timeless, affordable fashion for the entire family."
|
||||
},
|
||||
{
|
||||
"Code": "CUBY",
|
||||
"Name": "Cubby Blast",
|
||||
"Reference": "d73a45ce-6b59-4791-937d-1bd7a0819ee0"
|
||||
},
|
||||
{
|
||||
"Code": "Commodity_UILO_C",
|
||||
"Name": "<= PLACEHOLDER =>",
|
||||
"Reference": "9fcc226b-9200-4966-9ea4-2054dac4d897"
|
||||
},
|
||||
{
|
||||
"Code": "DMC",
|
||||
"Name": "Denim Manufacture Corporation",
|
||||
"Reference": "a53bbc2b-aa43-483d-967e-0cebc5600c04",
|
||||
"Description": "Still renowned for their denim, DMC (Denim Manufacture Corporation) has become so much more. From durable outerwear to rugged fashion, DMC knows that feeling good is just as important as looking good."
|
||||
},
|
||||
{
|
||||
"Code": "DOOM",
|
||||
"Name": "Doomsday",
|
||||
"Reference": "b495993e-c10a-41e4-876d-1de96de54e33",
|
||||
"Description": "Extremely aggressive looking homemade armor meant to protect and intimidate. This kind of armor is not only expected to survive the apocalypse, but also dominate its aftermath."
|
||||
},
|
||||
{
|
||||
"Code": "DRAK",
|
||||
"Name": "Drake Interplanetary",
|
||||
"Reference": "6116471c-891d-4830-8968-650b6deafc00",
|
||||
"Description": "Makes affordable, modular ships that have become the go-to manufacturer for anyone looking to get their first ship, but have become associated with criminal elements."
|
||||
},
|
||||
{
|
||||
"Code": "DRN",
|
||||
"Name": "Derion",
|
||||
"Reference": "2ab62815-6273-44b9-bbc6-28d36fed6fd8",
|
||||
"Description": "With their muted colors and classic cuts, Derion has become the definition of high-end business wear for men and women."
|
||||
},
|
||||
{
|
||||
"Code": "DYL",
|
||||
"Name": "Dyna Lei",
|
||||
"Reference": "92b20988-dbf3-4e95-b4a9-7e5e22112516",
|
||||
"Description": "- Dyna Lei Description"
|
||||
},
|
||||
{
|
||||
"Code": "ELD",
|
||||
"Name": "Escar Limited",
|
||||
"Reference": "190a2e0e-6d60-4719-8238-ac7e1c746626",
|
||||
"Description": "Escar Limited takes comfortable casual wear to the next level through interesting textiles and exquisite craftsmanship."
|
||||
},
|
||||
{
|
||||
"Code": "ESPR",
|
||||
"Name": "Esperia",
|
||||
"Reference": "4ecc1a24-d228-4dd5-8857-21ba6145a1d3",
|
||||
"Description": "Originally formed as an archive for ship designs, Esperia began to build historically accurate reproductions for collectors, enthusiasts, and even the military."
|
||||
},
|
||||
{
|
||||
"Code": "EVL",
|
||||
"Name": "EvaLight",
|
||||
"Reference": "c0635984-4973-41c6-ac27-0c9a39ae051d",
|
||||
"Description": "They can't define you. They can't tell you who you are. EvaLight and let your voice be heard."
|
||||
},
|
||||
{
|
||||
"Code": "FFSY",
|
||||
"Name": "Flashfire Systems",
|
||||
"Reference": "b9b97673-c585-4e0a-b229-a0a3c79668c3",
|
||||
"Description": "Failed laser weapon manufacturer. Transformed into an engineering firm that came up with the weapon mount system that's almost uniformly used."
|
||||
},
|
||||
{
|
||||
"Code": "FIO",
|
||||
"Name": "Fiore",
|
||||
"Reference": "503b0974-5027-4c69-bce6-a38c42e87ebe",
|
||||
"Description": "A timeless elegance lends an ease of wear that defines each sophisticated piece in Fiore's signature collection."
|
||||
},
|
||||
{
|
||||
"Code": "FSKI",
|
||||
"Name": "FireStorm Kinetics",
|
||||
"Reference": "5f81335d-44e6-4104-841e-c5af9df06829",
|
||||
"Description": "Aegis grew to prominence as a manufacturer of military spacecraft during the First Tevarin War. Favored by Ivar Messer, their orbital bombers grew to public prominence after his infamous victory on Idris IV and became synonymous with the ruthless Messer regime that followed. After the Fall of the Imperator, Aegis' contracts were stripped, their ships fell out of favor with the public, and the company went into a tailspin. They downsized their craft production and turned to manufacturing parts. However, time heals all wounds and today Aegis has risen once again to be a top military contractor of ships and components."
|
||||
},
|
||||
{
|
||||
"Code": "FTA",
|
||||
"Name": "Fortified Armor",
|
||||
"Reference": "0b32addb-fed0-4a4a-85b2-e3b397c4f062"
|
||||
},
|
||||
{
|
||||
"Code": "FTLC",
|
||||
"Name": "FTL Courier Service",
|
||||
"Reference": "7db7b41d-ff7f-4ed9-9550-7aaaae9fa265"
|
||||
},
|
||||
{
|
||||
"Code": "GAMA",
|
||||
"Name": "Gatac Manufacture",
|
||||
"Reference": "31907472-a884-4ebc-86db-cd4f7797e514",
|
||||
"Description": "Responsible for the design and construction of any ships with commercial or industrial applications, Gatac Manufacture has a long-standing reputation as a ‘knowledge-driven’ corporation producing not only cutting edge ships, but extremely reliable ones."
|
||||
},
|
||||
{
|
||||
"Code": "GATS",
|
||||
"Name": "Gallenson Tactical Systems",
|
||||
"Reference": "a834198a-3465-4b02-86a5-887e922ec06d",
|
||||
"Description": "This Earth-based company is responsible for hard-hitting array of ship weapons."
|
||||
},
|
||||
{
|
||||
"Code": "GEND",
|
||||
"Name": "Consumable",
|
||||
"Reference": "f80ab8de-4d04-4a1f-a8f2-ee2bb01fd125",
|
||||
"Description": "PH Generic Drink Description"
|
||||
},
|
||||
{
|
||||
"Code": "GENF",
|
||||
"Name": "Consumable",
|
||||
"Reference": "1b6be99e-f430-459f-ab2a-a912fd6722f8",
|
||||
"Description": "PH Generic Food Description"
|
||||
},
|
||||
{
|
||||
"Code": "GLSN",
|
||||
"Name": "Grey's Market",
|
||||
"Reference": "768b4380-6486-4187-8c22-bc3deaf80770",
|
||||
"Description": "A fleet of mobile marketplaces that caters to high-end clients interested in experiencing the wild side of the 'verse to gain access to unusual and unique items."
|
||||
},
|
||||
{
|
||||
"Code": "GMNI",
|
||||
"Name": "Gemini",
|
||||
"Reference": "b33050cd-6ca9-4c57-baf8-f4114eb85237",
|
||||
"Description": "Personal weapon manufacturer. Generally makes kinetic weapons, but has recently acquired Octa, a small energy weapon start-up. Weapons are named sequentially."
|
||||
},
|
||||
{
|
||||
"Code": "GODI",
|
||||
"Name": "Gorgon Defender Industries",
|
||||
"Reference": "57bfcf3d-13f6-4c2c-b7c7-6bf8eacb8a24",
|
||||
"Description": "When Humanity started being attacked by Vanduul, it was often those who lived in the border system who suffered the most. Believing that civilian defensive components were not up to facing this deadly enemy, Gorgon Defender Industries, founded by former inhabitants of Caliban, was established to take military strength products and deliver it the population at large so they could properly protect themselves. With the strength of their designs, Gorgon earned a reputation that soon caught the eye of the military."
|
||||
},
|
||||
{
|
||||
"Code": "GRIN",
|
||||
"Name": "Greycat Industrial",
|
||||
"Reference": "819545f4-18af-49a6-814e-ca4fd08f56e5",
|
||||
"Description": "A trusted manufacturer of industrial tools since 2337, the company’s extensive line of industrial equipment and armor make it one of the most respected brand names around. Greycat has remained a UEE staple for centuries thanks to their streamlined-yet-inventive designs, exacting safety standards, and a diverse product line."
|
||||
},
|
||||
{
|
||||
"Code": "GRNP",
|
||||
"Name": "Groupe Nouveau Paradigme",
|
||||
"Reference": "3909dad3-eab3-4da7-9d90-74d7064d5900",
|
||||
"Description": "Seeking to stand out in the crowded field of manufacturers and to compete with the more developed “homeworlds,” the governments of four outer systems banded together to subsidize the creation of GNP, an entirely new kind of company that specialized in designing everything from full-range sensor suites to ship engines."
|
||||
},
|
||||
{
|
||||
"Code": "GSB",
|
||||
"Name": "Grindstone Boots",
|
||||
"Reference": "6d4b7375-8e0c-445a-a243-21f8f94f99cb",
|
||||
"Description": "GSB description"
|
||||
},
|
||||
{
|
||||
"Code": "GYS",
|
||||
"Name": "Gyson Inc.",
|
||||
"Reference": "d56a3985-2f29-4095-b2f2-542376cc74f4",
|
||||
"Description": "If you need it, Gyson makes it. The company and its subsidiaries produce everything from carpets to cleaning solvents to industrial uniforms and even EVA suits at prices that can't be beat."
|
||||
},
|
||||
{
|
||||
"Code": "HDGW",
|
||||
"Name": "Hedeby Gunworks",
|
||||
"Reference": "9d3ee727-bb52-4ed2-bacf-47d93d8bb0d5",
|
||||
"Description": "Hedeby Gunworks Description"
|
||||
},
|
||||
{
|
||||
"Code": "HDH",
|
||||
"Name": "Habidash",
|
||||
"Reference": "70ce6432-3a1e-45a1-8179-fe30a3ee54dc",
|
||||
"Description": "Very simply made clothing that is made for extreme environments. Takes inspiration from middle east and inuit designs."
|
||||
},
|
||||
{
|
||||
"Code": "HDTC",
|
||||
"Name": "Hardin Tactical",
|
||||
"Reference": "8368a760-84b4-4359-bda1-7b015dc0c884",
|
||||
"Description": "PH Hardin Tactical Desc"
|
||||
},
|
||||
{
|
||||
"Code": "HRST",
|
||||
"Name": "Hurston Dynamics",
|
||||
"Reference": "ee8b7bb3-d420-435c-924f-561e67e64588",
|
||||
"Description": "Hurston Dynamics Manufacturer placeholder info about company."
|
||||
},
|
||||
{
|
||||
"Code": "HWK",
|
||||
"Name": "Hawksworth's",
|
||||
"Reference": "d7a8b196-8778-4e18-8c8c-38229eef01f1",
|
||||
"Description": "When they say that shoes make the man, Hawksworth's are the shoes they are talking about. Formal, classic, and constructed to the same exacting standards that has made the Hawksworth brand the standard to beat."
|
||||
},
|
||||
{
|
||||
"Code": "JOKR",
|
||||
"Name": "Joker Engineering",
|
||||
"Reference": "19fbaf6d-d6dc-4e15-8fdf-67101934ff3c",
|
||||
"Description": "From the mind of underground engineer Barlowe Elias, Joker Engineering is here to push the boundaries of tech, the foundations of civilization with their wild products."
|
||||
},
|
||||
{
|
||||
"Code": "JSPN",
|
||||
"Name": "J-Span",
|
||||
"Reference": "6cf93625-789a-4e8f-a857-325d64a58c93",
|
||||
"Description": "J-Span does only one thing, but does it well. For over a century they have focused on producing quality coolers at a reasonable price. Half the time these days, when you pop the hull of a spacecraft, chances are you’re going to find a J-Span. Plus, their coolers can be found in more than just ships. They have a wide variety of sizes and shapes that can be found in everything from land transports, to computer systems, to refrigerator units. Appealing to the broader consumer market, their units might not have all the bells and whistles as some of the other manufacturer’s lines, but for the most part they avoid their quirks as well."
|
||||
},
|
||||
{
|
||||
"Code": "JUST",
|
||||
"Name": "Juno Starwerk",
|
||||
"Reference": "d7cc897f-b760-4a73-93c0-27e23c52db0a",
|
||||
"Description": "Second only to RSI in terms of longevity in the space industry, Juno Starwerk was founded in the 24th century to build long haul freighters to support the rapid Human expansion into other star systems. Utilitarian above all us, these ships were designed to do one thing: haul as much weight as possible as cheaply as possible. While, over time, as competition increased, their share of the ship market shrank, their components had earned a reputation for dependability and ease of maintenance. Their direct sales of these components soon made up the majority of the companies income and in 2513, the ceased ship manufacture entirely to focus on supplying industrial strength components. In parts of the ’verse there are generations of mechanics who have passed knowledge and techniques for working on Juno power plants like family heirlooms. This long history also means that Juno parts and repairs are easy to come by."
|
||||
},
|
||||
{
|
||||
"Code": "KAP",
|
||||
"Name": "Kilgore and Poole",
|
||||
"Reference": "a349d45f-cda5-4fdb-89ea-4ea91804a163",
|
||||
"Description": "Kilgore and Poole caters its armor and image to a high-end clientele. Criticized by some as prioritizing form over function, their eye-catching armor favors ease of movement and agility over heavy defense."
|
||||
},
|
||||
{
|
||||
"Code": "KBAR",
|
||||
"Name": "KnightBridge Arms",
|
||||
"Reference": "7b4402a1-f17f-4125-afcc-4a159dcd29e0",
|
||||
"Description": "Headquartered in the Kiel system, KnightBridge Arms is there to protect you and yours. With a weapon for every price point, you can rest assured that KnightBridge has your back."
|
||||
},
|
||||
{
|
||||
"Code": "KCTR",
|
||||
"Name": "KC Trending",
|
||||
"Reference": "c52dc2d9-624c-4722-b63a-33a38ac0039c"
|
||||
},
|
||||
{
|
||||
"Code": "KLWE",
|
||||
"Name": "Klaus & Werner",
|
||||
"Reference": "1aa076d5-ac29-4c61-a905-d3ea39cf167a",
|
||||
"Description": "Formed in 2893, Klaus & Werner makes guns, and they make them well. This simple philosophy was the brainchild of Hector Klaus, a successful weapons inventor who believed above all else that simplicity of design would triumph in a combat situation."
|
||||
},
|
||||
{
|
||||
"Code": "KRIG",
|
||||
"Name": "Kruger Intergalactic",
|
||||
"Reference": "7b5d1ed3-11e7-49e6-9ed3-9d0cb52a3dc2",
|
||||
"Description": "Kruger Intergalactic started as a custom machined parts manufacturer on Borea in the Magnus system, eventually expanded into weapon development and finally as a manufacturer of short-range ships like the P-52."
|
||||
},
|
||||
{
|
||||
"Code": "KRON",
|
||||
"Name": "Kroneg",
|
||||
"Reference": "2a64f92e-8f6d-47ac-a6ed-643465e91d4b",
|
||||
"Description": "Kroneg Description"
|
||||
},
|
||||
{
|
||||
"Code": "KSAR",
|
||||
"Name": "Kastak Arms",
|
||||
"Reference": "709e8255-a0b1-4c5e-8b0e-28781be25984",
|
||||
"Description": "Kastak Arms makes inexpensive energy weapons with intimidating names like Devastator and Firestorm, and aggressively markets them to distressed parts of the Empire. Even though the company portrays itself as providing weapons for personal defense, the brand has become more readily identified with people who use them for aggressive and unlawful purposes."
|
||||
},
|
||||
{
|
||||
"Code": "LBCO",
|
||||
"Name": "Lightning Bolt Co.",
|
||||
"Reference": "98bb2e9e-fcc7-46e5-9ba6-d34c42c414e0"
|
||||
},
|
||||
{
|
||||
"Code": "LOCO",
|
||||
"Name": "Lortell Computing",
|
||||
"Reference": "4ee4ba50-f4e3-4c54-9f44-9a6c5691c77e",
|
||||
"Description": "A manufacturer of affordable computers, computer parts, and accessories. There products don't have a lot of bells and whistles and often incorporate last gen tech, but because of that, what they do produce is reliable. Produce the Re-Authorized brand cryptokey."
|
||||
},
|
||||
{
|
||||
"Code": "LPLT",
|
||||
"Name": "Lightning Power Ltd.",
|
||||
"Reference": "60d0f346-0c22-4869-81d3-1168b526a258",
|
||||
"Description": "Made out of less expensive materials, Lightning Power continues to introduce new aesthetic changes to their ship components while relying on tried and true technology to run them."
|
||||
},
|
||||
{
|
||||
"Code": "MISC",
|
||||
"Name": "Musashi Industrial and Starflight Concern",
|
||||
"Reference": "b28a5c61-63a4-478b-8047-5fba545d5b8a",
|
||||
"Description": "Mass produces very efficient, modular ships, mostly armored freighters of different sizes that make them the preferred brand for traders and larger corporations. Over thirty years ago, MISC signed a lend-lease agreement to establish a tech trade with Xi’An engineers."
|
||||
},
|
||||
{
|
||||
"Code": "MITE",
|
||||
"Name": "microTech",
|
||||
"Reference": "9f0f7735-d389-4c7d-95f1-c4d0c57f7214",
|
||||
"Description": "Ubiquitous around the Empire, microTech and their range of electronics, datapads, mobile interfaces, handheld sensors and computer software upgrades for starships are used daily by almost everyone. Their line of mobiGlas personal computers have become an integral part of daily life for millions of people."
|
||||
},
|
||||
{
|
||||
"Code": "MRAI",
|
||||
"Name": "Mirai",
|
||||
"Reference": "cdf6ea1e-7de3-4b58-bf9d-13c5fbafc49a",
|
||||
"Description": "The Mirai design team is dedicated to pairing the craftsmanship of their parent company MISC with Xi’an technology and traditional Saisei aesthetics to create the next generation of cutting-edge vehicles."
|
||||
},
|
||||
{
|
||||
"Code": "MXOX",
|
||||
"Name": "MaxOx",
|
||||
"Reference": "de0d3594-9f18-42c0-8a90-6edd723d80f8",
|
||||
"Description": "Aegis grew to prominence as a manufacturer of military spacecraft during the First Tevarin War. Favored by Ivar Messer, their orbital bombers grew to public prominence after his infamous victory on Idris IV and became synonymous with the ruthless Messer regime that followed. After the Fall of the Imperator, Aegis' contracts were stripped, their ships fell out of favor with the public, and the company went into a tailspin. They downsized their craft production and turned to manufacturing parts. However, time heals all wounds and today Aegis has risen once again to be a top military contractor of ships and components."
|
||||
},
|
||||
{
|
||||
"Code": "MYM",
|
||||
"Name": "MuyMuy",
|
||||
"Reference": "cc1731c4-8156-4750-a138-96838df79117",
|
||||
"Description": "MYM description"
|
||||
},
|
||||
{
|
||||
"Code": "NAVE",
|
||||
"Name": "Nav-E7 Gadgets",
|
||||
"Reference": "afff101e-eb2c-4f93-960f-cec9936b9f39",
|
||||
"Description": "In the short time the company has been around, Nav-E7 has become an institution. Manufacturing various pieces of equipment used on a wide range of ships has given them quick renown as most ships in the 'verse feature at least one of their products."
|
||||
},
|
||||
{
|
||||
"Code": "NOVP",
|
||||
"Name": "Nova Pyrotechnica",
|
||||
"Reference": "67fd3077-ee8c-4a7c-8bad-91500b2a9509",
|
||||
"Description": "An company out of Terra that offers missiles and rockets at a low price."
|
||||
},
|
||||
{
|
||||
"Code": "NRS",
|
||||
"Name": "NorthStar",
|
||||
"Reference": "2d1922e0-f8dc-46d3-a453-6a85711f3771",
|
||||
"Description": "NorthStar Description"
|
||||
},
|
||||
{
|
||||
"Code": "NVS",
|
||||
"Name": "Navy Surplus",
|
||||
"Reference": "f8efa046-7f79-4e09-b2fc-35d0df2429cf",
|
||||
"Description": "PH - Navy Surplus Description"
|
||||
},
|
||||
{
|
||||
"Code": "NVTC",
|
||||
"Name": "NV-TAC",
|
||||
"Reference": "f113387e-0905-489a-829d-8cebfdc19e66",
|
||||
"Description": "High-end company known for manufacturing technically precise and experimental optics and weapon attachments. Founded in 2811, NV-TAC initially built sensors for long-range scanning stations and surveying operations before shifting operations to the more profitable personal weapon market. Seen by some as too expensive and feature-dense, but beloved by soldiers and security professionals for their durable design, precision adjustments, and reliable performance."
|
||||
},
|
||||
{
|
||||
"Code": "NVY",
|
||||
"Name": "United Empire of Earth Navy",
|
||||
"Reference": "d133f90d-bc69-4273-9867-6c03631f0c4b",
|
||||
"Description": "(PH) UEE Navy - Description"
|
||||
},
|
||||
{
|
||||
"Code": "NYX_A",
|
||||
"Name": "",
|
||||
"Reference": "33c31854-6b5e-4687-9b75-322b6610d734"
|
||||
},
|
||||
{
|
||||
"Code": "OCT",
|
||||
"Name": "Octagon",
|
||||
"Reference": "4a05001d-0511-415f-8e32-618fbd75242b",
|
||||
"Description": "PH Octagon Description"
|
||||
},
|
||||
{
|
||||
"Code": "OGPO",
|
||||
"Name": "Omega Pro",
|
||||
"Reference": "9b460be8-0c7c-47fb-a6ac-21aa88a6c09f"
|
||||
},
|
||||
{
|
||||
"Code": "OPS",
|
||||
"Name": "OpalSky",
|
||||
"Reference": "6f5af1a4-a50d-41e5-8bb0-c1acd89d4b9e",
|
||||
"Description": "Classic, simple and timeless, OpalSky creates casual attire. What millionaires wear when lounging on their space yachts."
|
||||
},
|
||||
{
|
||||
"Code": "ORIG",
|
||||
"Name": "Origin Jumpworks",
|
||||
"Reference": "1e47a9ec-a3f0-431d-be0e-377712e1ed84",
|
||||
"Description": "Known primarily for high-end ship manufacturing, but also provides some custom parts for their ships - particularly maneuvering thrusters. Sleek and elegant fusion of art and engineering"
|
||||
},
|
||||
{
|
||||
"Code": "OUTL",
|
||||
"Name": "Outlaw",
|
||||
"Reference": "7e8a8608-3b02-42cc-83ea-4efe430c60a8"
|
||||
},
|
||||
{
|
||||
"Code": "PCDF",
|
||||
"Name": "Procyon CDF",
|
||||
"Reference": "1b4babeb-5c3d-4c31-a9dd-5f53e0e8893c"
|
||||
},
|
||||
{
|
||||
"Code": "PRAR",
|
||||
"Name": "Preacher Armament",
|
||||
"Reference": "a2ba1db6-a7e8-49db-ba52-c61219693531",
|
||||
"Description": "Aegis grew to prominence as a manufacturer of military spacecraft during the First Tevarin War. Favored by Ivar Messer, their orbital bombers grew to public prominence after his infamous victory on Idris IV and became synonymous with the ruthless Messer regime that followed. After the Fall of the Imperator, Aegis' contracts were stripped, their ships fell out of favor with the public, and the company went into a tailspin. They downsized their craft production and turned to manufacturing parts. However, time heals all wounds and today Aegis has risen once again to be a top military contractor of ships and components."
|
||||
},
|
||||
{
|
||||
"Code": "QRT",
|
||||
"Name": "Quirinus Tech",
|
||||
"Reference": "64f8b397-8c0d-40b0-96a0-52bbeb41e5ad",
|
||||
"Description": "Quirinus Tech Description"
|
||||
},
|
||||
{
|
||||
"Code": "R6P",
|
||||
"Name": "R6 Pro",
|
||||
"Reference": "1ddcadf0-7bb0-4fa9-94f0-6af19432631c",
|
||||
"Description": "R6 Pro's rugged footwear is made to withstand wear and tear in even the most extreme environments, so even if you get knocked down, we're there to help you get back up again."
|
||||
},
|
||||
{
|
||||
"Code": "RACO",
|
||||
"Name": "RAMP Corporation",
|
||||
"Reference": "57432775-b421-45cc-8616-ac2d35018193",
|
||||
"Description": "Manufacturer of consumer and pro-grade ship components, RAMP Corp sponsors the Team RAMP M50 racing team."
|
||||
},
|
||||
{
|
||||
"Code": "REYO",
|
||||
"Name": "Revel & York",
|
||||
"Reference": "c4052a24-2547-4ebc-9137-aa9f09a907af"
|
||||
},
|
||||
{
|
||||
"Code": "RMB",
|
||||
"Name": "Rambler",
|
||||
"Reference": "e7489831-23c1-4629-ab36-221bb9bad06f",
|
||||
"Description": "PH - Rambler Description"
|
||||
},
|
||||
{
|
||||
"Code": "ROO",
|
||||
"Name": "Republic of One",
|
||||
"Reference": "4835d29c-3cdd-4983-97b2-1af4674c4c00",
|
||||
"Description": "Soft drinks, high end kitchen equipment, children’s toys? Sakura Sun. Rover platforms, terraforming solutions, bioengineered livestock? Sakura Sun. High-tech wave cannons, starship power plants, antimatter ordinance? Sakura Sun! While corporate conglomerates are not uncommon, it’s rare for any company to advertise them all under the same banner. But at Sakura Sun, empire-building is in full effect: you’ll find a little bit of everything branded with a distinctive purple sun-and-cherry-blossom logo, from plastic Hi-Ho Flatcat figurines to top-of-the-line multi-particle/wave cannons. Stranger still, Sakura Sun has successfully gained a reputation as a company that not only does a little bit of everything, but does it well."
|
||||
},
|
||||
{
|
||||
"Code": "RRS",
|
||||
"Name": "Roussimoff Rehabilitation Systems",
|
||||
"Reference": "73a6ba90-8f53-423d-ad0b-96e81b7e18a5",
|
||||
"Description": "Roussimoff Rehabilitation Systems (RRS) is an Earth-based corporation that provides rehabilitation services, personal body armor and defensive systems throughout the UEE. Their arms division is responsible for a groundbreaking series of new weaponry designed to incapacitate opponents without taking lives."
|
||||
},
|
||||
{
|
||||
"Code": "RSI",
|
||||
"Name": "Roberts Space Industries",
|
||||
"Reference": "093e6eba-93fa-4dad-b3dd-38966934e34e",
|
||||
"Description": "The original creators of the engine that kickstarted Humanity’s expansion into space, Roberts Space Industries builds a wide range of spaceships that serve all needs, from basic interstellar travel to deep exploration on the outer edges of the galaxy. The tagline is “Roberts Space Industries: Delivering the Stars since 2075”"
|
||||
},
|
||||
{
|
||||
"Code": "SASU",
|
||||
"Name": "Sakura Sun",
|
||||
"Reference": "40c59850-8fb5-4a0e-ab61-7fd3871a632a",
|
||||
"Description": "Soft drinks, high end kitchen equipment, children’s toys? Sakura Sun. Rover platforms, terraforming solutions, bioengineered livestock? Sakura Sun. High-tech wave cannons, starship power plants, antimatter ordinance? Sakura Sun! While corporate conglomerates are not uncommon, it’s rare for any company to advertise them all under the same banner. But at Sakura Sun, empire-building is in full effect: you’ll find a little bit of everything branded with a distinctive purple sun-and-cherry-blossom logo, from plastic Hi-Ho Flatcat figurines to top-of-the-line multi-particle/wave cannons. Stranger still, Sakura Sun has successfully gained a reputation as a company that not only does a little bit of everything, but does it well."
|
||||
},
|
||||
{
|
||||
"Code": "SCU",
|
||||
"Name": "Stegman's Clothing and Uniforms",
|
||||
"Reference": "20121812-97a6-4ce1-a6a4-88a5ed646c58",
|
||||
"Description": "SCU description"
|
||||
},
|
||||
{
|
||||
"Code": "SECO",
|
||||
"Name": "Seal Corporation",
|
||||
"Reference": "f91beb30-915c-42f6-9b1f-f5bd74633fd6",
|
||||
"Description": "One of the earliest companies to begin selling energy based shielding to civilians when the technology was declassified, Seal Corp has been in the industry centuries longer than its current modern day competition. For a while, the term ‘seal’ was ubiquitous with shield and Sammy the Seal Corp seal is still one of the most recognizable mascots across the ’verse. While they have lost some market share to competitors over the years, they are still the most widely used brand of shields and their products can be bought easily in almost every system."
|
||||
},
|
||||
{
|
||||
"Code": "SELA",
|
||||
"Name": "SELF-LAND",
|
||||
"Reference": "4526be72-2c19-453c-ab43-29cc23f77154"
|
||||
},
|
||||
{
|
||||
"Code": "SHIN",
|
||||
"Name": "Shubin Interstellar",
|
||||
"Reference": "ab35f85f-919f-4457-8d58-4e885ea6c5c0",
|
||||
"Description": "Shubin Interstellar Description"
|
||||
},
|
||||
{
|
||||
"Code": "SPV",
|
||||
"Name": "Spar Van Miles",
|
||||
"Reference": "e7c9f9ee-a1db-40ee-bfa8-a8ffead4ac1d",
|
||||
"Description": "SPV brings you inventive twists and inspirational detailing for a day to night collection that is whatever you want it to be."
|
||||
},
|
||||
{
|
||||
"Code": "STOR",
|
||||
"Name": "Stor*All",
|
||||
"Reference": "c4a97eae-00ee-4873-93c5-ee73209e3ec8",
|
||||
"Description": "Stor*All is the largest manufacturer of storage containers in the empire, making everything from general purpose boxes to highly specialized containers for hazardous materials."
|
||||
},
|
||||
{
|
||||
"Code": "SYFB",
|
||||
"Name": "Syang Fabrication",
|
||||
"Reference": "0d3e6377-4be0-4c38-8269-3ac5dbd132d8",
|
||||
"Description": "PH Syang Fabrication Description"
|
||||
},
|
||||
{
|
||||
"Code": "TALN",
|
||||
"Name": "Talon Weapons Systems",
|
||||
"Reference": "587fcede-4fe9-4fb4-85ec-124abc8542b0",
|
||||
"Description": "A Croshaw-based munitions manufacturer that focuses on missiles, bombs, and torpedoes. \n\nIf it exists, there’s a Talon that can blow it up."
|
||||
},
|
||||
{
|
||||
"Code": "TARS",
|
||||
"Name": "Tarsus",
|
||||
"Reference": "2b25a2dd-47ea-4ab0-8711-03d34be214b1",
|
||||
"Description": "Founded by a pair of mechanics who were attempting to make an affordable module that would convert a quantum drive into a jump drive, Tarsus has evolved to become one of the main producers of dependable jump drives in the UEE. Over time they also expanded to build their own quantum drives as well."
|
||||
},
|
||||
{
|
||||
"Code": "TDD",
|
||||
"Name": "Trade & Development Division",
|
||||
"Reference": "4f6dafd0-0b7e-4aac-844a-356896049adc"
|
||||
},
|
||||
{
|
||||
"Code": "THCN",
|
||||
"Name": "Thermyte Concern",
|
||||
"Reference": "d1a1bf03-89d9-4eae-ac34-11eeb8e45d8f",
|
||||
"Description": "Thermyte Concern"
|
||||
},
|
||||
{
|
||||
"Code": "THP",
|
||||
"Name": "Tehachapi",
|
||||
"Reference": "c0bc7b2d-9c1a-4029-9715-f28b81122863",
|
||||
"Description": "PH - Tehachapi Description"
|
||||
},
|
||||
{
|
||||
"Code": "TMBL",
|
||||
"Name": "Tumbril Land Systems",
|
||||
"Reference": "abe53599-feb8-4502-9c62-14d51ca80c7c",
|
||||
"Description": "Tumbril Land Systems got its start manufacturing ultra-rugged land vehicles built to explore the strange and dangerous landscapes of newly discovered planets. With the onset of the First Tevarin War, Tumbril expanded their operations when they won a government contract to build tanks and other military vehicles for the UPE military. Over the centuries the company eventually folded. Today, the Tumbril brand has been revived to build land vehicles from tanks to exploration vehicles and personal HOVs in both the civilian and military market."
|
||||
},
|
||||
{
|
||||
"Code": "TRAS",
|
||||
"Name": "Trident Attack Systems",
|
||||
"Reference": "d70afa6f-c7aa-4a3f-b222-d2208e6c21a9",
|
||||
"Description": "Aegis grew to prominence as a manufacturer of military spacecraft during the First Tevarin War. Favored by Ivar Messer, their orbital bombers grew to public prominence after his infamous victory on Idris IV and became synonymous with the ruthless Messer regime that followed. After the Fall of the Imperator, Aegis' contracts were stripped, their ships fell out of favor with the public, and the company went into a tailspin. They downsized their craft production and turned to manufacturing parts. However, time heals all wounds and today Aegis has risen once again to be a top military contractor of ships and components."
|
||||
},
|
||||
{
|
||||
"Code": "TRMI",
|
||||
"Name": "Terra Mills",
|
||||
"Reference": "a2d3a876-d263-443a-bc16-39444072d787"
|
||||
},
|
||||
{
|
||||
"Code": "TYDT",
|
||||
"Name": "Tyler Design & Tech",
|
||||
"Reference": "a2a97679-2a6e-4e89-80d8-439c88a01427",
|
||||
"Description": "Tyler Design & Tech began its life manufacturing generators, air purifiers, and other necessities for planetary settlers. Tyler focused on making products that, while not as powerful as some other brands, were durable and extremely efficient with little heat byproduct which led to the development of low-signature power plants. It wasn’t long before even the UEE Navy began to outfit some of their own stealth crafts with Tyler parts, and with their new success the company was able to expand their ship component line into what it is today."
|
||||
},
|
||||
{
|
||||
"Code": "UBA",
|
||||
"Name": "Universal Body Armor",
|
||||
"Reference": "04ded06f-7862-4771-b40f-aa310b74ee17",
|
||||
"Description": "- Universal Body Armor Description"
|
||||
},
|
||||
{
|
||||
"Code": "UEE",
|
||||
"Name": "United Empire of Earth",
|
||||
"Reference": "2e06e775-940b-4c24-8974-31fb5617ea56",
|
||||
"Description": "The unified collection of planets and systems under Human control, the UEE strives to create a safe and just empire, championing equality and opportunity for all its inhabitants."
|
||||
},
|
||||
{
|
||||
"Code": "UIHI_A",
|
||||
"Name": "<= PLACEHOLDER =>",
|
||||
"Reference": "d55cfa9f-5dab-4b8d-a105-cf2d16976003"
|
||||
},
|
||||
{
|
||||
"Code": "UILO_A",
|
||||
"Name": "<= PLACEHOLDER =>",
|
||||
"Reference": "fa121997-f0c5-43f7-a8e0-d5afff541c9e"
|
||||
},
|
||||
{
|
||||
"Code": "UILO_B",
|
||||
"Name": "<= PLACEHOLDER =>",
|
||||
"Reference": "e6ba6e48-e801-45a9-b07f-6a6c206bb4ba"
|
||||
},
|
||||
{
|
||||
"Code": "UILO_C",
|
||||
"Name": "<= PLACEHOLDER =>",
|
||||
"Reference": "23788dd9-6222-4a4a-b233-0f421afc8eb1"
|
||||
},
|
||||
{
|
||||
"Code": "UILO_C_2",
|
||||
"Name": "<= PLACEHOLDER =>",
|
||||
"Reference": "74da93a1-462b-4ca0-8374-6b482a617b32"
|
||||
},
|
||||
{
|
||||
"Code": "UIMI_A",
|
||||
"Name": "<= PLACEHOLDER =>",
|
||||
"Reference": "e289a3ba-8d54-4887-90d6-f03aa3c7961f"
|
||||
},
|
||||
{
|
||||
"Code": "UNKN",
|
||||
"Name": "Unknown",
|
||||
"Reference": "587a3cae-5e66-4f22-b0fc-1a47d41cafef",
|
||||
"Description": "PH Unknown Manufacturer Description"
|
||||
},
|
||||
{
|
||||
"Code": "UPS",
|
||||
"Name": "Upsiders",
|
||||
"Reference": "f444bb54-a422-44a0-abdb-a0384f0f5593",
|
||||
"Description": "Upsiders is a lifestyle footwear brand with excellent options for every member of your family."
|
||||
},
|
||||
{
|
||||
"Code": "UTFL",
|
||||
"Name": "UltiFlex",
|
||||
"Reference": "da264d7b-755b-48a2-9580-4adc17ff7ad5",
|
||||
"Description": "Been around for a couple hundred years. Survival company that makes knives. Got their start by making combat knives for the UEE Army. Expanded their operations to the public after decades of proud service."
|
||||
},
|
||||
{
|
||||
"Code": "VFGI",
|
||||
"Name": "VFG Industrial",
|
||||
"Reference": "3ce33ad8-82e7-4d8d-9ad1-bc95b976c17c"
|
||||
},
|
||||
{
|
||||
"Code": "VGL",
|
||||
"Name": "Virgil Ltd",
|
||||
"Reference": "0e6d8f1b-ebb8-4773-8ca4-f25d737c8709",
|
||||
"Description": "High-end Commercial and Personal armor manufacturer. Aside from combat uses, they build systems for heavy commercial and mining applications.\n\nModels: Achilles PDS (Heavy Combat Armor)\n\nZeus System (Titan Armor)"
|
||||
},
|
||||
{
|
||||
"Code": "VNCL",
|
||||
"Name": "Vanduul Clans",
|
||||
"Reference": "4668760a-d8a1-48d0-be4d-42304b702de0",
|
||||
"Description": "Military designations for the various Vanduul ship components."
|
||||
},
|
||||
{
|
||||
"Code": "VOLT",
|
||||
"Name": "Verified Offworld Laser Technologies",
|
||||
"Reference": "e2787f3b-b892-4276-bb2e-eb2b4dc9666b"
|
||||
},
|
||||
{
|
||||
"Code": "VPW",
|
||||
"Name": "Vaporwear",
|
||||
"Reference": "7dfff1b9-2bfa-4ace-86d9-30329a2a0f30",
|
||||
"Description": "PH - Vaporwear Description"
|
||||
},
|
||||
{
|
||||
"Code": "WCPR",
|
||||
"Name": "Wen-Cassel Propulsion",
|
||||
"Reference": "d8ef0f35-e196-4d18-affa-750f6cdf8cb4",
|
||||
"Description": "Subsidiary of RSI, handles a small percentage of their thruster and ship options construction as well as cooling systems."
|
||||
},
|
||||
{
|
||||
"Code": "WETK",
|
||||
"Name": "Wei-Tek",
|
||||
"Reference": "0f9ffe57-8542-4d83-a246-72568a5f2f91",
|
||||
"Description": "Upstart company made by former employees of Ascension Astro."
|
||||
},
|
||||
{
|
||||
"Code": "WLOP",
|
||||
"Name": "WillsOp",
|
||||
"Reference": "967cd454-20d9-4cb8-8465-990ff1a1f769",
|
||||
"Description": "WillsOp have products to cover the whole spectrum of electronic analysis from tracking and targeting to scanners and deep-space radar."
|
||||
},
|
||||
{
|
||||
"Code": "XNAA",
|
||||
"Name": "Aopoa",
|
||||
"Reference": "70ac7035-10a0-4bbb-90b0-9bddf047b504",
|
||||
"Description": "Though many attributed their elevation to the sole provider of light craft to audacity and daring, Aopoa cemented their position thanks to the innovative dual-vector thruster technology that has revolutionized their ship design."
|
||||
},
|
||||
{
|
||||
"Code": "YORM",
|
||||
"Name": "Yorm",
|
||||
"Reference": "435366d8-8b47-4cbc-b0cf-688a95a0847d",
|
||||
"Description": "Gotlieb Yorm was a top racing pilot on the professional circuit, who was known for his willingness to do anything to shave a few more seconds off his lap times. For a short while, before the Safety Commission stepped in, he would race naked, claiming clothes were an unnecessary weight. Another such innovation that was considerably more successful (and modest) was his insistence that the shields being used on his racer were too powerful. He really only needed to be able to fend off a few shots, because after that he would either have maneuvered his ship out of targeting range, or he would have lost the race anyway. Gutting apart an existing shield by hand, Yorm hacked together a lighter shield that would draw less power and only protect him just enough to escape. Soon, many other racers were trying to mimic his efforts, and when Yorm retired, he went on to successfully sell his modified shield design."
|
||||
}
|
||||
]
|
||||
6337
roles/trade_hub/files/trade-hub/backend/data/samples/ship-items.json
Normal file
6337
roles/trade_hub/files/trade-hub/backend/data/samples/ship-items.json
Normal file
File diff suppressed because it is too large
Load Diff
284334
roles/trade_hub/files/trade-hub/backend/data/samples/ships.json
Normal file
284334
roles/trade_hub/files/trade-hub/backend/data/samples/ships.json
Normal file
File diff suppressed because it is too large
Load Diff
2
roles/trade_hub/files/trade-hub/backend/main.py
Normal file
2
roles/trade_hub/files/trade-hub/backend/main.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Uvicorn entrypoint: `uvicorn main:app --host 0.0.0.0 --port 8000`."""
|
||||
from app.api import app # noqa: F401
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=8
|
||||
httpx>=0.27
|
||||
5
roles/trade_hub/files/trade-hub/backend/requirements.txt
Normal file
5
roles/trade_hub/files/trade-hub/backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
SQLAlchemy>=2.0
|
||||
psycopg[binary]>=3.2
|
||||
argon2-cffi>=23.1
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Download the full scunpacked dataset into a target directory.
|
||||
|
||||
Usage:
|
||||
python scripts/fetch_data.py [--output-dir DIR]
|
||||
|
||||
Default output: backend/data/full (used by the Docker image build).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
BASE_URL = "https://raw.githubusercontent.com/StarCitizenWiki/scunpacked-data/master"
|
||||
FILES = [
|
||||
"ships.json",
|
||||
"ship-items.json",
|
||||
"fps-items.json",
|
||||
"manufacturers.json",
|
||||
"items.json",
|
||||
]
|
||||
COMMODITY_KEYS = ("className", "name", "type", "subType", "reference")
|
||||
PLACEHOLDER = "<= PLACEHOLDER =>"
|
||||
|
||||
|
||||
def _commodity_rank(entry):
|
||||
class_name = entry.get("className") or ""
|
||||
return (0 if "_1SCU_" in class_name else 1, entry.get("size") or 0, class_name)
|
||||
|
||||
|
||||
def build_commodities(items_path, out_path):
|
||||
with open(items_path, encoding="utf-8") as f:
|
||||
items = json.load(f)
|
||||
|
||||
grouped = {}
|
||||
for entry in items:
|
||||
class_name = (entry.get("className") or "").lower()
|
||||
name = (entry.get("name") or "").strip()
|
||||
if entry.get("type") != "Cargo" or "commodity" not in class_name:
|
||||
continue
|
||||
if not name or name == PLACEHOLDER:
|
||||
continue
|
||||
grouped.setdefault(name, []).append(entry)
|
||||
|
||||
commodities = [
|
||||
{key: best.get(key) for key in COMMODITY_KEYS}
|
||||
for best in (min(grouped[name], key=_commodity_rank) for name in sorted(grouped))
|
||||
]
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(commodities, f, ensure_ascii=False, separators=(",", ":"))
|
||||
return len(commodities)
|
||||
|
||||
|
||||
def main():
|
||||
default_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "data", "full")
|
||||
)
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output-dir", default=default_dir)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
for name in FILES:
|
||||
url = f"{BASE_URL}/{name}"
|
||||
dest = os.path.join(args.output_dir, name)
|
||||
print(f"Downloading {url} -> {dest}", flush=True)
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
size = os.path.getsize(dest)
|
||||
print(f" done ({size / 1_000_000:.1f} MB)", flush=True)
|
||||
|
||||
items_path = os.path.join(args.output_dir, "items.json")
|
||||
count = build_commodities(
|
||||
items_path, os.path.join(args.output_dir, "commodities.json")
|
||||
)
|
||||
os.remove(items_path)
|
||||
print(f"Wrote {count} commodities (items.json discarded)", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
31
roles/trade_hub/files/trade-hub/backend/tests/conftest.py
Normal file
31
roles/trade_hub/files/trade-hub/backend/tests/conftest.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Test bootstrap.
|
||||
|
||||
Runs the suite against a throwaway in-memory SQLite database and the tiny
|
||||
fixture catalog, so no PostgreSQL server is needed. Environment variables must
|
||||
be set before any app module is imported, because config reads them at import
|
||||
time.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ["DATA_DIR"] = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||
os.environ["DATABASE_URL"] = "sqlite://"
|
||||
os.environ["AUTO_INIT_DB"] = "false"
|
||||
os.environ["COOKIE_SECURE"] = "false"
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from app import db # noqa: E402
|
||||
from app.models import Base # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db():
|
||||
"""Give every test an empty schema and a clean login throttle."""
|
||||
engine = db.get_engine()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
from app.auth import throttle
|
||||
|
||||
throttle.clear()
|
||||
yield
|
||||
30
roles/trade_hub/files/trade-hub/backend/tests/fixtures/commodities.json
vendored
Normal file
30
roles/trade_hub/files/trade-hub/backend/tests/fixtures/commodities.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials",
|
||||
"name": "Construction Materials",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "5ee15a9b-4ce5-445b-a02d-415c63f43764"
|
||||
},
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron",
|
||||
"name": "Iron",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "332096b9-1cbf-436d-b8b0-5ed422c23da9"
|
||||
},
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron",
|
||||
"name": "Iron (Ore)",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "271effd5-3aa8-4538-b9a0-b0f33dc86d68"
|
||||
},
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_RMC",
|
||||
"name": "Recycled Material Composite",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "2e50dde5-30d4-4faa-af8f-8e5aa8004b96"
|
||||
}
|
||||
]
|
||||
3
roles/trade_hub/files/trade-hub/backend/tests/fixtures/fps-items.json
vendored
Normal file
3
roles/trade_hub/files/trade-hub/backend/tests/fixtures/fps-items.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{"className": "BEHR_Rifle_Ballistic_01", "reference": "ref-3", "itemName": "behr_rifle_ballistic_01", "type": "WeaponPersonal", "subType": "Medium", "size": 2, "grade": 1, "name": "<= PLACEHOLDER =>", "tags": "weapon", "classification": "FPS.Weapon.Medium", "stdItem": {"UUID": "fps-uuid-1", "ClassName": "BEHR_Rifle_Ballistic_01", "Size": 2, "Grade": 1, "Width": 0.15, "Height": 0.15, "Length": 0.9, "Mass": 4, "Type": "WeaponPersonal.Medium", "Name": "Behring Rifle", "Description": "A ballistic rifle.", "DescriptionText": "", "Manufacturer": {"Name": "Behring", "Code": "BEHR", "UUID": "m-behr"}, "Tags": ["weapon"], "Ports": [{"PortName": "magazine_attach", "Size": 1}]}}
|
||||
]
|
||||
6
roles/trade_hub/files/trade-hub/backend/tests/fixtures/manufacturers.json
vendored
Normal file
6
roles/trade_hub/files/trade-hub/backend/tests/fixtures/manufacturers.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"Code": "ANVL", "Name": "Anvil Aerospace", "Reference": "m1", "Description": "Anvil makes ships."},
|
||||
{"Code": "BEHR", "Name": "Behring", "Reference": "m-behr", "Description": "Behring makes weapons."},
|
||||
{"Code": "UNKN", "Name": "Unknown Manufacturer", "Reference": "m-unkn", "Description": ""},
|
||||
{"Code": "PHLD", "Name": "<= PLACEHOLDER =>", "Reference": "m-phld", "Description": ""}
|
||||
]
|
||||
4
roles/trade_hub/files/trade-hub/backend/tests/fixtures/ship-items.json
vendored
Normal file
4
roles/trade_hub/files/trade-hub/backend/tests/fixtures/ship-items.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[
|
||||
{"className": "BEHR_Shield_S1", "reference": "ref-1", "itemName": "behr_shield_s1", "type": "Shield", "subType": "UNDEFINED", "size": 1, "grade": 2, "name": "<= PLACEHOLDER =>", "tags": "shield", "classification": "Ship.Shield", "stdItem": {"UUID": "si-uuid-1", "ClassName": "BEHR_Shield_S1", "Size": 1, "Grade": 2, "Width": 0.5, "Height": 0.5, "Length": 1.0, "Mass": 20, "Type": "Shield.UNDEFINED", "Name": "Behring Shield S1", "Description": "A size 1 shield.", "DescriptionText": "", "Manufacturer": {"Name": "Behring", "Code": "BEHR", "UUID": "m-behr"}, "Tags": ["shield"], "Ports": []}},
|
||||
{"className": "TEST_Ph_Item", "reference": "ref-2", "itemName": "test_ph_item", "type": "PowerPlant", "subType": "UNDEFINED", "size": 2, "grade": 1, "name": "<= PLACEHOLDER =>", "tags": "", "classification": "Ship.PowerPlant", "stdItem": {"UUID": "si-uuid-2", "ClassName": "TEST_Ph_Item", "Size": 2, "Grade": 1, "Width": 0.5, "Height": 0.5, "Length": 0.5, "Mass": 10, "Type": "PowerPlant.UNDEFINED", "Name": "<= PLACEHOLDER =>", "Description": "", "DescriptionText": "", "Manufacturer": {"Name": "<= PLACEHOLDER =>", "Code": "UNKN", "UUID": "m-unkn"}, "Tags": [], "Ports": [{"PortName": "connect", "Size": 1}]}}
|
||||
]
|
||||
4
roles/trade_hub/files/trade-hub/backend/tests/fixtures/ships.json
vendored
Normal file
4
roles/trade_hub/files/trade-hub/backend/tests/fixtures/ships.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[
|
||||
{"UUID": "ship-uuid-1", "ClassName": "ANVL_Carrack", "Name": "Anvil Carrack", "Description": "Long-range explorer.", "DescriptionText": "Long-range explorer.", "Career": "Exploration", "Role": "Expedition", "Manufacturer": {"UUID": "m1", "Code": "ANVL", "Name": "Anvil Aerospace"}, "Size": 4, "Length": 35.0, "Width": 23.0, "Height": 8.5, "Crew": 4, "Mass": 2000000, "IsSpaceship": true, "IsVehicle": false, "IsGravlev": false},
|
||||
{"UUID": "ship-uuid-2", "ClassName": "TEST_Placeholder_Ship", "Name": "<= PLACEHOLDER =>", "Description": "", "DescriptionText": "", "Career": "", "Role": "", "Manufacturer": {"UUID": "m2", "Code": "UNKN", "Name": "Unknown Manufacturer"}, "Size": 1, "Length": 10.0, "Width": 5.0, "Height": 3.0, "Crew": 1, "Mass": 50000, "IsSpaceship": true, "IsVehicle": false, "IsGravlev": false}
|
||||
]
|
||||
193
roles/trade_hub/files/trade-hub/backend/tests/test_api.py
Normal file
193
roles/trade_hub/files/trade-hub/backend/tests/test_api.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""API tests for the Star Citizen inventory backend (fixture dataset)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_healthz():
|
||||
resp = client.get("/healthz")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["counts"] == {
|
||||
"ships": 2,
|
||||
"ship-items": 2,
|
||||
"fps-items": 1,
|
||||
"commodities": 4,
|
||||
}
|
||||
|
||||
|
||||
def test_stats():
|
||||
resp = client.get("/api/v1/stats")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["categories"] == {
|
||||
"ships": 2,
|
||||
"ship-items": 2,
|
||||
"fps-items": 1,
|
||||
"commodities": 4,
|
||||
}
|
||||
assert body["manufacturers"] == 4
|
||||
|
||||
|
||||
def test_manufacturers_sorted_by_name():
|
||||
resp = client.get("/api/v1/manufacturers")
|
||||
assert resp.status_code == 200
|
||||
names = [m["name"] for m in resp.json()]
|
||||
assert names == ["Anvil Aerospace", "Behring", "PHLD", "Unknown Manufacturer"]
|
||||
|
||||
|
||||
def test_items_default_pagination():
|
||||
resp = client.get("/api/v1/items")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 9
|
||||
assert body["page"] == 1
|
||||
assert body["page_size"] == 25
|
||||
assert body["total_pages"] == 1
|
||||
assert len(body["items"]) == 9
|
||||
|
||||
|
||||
def test_items_page_size_capped():
|
||||
resp = client.get("/api/v1/items", params={"page_size": 500})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["page_size"] == 100
|
||||
|
||||
|
||||
def test_items_page_size_minimum_enforced():
|
||||
resp = client.get("/api/v1/items", params={"page_size": 0})
|
||||
assert resp.status_code == 422 # pydantic ge=1 validation
|
||||
|
||||
|
||||
def test_items_category_filter():
|
||||
resp = client.get("/api/v1/items", params={"category": "ships"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
assert all(i["category"] == "ships" for i in body["items"])
|
||||
|
||||
|
||||
def test_items_invalid_category_rejected():
|
||||
resp = client.get("/api/v1/items", params={"category": "bogus"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_items_search_matches_name_case_insensitive():
|
||||
resp = client.get("/api/v1/items", params={"search": "carrack"})
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["name"] == "Anvil Carrack"
|
||||
|
||||
|
||||
def test_items_search_matches_class_name():
|
||||
resp = client.get("/api/v1/items", params={"search": "test_ph"})
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["class_name"] == "TEST_Ph_Item"
|
||||
|
||||
|
||||
def test_items_manufacturer_filter_case_insensitive():
|
||||
resp = client.get("/api/v1/items", params={"manufacturer": "behr"})
|
||||
body = resp.json()
|
||||
assert body["total"] == 2 # Behring shield + Behring rifle
|
||||
assert all(i["manufacturer"]["code"] == "BEHR" for i in body["items"])
|
||||
|
||||
|
||||
def test_items_type_filter_case_insensitive():
|
||||
resp = client.get("/api/v1/items", params={"type": "weaponpersonal"})
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["class_name"] == "BEHR_Rifle_Ballistic_01"
|
||||
|
||||
|
||||
def test_items_size_filter():
|
||||
resp = client.get("/api/v1/items", params={"size": 2})
|
||||
body = resp.json()
|
||||
assert body["total"] == 2 # TEST_Ph_Item (size 2) + rifle (size 2)
|
||||
assert {i["class_name"] for i in body["items"]} == {
|
||||
"TEST_Ph_Item",
|
||||
"BEHR_Rifle_Ballistic_01",
|
||||
}
|
||||
|
||||
|
||||
def test_item_detail_ship():
|
||||
resp = client.get("/api/v1/items/ANVL_Carrack")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["name"] == "Anvil Carrack"
|
||||
assert body["category"] == "ships"
|
||||
assert body["details"]["crew"] == 4
|
||||
assert body["details"]["is_spaceship"] is True
|
||||
assert body["details"]["length"] == 35.0
|
||||
|
||||
|
||||
def test_item_detail_item_ports():
|
||||
resp = client.get("/api/v1/items/BEHR_Rifle_Ballistic_01")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["details"]["ports"][0]["PortName"] == "magazine_attach"
|
||||
assert body["details"]["mass"] == 4
|
||||
|
||||
|
||||
def test_item_detail_404():
|
||||
resp = client.get("/api/v1/items/NOPE_Does_Not_Exist")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"] == "Item 'NOPE_Does_Not_Exist' not found"
|
||||
|
||||
|
||||
def test_placeholder_name_falls_back_to_class_name():
|
||||
# Ship with Name == "<= PLACEHOLDER =>"
|
||||
resp = client.get("/api/v1/items/TEST_Placeholder_Ship")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "TEST Placeholder Ship"
|
||||
|
||||
# Item with stdItem.Name == "<= PLACEHOLDER =>"
|
||||
resp = client.get("/api/v1/items/TEST_Ph_Item")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "TEST Ph Item"
|
||||
|
||||
|
||||
def test_manufacturer_placeholder_name_falls_back_to_code():
|
||||
# manufacturers.json entry with Name == "<= PLACEHOLDER =>"
|
||||
resp = client.get("/api/v1/manufacturers")
|
||||
phld = [m for m in resp.json() if m["code"] == "PHLD"]
|
||||
assert len(phld) == 1
|
||||
assert phld[0]["name"] == "PHLD"
|
||||
|
||||
# Item whose embedded manufacturer Name is a placeholder
|
||||
resp = client.get("/api/v1/items/TEST_Ph_Item")
|
||||
assert resp.json()["manufacturer"]["name"] == "UNKN"
|
||||
|
||||
|
||||
def test_deterministic_sort_order():
|
||||
body = client.get("/api/v1/items", params={"page_size": 100}).json()
|
||||
keys = [(i["category"], i["name"].lower()) for i in body["items"]]
|
||||
assert keys == sorted(keys)
|
||||
|
||||
|
||||
def test_items_category_filter_commodities():
|
||||
resp = client.get("/api/v1/items", params={"category": "commodities"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 4
|
||||
assert all(i["category"] == "commodities" for i in body["items"])
|
||||
|
||||
|
||||
def test_items_search_matches_refined_and_ore_iron():
|
||||
resp = client.get("/api/v1/items", params={"search": "iron"})
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
assert {i["name"] for i in body["items"]} == {"Iron", "Iron (Ore)"}
|
||||
|
||||
|
||||
def test_item_detail_commodity():
|
||||
resp = client.get("/api/v1/items/Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["name"] == "Iron (Ore)"
|
||||
assert body["category"] == "commodities"
|
||||
assert body["type"] == "Cargo"
|
||||
assert body["size"] is None
|
||||
assert isinstance(body["details"], dict)
|
||||
151
roles/trade_hub/files/trade-hub/backend/tests/test_auth.py
Normal file
151
roles/trade_hub/files/trade-hub/backend/tests/test_auth.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Authentication tests: registration, login, sessions, throttling, CSRF."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import app
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
|
||||
|
||||
def new_client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def register(client: TestClient, username: str = "alice", password: str = PASSWORD):
|
||||
return client.post(
|
||||
"/api/v1/auth/register", json={"username": username, "password": password}
|
||||
)
|
||||
|
||||
|
||||
def login(client: TestClient, username: str = "alice", password: str = PASSWORD):
|
||||
return client.post(
|
||||
"/api/v1/auth/login", json={"username": username, "password": password}
|
||||
)
|
||||
|
||||
|
||||
def test_register_returns_user_and_authenticates():
|
||||
client = new_client()
|
||||
resp = register(client)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["user"]["username"] == "alice"
|
||||
assert "password" not in body["user"]
|
||||
assert client.cookies.get("sc_session")
|
||||
|
||||
me = client.get("/api/v1/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["user"]["username"] == "alice"
|
||||
|
||||
|
||||
def test_register_duplicate_username_is_conflict_case_insensitive():
|
||||
client = new_client()
|
||||
assert register(client, "Alice").status_code == 201
|
||||
assert register(new_client(), "alice").status_code == 409
|
||||
|
||||
|
||||
def test_register_rejects_weak_password():
|
||||
resp = register(new_client(), password="short")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_register_rejects_invalid_username():
|
||||
for username in ["ab", "has space", "bad!", "x" * 33]:
|
||||
assert register(new_client(), username).status_code == 422
|
||||
|
||||
|
||||
def test_me_requires_authentication():
|
||||
resp = new_client().get("/api/v1/auth/me")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_login_is_case_insensitive_and_works_after_logout():
|
||||
client = new_client()
|
||||
assert register(client, "alice").status_code == 201
|
||||
assert client.post("/api/v1/auth/logout").status_code == 204
|
||||
assert client.get("/api/v1/auth/me").status_code == 401
|
||||
|
||||
resp = login(client, "ALICE")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["user"]["username"] == "alice"
|
||||
assert client.get("/api/v1/auth/me").status_code == 200
|
||||
|
||||
|
||||
def test_wrong_password_and_unknown_user_are_indistinguishable():
|
||||
client = new_client()
|
||||
assert register(client, "alice").status_code == 201
|
||||
|
||||
wrong = login(new_client(), "alice", "definitely wrong")
|
||||
unknown = login(new_client(), "nobody", "definitely wrong")
|
||||
|
||||
assert wrong.status_code == unknown.status_code == 401
|
||||
assert wrong.json()["detail"] == unknown.json()["detail"]
|
||||
|
||||
|
||||
def test_logout_invalidates_session_token():
|
||||
client = new_client()
|
||||
assert register(client).status_code == 201
|
||||
token = client.cookies.get("sc_session")
|
||||
|
||||
assert client.post("/api/v1/auth/logout").status_code == 204
|
||||
|
||||
# Replay the old cookie on a fresh client: it must be rejected.
|
||||
replay = new_client()
|
||||
replay.cookies.set("sc_session", token)
|
||||
assert replay.get("/api/v1/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_login_is_rate_limited():
|
||||
client = new_client()
|
||||
assert register(client, "alice").status_code == 201
|
||||
|
||||
for _ in range(5):
|
||||
assert login(new_client(), "alice", "wrong password").status_code == 401
|
||||
|
||||
blocked = login(new_client(), "alice", PASSWORD)
|
||||
assert blocked.status_code == 429
|
||||
assert int(blocked.headers["Retry-After"]) > 0
|
||||
|
||||
|
||||
def test_cross_origin_mutation_is_rejected():
|
||||
client = new_client()
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "alice", "password": PASSWORD},
|
||||
headers={"Origin": "http://evil.example"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_same_origin_mutation_is_allowed():
|
||||
client = new_client()
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "alice", "password": PASSWORD},
|
||||
headers={"Origin": "http://testserver"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
def test_same_origin_with_forwarded_port_is_allowed():
|
||||
client = new_client()
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "alice", "password": PASSWORD},
|
||||
headers={
|
||||
"Origin": "http://testserver:8080",
|
||||
"X-Forwarded-Host": "testserver:8080",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
def test_forwarded_host_without_port_is_rejected():
|
||||
client = new_client()
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "alice", "password": PASSWORD},
|
||||
headers={
|
||||
"Origin": "http://testserver:8080",
|
||||
"X-Forwarded-Host": "testserver",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
156
roles/trade_hub/files/trade-hub/backend/tests/test_available.py
Normal file
156
roles/trade_hub/files/trade-hub/backend/tests/test_available.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Public available-items listing: open access, owner attribution, no notes."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import app
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
CARROCK = "ANVL_Carrack"
|
||||
RIFLE = "BEHR_Rifle_Ballistic_01"
|
||||
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
|
||||
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
|
||||
|
||||
|
||||
def authed_client(username: str) -> TestClient:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return client
|
||||
|
||||
|
||||
def add(
|
||||
client: TestClient,
|
||||
class_name: str,
|
||||
quantity: int = 1,
|
||||
notes: str = "",
|
||||
quality: int | None = None,
|
||||
):
|
||||
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
|
||||
if quality is not None:
|
||||
payload["quality"] = quality
|
||||
resp = client.post("/api/v1/me/inventory", json=payload)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
return resp
|
||||
|
||||
|
||||
def offer(client: TestClient, class_name: str, quantity: int = 1):
|
||||
add(client, class_name, quantity)
|
||||
resp = client.patch(
|
||||
f"/api/v1/me/inventory/{class_name}", json={"visibility": "available"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def public_items(**params):
|
||||
return TestClient(app).get("/api/v1/available", params=params).json()
|
||||
|
||||
|
||||
def test_listing_is_public_and_empty_by_default():
|
||||
body = public_items()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
assert body["page"] == 1
|
||||
|
||||
|
||||
def test_private_items_are_not_listed_until_marked_available():
|
||||
alice = authed_client("alice")
|
||||
add(alice, CARROCK)
|
||||
assert public_items()["total"] == 0
|
||||
|
||||
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"})
|
||||
body = public_items()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["class_name"] == CARROCK
|
||||
|
||||
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "private"})
|
||||
assert public_items()["total"] == 0
|
||||
|
||||
|
||||
def test_listing_shows_owner_and_catalog_details_but_not_notes():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, CARROCK, quantity=3)
|
||||
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"notes": "hidden stash"})
|
||||
|
||||
item = public_items()["items"][0]
|
||||
assert item["username"] == "alice"
|
||||
assert item["name"] == "Anvil Carrack"
|
||||
assert item["category"] == "ships"
|
||||
assert item["manufacturer"]["code"] == "ANVL"
|
||||
assert item["quantity"] == 3
|
||||
assert "notes" not in item
|
||||
assert "hidden stash" not in str(public_items())
|
||||
|
||||
|
||||
def test_each_owner_appears_as_its_own_offer():
|
||||
offer(authed_client("alice"), CARROCK, quantity=1)
|
||||
offer(authed_client("bob"), CARROCK, quantity=2)
|
||||
|
||||
items = public_items(sort="quantity")["items"]
|
||||
assert [(i["username"], i["quantity"], i["class_name"]) for i in items] == [
|
||||
("bob", 2, CARROCK),
|
||||
("alice", 1, CARROCK),
|
||||
]
|
||||
|
||||
|
||||
def test_removing_an_offer_removes_it_from_the_listing():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, CARROCK)
|
||||
assert public_items()["total"] == 1
|
||||
|
||||
assert alice.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
|
||||
assert public_items()["total"] == 0
|
||||
assert public_items()["items"] == []
|
||||
|
||||
|
||||
def test_listing_filters_by_category_and_searches_owner_or_item():
|
||||
offer(authed_client("alice"), CARROCK)
|
||||
offer(authed_client("bob"), RIFLE)
|
||||
|
||||
assert [i["class_name"] for i in public_items(category="ships")["items"]] == [CARROCK]
|
||||
assert [i["username"] for i in public_items(search="bob")["items"]] == ["bob"]
|
||||
assert [i["class_name"] for i in public_items(search="carrack")["items"]] == [CARROCK]
|
||||
|
||||
|
||||
def test_listing_paginates_and_rejects_unknown_sort():
|
||||
for quantity, class_name in ((1, CARROCK), (2, RIFLE)):
|
||||
offer(authed_client(f"pilot{quantity}"), class_name, quantity=quantity)
|
||||
|
||||
body = public_items(page=2, page_size=1)
|
||||
assert body["total"] == 2
|
||||
assert body["total_pages"] == 2
|
||||
assert len(body["items"]) == 1
|
||||
|
||||
assert public_items(page_size=500)["page_size"] == 100
|
||||
assert TestClient(app).get("/api/v1/available?sort=bogus").status_code == 422
|
||||
|
||||
|
||||
def test_commodity_offers_are_listed_with_owner():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, IRON_ORE, quantity=12)
|
||||
body = public_items()
|
||||
assert body["total"] == 1
|
||||
item = body["items"][0]
|
||||
assert item["name"] == "Iron (Ore)"
|
||||
assert item["category"] == "commodities"
|
||||
assert item["quantity"] == 12
|
||||
assert item["username"] == "alice"
|
||||
|
||||
|
||||
def test_commodity_quality_is_listed_publicly():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, IRON_ORE, quantity=12)
|
||||
add(alice, IRON_ORE, quality=875)
|
||||
|
||||
item = public_items()["items"][0]
|
||||
assert item["quality"] == 875
|
||||
assert item["quality_applicable"] is True
|
||||
|
||||
|
||||
def test_non_quality_commodity_is_listed_without_quality():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, CONSTRUCTION)
|
||||
|
||||
item = public_items()["items"][0]
|
||||
assert item["quality"] is None
|
||||
assert item["quality_applicable"] is False
|
||||
356
roles/trade_hub/files/trade-hub/backend/tests/test_inventory.py
Normal file
356
roles/trade_hub/files/trade-hub/backend/tests/test_inventory.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""Personal inventory tests: auth gating, CRUD, isolation, catalog immutability."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import app
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
CARROCK = "ANVL_Carrack"
|
||||
RIFLE = "BEHR_Rifle_Ballistic_01"
|
||||
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
|
||||
IRON = "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron"
|
||||
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
|
||||
|
||||
|
||||
def authed_client(username: str = "alice") -> TestClient:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return client
|
||||
|
||||
|
||||
def add(
|
||||
client: TestClient,
|
||||
class_name: str,
|
||||
quantity: int = 1,
|
||||
notes: str = "",
|
||||
quality: int | None = None,
|
||||
):
|
||||
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
|
||||
if quality is not None:
|
||||
payload["quality"] = quality
|
||||
return client.post("/api/v1/me/inventory", json=payload)
|
||||
|
||||
|
||||
def test_new_account_starts_with_empty_inventory():
|
||||
client = authed_client("freshpilot")
|
||||
listing = client.get("/api/v1/me/inventory").json()
|
||||
assert listing["total"] == 0
|
||||
assert listing["items"] == []
|
||||
assert client.get("/api/v1/me/inventory/summary").json() == {
|
||||
"items": 0,
|
||||
"quantity": 0,
|
||||
"categories": {},
|
||||
"unavailable": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_inventory_requires_authentication():
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/v1/me/inventory").status_code == 401
|
||||
assert add(client, CARROCK).status_code == 401
|
||||
assert client.get("/api/v1/me/inventory/summary").status_code == 401
|
||||
|
||||
|
||||
def test_add_unknown_catalog_item_is_not_found():
|
||||
client = authed_client()
|
||||
assert add(client, "NOPE_Does_Not_Exist").status_code == 404
|
||||
|
||||
|
||||
def test_add_creates_enriched_entry():
|
||||
client = authed_client()
|
||||
resp = add(client, CARROCK)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["created"] is True
|
||||
entry = body["entry"]
|
||||
assert entry["class_name"] == CARROCK
|
||||
assert entry["name"] == "Anvil Carrack"
|
||||
assert entry["category"] == "ships"
|
||||
assert entry["quantity"] == 1
|
||||
assert entry["visibility"] == "private"
|
||||
assert entry["available"] is True
|
||||
assert entry["manufacturer"]["code"] == "ANVL"
|
||||
|
||||
|
||||
def test_add_same_item_increments_quantity():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK, quantity=1).status_code == 201
|
||||
resp = add(client, CARROCK, quantity=2, notes="pledged")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["created"] is False
|
||||
assert body["entry"]["quantity"] == 3
|
||||
assert body["entry"]["notes"] == "pledged"
|
||||
|
||||
listing = client.get("/api/v1/me/inventory").json()
|
||||
assert listing["total"] == 1
|
||||
|
||||
|
||||
def test_inventories_are_isolated_between_users():
|
||||
alice = authed_client("alice")
|
||||
bob = authed_client("bob")
|
||||
|
||||
assert add(alice, CARROCK).status_code == 201
|
||||
assert add(bob, RIFLE).status_code == 201
|
||||
|
||||
alice_items = alice.get("/api/v1/me/inventory").json()["items"]
|
||||
bob_items = bob.get("/api/v1/me/inventory").json()["items"]
|
||||
|
||||
assert [i["class_name"] for i in alice_items] == [CARROCK]
|
||||
assert [i["class_name"] for i in bob_items] == [RIFLE]
|
||||
|
||||
|
||||
def test_patch_updates_quantity_and_notes():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 4, "notes": "hangar 3"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["quantity"] == 4
|
||||
assert body["notes"] == "hangar 3"
|
||||
|
||||
|
||||
def test_visibility_toggles_between_private_and_available():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["visibility"] == "available"
|
||||
listed = client.get("/api/v1/me/inventory").json()["items"]
|
||||
assert listed[0]["visibility"] == "available"
|
||||
|
||||
back = client.patch(
|
||||
f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "private"}
|
||||
)
|
||||
assert back.json()["visibility"] == "private"
|
||||
|
||||
|
||||
def test_patch_rejects_unknown_visibility():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "public"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_visibility_is_kept_when_quantity_changes_and_item_is_readded():
|
||||
client = authed_client()
|
||||
add(client, CARROCK)
|
||||
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"})
|
||||
|
||||
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 3})
|
||||
assert add(client, CARROCK, quantity=1).status_code == 200
|
||||
entry = client.get("/api/v1/me/inventory").json()["items"][0]
|
||||
assert entry["quantity"] == 4
|
||||
assert entry["visibility"] == "available"
|
||||
|
||||
|
||||
def test_patch_unknown_entry_is_not_found():
|
||||
client = authed_client()
|
||||
assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404
|
||||
|
||||
|
||||
def test_patch_rejects_zero_quantity():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 0})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_delete_removes_entry():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
|
||||
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
|
||||
assert client.get("/api/v1/me/inventory").json()["total"] == 0
|
||||
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 404
|
||||
|
||||
|
||||
def test_list_filters_by_category_and_search():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
assert add(client, RIFLE).status_code == 201
|
||||
|
||||
ships = client.get("/api/v1/me/inventory", params={"category": "ships"}).json()
|
||||
assert ships["total"] == 1
|
||||
assert ships["items"][0]["class_name"] == CARROCK
|
||||
|
||||
found = client.get("/api/v1/me/inventory", params={"search": "carrack"}).json()
|
||||
assert found["total"] == 1
|
||||
assert found["items"][0]["class_name"] == CARROCK
|
||||
|
||||
|
||||
def test_list_pagination_envelope_and_cap():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
assert add(client, RIFLE).status_code == 201
|
||||
|
||||
body = client.get(
|
||||
"/api/v1/me/inventory", params={"page_size": 1, "page": 2}
|
||||
).json()
|
||||
assert body["total"] == 2
|
||||
assert body["page"] == 2
|
||||
assert body["page_size"] == 1
|
||||
assert body["total_pages"] == 2
|
||||
assert len(body["items"]) == 1
|
||||
|
||||
capped = client.get("/api/v1/me/inventory", params={"page_size": 500}).json()
|
||||
assert capped["page_size"] == 100
|
||||
|
||||
|
||||
def test_summary_aggregates_quantity_and_categories():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK, quantity=2).status_code == 201
|
||||
assert add(client, RIFLE, quantity=3).status_code == 201
|
||||
|
||||
summary = client.get("/api/v1/me/inventory/summary").json()
|
||||
assert summary["items"] == 2
|
||||
assert summary["quantity"] == 5
|
||||
assert summary["unavailable"] == 0
|
||||
assert summary["categories"]["ships"] == 2
|
||||
|
||||
|
||||
def test_notes_length_is_bounded():
|
||||
client = authed_client()
|
||||
resp = add(client, CARROCK, notes="x" * 1001)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_catalog_is_not_mutated_by_inventory_operations():
|
||||
client = authed_client()
|
||||
before = client.get("/api/v1/items", params={"page_size": 100}).json()
|
||||
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 9})
|
||||
client.delete(f"/api/v1/me/inventory/{CARROCK}")
|
||||
|
||||
after = client.get("/api/v1/items", params={"page_size": 100}).json()
|
||||
assert after == before
|
||||
carrack = client.get(f"/api/v1/items/{CARROCK}").json()
|
||||
assert "quantity" not in carrack
|
||||
assert "notes" not in carrack
|
||||
|
||||
|
||||
def test_sort_by_category_groups_categories():
|
||||
client = authed_client()
|
||||
add(client, CARROCK)
|
||||
add(client, RIFLE)
|
||||
items = client.get("/api/v1/me/inventory?sort=category").json()["items"]
|
||||
assert [i["class_name"] for i in items] == [RIFLE, CARROCK]
|
||||
|
||||
|
||||
def test_sort_by_quantity_is_descending():
|
||||
client = authed_client()
|
||||
add(client, CARROCK, quantity=5)
|
||||
add(client, RIFLE, quantity=1)
|
||||
items = client.get("/api/v1/me/inventory?sort=quantity").json()["items"]
|
||||
assert [i["class_name"] for i in items] == [CARROCK, RIFLE]
|
||||
|
||||
|
||||
def test_sort_defaults_to_name_and_rejects_unknown_values():
|
||||
client = authed_client()
|
||||
add(client, CARROCK)
|
||||
listing = client.get("/api/v1/me/inventory").json()
|
||||
assert [i["class_name"] for i in listing["items"]] == [CARROCK]
|
||||
assert client.get("/api/v1/me/inventory?sort=bogus").status_code == 422
|
||||
|
||||
|
||||
def test_add_commodity_uses_catalog_name_and_quantity():
|
||||
client = authed_client("refiner")
|
||||
resp = add(client, IRON_ORE, quantity=12, notes="from Aaron Halo")
|
||||
assert resp.status_code == 201
|
||||
entry = client.get("/api/v1/me/inventory").json()["items"][0]
|
||||
assert entry["name"] == "Iron (Ore)"
|
||||
assert entry["category"] == "commodities"
|
||||
assert entry["quantity"] == 12
|
||||
assert entry["available"] is True
|
||||
assert entry["visibility"] == "private"
|
||||
|
||||
|
||||
def test_catalog_marks_only_mineable_commodities_as_quality_applicable():
|
||||
client = TestClient(app)
|
||||
items = client.get(
|
||||
"/api/v1/items", params={"category": "commodities", "page_size": 100}
|
||||
).json()["items"]
|
||||
flags = {item["class_name"]: item["quality_applicable"] for item in items}
|
||||
assert flags[IRON] is True
|
||||
assert flags[IRON_ORE] is True
|
||||
assert flags[CONSTRUCTION] is False
|
||||
assert flags["Carryable_TBO_FL_1SCU_Commodity_Metal_RMC"] is False
|
||||
|
||||
ships = client.get("/api/v1/items", params={"category": "ships"}).json()["items"]
|
||||
assert all(item["quality_applicable"] is False for item in ships)
|
||||
|
||||
|
||||
def test_quality_can_be_set_and_cleared_on_a_quality_commodity():
|
||||
client = authed_client()
|
||||
assert add(client, IRON_ORE).status_code == 201
|
||||
|
||||
resp = client.patch(f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": 750})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["quality"] == 750
|
||||
assert body["quality_applicable"] is True
|
||||
|
||||
cleared = client.patch(
|
||||
f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": None}
|
||||
)
|
||||
assert cleared.status_code == 200
|
||||
assert cleared.json()["quality"] is None
|
||||
|
||||
|
||||
def test_add_persists_quality_and_keeps_it_when_quantity_is_incremented():
|
||||
client = authed_client()
|
||||
first = add(client, IRON, quality=900)
|
||||
assert first.status_code == 201
|
||||
assert first.json()["entry"]["quality"] == 900
|
||||
|
||||
second = add(client, IRON, quantity=2)
|
||||
assert second.status_code == 200
|
||||
entry = second.json()["entry"]
|
||||
assert entry["quality"] == 900
|
||||
assert entry["quantity"] == 3
|
||||
|
||||
replaced = add(client, IRON, quality=400)
|
||||
assert replaced.json()["entry"]["quality"] == 400
|
||||
|
||||
|
||||
def test_quality_is_rejected_for_non_mineable_commodities():
|
||||
client = authed_client()
|
||||
assert add(client, CONSTRUCTION).status_code == 201
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/me/inventory/{CONSTRUCTION}", json={"quality": 500}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert "does not apply" in resp.json()["detail"]
|
||||
|
||||
add_resp = add(client, CONSTRUCTION, quality=500)
|
||||
assert add_resp.status_code == 422
|
||||
|
||||
|
||||
def test_quality_is_rejected_for_ships():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quality": 100})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_quality_range_is_enforced():
|
||||
client = authed_client()
|
||||
assert add(client, IRON_ORE, quality=1).status_code == 201
|
||||
assert add(client, IRON, quality=1000).status_code == 201
|
||||
assert add(client, IRON_ORE, quality=0).status_code == 422
|
||||
assert add(client, IRON_ORE, quality=1001).status_code == 422
|
||||
|
||||
assert add(client, IRON, quantity=1).status_code == 200
|
||||
assert client.patch(
|
||||
f"/api/v1/me/inventory/{IRON}", json={"quality": 1001}
|
||||
).status_code == 422
|
||||
176
roles/trade_hub/files/trade-hub/backend/tests/test_orders.py
Normal file
176
roles/trade_hub/files/trade-hub/backend/tests/test_orders.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Public order board: authenticated creation, public read, owner-only edits."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import app
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
CARROCK = "ANVL_Carrack"
|
||||
RIFLE = "BEHR_Rifle_Ballistic_01"
|
||||
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
|
||||
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
|
||||
|
||||
|
||||
def authed_client(username: str) -> TestClient:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return client
|
||||
|
||||
|
||||
def place(
|
||||
client: TestClient,
|
||||
class_name: str,
|
||||
quantity: int = 1,
|
||||
notes: str = "",
|
||||
quality: int | None = None,
|
||||
):
|
||||
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
|
||||
if quality is not None:
|
||||
payload["quality"] = quality
|
||||
return client.post("/api/v1/orders", json=payload)
|
||||
|
||||
|
||||
def public_orders(**params):
|
||||
return TestClient(app).get("/api/v1/orders", params=params).json()
|
||||
|
||||
|
||||
def test_order_board_is_public_and_empty_by_default():
|
||||
body = public_orders()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
assert body["page"] == 1
|
||||
|
||||
|
||||
def test_placing_an_order_requires_authentication():
|
||||
assert place(TestClient(app), CARROCK).status_code == 401
|
||||
|
||||
|
||||
def test_placing_an_order_returns_enriched_public_entry():
|
||||
alice = authed_client("alice")
|
||||
resp = place(alice, CARROCK, quantity=2, notes="Need a hauler")
|
||||
assert resp.status_code == 201, resp.text
|
||||
order = resp.json()
|
||||
assert order["class_name"] == CARROCK
|
||||
assert order["name"] == "Anvil Carrack"
|
||||
assert order["category"] == "ships"
|
||||
assert order["manufacturer"]["code"] == "ANVL"
|
||||
assert order["quantity"] == 2
|
||||
assert order["notes"] == "Need a hauler"
|
||||
assert order["username"] == "alice"
|
||||
assert order["status"] == "open"
|
||||
assert order["available"] is True
|
||||
|
||||
public = public_orders()
|
||||
assert public["total"] == 1
|
||||
assert public["items"][0]["id"] == order["id"]
|
||||
assert public["items"][0]["username"] == "alice"
|
||||
|
||||
|
||||
def test_orders_from_every_user_are_visible():
|
||||
place(authed_client("alice"), CARROCK)
|
||||
place(authed_client("bob"), RIFLE)
|
||||
|
||||
items = public_orders(sort="name")["items"]
|
||||
assert [(i["username"], i["class_name"]) for i in items] == [
|
||||
("alice", CARROCK),
|
||||
("bob", RIFLE),
|
||||
]
|
||||
|
||||
|
||||
def test_order_quality_is_stored_and_public_for_ore():
|
||||
alice = authed_client("alice")
|
||||
resp = place(alice, IRON_ORE, quantity=10, quality=875)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["quality"] == 875
|
||||
assert resp.json()["quality_applicable"] is True
|
||||
|
||||
item = public_orders()["items"][0]
|
||||
assert item["quality"] == 875
|
||||
assert item["quality_applicable"] is True
|
||||
|
||||
|
||||
def test_quality_is_rejected_for_non_mineable_items():
|
||||
alice = authed_client("alice")
|
||||
assert place(alice, CONSTRUCTION, quality=500).status_code == 422
|
||||
assert place(alice, CARROCK, quality=100).status_code == 422
|
||||
assert place(alice, IRON_ORE, quality=1001).status_code == 422
|
||||
|
||||
|
||||
def test_unknown_catalog_item_is_rejected():
|
||||
assert place(authed_client("alice"), "NOPE_Does_Not_Exist").status_code == 404
|
||||
|
||||
|
||||
def test_listing_filters_by_category_search_and_status():
|
||||
alice = authed_client("alice")
|
||||
bob = authed_client("bob")
|
||||
first = place(alice, CARROCK).json()
|
||||
place(bob, RIFLE)
|
||||
place(alice, IRON_ORE, quality=400)
|
||||
|
||||
assert [i["class_name"] for i in public_orders(category="ships")["items"]] == [
|
||||
CARROCK
|
||||
]
|
||||
assert [i["username"] for i in public_orders(search="bob")["items"]] == ["bob"]
|
||||
assert [
|
||||
i["class_name"] for i in public_orders(search="iron")["items"]
|
||||
] == [IRON_ORE]
|
||||
|
||||
alice.patch(f"/api/v1/orders/{first['id']}", json={"status": "fulfilled"})
|
||||
assert [i["class_name"] for i in public_orders(status="open")["items"]] == [
|
||||
IRON_ORE,
|
||||
RIFLE,
|
||||
]
|
||||
assert [i["class_name"] for i in public_orders(status="fulfilled")["items"]] == [
|
||||
CARROCK
|
||||
]
|
||||
|
||||
|
||||
def test_listing_paginates_and_rejects_unknown_sort():
|
||||
alice = authed_client("alice")
|
||||
place(alice, CARROCK)
|
||||
place(alice, RIFLE)
|
||||
|
||||
body = public_orders(page=2, page_size=1)
|
||||
assert body["total"] == 2
|
||||
assert body["total_pages"] == 2
|
||||
assert len(body["items"]) == 1
|
||||
|
||||
assert public_orders(page_size=500)["page_size"] == 100
|
||||
assert TestClient(app).get("/api/v1/orders?sort=bogus").status_code == 422
|
||||
assert TestClient(app).get("/api/v1/orders?status=bogus").status_code == 422
|
||||
|
||||
|
||||
def test_only_the_owner_can_edit_or_remove_an_order():
|
||||
alice = authed_client("alice")
|
||||
bob = authed_client("bob")
|
||||
order = place(alice, IRON_ORE, quality=500).json()
|
||||
|
||||
assert (
|
||||
bob.patch(
|
||||
f"/api/v1/orders/{order['id']}", json={"quantity": 99}
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert bob.delete(f"/api/v1/orders/{order['id']}").status_code == 404
|
||||
|
||||
updated = alice.patch(
|
||||
f"/api/v1/orders/{order['id']}",
|
||||
json={"quantity": 3, "notes": "Updated", "status": "fulfilled"},
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
body = updated.json()
|
||||
assert body["quantity"] == 3
|
||||
assert body["notes"] == "Updated"
|
||||
assert body["status"] == "fulfilled"
|
||||
assert body["quality"] == 500
|
||||
|
||||
cleared = alice.patch(
|
||||
f"/api/v1/orders/{order['id']}", json={"quality": None}
|
||||
)
|
||||
assert cleared.status_code == 200, cleared.text
|
||||
assert cleared.json()["quality"] is None
|
||||
|
||||
assert alice.delete(f"/api/v1/orders/{order['id']}").status_code == 204
|
||||
assert public_orders()["total"] == 0
|
||||
6
roles/trade_hub/files/trade-hub/frontend/Dockerfile
Normal file
6
roles/trade_hub/files/trade-hub/frontend/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM docker.io/library/nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY src/ /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 8071
|
||||
39
roles/trade_hub/files/trade-hub/frontend/nginx.conf
Normal file
39
roles/trade_hub/files/trade-hub/frontend/nginx.conf
Normal file
@@ -0,0 +1,39 @@
|
||||
server {
|
||||
listen 8071;
|
||||
server_name _;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# API and health checks are proxied to the backend container on the
|
||||
# shared podman network (service DNS name: backend).
|
||||
#
|
||||
# X-Real-IP and X-Forwarded-Proto are set here (not passed through), so the
|
||||
# backend can trust them for login throttling and redirect/scheme decisions.
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
# $http_host keeps the client's port, which the backend's Origin check
|
||||
# compares against; $host would drop the port and reject real browsers.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
1225
roles/trade_hub/files/trade-hub/frontend/src/app.js
Normal file
1225
roles/trade_hub/files/trade-hub/frontend/src/app.js
Normal file
File diff suppressed because it is too large
Load Diff
244
roles/trade_hub/files/trade-hub/frontend/src/index.html
Normal file
244
roles/trade_hub/files/trade-hub/frontend/src/index.html
Normal file
@@ -0,0 +1,244 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FPOV Logistics Readiness</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
<h1>Free People of the Verse <span>NEXUS</span></h1>
|
||||
<p class="subtitle">Network for Exchange of Xeno and Utility Supplies</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-status">
|
||||
<div id="api-status" class="status-pill checking">CHECKING API…</div>
|
||||
<div id="stats-line" class="stats-line">Loading counts…</div>
|
||||
</div>
|
||||
<div class="account-bar" id="account-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" id="category-tabs">
|
||||
<button class="tab active" data-view="catalog" data-category="">All Items</button>
|
||||
<button class="tab" data-view="catalog" data-category="ships">Ships</button>
|
||||
<button class="tab" data-view="catalog" data-category="ship-items">Ship Equipment</button>
|
||||
<button class="tab" data-view="catalog" data-category="fps-items">FPS Items</button>
|
||||
<button class="tab" data-view="catalog" data-category="commodities">Commodities</button>
|
||||
<button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button>
|
||||
<button class="tab" id="tab-available" data-view="available">Available</button>
|
||||
<button class="tab" id="tab-orders" data-view="orders">Orders</button>
|
||||
</nav>
|
||||
|
||||
<div id="catalog-view">
|
||||
<section class="controls">
|
||||
<input type="search" id="search-input" placeholder="Search name or class name…" autocomplete="off">
|
||||
<select id="manufacturer-select">
|
||||
<option value="">All manufacturers</option>
|
||||
</select>
|
||||
<input type="text" id="type-input" placeholder="Type (e.g. WeaponPersonal)">
|
||||
<input type="number" id="size-input" placeholder="Size" min="0" max="12">
|
||||
<button id="reset-filters" class="btn-secondary">Reset</button>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<div id="error-banner" class="error-banner hidden"></div>
|
||||
<div id="loading" class="loading">Loading inventory…</div>
|
||||
<div id="empty-state" class="empty-state hidden">
|
||||
<p>No items match your filters.</p>
|
||||
<p class="hint">Try clearing the search or resetting the filters.</p>
|
||||
</div>
|
||||
<div id="results-grid" class="results-grid"></div>
|
||||
|
||||
<div class="pagination" id="pagination">
|
||||
<button id="prev-page" class="btn-secondary">← Prev</button>
|
||||
<span id="page-info">Page 1 of 1</span>
|
||||
<button id="next-page" class="btn-secondary">Next →</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<section id="inventory-view" class="hidden">
|
||||
<div class="inventory-controls">
|
||||
<input type="search" id="inv-search" placeholder="Search my inventory…" autocomplete="off">
|
||||
<select id="inv-category">
|
||||
<option value="">All categories</option>
|
||||
<option value="ships">Ships</option>
|
||||
<option value="ship-items">Ship Equipment</option>
|
||||
<option value="fps-items">FPS Items</option>
|
||||
<option value="commodities">Commodities</option>
|
||||
</select>
|
||||
<select id="inv-sort" aria-label="Sort inventory">
|
||||
<option value="name">Sort: Name</option>
|
||||
<option value="category">Sort: Category</option>
|
||||
<option value="quantity">Sort: Quantity</option>
|
||||
<option value="updated">Sort: Recently updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="inv-summary" class="inv-summary"></div>
|
||||
<div id="inv-empty" class="empty-state hidden">
|
||||
<p>Your inventory is empty.</p>
|
||||
<p class="hint">Browse the catalog and use “Add to my inventory” on any item.</p>
|
||||
</div>
|
||||
<div id="inv-table-wrap" class="inv-table-wrap hidden">
|
||||
<table class="inv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Qty</th>
|
||||
<th>Quality</th>
|
||||
<th>Notes</th>
|
||||
<th>Visibility</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="inv-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="available-view" class="hidden">
|
||||
<div class="inventory-controls">
|
||||
<input type="search" id="avail-search" placeholder="Search item or pilot…" autocomplete="off">
|
||||
<select id="avail-category">
|
||||
<option value="">All categories</option>
|
||||
<option value="ships">Ships</option>
|
||||
<option value="ship-items">Ship Equipment</option>
|
||||
<option value="fps-items">FPS Items</option>
|
||||
<option value="commodities">Commodities</option>
|
||||
</select>
|
||||
<select id="avail-sort" aria-label="Sort available items">
|
||||
<option value="updated">Sort: Recently updated</option>
|
||||
<option value="name">Sort: Name</option>
|
||||
<option value="category">Sort: Category</option>
|
||||
<option value="quantity">Sort: Quantity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="avail-summary" class="inv-summary"></div>
|
||||
<div id="avail-empty" class="empty-state hidden">
|
||||
<p>Nothing is being offered right now.</p>
|
||||
<p class="hint">Mark items as available in My Inventory to list them here.</p>
|
||||
</div>
|
||||
<div id="avail-table-wrap" class="inv-table-wrap hidden">
|
||||
<table class="inv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Qty</th>
|
||||
<th>Quality</th>
|
||||
<th>Offered by</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="avail-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="orders-view" class="hidden">
|
||||
<div class="inventory-controls">
|
||||
<input type="search" id="order-search" placeholder="Search item or pilot…" autocomplete="off">
|
||||
<select id="order-category">
|
||||
<option value="">All categories</option>
|
||||
<option value="ships">Ships</option>
|
||||
<option value="ship-items">Ship Equipment</option>
|
||||
<option value="fps-items">FPS Items</option>
|
||||
<option value="commodities">Commodities</option>
|
||||
</select>
|
||||
<select id="order-status" aria-label="Filter by order status">
|
||||
<option value="">All statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="fulfilled">Fulfilled</option>
|
||||
</select>
|
||||
<select id="order-sort" aria-label="Sort orders">
|
||||
<option value="updated">Sort: Recently updated</option>
|
||||
<option value="name">Sort: Name</option>
|
||||
<option value="category">Sort: Category</option>
|
||||
<option value="quantity">Sort: Quantity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="order-summary" class="inv-summary"></div>
|
||||
<div id="order-empty" class="empty-state hidden">
|
||||
<p>No orders yet.</p>
|
||||
<p class="hint">Log in, open any catalog item and use “Request this item” to set it up here.</p>
|
||||
</div>
|
||||
<div id="order-table-wrap" class="inv-table-wrap hidden">
|
||||
<table class="inv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Qty</th>
|
||||
<th>Quality</th>
|
||||
<th>Notes</th>
|
||||
<th>Requested by</th>
|
||||
<th>Status</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="order-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>Data source:
|
||||
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>
|
||||
— unofficial Star Citizen fansite content, not affiliated with Cloud Imperium Games.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<div id="modal-backdrop" class="modal-backdrop hidden">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<button class="modal-close" id="modal-close" aria-label="Close">×</button>
|
||||
<h2 id="modal-title">Item</h2>
|
||||
<div id="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="confirm-backdrop" class="modal-backdrop hidden">
|
||||
<div class="modal modal-narrow" role="alertdialog" aria-modal="true"
|
||||
aria-labelledby="confirm-title" aria-describedby="confirm-message">
|
||||
<h2 id="confirm-title">Remove item</h2>
|
||||
<p id="confirm-message" class="modal-desc"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="confirm-cancel" class="btn-secondary">Cancel</button>
|
||||
<button type="button" id="confirm-ok" class="btn-danger">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="auth-backdrop" class="modal-backdrop hidden">
|
||||
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="auth-title">
|
||||
<button class="modal-close" id="auth-close" aria-label="Close">×</button>
|
||||
<h2 id="auth-title">Log in</h2>
|
||||
<form id="auth-form" class="auth-form" novalidate>
|
||||
<label>Username
|
||||
<input type="text" id="auth-username" name="username" autocomplete="username"
|
||||
minlength="3" maxlength="32" required>
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" id="auth-password" name="password"
|
||||
autocomplete="current-password" minlength="8" maxlength="128" required>
|
||||
</label>
|
||||
<p id="auth-error" class="auth-error" role="alert"></p>
|
||||
<button type="submit" id="auth-submit" class="btn-primary">Log in</button>
|
||||
</form>
|
||||
<p class="auth-switch">
|
||||
<button type="button" id="auth-toggle" class="btn-link">Need an account? Register</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden" role="status"></div>
|
||||
|
||||
<script src="app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
598
roles/trade_hub/files/trade-hub/frontend/src/style.css
Normal file
598
roles/trade_hub/files/trade-hub/frontend/src/style.css
Normal file
@@ -0,0 +1,598 @@
|
||||
/* Star Citizen Inventory — dark sci-fi theme */
|
||||
|
||||
:root {
|
||||
--bg: #0b0e14;
|
||||
--bg-panel: #11151f;
|
||||
--bg-card: #151a26;
|
||||
--border: #232b3d;
|
||||
--text: #d7dce8;
|
||||
--text-dim: #8b93a7;
|
||||
--accent: #38bdf8;
|
||||
--accent-warm: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--cat-ships: #38bdf8;
|
||||
--cat-ship-items: #a78bfa;
|
||||
--cat-fps-items: #f59e0b;
|
||||
--cat-commodities: #4ade80;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Inter", system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.site-header {
|
||||
background: linear-gradient(180deg, #0d1220 0%, var(--bg) 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.25rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 800;
|
||||
}
|
||||
.brand h1 span { color: var(--accent); }
|
||||
.subtitle { margin: 0.25rem 0 0; color: var(--text-dim); font-size: 0.85rem; }
|
||||
|
||||
.header-status { text-align: right; }
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.status-pill.online { color: var(--success); border-color: var(--success); background: rgba(34,197,94,0.08); }
|
||||
.status-pill.offline { color: var(--danger); border-color: var(--danger); background: rgba(239,68,68,0.08); }
|
||||
.status-pill.checking { color: var(--text-dim); border-color: var(--border); }
|
||||
.stats-line { margin-top: 0.4rem; font-size: 0.8rem; color: var(--text-dim); }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
max-width: 1200px;
|
||||
margin: 1rem auto 0;
|
||||
padding: 0 1.25rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tab {
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 0.6rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab-mine { margin-left: auto; }
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.controls input, .controls select {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
#search-input { flex: 2 1 240px; }
|
||||
#manufacturer-select { flex: 1 1 180px; }
|
||||
#type-input { flex: 1 1 160px; }
|
||||
#size-input { flex: 0 1 90px; }
|
||||
.controls input:focus, .controls select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
|
||||
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #1b0505;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
/* Main */
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: rgba(239,68,68,0.1);
|
||||
border: 1px solid var(--danger);
|
||||
color: #fca5a5;
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading, .empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
padding: 3rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.empty-state .hint { font-size: 0.85rem; opacity: 0.7; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Results grid */
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.item-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.1s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.item-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||
.item-card .item-name { font-weight: 700; font-size: 0.98rem; line-height: 1.3; }
|
||||
.item-card .item-manufacturer { color: var(--text-dim); font-size: 0.8rem; }
|
||||
.item-card .item-meta {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
}
|
||||
.badge.cat-ships { color: var(--cat-ships); border-color: var(--cat-ships); }
|
||||
.badge.cat-ship-items { color: var(--cat-ship-items); border-color: var(--cat-ship-items); }
|
||||
.badge.cat-fps-items { color: var(--cat-fps-items); border-color: var(--cat-fps-items); }
|
||||
.badge.cat-commodities { color: var(--cat-commodities); border-color: var(--cat-commodities); }
|
||||
.badge.size { color: var(--text-dim); border-color: var(--border); }
|
||||
.item-classification { color: var(--text-dim); font-size: 0.78rem; font-family: ui-monospace, monospace; }
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
#page-info { color: var(--text-dim); font-size: 0.9rem; }
|
||||
|
||||
/* Footer */
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 2rem;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.site-footer a { color: var(--accent); text-decoration: none; }
|
||||
.site-footer a:hover { text-decoration: underline; }
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5,8,14,0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.9rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.6rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
.modal h2 { margin: 0 2rem 0.25rem 0; font-size: 1.25rem; }
|
||||
.modal .modal-sub { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
.modal .modal-desc { font-size: 0.92rem; line-height: 1.55; margin-bottom: 1rem; }
|
||||
.modal .tag-row { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||
.spec-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||
.spec-table th, .spec-table td {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.spec-table th { color: var(--text-dim); font-weight: 600; width: 40%; }
|
||||
.spec-table td { font-family: ui-monospace, monospace; }
|
||||
|
||||
/* Account controls */
|
||||
.header-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.account-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.account-bar .who {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #04121c;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
}
|
||||
.btn-link:hover { color: var(--accent); }
|
||||
|
||||
/* Card add button */
|
||||
.card-add {
|
||||
margin-left: auto;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
line-height: 1;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
background: var(--bg-panel);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-add:hover { border-color: var(--accent); }
|
||||
|
||||
/* Personal inventory view */
|
||||
.inventory-controls {
|
||||
max-width: 1200px;
|
||||
margin: 1rem auto 0;
|
||||
padding: 0 1.25rem;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.inventory-controls input,
|
||||
.inventory-controls select {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
#inv-search { flex: 2 1 280px; }
|
||||
#inv-category { flex: 1 1 180px; }
|
||||
#inv-sort { flex: 1 1 170px; }
|
||||
.inventory-controls input:focus,
|
||||
.inventory-controls select:focus { outline: none; border-color: var(--accent); }
|
||||
#inventory-view,
|
||||
#available-view {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem 0;
|
||||
}
|
||||
.inv-summary {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
.inv-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.inv-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
.inv-table th,
|
||||
.inv-table td {
|
||||
text-align: left;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.inv-table th {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.inv-table tr:last-child td { border-bottom: none; }
|
||||
.inv-item-name { font-weight: 600; }
|
||||
.inv-item-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.inv-item-link:hover .inv-item-name { text-decoration: underline; }
|
||||
.inv-item-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.inv-item-link.is-static { cursor: default; }
|
||||
.inv-item-link.is-static:hover .inv-item-name { text-decoration: none; }
|
||||
.inv-item-sub {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.inv-qty {
|
||||
width: 5rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.inv-quality {
|
||||
width: 5rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.inv-quality-na { color: var(--text-dim); }
|
||||
.inv-quality:focus { outline: none; border-color: var(--accent); }
|
||||
.inv-notes {
|
||||
width: 100%;
|
||||
min-width: 12rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.inv-qty:focus, .inv-notes:focus { outline: none; border-color: var(--accent); }
|
||||
.inv-remove {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--danger);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.inv-remove:hover { border-color: var(--danger); }
|
||||
.visibility-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.visibility-toggle:hover { border-color: var(--accent); color: var(--text); }
|
||||
.visibility-toggle.is-available {
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.visibility-toggle:disabled { opacity: 0.6; cursor: progress; }
|
||||
.avail-user { font-weight: 600; }
|
||||
.avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
|
||||
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; }
|
||||
|
||||
/* Orders board */
|
||||
.order-notes {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.82rem;
|
||||
min-width: 10rem;
|
||||
max-width: 18rem;
|
||||
white-space: normal;
|
||||
}
|
||||
.order-status {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.order-status.open {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.order-status.fulfilled {
|
||||
color: var(--success);
|
||||
border-color: var(--success);
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.order-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Auth modal */
|
||||
.modal-narrow { max-width: 400px; }
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.auth-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.auth-form input {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.auth-form input:focus { outline: none; border-color: var(--accent); }
|
||||
.auth-error {
|
||||
color: #fca5a5;
|
||||
font-size: 0.85rem;
|
||||
min-height: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.auth-switch { margin-top: 0.9rem; font-size: 0.85rem; color: var(--text-dim); }
|
||||
|
||||
/* Detail modal add row */
|
||||
.add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.add-row input {
|
||||
width: 5rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
.add-row input:focus { outline: none; border-color: var(--accent); }
|
||||
.add-row input.order-notes-input {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--text);
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
z-index: 100;
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
.toast.error { border-color: var(--danger); color: #fca5a5; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header-inner { flex-direction: column; align-items: flex-start; }
|
||||
.header-status { text-align: left; }
|
||||
.header-right { align-items: flex-start; }
|
||||
.controls input, .controls select { flex: 1 1 100%; }
|
||||
.inventory-controls input, .inventory-controls select { flex: 1 1 100%; }
|
||||
}
|
||||
38
roles/trade_hub/files/trade-hub/podman-compose.yml
Normal file
38
roles/trade_hub/files/trade-hub/podman-compose.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
db:
|
||||
image: docker.io/library/postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: sc_inventory
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sc_inventory_dev}
|
||||
POSTGRES_DB: sc_inventory
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U sc_inventory -d sc_inventory"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
image: localhost/sc-inventory-backend:latest
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://sc_inventory:${POSTGRES_PASSWORD:-sc_inventory_dev}@db:5432/sc_inventory
|
||||
DB_WAIT_SECONDS: "60"
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
image: localhost/sc-inventory-frontend:latest
|
||||
ports:
|
||||
- "8071:8071"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
16
roles/trade_hub/meta/main.yml
Normal file
16
roles/trade_hub/meta/main.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: trade_hub
|
||||
author: boxswapper
|
||||
description: Deploy the Free People of the Verse trade hub (FastAPI, nginx, PostgreSQL) with Podman.
|
||||
license: MIT
|
||||
min_ansible_version: "2.15"
|
||||
platforms:
|
||||
- name: GenericLinux
|
||||
versions:
|
||||
- all
|
||||
galaxy_tags:
|
||||
- podman
|
||||
- fastapi
|
||||
- postgresql
|
||||
dependencies: []
|
||||
245
roles/trade_hub/tasks/main.yml
Normal file
245
roles/trade_hub/tasks/main.yml
Normal file
@@ -0,0 +1,245 @@
|
||||
---
|
||||
- name: Check whether podman is installed
|
||||
ansible.builtin.command: podman --version
|
||||
register: trade_hub_podman_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Install podman when requested
|
||||
ansible.builtin.package:
|
||||
name: podman
|
||||
state: present
|
||||
become: true
|
||||
when:
|
||||
- trade_hub_podman_check.rc != 0
|
||||
- trade_hub_install_podman | bool
|
||||
|
||||
- name: Re-check podman after installation
|
||||
ansible.builtin.command: podman --version
|
||||
register: trade_hub_podman_recheck
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
when: trade_hub_podman_check.rc != 0
|
||||
|
||||
- name: Require podman to be available
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- trade_hub_podman_check.rc == 0 or (trade_hub_podman_recheck.rc | default(1)) == 0
|
||||
fail_msg: >-
|
||||
podman is not installed on {{ inventory_hostname }}. Install it first
|
||||
(for example with the "packages" role) or run this role with
|
||||
-e trade_hub_install_podman=true.
|
||||
quiet: true
|
||||
|
||||
- name: Remove the legacy Apache container when requested
|
||||
containers.podman.podman_container:
|
||||
name: "{{ trade_hub_legacy_apache_container }}"
|
||||
state: absent
|
||||
when: trade_hub_remove_legacy_apache | bool
|
||||
|
||||
- name: Create the application directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ trade_hub_app_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Create the application source directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ trade_hub_app_dir }}/{{ item }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop: "{{ trade_hub_app_subdirs }}"
|
||||
|
||||
- name: Stage the application build context
|
||||
ansible.builtin.copy:
|
||||
src: "{{ role_path }}/files/trade-hub/{{ item.src }}"
|
||||
dest: "{{ trade_hub_app_dir }}/{{ item.dest }}"
|
||||
mode: preserve
|
||||
loop:
|
||||
# Only the files needed by the two Dockerfiles are staged. Copying the
|
||||
# whole tree would also ship local development artefacts such as .venv.
|
||||
- { src: "backend/Dockerfile", dest: "backend/Dockerfile" }
|
||||
- { src: "backend/requirements.txt", dest: "backend/requirements.txt" }
|
||||
- { src: "backend/main.py", dest: "backend/main.py" }
|
||||
- { src: "backend/app/", dest: "backend/app/" }
|
||||
- { src: "backend/scripts/", dest: "backend/scripts/" }
|
||||
- { src: "frontend/Dockerfile", dest: "frontend/Dockerfile" }
|
||||
- { src: "frontend/nginx.conf", dest: "frontend/nginx.conf" }
|
||||
- { src: "frontend/src/", dest: "frontend/src/" }
|
||||
register: trade_hub_source
|
||||
|
||||
- name: Check whether the backend image exists
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- podman
|
||||
- image
|
||||
- exists
|
||||
- "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
|
||||
register: trade_hub_backend_image_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Check whether the frontend image exists
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- podman
|
||||
- image
|
||||
- exists
|
||||
- "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
|
||||
register: trade_hub_frontend_image_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Build the backend image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- podman
|
||||
- build
|
||||
- --tag
|
||||
- "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
|
||||
- "{{ trade_hub_app_dir }}/backend"
|
||||
when:
|
||||
- trade_hub_build_images | bool
|
||||
- trade_hub_force_rebuild | bool or trade_hub_source.changed or trade_hub_backend_image_check.rc != 0
|
||||
register: trade_hub_backend_build
|
||||
|
||||
- name: Build the frontend image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- podman
|
||||
- build
|
||||
- --tag
|
||||
- "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
|
||||
- "{{ trade_hub_app_dir }}/frontend"
|
||||
when:
|
||||
- trade_hub_build_images | bool
|
||||
- trade_hub_force_rebuild | bool or trade_hub_source.changed or trade_hub_frontend_image_check.rc != 0
|
||||
register: trade_hub_frontend_build
|
||||
|
||||
- name: Require prebuilt images when image builds are disabled
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- trade_hub_backend_image_check.rc == 0
|
||||
- trade_hub_frontend_image_check.rc == 0
|
||||
fail_msg: >-
|
||||
trade_hub_build_images is false but {{ trade_hub_backend_image }}:{{
|
||||
trade_hub_image_tag }} and/or {{ trade_hub_frontend_image }}:{{
|
||||
trade_hub_image_tag }} are missing on {{ inventory_hostname }}.
|
||||
quiet: true
|
||||
when: not trade_hub_build_images | bool
|
||||
|
||||
- name: Record whether the application containers need to be recreated
|
||||
ansible.builtin.set_fact:
|
||||
trade_hub_recreate_containers: >-
|
||||
{{ (trade_hub_force_rebuild | bool)
|
||||
or ((trade_hub_build_images | bool) and (trade_hub_source.changed | default(false)))
|
||||
or (trade_hub_backend_build.changed | default(false))
|
||||
or (trade_hub_frontend_build.changed | default(false)) }}
|
||||
|
||||
- name: Ensure the PostgreSQL image is present
|
||||
containers.podman.podman_image:
|
||||
name: "{{ trade_hub_postgres_image }}"
|
||||
state: present
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Create the podman network
|
||||
containers.podman.podman_network:
|
||||
name: "{{ trade_hub_network }}"
|
||||
state: present
|
||||
|
||||
- name: Check whether the shared proxy network exists
|
||||
ansible.builtin.command: podman network exists npm_internal
|
||||
register: trade_hub_npm_network_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Require the shared proxy network to exist
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- trade_hub_npm_network_check.rc == 0
|
||||
fail_msg: >-
|
||||
The npm_internal podman network does not exist on {{ inventory_hostname }}.
|
||||
Run the network role first, e.g.
|
||||
ansible-playbook -i inventory.ini ansible-playbook.yml --tags "network,trade_hub".
|
||||
quiet: true
|
||||
|
||||
- name: Create the PostgreSQL data volume
|
||||
containers.podman.podman_volume:
|
||||
name: "{{ trade_hub_volume }}"
|
||||
state: present
|
||||
|
||||
- name: Run the PostgreSQL container
|
||||
containers.podman.podman_container:
|
||||
name: "{{ trade_hub_db_container }}"
|
||||
image: "{{ trade_hub_postgres_image }}"
|
||||
state: started
|
||||
recreate: "{{ trade_hub_force_rebuild | bool }}"
|
||||
network: "{{ trade_hub_network }}"
|
||||
network_aliases:
|
||||
- db
|
||||
env:
|
||||
POSTGRES_USER: "{{ trade_hub_postgres_user }}"
|
||||
POSTGRES_PASSWORD: "{{ trade_hub_postgres_password }}"
|
||||
POSTGRES_DB: "{{ trade_hub_postgres_db }}"
|
||||
volumes:
|
||||
- "{{ trade_hub_volume }}:/var/lib/postgresql/data:Z"
|
||||
restart_policy: unless-stopped
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Run the backend container
|
||||
containers.podman.podman_container:
|
||||
name: "{{ trade_hub_backend_container }}"
|
||||
image: "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
|
||||
state: started
|
||||
# podman does not recreate a container when its image tag resolves to a new
|
||||
# ID, so recreate whenever the image was rebuilt or the app changed.
|
||||
recreate: "{{ trade_hub_recreate_containers | bool }}"
|
||||
network: "{{ trade_hub_network }}"
|
||||
# The nginx upstream is http://backend:8000, so this alias must match.
|
||||
network_aliases:
|
||||
- backend
|
||||
env:
|
||||
DATABASE_URL: "{{ trade_hub_database_url }}"
|
||||
DB_WAIT_SECONDS: "{{ trade_hub_db_wait_seconds }}"
|
||||
COOKIE_SECURE: "{{ trade_hub_cookie_secure | bool | lower }}"
|
||||
restart_policy: unless-stopped
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Run the frontend container
|
||||
containers.podman.podman_container:
|
||||
name: "{{ trade_hub_frontend_container }}"
|
||||
image: "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
|
||||
state: started
|
||||
recreate: "{{ trade_hub_recreate_containers | bool }}"
|
||||
# Keep sc-inventory for the "backend" DNS alias and join the shared proxy
|
||||
# network with a fixed address so the proxy-manager upstream never changes.
|
||||
network:
|
||||
- "{{ trade_hub_network }}"
|
||||
- "npm_internal:ip={{ trade_hub_frontend_ip }}"
|
||||
ports:
|
||||
- "{{ trade_hub_frontend_bind }}:{{ trade_hub_frontend_port }}:{{ trade_hub_frontend_port }}"
|
||||
restart_policy: unless-stopped
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Enable podman-restart so containers survive a reboot
|
||||
ansible.builtin.systemd:
|
||||
name: podman-restart.service
|
||||
enabled: true
|
||||
state: started
|
||||
when: trade_hub_enable_restart_service | bool
|
||||
|
||||
- name: Wait for the application to answer health checks
|
||||
ansible.builtin.uri:
|
||||
url: "{{ trade_hub_healthcheck_url }}"
|
||||
status_code: 200
|
||||
register: trade_hub_health
|
||||
until: trade_hub_health.status == 200
|
||||
retries: "{{ trade_hub_healthcheck_retries }}"
|
||||
delay: "{{ trade_hub_healthcheck_delay }}"
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
71
vars.yml
71
vars.yml
@@ -1,3 +1,4 @@
|
||||
|
||||
## add generic 755 directory for application here
|
||||
directories:
|
||||
- /opt/apps/npm
|
||||
@@ -50,6 +51,10 @@ POSTGRES_ADMIN_USER: theuseriwant
|
||||
POSTGRES_PASSWORD: theuserthatineedpaswoord
|
||||
POSTGRES_DB: thatoneit
|
||||
|
||||
## trade_hub database password (the role also honours POSTGRES_PASSWORD exported
|
||||
## on the control node; this variable is the in-repo fallback).
|
||||
trade_hub_postgres_pw: "82uBeJkCzm483"
|
||||
|
||||
## WireGuard Easy VPN
|
||||
## IMPORTANT: After first run, change wireguard_ui_password to a strong password
|
||||
## Public IP or DDNS hostname clients connect to — CHANGE THIS before deploying
|
||||
@@ -83,7 +88,7 @@ minecraft_ip: 10.10.0.45
|
||||
minecraft_version: latest
|
||||
minecraft_memory: 8G
|
||||
minecraft_motd: "Welcome Retards"
|
||||
minecraft_difficulty: normal
|
||||
minecraft_difficulty: hard
|
||||
minecraft_gamemode: survival
|
||||
minecraft_max_players: 20
|
||||
minecraft_view_distance: 10
|
||||
@@ -166,24 +171,36 @@ sdtd_telnet_password: HDRSEsueredu3rf3
|
||||
## Change the seed + set sdtd_game_world: RWG for a fresh random-gen world on first boot
|
||||
## (Navezgane = built-in map, instant start, no generation time).
|
||||
sdtd_servername: "Boxswapper 7DTD"
|
||||
sdtd_server_description: "You wiill die in 7 Days, retard"
|
||||
sdtd_server_description: "You will die in 7 Days, retard"
|
||||
sdtd_server_confirmation: "You will die, and you will like it, retard"
|
||||
sdtd_website_url: ""
|
||||
sdtd_region: NorthAmericaEast
|
||||
sdtd_language: English
|
||||
## Visibility of this server: 2 = public, 1 = only shown to friends, 0 = not listed. As you are never friend of a dedicated server setting this to "1" will only work when the first player connects manually by IP.
|
||||
sdtd_visibility: "0"
|
||||
sdtd_visibility: "1"
|
||||
## SteamNetworking disabled = direct connect via forwarded ports (correct setup for this box);
|
||||
## leave empty ("" ) to allow the Steam relay fallback if port forwards break.
|
||||
sdtd_disabled_network_protocols: SteamNetworking
|
||||
sdtd_port: 26900
|
||||
sdtd_max_players: 8
|
||||
sdtd_max_players: 10
|
||||
sdtd_game_mode: GameModeSurvival
|
||||
sdtd_game_world: Navezgane
|
||||
sdtd_game_world: RWG
|
||||
sdtd_worldgen_seed: boxswapper
|
||||
sdtd_worldgen_size: 8192
|
||||
sdtd_worldgen_size: 10240
|
||||
sdtd_game_name: Boxswapper
|
||||
## v3.0+ SandboxCode — the ONLY gameplay tuning v3.x reads. The vars below this block
|
||||
## (sdtd_difficulty .. sdtd_airdrop_frequency) are REFERENCE-ONLY: v3.0 removed them from
|
||||
## serverconfig.xml, the template no longer renders them, and the game IGNORES them. Empty
|
||||
## code = Adventurer defaults. To restore the server's intended tuning generate a code:
|
||||
## client -> New Game -> Sandbox Options -> match the vars below — Difficulty Nomad, XP 200
|
||||
## (2x), DayNight 60, DayLight 18, Blood Moon every 7 days, DropOnDeath Everything, Loot 150,
|
||||
## AirDrop 24h — with BOTH Day/Night Animal Density and Respawn = Default (so the MoreAnimals
|
||||
## modlet stays in charge of wildlife; XML applies only while density is Default) ->
|
||||
## copy -> paste here.
|
||||
sdtd_sandbox_code: ""
|
||||
## ── REFERENCE VARS FOR SANDBOXCODE GENERATION (not rendered to serverconfig.xml on v3.x) ──
|
||||
## Difficulty: 0 = Scavenger, 1 = Adventurer, 2 = Nomad, 3 = Warrior, 4 = Survivalist, 5 = Insane
|
||||
sdtd_difficulty: "2"
|
||||
sdtd_difficulty: "1"
|
||||
sdtd_day_night_length: 60
|
||||
sdtd_day_light_length: 18
|
||||
## DropOnDeath: 0 = nothing, 1 = everything, 2 = toolbelt only, 3 = backpack only, 4 = delete all
|
||||
@@ -191,29 +208,25 @@ sdtd_drop_on_death: "1"
|
||||
## ZombieMove: 0 = walk, 1 = jog, 2 = run, 3 = sprint, 4 = nightmare
|
||||
sdtd_zombie_move: "0"
|
||||
sdtd_zombie_move_night: "3"
|
||||
sdtd_bloodmoon_frequency: 7
|
||||
sdtd_bloodmoon_enemy_count: 8
|
||||
sdtd_loot_abundance: 100
|
||||
sdtd_airdrop_frequency: 72
|
||||
sdtd_bloodmoon_frequency: 2
|
||||
sdtd_bloodmoon_enemy_count: 16
|
||||
sdtd_loot_abundance: 150
|
||||
sdtd_airdrop_frequency: 24
|
||||
## PlayerKillingMode: 0 = no killing, 1 = kill allies only, 2 = kill strangers only, 3 = kill everyone
|
||||
sdtd_player_killing_mode: "3"
|
||||
sdtd_max_spawned_zombies: 64
|
||||
## EAC must be OFF because we deploy code mods (Singularity framework + the No Respawn Near
|
||||
## Backpack patch below) — EAC blocks their Harmony hooks. Keep "false" while mods are installed.
|
||||
sdtd_eac_enabled: "false"
|
||||
## Game mods, vendored under roles/7dtd/files/mods/ and extracted into serverfiles/Mods (the
|
||||
## container's bind-mounted Mods dir) by the role. Source of truth: add the zip name here AND
|
||||
## drop the file into the role's files/mods dir. Load order is handled by folder prefixes —
|
||||
## Singularity's leading underscore loads FIRST, the mod's z- prefix loads LAST (7dtd sorts
|
||||
## Mods alphabetically).
|
||||
## - Singularity (4.1.3): Byteblazar's core framework; No Respawn Near Backpack is a server
|
||||
## policy implemented here, disabled by default, enabled via XML (the mod below).
|
||||
## - No Respawn Near Backpack for V2 (2.0.0.0): server-side XML patch that sets Singularity's
|
||||
## ServerPolicies.NoRespawnNearBackpack=true -> players CANNOT respawn near their backpack,
|
||||
## they respawn at a world starting point instead. Requires EAC off (see above).
|
||||
sdtd_mods:
|
||||
- Singularity-v4.1.3.zip
|
||||
- No-Respawn-Near-Backpack-2-0-0-0.zip
|
||||
sdtd_xp_multiplier: 100
|
||||
sdtd_max_spawned_zombies: 100
|
||||
sdtd_max_spawn_animals: "200"
|
||||
## EAC is ON (default protection). EAC blocks CODE mods (Harmony DLL hooks) — that is why it was
|
||||
## OFF while code mods were deployed. PURE XML data modlets (like MoreAnimals below) carry no DLL
|
||||
## and work fine with EAC ON; keep "false" only if code mods are ever reinstalled.
|
||||
sdtd_eac_enabled: "true"
|
||||
## Game mods deployed: MoreAnimals.zip — pure XML spawn-density patch (roles/7dtd/files/mods).
|
||||
## Server-side only: clients join vanilla with EAC ON, nothing to install. To re-add/swap a mod:
|
||||
## drop the zip into roles/7dtd/files/mods/ and list its name here. unarchive only ADDS — it
|
||||
## never removes, so a mod dropped from this list would keep loading: with an EMPTY list we wipe
|
||||
## the whole server Mods dir; swapping one mod for another (non-empty list) requires removing the
|
||||
## retired folder from the box by hand.
|
||||
sdtd_mods: [MoreAnimals.zip]
|
||||
sdtd_xp_multiplier: 200
|
||||
sdtd_telnet_enabled: "true"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user