station/README.md

443 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Station
A persistent-world, RPG-style station simulation inspired by Space Station 13, built
with [Bevy](https://bevy.org) in Rust.
- **ECS** – all game state lives in Bevy entities and components.
- **Data driven** – items, recipes, buildings, machines and skills are defined in
[rsn](https://crates.io/crates/rsn) files under `assets/data/`.
- **Scriptable** – behaviour is written in [Rune](https://rune-rs.github.io) scripts
under `assets/scripts/`.
- **Tiled maps** – maps are regular [Tiled](https://www.mapeditor.org) `.tmx` files.
- **Persistent world** – no rounds. The world and every character (inventory, skills,
vitals, position) are saved and restored across restarts.
- **Free movement** – characters walk anywhere, not tile by tile, and slide along
walls. Interactions still target the tile you face.
- **Singleplayer and multiplayer** – one authoritative server simulation, used locally
in singleplayer and over UDP in multiplayer
([bevy_replicon](https://crates.io/crates/bevy_replicon) + renet).
## Crates
| Crate | Purpose |
|----------------------|-------------------------------------------------------------------------|
| `crates/game_core` | Shared data definitions, components, Tiled loader, network protocol |
| `crates/game_server` | Authoritative simulation, Rune scripting, persistence, `game-server` binary |
| `crates/game_client` | Rendering, UI and input (`game` binary); embeds the server for singleplayer/host |
The client never changes game state directly. It sends requests (`Login`, `SetMovement`,
`Interact`, `Craft`, …) and the server replicates the resulting state back. In
singleplayer the same server systems run inside the client process, so singleplayer and
multiplayer behave identically.
## Running
```sh
cargo run # start menu
cargo run -p game_server --bin game-server # dedicated server (saves/server.sqlite, world `main`)
```
The start menu offers **New game** (optionally open to other players),
**Load game** (play or delete a save) and **Join server**. Command-line options
skip the menu:
```sh
cargo run -- --save mars # play (or create) the local world `mars`
cargo run -- --save mars --host 5000 --password secret # let others join it
cargo run -- --connect 127.0.0.1:5000 --password secret # join a server or host
```
Client options: `--save NAME`, `--no-save`, `--host [PORT]`,
`--connect ADDR[:PORT]`, `--name NAME`, `--password TEXT`, `--map MAP`.
Server options: `--config FILE`, `--port PORT`, `--password TEXT`,
`--max-players N`, `--database URL`, `--world NAME|UUID`, `--no-save`,
`--map MAP`, `--autosave SECS`, `--motd TEXT`, `--admin ID`, `--whitelist`.
On Windows, release builds of the client open no console window, so command-line
output such as `--help` and logs isn't shown. Use a debug build to see it.
The world autosaves periodically (in the background) and on exit (window close,
Ctrl-C, SIGTERM).
The client keeps a profile in `client.toml` in the platform config directory
(`~/.config/station/` on Linux, `%APPDATA%\station\config\` on Windows,
`~/Library/Application Support/station/` on macOS). It holds a random player
id, the last character name and the last server address. Characters belong to
the player id: logging in again restores your character, and its name is fixed
once it exists. Hosting a world uses the server settings in `host.toml` in the
same directory, except that the port and password come from the menu or the
command line.
### Save games and databases
Worlds are stored with [sqlx](https://crates.io/crates/sqlx) in SQLite or
PostgreSQL. Every world has a UUID, and all rows are keyed by it, so any number of
worlds can share one database.
- **Singleplayer/host:** each save game is its own SQLite file in the platform data
directory: `~/.local/share/station/saves/` on Linux,
`%APPDATA%\station\data\saves\` on Windows and
`~/Library/Application Support/station/saves/` on macOS.
- **Dedicated server:** `--database` takes a sqlx URL and defaults to
`sqlite://saves/server.sqlite`. Use `postgres://user:password@host/db` for
PostgreSQL. `--world` selects a world by name (created if missing) or by UUID.
```sh
game-server --database postgres://station:secret@db/station --world europa
```
The schema is created and migrated automatically. To run the PostgreSQL tests,
set `STATION_TEST_POSTGRES_URL` to a database URL; without it they are skipped.
The native chat clipboard test can be enabled with `STATION_TEST_CLIPBOARD=1`
when running tests in a desktop session with an available system clipboard.
### Dedicated server configuration
The dedicated server reads `server.toml` by default and creates it on first
start. Command-line options override the file for one run only:
```sh
game-server --config prod.toml --port 5001 --database sqlite://saves/prod.sqlite
```
The config file also stores the server password, MOTD, max players, the
database URL and world, plus access lists (admins, whitelist, bans). While the
server is running, type commands into its terminal:
```text
help
players
save
config get database.world
config set autosave_secs 30
stop
```
### Passwords, admins and bans
A server with a `password` only accepts players who send it. Admins are listed
by player id or character name in `access.admins` (`--admin ID` adds one for a
run). The player who hosts a world from the client is always admin and may stop
it; in singleplayer you are always admin. With `access.whitelist_enabled`, only
players in `access.whitelist` (and admins) may join. Banned ids and names in
`access.banned` are rejected.
Commands are typed into the server terminal or, prefixed with `/`, into the
in-game chat. Admin commands change `server.toml` (or `host.toml`) immediately.
| Command | Who | Action |
|--------------------------------|----------|---------------------------------------------|
| `help`, `players` | everyone | List commands / online players |
| `say MESSAGE` | admins | Message all players |
| `kick PLAYER [REASON]` | admins | Disconnect a player |
| `ban PLAYER`, `unban PLAYER`, `bans` | admins | Ban by name or id (kicks if online), lift or list bans |
| `whitelist on\|off\|list` | admins | Toggle or show the whitelist |
| `whitelist add\|remove PLAYER` | admins | Edit the whitelist |
| `admin add\|remove PLAYER`, `admin list` | admins | Grant, revoke or list admin rights |
| `rejected` | admins | Recently rejected logins (with player ids) |
| `save` | admins | Save the world now |
| `export [FILE]` | admins | Export terrain, buildings and ground items to Tiled |
| `import [FILE]` | admins | Replace terrain, buildings and ground items from Tiled; keep characters |
| `config get KEY`, `config set KEY VALUE` | admins | Read or change a setting, e.g. `config set motd Hi` |
| `stop` | console, host | Save and shut down |
> **Security:** the network transport is unencrypted and unauthenticated. The
> password is sent in plain text and player ids can be impersonated by anyone who
> knows them. Treat the password as a guard against casual visitors, not as
> real security, and don't reuse a valuable password.
## Controls
Chat supports cursor navigation (arrows, Home/End, Ctrl+Left/Right), Shift selection,
Ctrl+A, mouse positioning/drag selection, double-click word selection, and system
clipboard copy/cut/paste (Ctrl+C/X/V). On macOS, use the usual Command shortcuts and
Option for word navigation. Up/Down recall the last 100 sent messages and commands;
Down past the newest entry restores the unsent draft. Tab completes command names
and subcommands allowed for your role; repeated Tab cycles matches, and Shift+Tab
cycles backwards. Escape closes chat without sending.
| Key | Action |
|---------------------|------------------------------------------------------|
| WASD / arrows | Move freely, also diagonally (you face that way) |
| E | Pick up (own tile, otherwise the faced tile) |
| F / left click | Interact with the faced / clicked building; machines open their window |
| U | Use the selected item (eat, heal, …) |
| G | Drop the selected item |
| X | Deconstruct the faced building |
| 1–9, 0, mouse wheel | Select an inventory slot (or click it) |
| C / B | Crafting / build menu (W/S select, Enter confirm, or click) |
| H | Help |
| Enter / T | Chat; `/` starts a command |
| Esc | Close the open window, otherwise pause menu (leave world, quit) |
In a machine window, click an empty input slot to insert the selected inventory
item and click a filled slot to take its items. Hover over items, recipes and
buttons for tooltips.
## User interface
The menus and the HUD are built from layout files in `assets/ui/` and can be
restyled or rearranged without recompiling:
| File | Screen |
|--------------------|------------------------------------------------|
| `theme.rsn` | Font sizes, spacing and named colors used by all layouts |
| `main_menu.rsn`, `new_game.rsn`, `load_game.rsn`, `connect.rsn`, `connecting.rsn` | Start menu screens |
| `hud.rsn` | In-game HUD, menus, chat and pause overlay |
A layout has one `root` node. Nodes:
| Node | Fields |
|------------------------------|------------------------------------------------------------|
| `Column {..}`, `Row {..}`, `Panel {..}` | `children`, `gap`, `padding`, `align` (`Start`, `Center`, `End`, `Stretch`), `justify` (`Start`, `Center`, `End`, `SpaceBetween`), `width`/`height` (`Auto`, `Px(n)`, `Percent(n)`, `Fill`), `anchor` (`TopLeft` … `BottomRight`, relative to the screen), `offset: (x, y)`, `background`, `border`, `radius` |
| `Text {..}` | `text`, `size`, `color`, `max_width` |
| `Button {..}` | `text`, `action`, `width` |
| `Input {..}` | `id`, `value`, `placeholder`, `password`, `max_len`, `width`, `submit` (action on Enter) |
| `Checkbox {..}` | `id`, `text`, `checked` |
| `Image {..}` | `path` (PNG below `assets/`), `width`, `height` |
| `Spacer` | Takes up the remaining space |
| `Widget("name")` | Content generated by the game |
Every node except `Spacer` and `Widget` also accepts `tooltip` and
`visible`. `visible` names a binding or form field and shows the node only
while it is set; prefix it with `!` to invert it. Colors are `#rrggbb`,
`#rrggbbaa` or a theme color name. Texts, tooltips and input values may contain
`{binding}` placeholders:
```rsn
Panel {
anchor: TopLeft,
offset: (10, 10),
children: [
Text { text: "{character}", color: "accent", tooltip: "Playing on {server}" },
Text { text: "You are hungry!", color: "error", visible: "hungry" },
Button { text: "Craft", action: "menu:craft", tooltip: "Crafting (C)" },
],
}
```
- **Bindings:** menu: `message`, `profile_name`, `last_address`,
`default_port`, `saves_dir`, `save_count`, `has_saves`, `suggested_world`,
`version`, `status`. HUD: `character`, `server`, `admin`, `health`,
`max_health`, `nutrition`, `hungry`, `position`, `craft_open`, `build_open`,
`help_open`, `chat_open`, `paused`.
- **Widgets:** `save_list` (menu); `inventory`, `skills`, `log`, `craft_list`,
`build_list` (HUD).
- **Actions:** `screen:main|new_game|load_game|connect`, `new_game`, `join`,
`quit` (menu); `menu:craft|build|help|close`, `chat_send`, `resume`, `leave`,
`quit` (HUD). The menu forms read the inputs `world`, `name`, `address`,
`password`, `port` and the checkbox `host`; the HUD chat input is `chat`.
## Modding
All content is loaded from `assets/` at startup. Every `.rsn` file below `assets/data/`
(recursively) may contain any mix of `items`, `recipes`, `buildings`, `machines` and
`skills`, so a
feature can ship in a single file. References between prototypes and script hooks are
validated at load time and errors name the offending file and id.
### Items
```rsn
items: [
{ id: "apple", name: "Apple", sprite: "sprites/items/apple.png", max_stack: 10,
tags: ["food"], on_use: Some("food::eat"), consume_on_use: true,
props: { "nutrition": 15 } },
]
```
Required: `id`, `name`, `sprite`. Optional: `description`, `max_stack` (default 50),
`tags`, `on_use` (script hook), `consume_on_use`, `props` (numbers readable by scripts).
### Recipes
```rsn
recipes: [
{ id: "make_pickaxe", inputs: [("iron_plate", 2), ("plank", 1)],
outputs: [("pickaxe", 1)], tools: ["wrench"], station: Some("workbench"),
skill: Some({ skill: "smithing", level: 1 }), xp: 25 },
]
```
Required: `id`, `inputs`, `outputs`. Optional: `name`, `tools` (needed but not consumed),
`station` (a tag provided by a building adjacent to the player), `skill`, `xp`, `on_craft`
(script hook). Ingredients are written as `("item", count)` or `{ item: "item", count: n }`.
### Buildings
Anything placed on the grid: walls, doors, machines, trees, rocks.
```rsn
buildings: [
{ id: "door", name: "Door", sprite: "sprites/buildings/door_closed.png",
variants: { "open": "sprites/buildings/door_open.png" },
construction: Some({ materials: [("iron_plate", 2), ("plank", 1)],
tools: ["screwdriver"], xp: 10,
skill: Some({ skill: "construction", level: 1 }) }),
on_interact: Some("door::toggle") },
]
```
Required: `id`, `name`, `sprite`. Optional: `description`, `variants` (name → sprite),
`solid` (default true), `max_health`, `stations` (tags for recipes), `construction`
(makes it buildable from the build menu), `deconstructible` (default true; refunds
materials), `on_interact` (script hook), `vars` (integer state, persisted per building).
### Machines
A machine adds item slots and its own recipes to the building with the same `id`.
Interacting with it opens the machine window: put items into the input slots, the
machine processes the first recipe whose inputs are present and whose outputs fit, and
the results appear in the output slots. Machine contents are saved with the world and
dropped when the machine is taken apart.
```rsn
machines: [
{ id: "assembler", input_slots: 3, output_slots: 1, active_variant: Some("active"),
recipes: [
{ id: "assemble_wrench", inputs: [("iron_plate", 2)], outputs: [("wrench", 1)],
time: 3.0 },
],
ui: { background: Some("ui/machines/assembler.png"), size: Some((260.0, 150.0)),
inputs: [(16.0, 14.0), (16.0, 96.0), (70.0, 55.0)], outputs: [(200.0, 55.0)],
progress: Some((124.0, 69.0, 60.0, 12.0)) } },
]
```
Required: `id` (an existing building), `recipes` (`id`, `inputs`, `outputs`, optional
`name` and `time` in seconds, default 1). Optional: `input_slots` and `output_slots`
(default 1), `active_variant` (building variant shown while working) and `ui`. Without
`ui` positions, inputs are stacked on the left and outputs on the right with a progress
bar between them. `ui` fields: `background` (PNG drawn behind the slots), `size`
(window content size in pixels), `slot_size` (default 40), `inputs`/`outputs` (one
`(x, y)` per slot, measured from the top-left corner) and `progress`
(`(x, y, width, height)`).
### Skills
```rsn
skills: [ { id: "smithing", name: "Smithing" } ]
```
### Scripts
Each `.rn` file in `assets/scripts/` is a module named after its path
(`scripts/door.rn` → `door`, `scripts/npc/guard.rn` → `npc::guard`). Data files refer to
public functions as `"module::function"`. Every hook receives one argument, `ctx`;
returning `false` signals failure (for example, a used item is then not consumed).
Script errors are logged and have no effect on the world.
```rune
// assets/scripts/door.rn
pub fn toggle(ctx) {
if ctx.is_solid() {
ctx.set_solid(false);
ctx.set_variant("open");
ctx.message("You open the door.");
} else {
ctx.set_solid(true);
ctx.set_variant("");
ctx.message("You close the door.");
}
}
```
`ctx` methods:
| Area | Methods |
|-----------|---------|
| Actor | `actor_name()`, `message(text)` |
| Inventory | `item_count(id)`, `has_item(id, n)`, `give_item(id, n)`, `take_item(id, n) -> bool`, `spawn_item(id, n)` (on the ground) |
| Vitals | `health()`, `max_health()`, `heal(n)`, `damage(n)`, `nutrition()`, `feed(n)` (integers) |
| Skills | `skill_level(id)`, `add_xp(id, n)` |
| Used item | `item_id()`, `item_prop(key)` |
| Building | `target_name()`, `get_var(key)`, `set_var(key, n)`, `is_solid()`, `set_solid(b)`, `variant()`, `set_variant(name)` (`""` = default sprite), `destroy_target()` |
| Misc | `random(max)` → `0..max` |
### Maps (Tiled)
Maps are finite orthogonal `.tmx` files (at most 1,000,000 cells) with embedded or
external tilesets. Each map cell is one game tile (drawn at 32×32; 32 px tilesets look best).
- Tile layers are rendered in order. A tile is solid if the tile has the boolean custom
property `solid = true` in its tileset, or its layer has `solid = true`.
- Object layers spawn gameplay entities by object **class**:
- `spawn` – player spawn point.
- `building` – custom property `proto` = building id. `state.health`,
`state.solid`, `state.variant` and `state.vars` override the prototype defaults.
Integer custom properties without a `state.`/`machine.` prefix also override
individual script variables, for compatibility with existing maps.
Machines accept `machine.inputs`, `machine.outputs`, `machine.recipe` and
`machine.progress`.
- `item` – custom property `proto` = item id and optional `count`.
- Objects are placed on the tile containing their position (point objects work well).
A default map seeds a new world; after that the database stores the authoritative
terrain and entity state. Existing saves acquire their terrain snapshot on their
next save. To apply a map to an existing world, use the admin `import` command.
#### Editing a running world's map
1. Run `/export` in chat, or `export` in the server terminal. The reply shows the
server-local file path. With SQLite it defaults to `<database-stem>.<world-uuid>.tmx`
beside the database. With PostgreSQL it defaults to `saves/<world-uuid>/world.tmx`;
without saving, `saves/world.tmx`. An explicit path, including spaces, may follow
either command; relative paths are relative to the server's working directory.
2. Open that file in Tiled, edit it and save. Buildings and ground items are point
objects with their prototype and full mutable state in custom properties.
Terrain layers and collision properties are included. Tilesets are embedded
with absolute image references into the server's assets directory; when editing
on another machine, relink the images to that machine's matching assets.
3. Run `/import` (or `/import FILE`). **Import replaces all terrain, buildings and
ground items, including deletions.** It does not merge objects or change online
or offline characters, their positions or inventories. Export a separate backup
before importing, and avoid editing a stale export while players keep building.
Booleans and small integers use native Tiled property types. Other state values
use JSON in string properties: for example `state.variant` is `"open"` (including
the quotes) or `null`, `state.vars` is `{"stock":2}`, and `machine.inputs` is
`[{"item":"iron_ore","count":4},null]`. `machine.recipe` is `"smelt_iron"` or `null`;
`machine.progress` is elapsed seconds. Large integers use JSON strings to avoid
Tiled's 32-bit integer limit. These same overrides work in default maps, not just
exports; omitted properties retain prototype defaults.
Import validates the whole map before changing anything, and commits it to the
database before applying it to the simulation. Invalid prototypes, properties,
positions or machine state abort the import. Saving-disabled worlds can still
import, but changes are only in memory. Terrain changes are replicated immediately
to connected clients and retained across restarts; remote clients need matching
sprite assets, not a copy of the exported map. Characters are left at their current
positions, so avoid enclosing them in newly solid terrain.
`tools/gen_placeholder_assets.py` regenerates the placeholder sprites and example map.
## Development
```sh
cargo nextest run --workspace # unit tests + headless full-simulation test
cargo clippy --workspace --all-targets
```
Tests run with [cargo-nextest](https://nexte.st) (`cargo install cargo-nextest --locked`);
its configuration is in `.config/nextest.toml`. CI uses `--profile ci`, which writes a
JUnit report to `target/nextest/ci/junit.xml`.
### Packaging
```sh
cargo xtask pack # for the host platform
cargo xtask pack --target x86_64-pc-windows-gnu # cross-compile for Windows (needs MinGW)
```
`pack` builds the client (`game`) and the dedicated server (`game-server`) with the
stripped `dist` profile and writes `dist/station-<version>-<target>.zip` (Windows) or
`.tar.gz` (other platforms). The archive contains both binaries, `assets/`, the README
and the changelog. Unpack it and run the binaries from that directory. The unpacked
staging directory is kept next to the archive. Options: `--format zip|tar.gz`,
`--out-dir DIR`, `--debug`.
The GitHub `Package` workflow (`.github/workflows/package.yml`) runs `pack` for Windows
(zip), Linux and macOS (tar.gz), uploads each archive as an artifact and attaches them
to the release when a `v*` tag is pushed. The Gitea workflow cross-compiles the Windows
package with MinGW.