station/README.md

283 lines
12 KiB
Markdown
Raw Normal View History

# 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.
- **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`, `SetMoveDir`,
`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 # singleplayer (save game `singleplayer`)
cargo run -- --host 5000 # singleplayer world that others can join
cargo run -- --connect 127.0.0.1:5000 # join a server or host
cargo run -p game_server --bin game-server # dedicated server (saves/server.sqlite, world `main`)
```
Client options: `--name NAME`, `--save NAME`, `--no-save`, `--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). Characters are identified by name and are restored when that
name logs in again.
### 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.
### 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
```
## Controls
| Key | Action |
|---------------------|------------------------------------------------------|
| WASD / arrows | Move (you face the direction you move) |
| E | Pick up (own tile, otherwise the faced tile) |
| F / left click | Interact with the faced / clicked building |
| 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 |
| C / B | Crafting / build menu (W/S select, Enter confirm, Esc close) |
| H | Help |
## 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 orthogonal `.tmx` files 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; any integer properties
override the building's `vars`.
- `item` – custom property `proto` = item id and optional `count`.
- Objects are placed on the tile containing their position (point objects work well).
A map is only used to create a new world; after that the saved world is
authoritative. Start a new save game (or world) to get a fresh copy of the map.
`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.