M1a: deterministischer Rust-Kern #3
@@ -0,0 +1,2 @@
|
||||
# Same line endings on every machine.
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,2 @@
|
||||
# Rust
|
||||
/target/
|
||||
@@ -0,0 +1,56 @@
|
||||
# Grovewright
|
||||
|
||||
Aufbauspiel für ein bis vier Spieler, in dem die Fabrik lebt. Gebaut wird zuerst eine Vertical Slice mit 2–3 Stunden Spielzeit.
|
||||
|
||||
Das verbindliche Konzept steht in [docs/KONZEPT.md](docs/KONZEPT.md). Lies es, bevor du Spielregeln, Architektur oder Umfang änderst. Was dort nicht steht, ist nicht beschlossen.
|
||||
|
||||
## Aufbau
|
||||
|
||||
| Ordner | Inhalt |
|
||||
|---|---|
|
||||
| `core/` | Rust-Kern: die gesamte gemeinsame Welt. Ohne Zugriff auf die Engine, ohne fremde Crates. |
|
||||
| `docs/KONZEPT.md` | Spielkonzept und Entscheidungen |
|
||||
| `docs/superpowers/plans/` | Umsetzungspläne |
|
||||
|
||||
Die Brücke zu Godot (`bridge/`), das Godot-Projekt (`game/`) und die Inhaltsdateien (`content/`) kommen mit späteren Plänen dazu.
|
||||
|
||||
Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern.
|
||||
|
||||
## Regeln für den Kern
|
||||
|
||||
Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gilt für den Quelltext des Kerns in `core/src/`. Die Tests in `core/tests/` dürfen zum Beispiel Dateien lesen.
|
||||
|
||||
- Nur ganze Zahlen, keine Gleitkommazahlen.
|
||||
- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen.
|
||||
- Zufall nur über `rng::Rng` mit fester Startzahl.
|
||||
- Keine Sammlung mit wechselnder Reihenfolge. Statt einer Hash-Tabelle nimmst du `Vec`, `BTreeMap` oder `BTreeSet`.
|
||||
- Die Welt ändert sich nur durch einen `Command` in `World::step`. Einzige Ausnahme ist `World::set_all_offline`: Es darf nur direkt nach dem Laden eines Speicherstands aufgerufen werden, bevor jemand beigetreten ist.
|
||||
- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es in `snapshot.rs` und erhöht `SNAPSHOT_VERSION`.
|
||||
- Wo eine Rechnung im Spiel überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`. Taktzähler (`u64` bei 20 Takten pro Sekunde) laufen praktisch nie über und werden normal addiert. Werte aus geladenen Dateien müssen deshalb beim Laden geprüft werden.
|
||||
- Keine fremden Crates im Kern.
|
||||
|
||||
Der Test `core/tests/forbidden_apis.rs` durchsucht den Quelltext des Kerns nach verbotenen Begriffen. Sie dürfen dort auch in Kommentaren nicht vorkommen.
|
||||
|
||||
## Prüfen
|
||||
|
||||
Vor jedem Commit müssen alle drei Befehle fehlerfrei laufen:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Die Fehlermeldung nennt die Versionskonstante, die dann erhöht werden muss. Erst danach wird der festgeschriebene Wert bewusst angepasst.
|
||||
|
||||
## Sprache
|
||||
|
||||
- Code, Bezeichner und Kommentare im Code: Englisch.
|
||||
- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch.
|
||||
- Texte im Spiel: Deutsch und Englisch über Übersetzungsdateien.
|
||||
|
||||
## Git
|
||||
|
||||
- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea).
|
||||
- Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`.
|
||||
- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben.
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "grovewright-core"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,8 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["core"]
|
||||
|
||||
# The shipped game and the tests must agree on what happens when a number
|
||||
# overflows, so release builds keep the overflow checks that debug builds have.
|
||||
[profile.release]
|
||||
overflow-checks = true
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "grovewright-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.94"
|
||||
publish = false
|
||||
description = "Deterministic simulation core of Grovewright"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
float_arithmetic = "deny"
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Minimal little-endian binary encoding for commands, snapshots and
|
||||
//! recordings.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Error returned when bytes cannot be decoded.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum DecodeError {
|
||||
/// The input ended before the value was complete.
|
||||
UnexpectedEnd,
|
||||
/// The input contained a value that is not allowed at this position.
|
||||
InvalidValue(&'static str),
|
||||
/// The input contained bytes after the last expected value.
|
||||
TrailingBytes,
|
||||
}
|
||||
|
||||
impl fmt::Display for DecodeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DecodeError::UnexpectedEnd => write!(f, "unexpected end of input"),
|
||||
DecodeError::InvalidValue(what) => write!(f, "invalid value: {what}"),
|
||||
DecodeError::TrailingBytes => write!(f, "trailing bytes after the last value"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DecodeError {}
|
||||
|
||||
/// Appends values to a growing byte buffer.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Writer {
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn u8(&mut self, value: u8) {
|
||||
self.buf.push(value);
|
||||
}
|
||||
|
||||
pub fn u16(&mut self, value: u16) {
|
||||
self.buf.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
pub fn u32(&mut self, value: u32) {
|
||||
self.buf.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
pub fn u64(&mut self, value: u64) {
|
||||
self.buf.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
pub fn i32(&mut self, value: i32) {
|
||||
self.buf.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
pub fn bool(&mut self, value: bool) {
|
||||
self.buf.push(u8::from(value));
|
||||
}
|
||||
|
||||
/// Writes bytes as they are, without a length.
|
||||
pub fn raw(&mut self, value: &[u8]) {
|
||||
self.buf.extend_from_slice(value);
|
||||
}
|
||||
|
||||
/// Writes a `u32` length followed by the bytes.
|
||||
pub fn bytes(&mut self, value: &[u8]) {
|
||||
let len = u32::try_from(value.len()).expect("length fits in u32");
|
||||
self.u32(len);
|
||||
self.buf.extend_from_slice(value);
|
||||
}
|
||||
|
||||
/// Writes a string as UTF-8 with a `u32` length in front.
|
||||
pub fn str(&mut self, value: &str) {
|
||||
self.bytes(value.as_bytes());
|
||||
}
|
||||
|
||||
pub fn into_bytes(self) -> Vec<u8> {
|
||||
self.buf
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads values from a byte slice in the order they were written.
|
||||
#[derive(Debug)]
|
||||
pub struct Reader<'a> {
|
||||
buf: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
pub fn new(buf: &'a [u8]) -> Self {
|
||||
Self { buf, pos: 0 }
|
||||
}
|
||||
|
||||
/// Number of bytes that have not been read yet.
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.buf.len() - self.pos
|
||||
}
|
||||
|
||||
/// Reads exactly `len` bytes.
|
||||
pub fn raw(&mut self, len: usize) -> Result<&'a [u8], DecodeError> {
|
||||
if len > self.remaining() {
|
||||
return Err(DecodeError::UnexpectedEnd);
|
||||
}
|
||||
let slice = &self.buf[self.pos..self.pos + len];
|
||||
self.pos += len;
|
||||
Ok(slice)
|
||||
}
|
||||
|
||||
fn array<const N: usize>(&mut self) -> Result<[u8; N], DecodeError> {
|
||||
let mut out = [0u8; N];
|
||||
out.copy_from_slice(self.raw(N)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn u8(&mut self) -> Result<u8, DecodeError> {
|
||||
Ok(self.array::<1>()?[0])
|
||||
}
|
||||
|
||||
pub fn u16(&mut self) -> Result<u16, DecodeError> {
|
||||
Ok(u16::from_le_bytes(self.array()?))
|
||||
}
|
||||
|
||||
pub fn u32(&mut self) -> Result<u32, DecodeError> {
|
||||
Ok(u32::from_le_bytes(self.array()?))
|
||||
}
|
||||
|
||||
pub fn u64(&mut self) -> Result<u64, DecodeError> {
|
||||
Ok(u64::from_le_bytes(self.array()?))
|
||||
}
|
||||
|
||||
pub fn i32(&mut self) -> Result<i32, DecodeError> {
|
||||
Ok(i32::from_le_bytes(self.array()?))
|
||||
}
|
||||
|
||||
pub fn bool(&mut self) -> Result<bool, DecodeError> {
|
||||
match self.u8()? {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
_ => Err(DecodeError::InvalidValue("bool")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a `u32` length followed by that many bytes.
|
||||
pub fn bytes(&mut self) -> Result<&'a [u8], DecodeError> {
|
||||
let len = self.u32()? as usize;
|
||||
self.raw(len)
|
||||
}
|
||||
|
||||
/// Reads a UTF-8 string with a `u32` length in front.
|
||||
pub fn string(&mut self) -> Result<String, DecodeError> {
|
||||
let bytes = self.bytes()?;
|
||||
std::str::from_utf8(bytes)
|
||||
.map(str::to_owned)
|
||||
.map_err(|_| DecodeError::InvalidValue("utf-8 string"))
|
||||
}
|
||||
|
||||
/// Succeeds only if every byte has been read.
|
||||
pub fn finish(self) -> Result<(), DecodeError> {
|
||||
if self.remaining() == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DecodeError::TrailingBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Everything a player can ask the world to do.
|
||||
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
use crate::map::Pos;
|
||||
use crate::player::{clamp_name, PlayerId, PlayerKey, MAX_NAME_BYTES};
|
||||
|
||||
/// A request that changes the world. Commands are the only way to change it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
/// A player enters the world, new or returning.
|
||||
Join { key: PlayerKey, name: String },
|
||||
/// A player leaves the world. Their inventory stays.
|
||||
Leave { player: PlayerId },
|
||||
/// A player hits the tile at `pos` with their pickaxe.
|
||||
Dig { player: PlayerId, pos: Pos },
|
||||
}
|
||||
|
||||
const TAG_JOIN: u8 = 1;
|
||||
const TAG_LEAVE: u8 = 2;
|
||||
const TAG_DIG: u8 = 3;
|
||||
|
||||
impl Command {
|
||||
pub fn encode(&self, w: &mut Writer) {
|
||||
match self {
|
||||
Command::Join { key, name } => {
|
||||
w.u8(TAG_JOIN);
|
||||
w.raw(key);
|
||||
w.str(&clamp_name(name));
|
||||
}
|
||||
Command::Leave { player } => {
|
||||
w.u8(TAG_LEAVE);
|
||||
w.u8(player.0);
|
||||
}
|
||||
Command::Dig { player, pos } => {
|
||||
w.u8(TAG_DIG);
|
||||
w.u8(player.0);
|
||||
w.i32(pos.x);
|
||||
w.i32(pos.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode(r: &mut Reader<'_>) -> Result<Self, DecodeError> {
|
||||
match r.u8()? {
|
||||
TAG_JOIN => {
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(r.raw(16)?);
|
||||
let name = r.string()?;
|
||||
if name.len() > MAX_NAME_BYTES {
|
||||
return Err(DecodeError::InvalidValue("player name"));
|
||||
}
|
||||
Ok(Command::Join { key, name })
|
||||
}
|
||||
TAG_LEAVE => Ok(Command::Leave {
|
||||
player: PlayerId(r.u8()?),
|
||||
}),
|
||||
TAG_DIG => {
|
||||
let player = PlayerId(r.u8()?);
|
||||
let x = r.i32()?;
|
||||
let y = r.i32()?;
|
||||
Ok(Command::Dig {
|
||||
player,
|
||||
pos: Pos::new(x, y),
|
||||
})
|
||||
}
|
||||
_ => Err(DecodeError::InvalidValue("command tag")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! FNV-1a hashing for state checksums.
|
||||
//!
|
||||
//! The hash only has to reveal accidental divergence between peers. It is
|
||||
//! not meant to resist tampering.
|
||||
|
||||
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
|
||||
|
||||
/// Returns the 64-bit FNV-1a hash of `bytes`.
|
||||
pub fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
let mut hash = FNV_OFFSET_BASIS;
|
||||
for &byte in bytes {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
hash
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Item stacks carried by a player.
|
||||
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
|
||||
/// Identifies a kind of item. The value zero is reserved for empty slots.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ItemId(pub u16);
|
||||
|
||||
/// Stone, gained by breaking rock.
|
||||
pub const ITEM_STONE: ItemId = ItemId(1);
|
||||
|
||||
/// Number of slots in an inventory.
|
||||
pub const INVENTORY_SLOTS: usize = 24;
|
||||
/// Largest number of items in one slot.
|
||||
pub const STACK_LIMIT: u32 = 99;
|
||||
|
||||
/// Items of one kind in one slot.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Stack {
|
||||
pub item: ItemId,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// Fixed number of slots, each empty or holding one stack.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Inventory {
|
||||
slots: [Option<Stack>; INVENTORY_SLOTS],
|
||||
}
|
||||
|
||||
impl Default for Inventory {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Inventory {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
slots: [None; INVENTORY_SLOTS],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slots(&self) -> &[Option<Stack>] {
|
||||
&self.slots
|
||||
}
|
||||
|
||||
/// Total number of `item` across all slots.
|
||||
pub fn count(&self, item: ItemId) -> u32 {
|
||||
self.slots
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|stack| stack.item == item)
|
||||
.map(|stack| stack.count)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// How many more of `item` fit.
|
||||
pub fn free_space(&self, item: ItemId) -> u32 {
|
||||
self.slots
|
||||
.iter()
|
||||
.map(|slot| match slot {
|
||||
None => STACK_LIMIT,
|
||||
Some(stack) if stack.item == item => STACK_LIMIT - stack.count,
|
||||
Some(_) => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Adds up to `count` of `item`. Existing stacks are topped up before
|
||||
/// empty slots are used. Returns how many did not fit.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `item` is the reserved id zero.
|
||||
pub fn add(&mut self, item: ItemId, count: u32) -> u32 {
|
||||
assert!(item.0 != 0, "item id zero is reserved for empty slots");
|
||||
let mut left = count;
|
||||
for slot in self.slots.iter_mut() {
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
let Some(stack) = slot else { continue };
|
||||
if stack.item != item {
|
||||
continue;
|
||||
}
|
||||
let moved = left.min(STACK_LIMIT - stack.count);
|
||||
stack.count += moved;
|
||||
left -= moved;
|
||||
}
|
||||
for slot in self.slots.iter_mut() {
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
if slot.is_none() {
|
||||
let moved = left.min(STACK_LIMIT);
|
||||
*slot = Some(Stack { item, count: moved });
|
||||
left -= moved;
|
||||
}
|
||||
}
|
||||
left
|
||||
}
|
||||
|
||||
/// Removes exactly `count` of `item`, emptying the last slots first.
|
||||
/// Returns `false` and changes nothing if fewer are present.
|
||||
pub fn remove(&mut self, item: ItemId, count: u32) -> bool {
|
||||
if self.count(item) < count {
|
||||
return false;
|
||||
}
|
||||
let mut left = count;
|
||||
for slot in self.slots.iter_mut().rev() {
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
let Some(stack) = slot else { continue };
|
||||
if stack.item != item {
|
||||
continue;
|
||||
}
|
||||
let moved = left.min(stack.count);
|
||||
stack.count -= moved;
|
||||
left -= moved;
|
||||
if stack.count == 0 {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn encode(&self, w: &mut Writer) {
|
||||
for slot in &self.slots {
|
||||
match slot {
|
||||
None => w.u16(0),
|
||||
Some(stack) => {
|
||||
w.u16(stack.item.0);
|
||||
w.u32(stack.count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode(r: &mut Reader<'_>) -> Result<Self, DecodeError> {
|
||||
let mut slots = [None; INVENTORY_SLOTS];
|
||||
for slot in slots.iter_mut() {
|
||||
let item = r.u16()?;
|
||||
if item == 0 {
|
||||
continue;
|
||||
}
|
||||
let count = r.u32()?;
|
||||
if count == 0 || count > STACK_LIMIT {
|
||||
return Err(DecodeError::InvalidValue("stack count"));
|
||||
}
|
||||
*slot = Some(Stack {
|
||||
item: ItemId(item),
|
||||
count,
|
||||
});
|
||||
}
|
||||
Ok(Self { slots })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Deterministic core of Grovewright.
|
||||
//!
|
||||
//! Everything that belongs to the shared world lives here. The core never
|
||||
//! touches the engine, the clock, files or the network, and it calculates
|
||||
//! only with whole numbers. Given the same commands it produces the same
|
||||
//! world on every machine. That is what lets every player simulate the whole
|
||||
//! world and exchange nothing but commands.
|
||||
|
||||
pub mod codec;
|
||||
pub mod command;
|
||||
pub mod hash;
|
||||
pub mod inventory;
|
||||
pub mod map;
|
||||
pub mod mapgen;
|
||||
pub mod player;
|
||||
pub mod replay;
|
||||
pub mod rng;
|
||||
pub mod snapshot;
|
||||
pub mod world;
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
//! Tile map of the shared world.
|
||||
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
|
||||
/// Longest accepted map edge, in tiles.
|
||||
pub const MAX_MAP_SIZE: u32 = 1024;
|
||||
|
||||
/// Tile coordinate. `x` grows to the right, `y` grows downwards.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Pos {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
impl Pos {
|
||||
pub const fn new(x: i32, y: i32) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a tile is made of.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Terrain {
|
||||
/// Open ground that can be walked on.
|
||||
Ground = 0,
|
||||
/// Rock that a stone pickaxe can break.
|
||||
SoftRock = 1,
|
||||
/// Rock that needs a copper pickaxe.
|
||||
HardRock = 2,
|
||||
/// Indestructible rock at the edge of the map.
|
||||
Bedrock = 3,
|
||||
}
|
||||
|
||||
impl Terrain {
|
||||
pub fn from_u8(value: u8) -> Option<Self> {
|
||||
match value {
|
||||
0 => Some(Terrain::Ground),
|
||||
1 => Some(Terrain::SoftRock),
|
||||
2 => Some(Terrain::HardRock),
|
||||
3 => Some(Terrain::Bedrock),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_walkable(self) -> bool {
|
||||
matches!(self, Terrain::Ground)
|
||||
}
|
||||
|
||||
/// Number of hits that break this terrain, or `None` if it cannot be dug.
|
||||
pub fn hits_to_break(self) -> Option<u8> {
|
||||
match self {
|
||||
Terrain::SoftRock => Some(3),
|
||||
Terrain::HardRock => Some(6),
|
||||
Terrain::Ground | Terrain::Bedrock => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowest pickaxe tier that can dig this terrain, or `None` if it cannot
|
||||
/// be dug.
|
||||
pub fn required_tool_tier(self) -> Option<u8> {
|
||||
match self {
|
||||
Terrain::SoftRock => Some(1),
|
||||
Terrain::HardRock => Some(2),
|
||||
Terrain::Ground | Terrain::Bedrock => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One cell of the map.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Tile {
|
||||
pub terrain: Terrain,
|
||||
/// Hits this tile has taken so far. Always below the number of hits that
|
||||
/// break its terrain, and zero for terrain that cannot be dug.
|
||||
pub damage: u8,
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
pub const fn new(terrain: Terrain) -> Self {
|
||||
Self { terrain, damage: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rectangular grid of tiles.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Map {
|
||||
width: u32,
|
||||
height: u32,
|
||||
tiles: Vec<Tile>,
|
||||
}
|
||||
|
||||
impl Map {
|
||||
/// Creates a map in which every tile has the given terrain.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if an edge is zero or longer than [`MAX_MAP_SIZE`].
|
||||
pub fn filled(width: u32, height: u32, terrain: Terrain) -> Self {
|
||||
assert!(width > 0 && height > 0, "map must not be empty");
|
||||
assert!(
|
||||
width <= MAX_MAP_SIZE && height <= MAX_MAP_SIZE,
|
||||
"map is too large"
|
||||
);
|
||||
let count = (width as usize) * (height as usize);
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
tiles: vec![Tile::new(terrain); count],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(&self) -> u32 {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> u32 {
|
||||
self.height
|
||||
}
|
||||
|
||||
fn index(&self, pos: Pos) -> Option<usize> {
|
||||
if pos.x < 0 || pos.y < 0 {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (pos.x as u32, pos.y as u32);
|
||||
if x >= self.width || y >= self.height {
|
||||
return None;
|
||||
}
|
||||
Some((y as usize) * (self.width as usize) + (x as usize))
|
||||
}
|
||||
|
||||
pub fn in_bounds(&self, pos: Pos) -> bool {
|
||||
self.index(pos).is_some()
|
||||
}
|
||||
|
||||
pub fn get(&self, pos: Pos) -> Option<Tile> {
|
||||
self.index(pos).map(|index| self.tiles[index])
|
||||
}
|
||||
|
||||
/// Replaces the tile at `pos`. Returns `false` if `pos` is outside the
|
||||
/// map.
|
||||
pub fn set(&mut self, pos: Pos, tile: Tile) -> bool {
|
||||
match self.index(pos) {
|
||||
Some(index) => {
|
||||
self.tiles[index] = tile;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// All tiles, row by row: the tile at `pos` has index `y * width + x`.
|
||||
pub fn tiles(&self) -> &[Tile] {
|
||||
&self.tiles
|
||||
}
|
||||
|
||||
pub fn encode(&self, w: &mut Writer) {
|
||||
w.u32(self.width);
|
||||
w.u32(self.height);
|
||||
for tile in &self.tiles {
|
||||
w.u8(tile.terrain as u8);
|
||||
w.u8(tile.damage);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode(r: &mut Reader<'_>) -> Result<Self, DecodeError> {
|
||||
let width = r.u32()?;
|
||||
let height = r.u32()?;
|
||||
if width == 0 || height == 0 || width > MAX_MAP_SIZE || height > MAX_MAP_SIZE {
|
||||
return Err(DecodeError::InvalidValue("map size"));
|
||||
}
|
||||
let count = (width as usize) * (height as usize);
|
||||
if r.remaining() < count * 2 {
|
||||
return Err(DecodeError::UnexpectedEnd);
|
||||
}
|
||||
let mut tiles = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let terrain = Terrain::from_u8(r.u8()?).ok_or(DecodeError::InvalidValue("terrain"))?;
|
||||
let damage = r.u8()?;
|
||||
if damage > 0 && damage >= terrain.hits_to_break().unwrap_or(0) {
|
||||
return Err(DecodeError::InvalidValue("tile damage"));
|
||||
}
|
||||
tiles.push(Tile { terrain, damage });
|
||||
}
|
||||
Ok(Self {
|
||||
width,
|
||||
height,
|
||||
tiles,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Generator for the milestone 1 test map.
|
||||
//!
|
||||
//! The broad layout is fixed and the details are random: a bedrock border, a
|
||||
//! clearing around the spawn point, a rock outcrop right next to the
|
||||
//! clearing, cave-like soft rock in the inner area and hard rock further out.
|
||||
|
||||
use crate::map::{Map, Pos, Terrain, Tile};
|
||||
use crate::rng::Rng;
|
||||
|
||||
/// Edge length of the test map, in tiles.
|
||||
pub const TEST_MAP_SIZE: u32 = 64;
|
||||
/// Tiles within this distance of the spawn point are always open ground.
|
||||
pub const SPAWN_CLEARING_RADIUS: i32 = 5;
|
||||
/// Edge length of the soft rock outcrop that every test map has.
|
||||
pub const OUTCROP_SIZE: i32 = 4;
|
||||
/// Rock at least this many tiles from the spawn point, counted along the
|
||||
/// longer axis, is hard rock.
|
||||
pub const HARD_ROCK_DISTANCE: i32 = 22;
|
||||
|
||||
const INITIAL_ROCK_PERCENT: u32 = 46;
|
||||
const SMOOTHING_PASSES: u32 = 4;
|
||||
/// Keeps the map sequence apart from other uses of the same seed.
|
||||
const SEED_SALT: u64 = 0x6D61_7067_656E_0001;
|
||||
|
||||
/// Spawn point of a map: its centre tile.
|
||||
pub fn spawn_pos(map: &Map) -> Pos {
|
||||
Pos::new((map.width() / 2) as i32, (map.height() / 2) as i32)
|
||||
}
|
||||
|
||||
/// Top left tile of the soft rock outcrop next to the spawn clearing.
|
||||
pub fn outcrop_origin(map: &Map) -> Pos {
|
||||
let spawn = spawn_pos(map);
|
||||
Pos::new(spawn.x + SPAWN_CLEARING_RADIUS + 2, spawn.y - 2)
|
||||
}
|
||||
|
||||
/// Builds the test map for `seed`. The same seed always gives the same map.
|
||||
pub fn generate_test_map(seed: u64) -> Map {
|
||||
let size = TEST_MAP_SIZE;
|
||||
let mut rng = Rng::new(seed ^ SEED_SALT);
|
||||
|
||||
let mut rock = vec![false; (size as usize) * (size as usize)];
|
||||
for cell in rock.iter_mut() {
|
||||
*cell = rng.below(100) < INITIAL_ROCK_PERCENT;
|
||||
}
|
||||
for _ in 0..SMOOTHING_PASSES {
|
||||
rock = smooth(&rock, size);
|
||||
}
|
||||
|
||||
let mut map = Map::filled(size, size, Terrain::Ground);
|
||||
let spawn = spawn_pos(&map);
|
||||
let outcrop = outcrop_origin(&map);
|
||||
let last = size as i32 - 1;
|
||||
|
||||
for y in 0..=last {
|
||||
for x in 0..=last {
|
||||
let dx = x - spawn.x;
|
||||
let dy = y - spawn.y;
|
||||
let on_border = x == 0 || y == 0 || x == last || y == last;
|
||||
let in_clearing = dx * dx + dy * dy <= SPAWN_CLEARING_RADIUS * SPAWN_CLEARING_RADIUS;
|
||||
let in_outcrop = (outcrop.x..outcrop.x + OUTCROP_SIZE).contains(&x)
|
||||
&& (outcrop.y..outcrop.y + OUTCROP_SIZE).contains(&y);
|
||||
let is_rock = rock[(y as usize) * (size as usize) + (x as usize)];
|
||||
|
||||
let terrain = if on_border {
|
||||
Terrain::Bedrock
|
||||
} else if in_clearing {
|
||||
Terrain::Ground
|
||||
} else if in_outcrop {
|
||||
Terrain::SoftRock
|
||||
} else if !is_rock {
|
||||
Terrain::Ground
|
||||
} else if dx.abs().max(dy.abs()) >= HARD_ROCK_DISTANCE {
|
||||
Terrain::HardRock
|
||||
} else {
|
||||
Terrain::SoftRock
|
||||
};
|
||||
map.set(Pos::new(x, y), Tile::new(terrain));
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// One pass of cave smoothing: a cell becomes rock when at least five of the
|
||||
/// nine cells around it, itself included, are rock. Cells outside the grid
|
||||
/// count as rock.
|
||||
fn smooth(rock: &[bool], size: u32) -> Vec<bool> {
|
||||
let size = size as i32;
|
||||
let mut out = vec![false; rock.len()];
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let mut count = 0;
|
||||
for ny in y - 1..=y + 1 {
|
||||
for nx in x - 1..=x + 1 {
|
||||
let outside = nx < 0 || ny < 0 || nx >= size || ny >= size;
|
||||
if outside || rock[(ny * size + nx) as usize] {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out[(y * size + x) as usize] = count >= 5;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Players known to a world.
|
||||
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
use crate::inventory::Inventory;
|
||||
|
||||
/// Stable identity of a player across sessions, chosen by the player's game.
|
||||
pub type PlayerKey = [u8; 16];
|
||||
|
||||
/// Index of a player in the world's player list.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct PlayerId(pub u8);
|
||||
|
||||
/// Most players that can be online at the same time.
|
||||
pub const MAX_ONLINE_PLAYERS: usize = 4;
|
||||
/// Most players a world remembers.
|
||||
pub const MAX_KNOWN_PLAYERS: usize = 16;
|
||||
/// Longest stored player name, in bytes of UTF-8.
|
||||
pub const MAX_NAME_BYTES: usize = 32;
|
||||
/// Pickaxe tier every player starts with: stone.
|
||||
pub const STARTING_TOOL_TIER: u8 = 1;
|
||||
|
||||
/// A player and everything that belongs to them personally.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Player {
|
||||
pub key: PlayerKey,
|
||||
pub name: String,
|
||||
pub online: bool,
|
||||
pub tool_tier: u8,
|
||||
/// First tick at which the player may act again.
|
||||
pub next_action_tick: u64,
|
||||
pub inventory: Inventory,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
/// Creates an online player with a stone pickaxe and an empty inventory.
|
||||
pub fn new(key: PlayerKey, name: &str) -> Self {
|
||||
Self {
|
||||
key,
|
||||
name: clamp_name(name),
|
||||
online: true,
|
||||
tool_tier: STARTING_TOOL_TIER,
|
||||
next_action_tick: 0,
|
||||
inventory: Inventory::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(&self, w: &mut Writer) {
|
||||
w.raw(&self.key);
|
||||
w.str(&self.name);
|
||||
w.bool(self.online);
|
||||
w.u8(self.tool_tier);
|
||||
w.u64(self.next_action_tick);
|
||||
self.inventory.encode(w);
|
||||
}
|
||||
|
||||
pub fn decode(r: &mut Reader<'_>) -> Result<Self, DecodeError> {
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(r.raw(16)?);
|
||||
let name = r.string()?;
|
||||
if name.len() > MAX_NAME_BYTES {
|
||||
return Err(DecodeError::InvalidValue("player name"));
|
||||
}
|
||||
let online = r.bool()?;
|
||||
let tool_tier = r.u8()?;
|
||||
let next_action_tick = r.u64()?;
|
||||
let inventory = Inventory::decode(r)?;
|
||||
Ok(Self {
|
||||
key,
|
||||
name,
|
||||
online,
|
||||
tool_tier,
|
||||
next_action_tick,
|
||||
inventory,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortens `name` to at most [`MAX_NAME_BYTES`] bytes without splitting a
|
||||
/// character.
|
||||
pub fn clamp_name(name: &str) -> String {
|
||||
let mut end = name.len().min(MAX_NAME_BYTES);
|
||||
while !name.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
name[..end].to_owned()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Recording of a session: the starting state and every command, tick by
|
||||
//! tick. Replaying a recording reproduces the session exactly.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
use crate::command::Command;
|
||||
use crate::snapshot::SnapshotError;
|
||||
use crate::world::World;
|
||||
|
||||
const MAGIC: [u8; 4] = *b"GRWR";
|
||||
/// Version of the recording format.
|
||||
pub const RECORDING_VERSION: u32 = 1;
|
||||
|
||||
/// Reason a recording could not be read or replayed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RecordingError {
|
||||
/// The bytes do not start with the recording marker.
|
||||
NotARecording,
|
||||
/// The recording was written by an incompatible version of the game.
|
||||
UnsupportedVersion(u32),
|
||||
/// The recording is damaged.
|
||||
Corrupt(DecodeError),
|
||||
/// The starting state inside the recording could not be restored.
|
||||
BadStart(SnapshotError),
|
||||
/// Replaying did not end in the recorded state.
|
||||
Diverged { expected: u64, actual: u64 },
|
||||
}
|
||||
|
||||
impl fmt::Display for RecordingError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
RecordingError::NotARecording => write!(f, "not a recording"),
|
||||
RecordingError::UnsupportedVersion(version) => {
|
||||
write!(f, "unsupported recording version {version}")
|
||||
}
|
||||
RecordingError::Corrupt(error) => write!(f, "damaged recording: {error}"),
|
||||
RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"),
|
||||
RecordingError::Diverged { expected, actual } => write!(
|
||||
f,
|
||||
"replay ended in state {actual:016x} instead of {expected:016x}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RecordingError {}
|
||||
|
||||
impl From<DecodeError> for RecordingError {
|
||||
fn from(error: DecodeError) -> Self {
|
||||
RecordingError::Corrupt(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// The starting state of a session and the commands of every tick after it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Recording {
|
||||
start: Vec<u8>,
|
||||
tick_count: u32,
|
||||
/// Ticks that had commands, as (tick index, commands), in rising order.
|
||||
busy_ticks: Vec<(u32, Vec<Command>)>,
|
||||
}
|
||||
|
||||
impl Recording {
|
||||
/// Starts a recording at the current state of `world`.
|
||||
pub fn start(world: &World) -> Self {
|
||||
Self {
|
||||
start: world.to_snapshot(),
|
||||
tick_count: 0,
|
||||
busy_ticks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the commands of one tick. Call it once for every
|
||||
/// [`World::step`], with the same commands.
|
||||
pub fn push_tick(&mut self, commands: &[Command]) {
|
||||
if !commands.is_empty() {
|
||||
self.busy_ticks.push((self.tick_count, commands.to_vec()));
|
||||
}
|
||||
self.tick_count += 1;
|
||||
}
|
||||
|
||||
/// Number of recorded ticks.
|
||||
pub fn tick_count(&self) -> u32 {
|
||||
self.tick_count
|
||||
}
|
||||
|
||||
/// Restores the starting state and applies every recorded tick.
|
||||
pub fn replay(&self) -> Result<World, RecordingError> {
|
||||
let mut world = World::from_snapshot(&self.start).map_err(RecordingError::BadStart)?;
|
||||
let mut busy = self.busy_ticks.iter().peekable();
|
||||
for index in 0..self.tick_count {
|
||||
match busy.peek() {
|
||||
Some((tick, commands)) if *tick == index => {
|
||||
world.step(commands);
|
||||
busy.next();
|
||||
}
|
||||
_ => {
|
||||
world.step(&[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
/// Encodes the recording together with the state that replaying it must
|
||||
/// reach. `end` is the world after the last recorded tick.
|
||||
pub fn to_bytes(&self, end: &World) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.raw(&MAGIC);
|
||||
w.u32(RECORDING_VERSION);
|
||||
w.u64(end.state_hash());
|
||||
w.bytes(&self.start);
|
||||
w.u32(self.tick_count);
|
||||
w.u32(u32::try_from(self.busy_ticks.len()).expect("busy tick count fits in u32"));
|
||||
for (tick, commands) in &self.busy_ticks {
|
||||
w.u32(*tick);
|
||||
w.u16(u16::try_from(commands.len()).expect("command count fits in u16"));
|
||||
for command in commands {
|
||||
command.encode(&mut w);
|
||||
}
|
||||
}
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
/// Decodes a recording and the state hash that replaying it must reach.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<(Self, u64), RecordingError> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let magic = r
|
||||
.raw(MAGIC.len())
|
||||
.map_err(|_| RecordingError::NotARecording)?;
|
||||
if magic != MAGIC.as_slice() {
|
||||
return Err(RecordingError::NotARecording);
|
||||
}
|
||||
let version = r.u32()?;
|
||||
if version != RECORDING_VERSION {
|
||||
return Err(RecordingError::UnsupportedVersion(version));
|
||||
}
|
||||
let expected_hash = r.u64()?;
|
||||
let start = r.bytes()?.to_vec();
|
||||
let tick_count = r.u32()?;
|
||||
let busy_count = r.u32()?;
|
||||
let mut busy_ticks = Vec::new();
|
||||
let mut first_free_tick = 0;
|
||||
for _ in 0..busy_count {
|
||||
let tick = r.u32()?;
|
||||
if tick < first_free_tick || tick >= tick_count {
|
||||
return Err(DecodeError::InvalidValue("tick index").into());
|
||||
}
|
||||
first_free_tick = tick + 1;
|
||||
let command_count = r.u16()?;
|
||||
if command_count == 0 {
|
||||
return Err(DecodeError::InvalidValue("command count").into());
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
for _ in 0..command_count {
|
||||
commands.push(Command::decode(&mut r)?);
|
||||
}
|
||||
busy_ticks.push((tick, commands));
|
||||
}
|
||||
r.finish()?;
|
||||
Ok((
|
||||
Self {
|
||||
start,
|
||||
tick_count,
|
||||
busy_ticks,
|
||||
},
|
||||
expected_hash,
|
||||
))
|
||||
}
|
||||
|
||||
/// Replays `bytes` and checks that the recorded final state is reached.
|
||||
pub fn verify(bytes: &[u8]) -> Result<World, RecordingError> {
|
||||
let (recording, expected) = Self::from_bytes(bytes)?;
|
||||
let world = recording.replay()?;
|
||||
let actual = world.state_hash();
|
||||
if actual == expected {
|
||||
Ok(world)
|
||||
} else {
|
||||
Err(RecordingError::Diverged { expected, actual })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Repeatable pseudo random numbers (SplitMix64).
|
||||
//!
|
||||
//! The core never uses any other source of randomness. The same seed always
|
||||
//! gives the same sequence, on every machine.
|
||||
|
||||
/// Pseudo random number generator with 64 bits of state.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Rng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl Rng {
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self { state: seed }
|
||||
}
|
||||
|
||||
/// Returns the next value of the sequence.
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// Returns a value in `0..bound`.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `bound` is zero.
|
||||
pub fn below(&mut self, bound: u32) -> u32 {
|
||||
assert!(bound > 0, "bound must be greater than zero");
|
||||
((u128::from(self.next_u64()) * u128::from(bound)) >> 64) as u32
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Complete, versioned encoding of a world. Saves, joining players and state
|
||||
//! checksums all use it.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
use crate::hash::fnv1a64;
|
||||
use crate::map::Map;
|
||||
use crate::player::{Player, MAX_KNOWN_PLAYERS};
|
||||
use crate::world::World;
|
||||
|
||||
const MAGIC: [u8; 4] = *b"GRWS";
|
||||
/// Version of the snapshot format. Raise it whenever the encoded state
|
||||
/// changes.
|
||||
pub const SNAPSHOT_VERSION: u32 = 1;
|
||||
|
||||
/// Reason a snapshot could not be restored.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SnapshotError {
|
||||
/// The bytes do not start with the snapshot marker.
|
||||
NotASnapshot,
|
||||
/// The snapshot was written by an incompatible version of the game.
|
||||
UnsupportedVersion(u32),
|
||||
/// The snapshot is damaged.
|
||||
Corrupt(DecodeError),
|
||||
}
|
||||
|
||||
impl fmt::Display for SnapshotError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SnapshotError::NotASnapshot => write!(f, "not a snapshot"),
|
||||
SnapshotError::UnsupportedVersion(version) => {
|
||||
write!(f, "unsupported snapshot version {version}")
|
||||
}
|
||||
SnapshotError::Corrupt(error) => write!(f, "damaged snapshot: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SnapshotError {}
|
||||
|
||||
impl From<DecodeError> for SnapshotError {
|
||||
fn from(error: DecodeError) -> Self {
|
||||
SnapshotError::Corrupt(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl World {
|
||||
fn encode_state(&self, w: &mut Writer) {
|
||||
w.u64(self.seed);
|
||||
w.u64(self.tick);
|
||||
self.map.encode(w);
|
||||
w.u8(self.players.len() as u8);
|
||||
for player in &self.players {
|
||||
player.encode(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checksum of the complete world state. Two worlds with the same value
|
||||
/// are in the same state, apart from hash collisions.
|
||||
pub fn state_hash(&self) -> u64 {
|
||||
let mut w = Writer::new();
|
||||
self.encode_state(&mut w);
|
||||
fnv1a64(&w.into_bytes())
|
||||
}
|
||||
|
||||
/// Encodes the complete world behind a header that names format and
|
||||
/// version.
|
||||
pub fn to_snapshot(&self) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.raw(&MAGIC);
|
||||
w.u32(SNAPSHOT_VERSION);
|
||||
self.encode_state(&mut w);
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
/// Restores a world from bytes written by [`World::to_snapshot`].
|
||||
pub fn from_snapshot(bytes: &[u8]) -> Result<World, SnapshotError> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let magic = r
|
||||
.raw(MAGIC.len())
|
||||
.map_err(|_| SnapshotError::NotASnapshot)?;
|
||||
if magic != MAGIC.as_slice() {
|
||||
return Err(SnapshotError::NotASnapshot);
|
||||
}
|
||||
let version = r.u32()?;
|
||||
if version != SNAPSHOT_VERSION {
|
||||
return Err(SnapshotError::UnsupportedVersion(version));
|
||||
}
|
||||
let seed = r.u64()?;
|
||||
let tick = r.u64()?;
|
||||
let map = Map::decode(&mut r)?;
|
||||
let count = r.u8()? as usize;
|
||||
if count > MAX_KNOWN_PLAYERS {
|
||||
return Err(DecodeError::InvalidValue("player count").into());
|
||||
}
|
||||
let mut players = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
players.push(Player::decode(&mut r)?);
|
||||
}
|
||||
r.finish()?;
|
||||
Ok(World {
|
||||
seed,
|
||||
tick,
|
||||
map,
|
||||
players,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! The shared world and the rules that change it.
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::inventory::{ItemId, ITEM_STONE};
|
||||
use crate::map::{Map, Pos, Terrain, Tile};
|
||||
use crate::mapgen::generate_test_map;
|
||||
use crate::player::{
|
||||
clamp_name, Player, PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS,
|
||||
};
|
||||
|
||||
/// Simulation steps per second of play.
|
||||
pub const TICKS_PER_SECOND: u32 = 20;
|
||||
/// Ticks a player has to wait between two pickaxe hits.
|
||||
pub const DIG_COOLDOWN_TICKS: u64 = 5;
|
||||
|
||||
/// Something that happened during a tick. Events tell the presentation what
|
||||
/// to show. They are not part of the world state.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Event {
|
||||
PlayerJoined {
|
||||
player: PlayerId,
|
||||
},
|
||||
PlayerLeft {
|
||||
player: PlayerId,
|
||||
},
|
||||
/// A player could not join because the world is full.
|
||||
JoinRefused {
|
||||
key: PlayerKey,
|
||||
},
|
||||
/// A pickaxe hit landed without breaking the tile.
|
||||
TileHit {
|
||||
player: PlayerId,
|
||||
pos: Pos,
|
||||
},
|
||||
/// A tile changed its terrain.
|
||||
TileChanged {
|
||||
pos: Pos,
|
||||
},
|
||||
ItemGained {
|
||||
player: PlayerId,
|
||||
item: ItemId,
|
||||
count: u32,
|
||||
},
|
||||
/// A hit was refused because the inventory has no room for the result.
|
||||
InventoryFull {
|
||||
player: PlayerId,
|
||||
},
|
||||
}
|
||||
|
||||
/// Complete state of the shared world.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct World {
|
||||
pub(crate) seed: u64,
|
||||
pub(crate) tick: u64,
|
||||
pub(crate) map: Map,
|
||||
pub(crate) players: Vec<Player>,
|
||||
}
|
||||
|
||||
impl World {
|
||||
/// Creates a fresh world with the test map for `seed` and no players.
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self {
|
||||
seed,
|
||||
tick: 0,
|
||||
map: generate_test_map(seed),
|
||||
players: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn seed(&self) -> u64 {
|
||||
self.seed
|
||||
}
|
||||
|
||||
/// Number of ticks simulated so far.
|
||||
pub fn tick(&self) -> u64 {
|
||||
self.tick
|
||||
}
|
||||
|
||||
pub fn map(&self) -> &Map {
|
||||
&self.map
|
||||
}
|
||||
|
||||
pub fn players(&self) -> &[Player] {
|
||||
&self.players
|
||||
}
|
||||
|
||||
pub fn player(&self, id: PlayerId) -> Option<&Player> {
|
||||
self.players.get(id.0 as usize)
|
||||
}
|
||||
|
||||
pub fn find_player(&self, key: &PlayerKey) -> Option<PlayerId> {
|
||||
self.players
|
||||
.iter()
|
||||
.position(|player| &player.key == key)
|
||||
.map(|index| PlayerId(index as u8))
|
||||
}
|
||||
|
||||
/// Marks every player as offline. Used after loading a save from disk,
|
||||
/// when nobody has joined yet.
|
||||
pub fn set_all_offline(&mut self) {
|
||||
for player in &mut self.players {
|
||||
player.online = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `commands` in order and advances the world by one tick.
|
||||
pub fn step(&mut self, commands: &[Command]) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
for command in commands {
|
||||
match command {
|
||||
Command::Join { key, name } => self.join(key, name, &mut events),
|
||||
Command::Leave { player } => self.leave(*player, &mut events),
|
||||
Command::Dig { player, pos } => self.dig(*player, *pos, &mut events),
|
||||
}
|
||||
}
|
||||
self.tick += 1;
|
||||
events
|
||||
}
|
||||
|
||||
fn join(&mut self, key: &PlayerKey, name: &str, events: &mut Vec<Event>) {
|
||||
let online = self.players.iter().filter(|player| player.online).count();
|
||||
match self.find_player(key) {
|
||||
Some(id) => {
|
||||
let player = &mut self.players[id.0 as usize];
|
||||
if player.online {
|
||||
return;
|
||||
}
|
||||
if online >= MAX_ONLINE_PLAYERS {
|
||||
events.push(Event::JoinRefused { key: *key });
|
||||
return;
|
||||
}
|
||||
player.online = true;
|
||||
player.name = clamp_name(name);
|
||||
events.push(Event::PlayerJoined { player: id });
|
||||
}
|
||||
None => {
|
||||
if online >= MAX_ONLINE_PLAYERS || self.players.len() >= MAX_KNOWN_PLAYERS {
|
||||
events.push(Event::JoinRefused { key: *key });
|
||||
return;
|
||||
}
|
||||
self.players.push(Player::new(*key, name));
|
||||
let id = PlayerId((self.players.len() - 1) as u8);
|
||||
events.push(Event::PlayerJoined { player: id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn leave(&mut self, id: PlayerId, events: &mut Vec<Event>) {
|
||||
let Some(player) = self.players.get_mut(id.0 as usize) else {
|
||||
return;
|
||||
};
|
||||
if !player.online {
|
||||
return;
|
||||
}
|
||||
player.online = false;
|
||||
events.push(Event::PlayerLeft { player: id });
|
||||
}
|
||||
|
||||
fn dig(&mut self, id: PlayerId, pos: Pos, events: &mut Vec<Event>) {
|
||||
let tick = self.tick;
|
||||
let Some(player) = self.players.get_mut(id.0 as usize) else {
|
||||
return;
|
||||
};
|
||||
if !player.online || tick < player.next_action_tick {
|
||||
return;
|
||||
}
|
||||
let Some(tile) = self.map.get(pos) else {
|
||||
return;
|
||||
};
|
||||
let (Some(hits), Some(tier)) = (
|
||||
tile.terrain.hits_to_break(),
|
||||
tile.terrain.required_tool_tier(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if player.tool_tier < tier {
|
||||
return;
|
||||
}
|
||||
|
||||
let damage = tile.damage.saturating_add(1);
|
||||
let breaks = damage >= hits;
|
||||
if breaks && player.inventory.free_space(ITEM_STONE) == 0 {
|
||||
events.push(Event::InventoryFull { player: id });
|
||||
return;
|
||||
}
|
||||
|
||||
player.next_action_tick = tick + DIG_COOLDOWN_TICKS;
|
||||
if breaks {
|
||||
self.map.set(pos, Tile::new(Terrain::Ground));
|
||||
player.inventory.add(ITEM_STONE, 1);
|
||||
events.push(Event::TileChanged { pos });
|
||||
events.push(Event::ItemGained {
|
||||
player: id,
|
||||
item: ITEM_STONE,
|
||||
count: 1,
|
||||
});
|
||||
} else {
|
||||
self.map.set(
|
||||
pos,
|
||||
Tile {
|
||||
terrain: tile.terrain,
|
||||
damage,
|
||||
},
|
||||
);
|
||||
events.push(Event::TileHit { player: id, pos });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::inventory::{INVENTORY_SLOTS, STACK_LIMIT};
|
||||
use crate::mapgen::outcrop_origin;
|
||||
|
||||
// Lives here instead of in `tests/world.rs` because filling an inventory
|
||||
// needs direct access to the player, which the public API does not give.
|
||||
#[test]
|
||||
fn a_full_inventory_refuses_the_breaking_hit() {
|
||||
let mut world = World::new(42);
|
||||
world.step(&[Command::Join {
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
}]);
|
||||
let id = PlayerId(0);
|
||||
let rock = outcrop_origin(world.map());
|
||||
let dig = [Command::Dig {
|
||||
player: id,
|
||||
pos: rock,
|
||||
}];
|
||||
let capacity = STACK_LIMIT * INVENTORY_SLOTS as u32;
|
||||
assert_eq!(world.players[0].inventory.add(ITEM_STONE, capacity), 0);
|
||||
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
world.step(&dig),
|
||||
vec![Event::TileHit {
|
||||
player: id,
|
||||
pos: rock
|
||||
}]
|
||||
);
|
||||
for _ in 0..DIG_COOLDOWN_TICKS {
|
||||
world.step(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(world.step(&dig), vec![Event::InventoryFull { player: id }]);
|
||||
let tile = world.map().get(rock).unwrap();
|
||||
assert_eq!((tile.terrain, tile.damage), (Terrain::SoftRock, 2));
|
||||
|
||||
// The refused hit starts no cooldown: once there is room, the very
|
||||
// next tick can break the tile.
|
||||
assert!(world.players[0].inventory.remove(ITEM_STONE, 1));
|
||||
assert_eq!(
|
||||
world.step(&dig),
|
||||
vec![
|
||||
Event::TileChanged { pos: rock },
|
||||
Event::ItemGained {
|
||||
player: id,
|
||||
item: ITEM_STONE,
|
||||
count: 1
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use grovewright_core::codec::{DecodeError, Reader, Writer};
|
||||
|
||||
#[test]
|
||||
fn every_kind_of_value_survives_a_round_trip() {
|
||||
let mut w = Writer::new();
|
||||
w.u8(0xAB);
|
||||
w.u16(0xBEEF);
|
||||
w.u32(0xDEAD_BEEF);
|
||||
w.u64(0x0123_4567_89AB_CDEF);
|
||||
w.i32(-42);
|
||||
w.bool(true);
|
||||
w.bool(false);
|
||||
w.bytes(&[1, 2, 3]);
|
||||
w.str("Grüner Hain");
|
||||
w.raw(&[9, 9]);
|
||||
let bytes = w.into_bytes();
|
||||
|
||||
let mut r = Reader::new(&bytes);
|
||||
assert_eq!(r.u8(), Ok(0xAB));
|
||||
assert_eq!(r.u16(), Ok(0xBEEF));
|
||||
assert_eq!(r.u32(), Ok(0xDEAD_BEEF));
|
||||
assert_eq!(r.u64(), Ok(0x0123_4567_89AB_CDEF));
|
||||
assert_eq!(r.i32(), Ok(-42));
|
||||
assert_eq!(r.bool(), Ok(true));
|
||||
assert_eq!(r.bool(), Ok(false));
|
||||
assert_eq!(r.bytes(), Ok(&[1u8, 2, 3][..]));
|
||||
assert_eq!(r.string(), Ok("Grüner Hain".to_owned()));
|
||||
assert_eq!(r.raw(2), Ok(&[9u8, 9][..]));
|
||||
assert_eq!(r.finish(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integers_are_little_endian() {
|
||||
let mut w = Writer::new();
|
||||
w.u32(0x0102_0304);
|
||||
assert_eq!(w.into_bytes(), vec![4, 3, 2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_past_the_end_fails() {
|
||||
let mut r = Reader::new(&[1, 2]);
|
||||
assert_eq!(r.u32(), Err(DecodeError::UnexpectedEnd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_length_fails_without_panicking() {
|
||||
let mut w = Writer::new();
|
||||
w.u32(u32::MAX);
|
||||
let bytes = w.into_bytes();
|
||||
let mut r = Reader::new(&bytes);
|
||||
assert_eq!(r.bytes(), Err(DecodeError::UnexpectedEnd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bool_and_invalid_utf8_are_rejected() {
|
||||
let mut r = Reader::new(&[2]);
|
||||
assert_eq!(r.bool(), Err(DecodeError::InvalidValue("bool")));
|
||||
|
||||
let mut w = Writer::new();
|
||||
w.bytes(&[0xFF, 0xFE]);
|
||||
let bytes = w.into_bytes();
|
||||
let mut r = Reader::new(&bytes);
|
||||
assert_eq!(r.string(), Err(DecodeError::InvalidValue("utf-8 string")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_rejects_bytes_that_were_not_read() {
|
||||
let r = Reader::new(&[0]);
|
||||
assert_eq!(r.finish(), Err(DecodeError::TrailingBytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_counts_down_as_values_are_read() {
|
||||
let mut r = Reader::new(&[1, 2, 3]);
|
||||
assert_eq!(r.remaining(), 3);
|
||||
r.u8().unwrap();
|
||||
assert_eq!(r.remaining(), 2);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use grovewright_core::codec::{DecodeError, Reader, Writer};
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::map::Pos;
|
||||
use grovewright_core::player::{PlayerId, MAX_NAME_BYTES};
|
||||
|
||||
fn round_trip(command: &Command) -> Command {
|
||||
let mut w = Writer::new();
|
||||
command.encode(&mut w);
|
||||
let bytes = w.into_bytes();
|
||||
let mut r = Reader::new(&bytes);
|
||||
let decoded = Command::decode(&mut r).expect("command decodes");
|
||||
r.finish().expect("no bytes are left over");
|
||||
decoded
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_survives_encoding() {
|
||||
let commands = [
|
||||
Command::Join {
|
||||
key: [7; 16],
|
||||
name: "Ada".to_owned(),
|
||||
},
|
||||
Command::Leave {
|
||||
player: PlayerId(3),
|
||||
},
|
||||
Command::Dig {
|
||||
player: PlayerId(1),
|
||||
pos: Pos::new(-4, 1_000),
|
||||
},
|
||||
];
|
||||
for command in &commands {
|
||||
assert_eq!(&round_trip(command), command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_name_is_shortened_when_encoded() {
|
||||
let command = Command::Join {
|
||||
key: [1; 16],
|
||||
name: "x".repeat(MAX_NAME_BYTES + 10),
|
||||
};
|
||||
assert_eq!(
|
||||
round_trip(&command),
|
||||
Command::Join {
|
||||
key: [1; 16],
|
||||
name: "x".repeat(MAX_NAME_BYTES),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_command_is_rejected() {
|
||||
assert_eq!(
|
||||
Command::decode(&mut Reader::new(&[0xEE])),
|
||||
Err(DecodeError::InvalidValue("command tag"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_off_command_is_rejected() {
|
||||
let mut w = Writer::new();
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: Pos::new(1, 2),
|
||||
}
|
||||
.encode(&mut w);
|
||||
let bytes = w.into_bytes();
|
||||
assert_eq!(
|
||||
Command::decode(&mut Reader::new(&bytes[..bytes.len() - 1])),
|
||||
Err(DecodeError::UnexpectedEnd)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_commands_can_follow_each_other_in_one_buffer() {
|
||||
let commands = vec![
|
||||
Command::Leave {
|
||||
player: PlayerId(2),
|
||||
},
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: Pos::new(5, 6),
|
||||
},
|
||||
];
|
||||
let mut w = Writer::new();
|
||||
for command in &commands {
|
||||
command.encode(&mut w);
|
||||
}
|
||||
let bytes = w.into_bytes();
|
||||
let mut r = Reader::new(&bytes);
|
||||
let mut decoded = Vec::new();
|
||||
while r.remaining() > 0 {
|
||||
decoded.push(Command::decode(&mut r).unwrap());
|
||||
}
|
||||
assert_eq!(decoded, commands);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! The central promise of the core: the same commands always produce the
|
||||
//! same world.
|
||||
|
||||
use grovewright_core::codec::{Reader, Writer};
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::inventory::ITEM_STONE;
|
||||
use grovewright_core::map::Pos;
|
||||
use grovewright_core::mapgen::TEST_MAP_SIZE;
|
||||
use grovewright_core::player::PlayerId;
|
||||
use grovewright_core::replay::Recording;
|
||||
use grovewright_core::rng::Rng;
|
||||
use grovewright_core::world::World;
|
||||
|
||||
/// Edge length of the square around the map centre in which the script digs.
|
||||
const DIG_AREA: u32 = 24;
|
||||
|
||||
/// Builds a busy, repeatable command stream: four players join, dig at
|
||||
/// random places near the centre, and now and then leave and return.
|
||||
fn script(seed: u64, ticks: usize) -> Vec<Vec<Command>> {
|
||||
let mut rng = Rng::new(seed);
|
||||
let first = (TEST_MAP_SIZE - DIG_AREA) / 2;
|
||||
let mut out = Vec::with_capacity(ticks);
|
||||
for tick in 0..ticks {
|
||||
let mut commands = Vec::new();
|
||||
if tick < 4 {
|
||||
commands.push(Command::Join {
|
||||
key: [tick as u8 + 1; 16],
|
||||
name: format!("P{tick}"),
|
||||
});
|
||||
}
|
||||
for player in 0..4 {
|
||||
if rng.below(3) == 0 {
|
||||
let x = first + rng.below(DIG_AREA);
|
||||
let y = first + rng.below(DIG_AREA);
|
||||
commands.push(Command::Dig {
|
||||
player: PlayerId(player),
|
||||
pos: Pos::new(x as i32, y as i32),
|
||||
});
|
||||
}
|
||||
}
|
||||
if rng.below(200) == 0 {
|
||||
commands.push(Command::Leave {
|
||||
player: PlayerId(rng.below(4) as u8),
|
||||
});
|
||||
}
|
||||
if rng.below(200) == 0 {
|
||||
let n = rng.below(4) as u8;
|
||||
commands.push(Command::Join {
|
||||
key: [n + 1; 16],
|
||||
name: format!("P{n}"),
|
||||
});
|
||||
}
|
||||
out.push(commands);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn stones(world: &World) -> u32 {
|
||||
world
|
||||
.players()
|
||||
.iter()
|
||||
.map(|player| player.inventory.count(ITEM_STONE))
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_commands_give_the_same_world() {
|
||||
let script = script(2026, 3_000);
|
||||
let mut a = World::new(99);
|
||||
let mut b = World::new(99);
|
||||
for (tick, commands) in script.iter().enumerate() {
|
||||
assert_eq!(
|
||||
a.step(commands),
|
||||
b.step(commands),
|
||||
"events differ at tick {tick}"
|
||||
);
|
||||
if tick.is_multiple_of(100) {
|
||||
assert_eq!(
|
||||
a.state_hash(),
|
||||
b.state_hash(),
|
||||
"state differs at tick {tick}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(a, b);
|
||||
assert!(
|
||||
stones(&a) > 50,
|
||||
"the script should break plenty of rock, broke {}",
|
||||
stones(&a)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_seed_gives_a_different_world() {
|
||||
let script = script(2026, 200);
|
||||
let mut a = World::new(1);
|
||||
let mut b = World::new(2);
|
||||
assert_ne!(a.map(), b.map(), "the seed must shape the map");
|
||||
for commands in &script {
|
||||
a.step(commands);
|
||||
b.step(commands);
|
||||
}
|
||||
assert_ne!(a.state_hash(), b.state_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_restored_in_the_middle_of_a_session_stays_in_step() {
|
||||
let script = script(7, 2_000);
|
||||
let mut original = World::new(5);
|
||||
for commands in &script[..1_000] {
|
||||
original.step(commands);
|
||||
}
|
||||
let mut restored = World::from_snapshot(&original.to_snapshot()).unwrap();
|
||||
for commands in &script[1_000..] {
|
||||
assert_eq!(original.step(commands), restored.step(commands));
|
||||
}
|
||||
assert_eq!(original.state_hash(), restored.state_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_session_replays_to_the_same_hash() {
|
||||
let script = script(31, 2_000);
|
||||
let mut world = World::new(8);
|
||||
let mut recording = Recording::start(&world);
|
||||
for commands in &script {
|
||||
recording.push_tick(commands);
|
||||
world.step(commands);
|
||||
}
|
||||
let bytes = recording.to_bytes(&world);
|
||||
let replayed = Recording::verify(&bytes).expect("the recording replays cleanly");
|
||||
assert_eq!(replayed.state_hash(), world.state_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_sent_as_bytes_give_the_same_world() {
|
||||
let script = script(3, 1_500);
|
||||
let mut local = World::new(21);
|
||||
let mut remote = World::new(21);
|
||||
for commands in &script {
|
||||
let mut w = Writer::new();
|
||||
for command in commands {
|
||||
command.encode(&mut w);
|
||||
}
|
||||
let bytes = w.into_bytes();
|
||||
let mut r = Reader::new(&bytes);
|
||||
let mut received = Vec::new();
|
||||
while r.remaining() > 0 {
|
||||
received.push(Command::decode(&mut r).expect("command decodes"));
|
||||
}
|
||||
local.step(commands);
|
||||
remote.step(&received);
|
||||
}
|
||||
assert_eq!(local.state_hash(), remote.state_hash());
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Guards the lockstep rules: the core must not use anything whose result
|
||||
//! can differ between machines or between runs.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Text that must not appear anywhere in the source of the core, and why.
|
||||
const FORBIDDEN: &[(&str, &str)] = &[
|
||||
("f32", "floating point results can differ between machines"),
|
||||
("f64", "floating point results can differ between machines"),
|
||||
(
|
||||
"HashMap",
|
||||
"iteration order differs between runs; use BTreeMap or Vec",
|
||||
),
|
||||
(
|
||||
"HashSet",
|
||||
"iteration order differs between runs; use BTreeSet or Vec",
|
||||
),
|
||||
("std::time", "the core must not read the clock"),
|
||||
("Instant", "the core must not read the clock"),
|
||||
("SystemTime", "the core must not read the clock"),
|
||||
("std::thread", "the core must stay single-threaded"),
|
||||
("std::env", "the core must not depend on the environment"),
|
||||
(
|
||||
"std::fs",
|
||||
"the core must not touch files; the shell does that",
|
||||
),
|
||||
(
|
||||
"std::net",
|
||||
"the core must not open connections; the shell does that",
|
||||
),
|
||||
("unsafe", "the core forbids unsafe code"),
|
||||
];
|
||||
|
||||
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for entry in fs::read_dir(dir).expect("source directory is readable") {
|
||||
let path = entry.expect("directory entry is readable").path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_source_uses_no_forbidden_apis() {
|
||||
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut files = Vec::new();
|
||||
rust_files(&src, &mut files);
|
||||
files.sort();
|
||||
assert!(
|
||||
!files.is_empty(),
|
||||
"no source files found in {}",
|
||||
src.display()
|
||||
);
|
||||
|
||||
let mut problems = Vec::new();
|
||||
for file in &files {
|
||||
let text = fs::read_to_string(file).expect("source file is readable");
|
||||
for (number, line) in text.lines().enumerate() {
|
||||
for (needle, reason) in FORBIDDEN {
|
||||
if line.contains(needle) {
|
||||
problems.push(format!(
|
||||
"{}:{}: `{needle}`: {reason}",
|
||||
file.display(),
|
||||
number + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
problems.is_empty(),
|
||||
"forbidden APIs in the core:\n{}",
|
||||
problems.join("\n")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Pinned values for everything that must not change unnoticed.
|
||||
//!
|
||||
//! Every other test compares two computations inside the same build, so a
|
||||
//! change that affects both sides alike stays green. This file compares
|
||||
//! against values recorded once. If one of them fails, rules or encodings
|
||||
//! changed, on purpose or by accident. If saves or recordings are affected,
|
||||
//! raise the version constant named in the failure message, then update the
|
||||
//! value here deliberately.
|
||||
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::hash::fnv1a64;
|
||||
use grovewright_core::map::Pos;
|
||||
use grovewright_core::mapgen::outcrop_origin;
|
||||
use grovewright_core::player::PlayerId;
|
||||
use grovewright_core::replay::Recording;
|
||||
use grovewright_core::world::World;
|
||||
|
||||
const SEED: u64 = 2026;
|
||||
|
||||
/// The commands of a session of 60 ticks. It is built from literal values
|
||||
/// and the outcrop origin of the map, not from constants such as
|
||||
/// `DIG_COOLDOWN_TICKS`, so that changing a rule changes the result and not
|
||||
/// the script.
|
||||
///
|
||||
/// Never change or extend this script. To pin more, add a new script and a
|
||||
/// new pinned value instead.
|
||||
fn frozen_script(world: &World) -> Vec<Vec<Command>> {
|
||||
let origin = outcrop_origin(world.map());
|
||||
let right_of_origin = Pos::new(origin.x + 1, origin.y);
|
||||
// Pairs of (tick, command). Commands of the same tick are applied in the
|
||||
// order listed here.
|
||||
let scripted = [
|
||||
// Two players join in the same tick.
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [2; 16],
|
||||
name: "Grace".to_owned(),
|
||||
},
|
||||
),
|
||||
// Player 0 hits the outcrop origin three times, five ticks apart.
|
||||
// The third hit breaks the rock and gives a stone.
|
||||
(
|
||||
2,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
7,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
12,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
// Player 1 hits the tile to the right of the origin once.
|
||||
(
|
||||
20,
|
||||
Command::Dig {
|
||||
player: PlayerId(1),
|
||||
pos: right_of_origin,
|
||||
},
|
||||
),
|
||||
// Player 1 leaves and returns with a name of 40 bytes, which the
|
||||
// world shortens to 32.
|
||||
(
|
||||
30,
|
||||
Command::Leave {
|
||||
player: PlayerId(1),
|
||||
},
|
||||
),
|
||||
(
|
||||
40,
|
||||
Command::Join {
|
||||
key: [2; 16],
|
||||
name: "ä".repeat(20),
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut ticks = vec![Vec::new(); 60];
|
||||
for (tick, command) in scripted {
|
||||
ticks[tick].push(command);
|
||||
}
|
||||
ticks
|
||||
}
|
||||
|
||||
/// Covers the map generator, the map encoding (terrain numbers, byte order of
|
||||
/// width and height) and the snapshot header.
|
||||
#[test]
|
||||
fn the_starting_snapshot_is_unchanged() {
|
||||
let actual = fnv1a64(&World::new(SEED).to_snapshot());
|
||||
assert_eq!(
|
||||
actual, 0x1b78_d996_1586_fbf1,
|
||||
"the starting world or the snapshot format changed; if saves are \
|
||||
affected, raise SNAPSHOT_VERSION in core/src/snapshot.rs; then \
|
||||
update this value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Covers the rules of joining, leaving and digging, the shortening of names
|
||||
/// and the encoding of players.
|
||||
#[test]
|
||||
fn the_frozen_session_ends_in_the_same_state() {
|
||||
let mut world = World::new(SEED);
|
||||
for commands in frozen_script(&world) {
|
||||
world.step(&commands);
|
||||
}
|
||||
assert_eq!(
|
||||
world.state_hash(),
|
||||
0xc304_43df_fc74_dcfc,
|
||||
"the rules or the snapshot format changed; old recordings no longer \
|
||||
verify; if saves are affected, raise SNAPSHOT_VERSION in \
|
||||
core/src/snapshot.rs; then update this value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Covers the recording header and layout and the wire format of commands.
|
||||
#[test]
|
||||
fn the_recording_format_is_unchanged() {
|
||||
let mut world = World::new(SEED);
|
||||
let mut recording = Recording::start(&world);
|
||||
for commands in frozen_script(&world) {
|
||||
recording.push_tick(&commands);
|
||||
world.step(&commands);
|
||||
}
|
||||
assert_eq!(
|
||||
fnv1a64(&recording.to_bytes(&world)),
|
||||
0x2312_5a7d_d8ef_5ae0,
|
||||
"the recording or command format changed; raise RECORDING_VERSION in \
|
||||
core/src/replay.rs; then update this value"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use grovewright_core::hash::fnv1a64;
|
||||
|
||||
#[test]
|
||||
fn matches_the_published_fnv1a_test_vectors() {
|
||||
assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325);
|
||||
assert_eq!(fnv1a64(b"a"), 0xaf63_dc4c_8601_ec8c);
|
||||
assert_eq!(fnv1a64(b"foobar"), 0x8594_4171_f739_67e8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_order_of_bytes_matters() {
|
||||
assert_ne!(fnv1a64(&[0, 1]), fnv1a64(&[1, 0]));
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use grovewright_core::codec::{DecodeError, Reader, Writer};
|
||||
use grovewright_core::inventory::{
|
||||
Inventory, ItemId, Stack, INVENTORY_SLOTS, ITEM_STONE, STACK_LIMIT,
|
||||
};
|
||||
|
||||
const OTHER: ItemId = ItemId(2);
|
||||
const CAPACITY: u32 = STACK_LIMIT * INVENTORY_SLOTS as u32;
|
||||
|
||||
#[test]
|
||||
fn a_new_inventory_is_empty() {
|
||||
let inventory = Inventory::new();
|
||||
assert_eq!(inventory.slots().len(), INVENTORY_SLOTS);
|
||||
assert!(inventory.slots().iter().all(Option::is_none));
|
||||
assert_eq!(inventory.count(ITEM_STONE), 0);
|
||||
assert_eq!(inventory.free_space(ITEM_STONE), CAPACITY);
|
||||
assert_eq!(Inventory::default(), inventory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_tops_up_a_stack_before_using_an_empty_slot() {
|
||||
let mut inventory = Inventory::new();
|
||||
assert_eq!(inventory.add(ITEM_STONE, 90), 0);
|
||||
assert_eq!(inventory.add(OTHER, 5), 0);
|
||||
assert_eq!(inventory.add(ITEM_STONE, 20), 0);
|
||||
|
||||
let stone = |count| {
|
||||
Some(Stack {
|
||||
item: ITEM_STONE,
|
||||
count,
|
||||
})
|
||||
};
|
||||
assert_eq!(inventory.slots()[0], stone(99));
|
||||
assert_eq!(
|
||||
inventory.slots()[1],
|
||||
Some(Stack {
|
||||
item: OTHER,
|
||||
count: 5
|
||||
})
|
||||
);
|
||||
assert_eq!(inventory.slots()[2], stone(11));
|
||||
assert_eq!(inventory.count(ITEM_STONE), 110);
|
||||
assert_eq!(inventory.count(OTHER), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_reports_what_does_not_fit() {
|
||||
let mut inventory = Inventory::new();
|
||||
assert_eq!(inventory.add(ITEM_STONE, CAPACITY + 7), 7);
|
||||
assert_eq!(inventory.count(ITEM_STONE), CAPACITY);
|
||||
assert_eq!(inventory.free_space(ITEM_STONE), 0);
|
||||
assert_eq!(inventory.free_space(OTHER), 0);
|
||||
assert_eq!(inventory.add(OTHER, 1), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_space_counts_empty_slots_and_matching_stacks() {
|
||||
let mut inventory = Inventory::new();
|
||||
inventory.add(ITEM_STONE, 10);
|
||||
inventory.add(OTHER, 1);
|
||||
assert_eq!(
|
||||
inventory.free_space(ITEM_STONE),
|
||||
CAPACITY - STACK_LIMIT - 10
|
||||
);
|
||||
assert_eq!(inventory.free_space(OTHER), CAPACITY - STACK_LIMIT - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_takes_exactly_the_requested_amount() {
|
||||
let mut inventory = Inventory::new();
|
||||
inventory.add(ITEM_STONE, 150);
|
||||
assert!(inventory.remove(ITEM_STONE, 60));
|
||||
assert_eq!(inventory.count(ITEM_STONE), 90);
|
||||
assert_eq!(
|
||||
inventory.slots()[0],
|
||||
Some(Stack {
|
||||
item: ITEM_STONE,
|
||||
count: 90
|
||||
})
|
||||
);
|
||||
assert_eq!(inventory.slots()[1], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_changes_nothing_when_too_few_are_present() {
|
||||
let mut inventory = Inventory::new();
|
||||
inventory.add(ITEM_STONE, 10);
|
||||
let before = inventory.clone();
|
||||
assert!(!inventory.remove(ITEM_STONE, 11));
|
||||
assert!(!inventory.remove(OTHER, 1));
|
||||
assert_eq!(inventory, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "item id zero is reserved")]
|
||||
fn the_reserved_item_id_cannot_be_added() {
|
||||
Inventory::new().add(ItemId(0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inventory_survives_encoding() {
|
||||
let mut inventory = Inventory::new();
|
||||
inventory.add(ITEM_STONE, 120);
|
||||
inventory.add(OTHER, 3);
|
||||
let mut w = Writer::new();
|
||||
inventory.encode(&mut w);
|
||||
let bytes = w.into_bytes();
|
||||
let mut r = Reader::new(&bytes);
|
||||
assert_eq!(Inventory::decode(&mut r), Ok(inventory));
|
||||
assert_eq!(r.finish(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoding_rejects_impossible_stack_counts() {
|
||||
for count in [0, STACK_LIMIT + 1] {
|
||||
let mut w = Writer::new();
|
||||
w.u16(ITEM_STONE.0);
|
||||
w.u32(count);
|
||||
let bytes = w.into_bytes();
|
||||
assert_eq!(
|
||||
Inventory::decode(&mut Reader::new(&bytes)),
|
||||
Err(DecodeError::InvalidValue("stack count")),
|
||||
"count {count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use grovewright_core::codec::{DecodeError, Reader, Writer};
|
||||
use grovewright_core::map::{Map, Pos, Terrain, Tile};
|
||||
|
||||
#[test]
|
||||
fn a_filled_map_has_the_requested_size_and_terrain() {
|
||||
let map = Map::filled(4, 3, Terrain::SoftRock);
|
||||
assert_eq!(map.width(), 4);
|
||||
assert_eq!(map.height(), 3);
|
||||
assert_eq!(map.tiles().len(), 12);
|
||||
assert_eq!(map.get(Pos::new(3, 2)), Some(Tile::new(Terrain::SoftRock)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_outside_the_map_are_rejected() {
|
||||
let mut map = Map::filled(4, 3, Terrain::Ground);
|
||||
for pos in [
|
||||
Pos::new(-1, 0),
|
||||
Pos::new(0, -1),
|
||||
Pos::new(4, 0),
|
||||
Pos::new(0, 3),
|
||||
] {
|
||||
assert!(!map.in_bounds(pos), "{pos:?}");
|
||||
assert_eq!(map.get(pos), None, "{pos:?}");
|
||||
assert!(!map.set(pos, Tile::new(Terrain::Bedrock)), "{pos:?}");
|
||||
}
|
||||
assert!(map.in_bounds(Pos::new(3, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_changes_exactly_one_tile() {
|
||||
let mut map = Map::filled(4, 3, Terrain::Ground);
|
||||
assert!(map.set(Pos::new(1, 2), Tile::new(Terrain::HardRock)));
|
||||
let hard_rock = map
|
||||
.tiles()
|
||||
.iter()
|
||||
.filter(|tile| tile.terrain == Terrain::HardRock)
|
||||
.count();
|
||||
assert_eq!(hard_rock, 1);
|
||||
assert_eq!(map.tiles()[2 * 4 + 1].terrain, Terrain::HardRock);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_rock_can_be_dug() {
|
||||
assert_eq!(Terrain::Ground.hits_to_break(), None);
|
||||
assert_eq!(Terrain::Bedrock.hits_to_break(), None);
|
||||
assert_eq!(Terrain::SoftRock.hits_to_break(), Some(3));
|
||||
assert_eq!(Terrain::HardRock.hits_to_break(), Some(6));
|
||||
assert_eq!(Terrain::Ground.required_tool_tier(), None);
|
||||
assert_eq!(Terrain::Bedrock.required_tool_tier(), None);
|
||||
assert_eq!(Terrain::SoftRock.required_tool_tier(), Some(1));
|
||||
assert_eq!(Terrain::HardRock.required_tool_tier(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ground_can_be_walked_on() {
|
||||
assert!(Terrain::Ground.is_walkable());
|
||||
assert!(!Terrain::SoftRock.is_walkable());
|
||||
assert!(!Terrain::HardRock.is_walkable());
|
||||
assert!(!Terrain::Bedrock.is_walkable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_numbers_are_stable() {
|
||||
for terrain in [
|
||||
Terrain::Ground,
|
||||
Terrain::SoftRock,
|
||||
Terrain::HardRock,
|
||||
Terrain::Bedrock,
|
||||
] {
|
||||
assert_eq!(Terrain::from_u8(terrain as u8), Some(terrain));
|
||||
}
|
||||
assert_eq!(Terrain::from_u8(4), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_map_survives_encoding() {
|
||||
let mut map = Map::filled(5, 4, Terrain::Ground);
|
||||
map.set(
|
||||
Pos::new(2, 1),
|
||||
Tile {
|
||||
terrain: Terrain::SoftRock,
|
||||
damage: 2,
|
||||
},
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
map.encode(&mut w);
|
||||
let bytes = w.into_bytes();
|
||||
let mut r = Reader::new(&bytes);
|
||||
assert_eq!(Map::decode(&mut r), Ok(map));
|
||||
assert_eq!(r.finish(), Ok(()));
|
||||
}
|
||||
|
||||
fn encoded_map(width: u32, height: u32, tiles: &[(u8, u8)]) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.u32(width);
|
||||
w.u32(height);
|
||||
for (terrain, damage) in tiles {
|
||||
w.u8(*terrain);
|
||||
w.u8(*damage);
|
||||
}
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoding_rejects_impossible_maps() {
|
||||
let cases: [(Vec<u8>, DecodeError); 6] = [
|
||||
(
|
||||
encoded_map(0, 4, &[]),
|
||||
DecodeError::InvalidValue("map size"),
|
||||
),
|
||||
(
|
||||
encoded_map(2_000, 4, &[]),
|
||||
DecodeError::InvalidValue("map size"),
|
||||
),
|
||||
(
|
||||
encoded_map(1, 1, &[(9, 0)]),
|
||||
DecodeError::InvalidValue("terrain"),
|
||||
),
|
||||
(
|
||||
encoded_map(1, 1, &[(0, 1)]),
|
||||
DecodeError::InvalidValue("tile damage"),
|
||||
),
|
||||
(
|
||||
encoded_map(1, 1, &[(1, 3)]),
|
||||
DecodeError::InvalidValue("tile damage"),
|
||||
),
|
||||
(encoded_map(2, 2, &[(0, 0)]), DecodeError::UnexpectedEnd),
|
||||
];
|
||||
for (bytes, expected) in cases {
|
||||
assert_eq!(Map::decode(&mut Reader::new(&bytes)), Err(expected));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Rules the map generator has to keep for every seed.
|
||||
|
||||
use grovewright_core::map::{Map, Pos, Terrain};
|
||||
use grovewright_core::mapgen::{
|
||||
generate_test_map, outcrop_origin, spawn_pos, HARD_ROCK_DISTANCE, OUTCROP_SIZE,
|
||||
SPAWN_CLEARING_RADIUS, TEST_MAP_SIZE,
|
||||
};
|
||||
|
||||
/// Number of seeds every rule is checked against.
|
||||
const SEEDS: u64 = 300;
|
||||
|
||||
fn terrain_at(map: &Map, x: i32, y: i32) -> Terrain {
|
||||
map.get(Pos::new(x, y))
|
||||
.expect("position is on the map")
|
||||
.terrain
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_seed_gives_the_same_map() {
|
||||
assert_eq!(generate_test_map(42), generate_test_map(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_give_different_maps() {
|
||||
assert_ne!(generate_test_map(1), generate_test_map(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_map_has_the_test_size() {
|
||||
let map = generate_test_map(0);
|
||||
assert_eq!((map.width(), map.height()), (TEST_MAP_SIZE, TEST_MAP_SIZE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bedrock_forms_the_border_and_nothing_else() {
|
||||
let last = TEST_MAP_SIZE as i32 - 1;
|
||||
for seed in 0..SEEDS {
|
||||
let map = generate_test_map(seed);
|
||||
for y in 0..=last {
|
||||
for x in 0..=last {
|
||||
let on_border = x == 0 || y == 0 || x == last || y == last;
|
||||
let is_bedrock = terrain_at(&map, x, y) == Terrain::Bedrock;
|
||||
assert_eq!(is_bedrock, on_border, "seed {seed}, tile {x}/{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spawn_clearing_is_open_ground() {
|
||||
for seed in 0..SEEDS {
|
||||
let map = generate_test_map(seed);
|
||||
let spawn = spawn_pos(&map);
|
||||
for dy in -SPAWN_CLEARING_RADIUS..=SPAWN_CLEARING_RADIUS {
|
||||
for dx in -SPAWN_CLEARING_RADIUS..=SPAWN_CLEARING_RADIUS {
|
||||
if dx * dx + dy * dy > SPAWN_CLEARING_RADIUS * SPAWN_CLEARING_RADIUS {
|
||||
continue;
|
||||
}
|
||||
let terrain = terrain_at(&map, spawn.x + dx, spawn.y + dy);
|
||||
assert_eq!(terrain, Terrain::Ground, "seed {seed}, offset {dx}/{dy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_soft_rock_outcrop_lies_next_to_the_clearing() {
|
||||
for seed in 0..SEEDS {
|
||||
let map = generate_test_map(seed);
|
||||
let spawn = spawn_pos(&map);
|
||||
let origin = outcrop_origin(&map);
|
||||
assert_eq!(origin.x - spawn.x, SPAWN_CLEARING_RADIUS + 2, "seed {seed}");
|
||||
for y in origin.y..origin.y + OUTCROP_SIZE {
|
||||
for x in origin.x..origin.x + OUTCROP_SIZE {
|
||||
assert_eq!(
|
||||
terrain_at(&map, x, y),
|
||||
Terrain::SoftRock,
|
||||
"seed {seed}, tile {x}/{y}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_rock_appears_only_far_from_the_spawn_point() {
|
||||
let last = TEST_MAP_SIZE as i32 - 1;
|
||||
for seed in 0..SEEDS {
|
||||
let map = generate_test_map(seed);
|
||||
let spawn = spawn_pos(&map);
|
||||
for y in 1..last {
|
||||
for x in 1..last {
|
||||
let far = (x - spawn.x).abs().max((y - spawn.y).abs()) >= HARD_ROCK_DISTANCE;
|
||||
match terrain_at(&map, x, y) {
|
||||
Terrain::HardRock => assert!(far, "seed {seed}: hard rock near spawn"),
|
||||
Terrain::SoftRock => assert!(!far, "seed {seed}: soft rock far out"),
|
||||
Terrain::Ground | Terrain::Bedrock => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_map_mixes_rock_and_open_ground() {
|
||||
for seed in 0..SEEDS {
|
||||
let map = generate_test_map(seed);
|
||||
let total = map.tiles().len();
|
||||
let rock = map
|
||||
.tiles()
|
||||
.iter()
|
||||
.filter(|tile| !tile.terrain.is_walkable())
|
||||
.count();
|
||||
let percent = rock * 100 / total;
|
||||
assert!(
|
||||
(20..=80).contains(&percent),
|
||||
"seed {seed}: {percent} percent rock"
|
||||
);
|
||||
let hard = map
|
||||
.tiles()
|
||||
.iter()
|
||||
.filter(|tile| tile.terrain == Terrain::HardRock)
|
||||
.count();
|
||||
assert!(hard > 0, "seed {seed}: no hard rock");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_generated_tile_starts_damaged() {
|
||||
let map = generate_test_map(5);
|
||||
assert!(map.tiles().iter().all(|tile| tile.damage == 0));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use grovewright_core::codec::{DecodeError, Reader, Writer};
|
||||
use grovewright_core::inventory::ITEM_STONE;
|
||||
use grovewright_core::player::{clamp_name, Player, MAX_NAME_BYTES, STARTING_TOOL_TIER};
|
||||
|
||||
#[test]
|
||||
fn a_new_player_is_online_with_a_stone_pickaxe_and_empty_pockets() {
|
||||
let player = Player::new([5; 16], "Ada");
|
||||
assert_eq!(player.key, [5; 16]);
|
||||
assert_eq!(player.name, "Ada");
|
||||
assert!(player.online);
|
||||
assert_eq!(player.tool_tier, STARTING_TOOL_TIER);
|
||||
assert_eq!(player.next_action_tick, 0);
|
||||
assert!(player.inventory.slots().iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_names_are_cut_at_a_character_boundary() {
|
||||
// "ä" takes two bytes, so twenty of them are forty bytes.
|
||||
let long = "ä".repeat(20);
|
||||
let clamped = clamp_name(&long);
|
||||
assert_eq!(clamped, "ä".repeat(16));
|
||||
assert_eq!(clamped.len(), MAX_NAME_BYTES);
|
||||
|
||||
// With one byte in front, the limit falls into the middle of a character.
|
||||
let odd = format!("a{}", "ä".repeat(20));
|
||||
assert_eq!(clamp_name(&odd), format!("a{}", "ä".repeat(15)));
|
||||
|
||||
assert_eq!(clamp_name("Ada"), "Ada");
|
||||
assert_eq!(Player::new([0; 16], &long).name, clamped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_player_survives_encoding() {
|
||||
let mut player = Player::new([9; 16], "Grace");
|
||||
player.online = false;
|
||||
player.next_action_tick = 1234;
|
||||
player.inventory.add(ITEM_STONE, 17);
|
||||
|
||||
let mut w = Writer::new();
|
||||
player.encode(&mut w);
|
||||
let bytes = w.into_bytes();
|
||||
let mut r = Reader::new(&bytes);
|
||||
assert_eq!(Player::decode(&mut r), Ok(player));
|
||||
assert_eq!(r.finish(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoding_rejects_an_overlong_name() {
|
||||
let mut w = Writer::new();
|
||||
w.raw(&[0; 16]);
|
||||
w.str(&"x".repeat(MAX_NAME_BYTES + 1));
|
||||
let bytes = w.into_bytes();
|
||||
assert_eq!(
|
||||
Player::decode(&mut Reader::new(&bytes)),
|
||||
Err(DecodeError::InvalidValue("player name"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use grovewright_core::codec::DecodeError;
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::inventory::ITEM_STONE;
|
||||
use grovewright_core::mapgen::outcrop_origin;
|
||||
use grovewright_core::player::PlayerId;
|
||||
use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION};
|
||||
use grovewright_core::world::World;
|
||||
|
||||
const ADA: PlayerId = PlayerId(0);
|
||||
|
||||
/// Commands of tick `tick` in a short scripted session.
|
||||
fn commands_for(world: &World, tick: u32) -> Vec<Command> {
|
||||
if tick == 0 {
|
||||
vec![Command::Join {
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
}]
|
||||
} else if tick.is_multiple_of(7) {
|
||||
vec![Command::Dig {
|
||||
player: ADA,
|
||||
pos: outcrop_origin(world.map()),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays 200 ticks from a fresh world and records them.
|
||||
fn recorded_session() -> (Recording, World) {
|
||||
let mut world = World::new(11);
|
||||
let mut recording = Recording::start(&world);
|
||||
for tick in 0..200 {
|
||||
let commands = commands_for(&world, tick);
|
||||
recording.push_tick(&commands);
|
||||
world.step(&commands);
|
||||
}
|
||||
(recording, world)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaying_reaches_the_same_state() {
|
||||
let (recording, world) = recorded_session();
|
||||
assert_eq!(recording.tick_count(), 200);
|
||||
assert_eq!(world.player(ADA).unwrap().inventory.count(ITEM_STONE), 1);
|
||||
assert_eq!(recording.replay(), Ok(world));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_survives_encoding() {
|
||||
let (recording, world) = recorded_session();
|
||||
let bytes = recording.to_bytes(&world);
|
||||
let (decoded, expected_hash) = Recording::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(decoded, recording);
|
||||
assert_eq!(expected_hash, world.state_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_accepts_a_faithful_recording() {
|
||||
let (recording, world) = recorded_session();
|
||||
assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_reports_a_replay_that_ends_in_another_state() {
|
||||
let (recording, mut world) = recorded_session();
|
||||
// One tick more than was recorded: the claimed end state is unreachable.
|
||||
world.step(&[]);
|
||||
let bytes = recording.to_bytes(&world);
|
||||
match Recording::verify(&bytes) {
|
||||
Err(RecordingError::Diverged { expected, actual }) => {
|
||||
assert_eq!(expected, world.state_hash());
|
||||
assert_ne!(actual, expected);
|
||||
}
|
||||
other => panic!("expected a divergence, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_can_start_in_the_middle_of_a_session() {
|
||||
let mut world = World::new(11);
|
||||
for tick in 0..50 {
|
||||
let commands = commands_for(&world, tick);
|
||||
world.step(&commands);
|
||||
}
|
||||
let mut recording = Recording::start(&world);
|
||||
for tick in 50..120 {
|
||||
let commands = commands_for(&world, tick);
|
||||
recording.push_tick(&commands);
|
||||
world.step(&commands);
|
||||
}
|
||||
assert_eq!(recording.tick_count(), 70);
|
||||
assert_eq!(recording.replay(), Ok(world));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_recording_replays_to_its_starting_state() {
|
||||
let world = World::new(3);
|
||||
let recording = Recording::start(&world);
|
||||
assert_eq!(recording.tick_count(), 0);
|
||||
assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_recordings_are_rejected() {
|
||||
let (recording, world) = recorded_session();
|
||||
let bytes = recording.to_bytes(&world);
|
||||
|
||||
assert_eq!(
|
||||
Recording::from_bytes(b""),
|
||||
Err(RecordingError::NotARecording)
|
||||
);
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&world.to_snapshot()),
|
||||
Err(RecordingError::NotARecording)
|
||||
);
|
||||
|
||||
let mut other_version = bytes.clone();
|
||||
other_version[4..8].copy_from_slice(&(RECORDING_VERSION + 1).to_le_bytes());
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&other_version),
|
||||
Err(RecordingError::UnsupportedVersion(RECORDING_VERSION + 1))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&bytes[..bytes.len() - 1]),
|
||||
Err(RecordingError::Corrupt(DecodeError::UnexpectedEnd))
|
||||
);
|
||||
|
||||
let mut padded = bytes.clone();
|
||||
padded.push(0);
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&padded),
|
||||
Err(RecordingError::Corrupt(DecodeError::TrailingBytes))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use grovewright_core::rng::Rng;
|
||||
|
||||
#[test]
|
||||
fn matches_the_splitmix64_reference_values() {
|
||||
let mut rng = Rng::new(0);
|
||||
assert_eq!(rng.next_u64(), 0xE220_A839_7B1D_CDAF);
|
||||
assert_eq!(rng.next_u64(), 0x6E78_9E6A_A1B9_65F4);
|
||||
assert_eq!(rng.next_u64(), 0x06C4_5D18_8009_454F);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_seed_gives_the_same_sequence() {
|
||||
let mut a = Rng::new(2026);
|
||||
let mut b = Rng::new(2026);
|
||||
for _ in 0..1_000 {
|
||||
assert_eq!(a.next_u64(), b.next_u64());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_give_different_sequences() {
|
||||
assert_ne!(Rng::new(1).next_u64(), Rng::new(2).next_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_stays_inside_the_bound() {
|
||||
let mut rng = Rng::new(7);
|
||||
for bound in [1u32, 2, 3, 10, 100, 1_000_000, u32::MAX] {
|
||||
for _ in 0..1_000 {
|
||||
assert!(rng.below(bound) < bound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_reaches_every_value_of_a_small_bound() {
|
||||
let mut rng = Rng::new(99);
|
||||
let mut seen = [false; 6];
|
||||
for _ in 0..1_000 {
|
||||
seen[rng.below(6) as usize] = true;
|
||||
}
|
||||
assert_eq!(seen, [true; 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "bound must be greater than zero")]
|
||||
fn below_refuses_a_bound_of_zero() {
|
||||
Rng::new(1).below(0);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use grovewright_core::codec::DecodeError;
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::map::Pos;
|
||||
use grovewright_core::mapgen::outcrop_origin;
|
||||
use grovewright_core::player::PlayerId;
|
||||
use grovewright_core::snapshot::{SnapshotError, SNAPSHOT_VERSION};
|
||||
use grovewright_core::world::World;
|
||||
|
||||
const ADA: PlayerId = PlayerId(0);
|
||||
|
||||
fn rock(world: &World) -> Pos {
|
||||
outcrop_origin(world.map())
|
||||
}
|
||||
|
||||
/// A world at tick 12 with one player who has hit a rock once.
|
||||
fn played_world() -> World {
|
||||
let mut world = World::new(7);
|
||||
world.step(&[Command::Join {
|
||||
key: [3; 16],
|
||||
name: "Ada".to_owned(),
|
||||
}]);
|
||||
let pos = rock(&world);
|
||||
world.step(&[Command::Dig { player: ADA, pos }]);
|
||||
for _ in 0..10 {
|
||||
world.step(&[]);
|
||||
}
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_restores_an_identical_world() {
|
||||
let world = played_world();
|
||||
let restored = World::from_snapshot(&world.to_snapshot()).unwrap();
|
||||
assert_eq!(restored, world);
|
||||
assert_eq!(restored.state_hash(), world.state_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_hash_covers_every_part_of_the_state() {
|
||||
let base = played_world();
|
||||
let pos = rock(&base);
|
||||
|
||||
let mut waited = base.clone();
|
||||
waited.step(&[]);
|
||||
assert_ne!(waited.state_hash(), base.state_hash(), "tick");
|
||||
|
||||
let mut dug = base.clone();
|
||||
dug.step(&[Command::Dig { player: ADA, pos }]);
|
||||
assert_ne!(dug.state_hash(), waited.state_hash(), "map and cooldown");
|
||||
|
||||
let mut left = base.clone();
|
||||
left.step(&[Command::Leave { player: ADA }]);
|
||||
assert_ne!(left.state_hash(), waited.state_hash(), "players");
|
||||
|
||||
assert_ne!(
|
||||
World::new(1).state_hash(),
|
||||
World::new(2).state_hash(),
|
||||
"seed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_worlds_have_equal_hashes() {
|
||||
assert_eq!(played_world().state_hash(), played_world().state_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restored_world_continues_exactly_like_the_original() {
|
||||
let mut original = played_world();
|
||||
let mut restored = World::from_snapshot(&original.to_snapshot()).unwrap();
|
||||
let pos = rock(&original);
|
||||
for tick in 0..40u32 {
|
||||
let commands = if tick.is_multiple_of(6) {
|
||||
vec![Command::Dig { player: ADA, pos }]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
assert_eq!(original.step(&commands), restored.step(&commands));
|
||||
}
|
||||
assert_eq!(original.state_hash(), restored.state_hash());
|
||||
assert_eq!(original, restored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_that_are_not_a_snapshot_are_rejected() {
|
||||
assert_eq!(World::from_snapshot(b""), Err(SnapshotError::NotASnapshot));
|
||||
assert_eq!(
|
||||
World::from_snapshot(b"GR"),
|
||||
Err(SnapshotError::NotASnapshot)
|
||||
);
|
||||
assert_eq!(
|
||||
World::from_snapshot(b"nope, not a save"),
|
||||
Err(SnapshotError::NotASnapshot)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_of_another_version_are_rejected() {
|
||||
let mut bytes = played_world().to_snapshot();
|
||||
bytes[4..8].copy_from_slice(&(SNAPSHOT_VERSION + 1).to_le_bytes());
|
||||
assert_eq!(
|
||||
World::from_snapshot(&bytes),
|
||||
Err(SnapshotError::UnsupportedVersion(SNAPSHOT_VERSION + 1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_off_and_padded_snapshots_are_rejected() {
|
||||
let bytes = played_world().to_snapshot();
|
||||
assert_eq!(
|
||||
World::from_snapshot(&bytes[..bytes.len() - 1]),
|
||||
Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd))
|
||||
);
|
||||
|
||||
let mut padded = bytes.clone();
|
||||
padded.push(0);
|
||||
assert_eq!(
|
||||
World::from_snapshot(&padded),
|
||||
Err(SnapshotError::Corrupt(DecodeError::TrailingBytes))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::inventory::ITEM_STONE;
|
||||
use grovewright_core::map::{Pos, Terrain};
|
||||
use grovewright_core::mapgen::{outcrop_origin, spawn_pos};
|
||||
use grovewright_core::player::{
|
||||
PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_NAME_BYTES, MAX_ONLINE_PLAYERS,
|
||||
};
|
||||
use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS, TICKS_PER_SECOND};
|
||||
|
||||
fn key(n: u8) -> PlayerKey {
|
||||
[n; 16]
|
||||
}
|
||||
|
||||
fn join(n: u8) -> Command {
|
||||
Command::Join {
|
||||
key: key(n),
|
||||
name: format!("Spieler {n}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A world with one online player, and a soft rock tile to dig at.
|
||||
fn world_with_player() -> (World, PlayerId, Pos) {
|
||||
let mut world = World::new(42);
|
||||
world.step(&[join(1)]);
|
||||
let rock = outcrop_origin(world.map());
|
||||
(world, PlayerId(0), rock)
|
||||
}
|
||||
|
||||
/// Steps without commands until a player who just hit may hit again.
|
||||
fn wait_for_cooldown(world: &mut World) {
|
||||
for _ in 0..DIG_COOLDOWN_TICKS {
|
||||
world.step(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_world_starts_at_tick_zero_without_players() {
|
||||
let world = World::new(42);
|
||||
assert_eq!(world.seed(), 42);
|
||||
assert_eq!(world.tick(), 0);
|
||||
assert!(world.players().is_empty());
|
||||
assert_eq!(TICKS_PER_SECOND, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_advances_the_tick() {
|
||||
let mut world = World::new(1);
|
||||
assert!(world.step(&[]).is_empty());
|
||||
assert_eq!(world.tick(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_adds_an_online_player() {
|
||||
let mut world = World::new(1);
|
||||
let events = world.step(&[join(7)]);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![Event::PlayerJoined {
|
||||
player: PlayerId(0)
|
||||
}]
|
||||
);
|
||||
let player = world.player(PlayerId(0)).unwrap();
|
||||
assert!(player.online);
|
||||
assert_eq!(player.name, "Spieler 7");
|
||||
assert_eq!(world.find_player(&key(7)), Some(PlayerId(0)));
|
||||
assert_eq!(world.find_player(&key(8)), None);
|
||||
assert!(world.player(PlayerId(1)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_twice_changes_nothing() {
|
||||
let mut world = World::new(1);
|
||||
world.step(&[join(7)]);
|
||||
assert!(world.step(&[join(7)]).is_empty());
|
||||
assert_eq!(world.players().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_and_returning_keeps_the_inventory() {
|
||||
let (mut world, id, rock) = world_with_player();
|
||||
for _ in 0..3 {
|
||||
world.step(&[Command::Dig {
|
||||
player: id,
|
||||
pos: rock,
|
||||
}]);
|
||||
wait_for_cooldown(&mut world);
|
||||
}
|
||||
assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1);
|
||||
|
||||
assert_eq!(
|
||||
world.step(&[Command::Leave { player: id }]),
|
||||
vec![Event::PlayerLeft { player: id }]
|
||||
);
|
||||
assert!(!world.player(id).unwrap().online);
|
||||
assert!(world.step(&[Command::Leave { player: id }]).is_empty());
|
||||
|
||||
assert_eq!(
|
||||
world.step(&[join(1)]),
|
||||
vec![Event::PlayerJoined { player: id }]
|
||||
);
|
||||
assert_eq!(world.players().len(), 1);
|
||||
assert!(world.player(id).unwrap().online);
|
||||
assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_returning_player_can_change_their_name() {
|
||||
let (mut world, id, _) = world_with_player();
|
||||
world.step(&[Command::Leave { player: id }]);
|
||||
world.step(&[Command::Join {
|
||||
key: key(1),
|
||||
name: "Neuer Name".to_owned(),
|
||||
}]);
|
||||
assert_eq!(world.player(id).unwrap().name, "Neuer Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_names_are_stored_shortened_on_both_join_paths() {
|
||||
let mut world = World::new(1);
|
||||
world.step(&[Command::Join {
|
||||
key: key(1),
|
||||
name: "ä".repeat(20),
|
||||
}]);
|
||||
assert_eq!(world.player(PlayerId(0)).unwrap().name, "ä".repeat(16));
|
||||
|
||||
world.step(&[Command::Leave {
|
||||
player: PlayerId(0),
|
||||
}]);
|
||||
world.step(&[Command::Join {
|
||||
key: key(1),
|
||||
name: "x".repeat(MAX_NAME_BYTES + 10),
|
||||
}]);
|
||||
assert_eq!(
|
||||
world.player(PlayerId(0)).unwrap().name,
|
||||
"x".repeat(MAX_NAME_BYTES)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fifth_online_player_is_refused() {
|
||||
let mut world = World::new(1);
|
||||
for n in 0..MAX_ONLINE_PLAYERS as u8 {
|
||||
world.step(&[join(n)]);
|
||||
}
|
||||
assert_eq!(
|
||||
world.step(&[join(99)]),
|
||||
vec![Event::JoinRefused { key: key(99) }]
|
||||
);
|
||||
assert_eq!(world.players().len(), MAX_ONLINE_PLAYERS);
|
||||
|
||||
// A known player who is offline is refused as well while the world is full.
|
||||
world.step(&[Command::Leave {
|
||||
player: PlayerId(0),
|
||||
}]);
|
||||
world.step(&[join(50)]);
|
||||
assert_eq!(
|
||||
world.step(&[join(0)]),
|
||||
vec![Event::JoinRefused { key: key(0) }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_world_remembers_a_limited_number_of_players() {
|
||||
let mut world = World::new(1);
|
||||
for n in 0..MAX_KNOWN_PLAYERS as u8 {
|
||||
world.step(&[join(n)]);
|
||||
world.step(&[Command::Leave {
|
||||
player: PlayerId(n),
|
||||
}]);
|
||||
}
|
||||
assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS);
|
||||
assert_eq!(
|
||||
world.step(&[join(200)]),
|
||||
vec![Event::JoinRefused { key: key(200) }]
|
||||
);
|
||||
assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_all_offline_marks_every_player_offline() {
|
||||
let mut world = World::new(1);
|
||||
world.step(&[join(1), join(2)]);
|
||||
world.set_all_offline();
|
||||
assert!(world.players().iter().all(|player| !player.online));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_hits_break_soft_rock_and_give_one_stone() {
|
||||
let (mut world, id, rock) = world_with_player();
|
||||
let dig = [Command::Dig {
|
||||
player: id,
|
||||
pos: rock,
|
||||
}];
|
||||
let hit = vec![Event::TileHit {
|
||||
player: id,
|
||||
pos: rock,
|
||||
}];
|
||||
|
||||
assert_eq!(world.step(&dig), hit);
|
||||
assert_eq!(world.map().get(rock).unwrap().damage, 1);
|
||||
wait_for_cooldown(&mut world);
|
||||
|
||||
assert_eq!(world.step(&dig), hit);
|
||||
assert_eq!(world.map().get(rock).unwrap().damage, 2);
|
||||
wait_for_cooldown(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
world.step(&dig),
|
||||
vec![
|
||||
Event::TileChanged { pos: rock },
|
||||
Event::ItemGained {
|
||||
player: id,
|
||||
item: ITEM_STONE,
|
||||
count: 1
|
||||
}
|
||||
]
|
||||
);
|
||||
let tile = world.map().get(rock).unwrap();
|
||||
assert_eq!((tile.terrain, tile.damage), (Terrain::Ground, 0));
|
||||
assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hits_during_the_cooldown_are_ignored() {
|
||||
let (mut world, id, rock) = world_with_player();
|
||||
let dig = [Command::Dig {
|
||||
player: id,
|
||||
pos: rock,
|
||||
}];
|
||||
world.step(&dig);
|
||||
for _ in 1..DIG_COOLDOWN_TICKS {
|
||||
assert!(world.step(&dig).is_empty());
|
||||
}
|
||||
assert_eq!(world.map().get(rock).unwrap().damage, 1);
|
||||
assert_eq!(world.step(&dig).len(), 1);
|
||||
assert_eq!(world.map().get(rock).unwrap().damage, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_hits_in_one_tick_count_once() {
|
||||
let (mut world, id, rock) = world_with_player();
|
||||
let dig = Command::Dig {
|
||||
player: id,
|
||||
pos: rock,
|
||||
};
|
||||
let twice = [dig.clone(), dig];
|
||||
assert_eq!(world.step(&twice).len(), 1);
|
||||
assert_eq!(world.map().get(rock).unwrap().damage, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ground_bedrock_and_positions_off_the_map_cannot_be_dug() {
|
||||
let (mut world, id, _) = world_with_player();
|
||||
let spawn = spawn_pos(world.map());
|
||||
for pos in [
|
||||
spawn,
|
||||
Pos::new(0, 0),
|
||||
Pos::new(-5, 3),
|
||||
Pos::new(10_000, 10_000),
|
||||
] {
|
||||
let before = world.map().clone();
|
||||
let events = world.step(&[Command::Dig { player: id, pos }]);
|
||||
assert!(events.is_empty(), "{pos:?}");
|
||||
assert_eq!(world.map(), &before, "{pos:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_rock_resists_the_stone_pickaxe() {
|
||||
let (mut world, id, _) = world_with_player();
|
||||
let width = world.map().width() as usize;
|
||||
let index = world
|
||||
.map()
|
||||
.tiles()
|
||||
.iter()
|
||||
.position(|tile| tile.terrain == Terrain::HardRock)
|
||||
.expect("the test map has hard rock");
|
||||
let pos = Pos::new((index % width) as i32, (index / width) as i32);
|
||||
|
||||
assert!(world.step(&[Command::Dig { player: id, pos }]).is_empty());
|
||||
assert_eq!(world.map().get(pos).unwrap().damage, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_offline_players_cannot_dig() {
|
||||
let (mut world, id, rock) = world_with_player();
|
||||
assert!(world
|
||||
.step(&[Command::Dig {
|
||||
player: PlayerId(9),
|
||||
pos: rock
|
||||
}])
|
||||
.is_empty());
|
||||
world.step(&[Command::Leave { player: id }]);
|
||||
assert!(world
|
||||
.step(&[Command::Dig {
|
||||
player: id,
|
||||
pos: rock
|
||||
}])
|
||||
.is_empty());
|
||||
assert_eq!(world.map().get(rock).unwrap().damage, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_of_one_tick_are_applied_in_order() {
|
||||
let (mut world, id, rock) = world_with_player();
|
||||
// Leaving first makes the hit in the same tick invalid.
|
||||
let events = world.step(&[
|
||||
Command::Leave { player: id },
|
||||
Command::Dig {
|
||||
player: id,
|
||||
pos: rock,
|
||||
},
|
||||
]);
|
||||
assert_eq!(events, vec![Event::PlayerLeft { player: id }]);
|
||||
assert_eq!(world.map().get(rock).unwrap().damage, 0);
|
||||
}
|
||||
+5
-6
@@ -1,7 +1,7 @@
|
||||
# Grovewright: Spielkonzept
|
||||
|
||||
**Stand:** 30. September 2026
|
||||
**Status:** Entwurf, wartet auf Bestätigung
|
||||
**Status:** Bestätigt am 30. September 2026
|
||||
|
||||
Dieses Dokument hält fest, worauf wir uns im Konzeptgespräch geeinigt haben. Es ist die Grundlage für den Bau. Was hier nicht steht, ist nicht beschlossen.
|
||||
|
||||
@@ -177,7 +177,7 @@ Die eigene Spielfigur bewegt sich sofort und läuft außerhalb des Gleichschritt
|
||||
- **Prüfsummen im Spiel.** Die Rechner vergleichen laufend ihren Zustand und gleichen notfalls neu ab.
|
||||
- **Aufzeichnung.** Das Spiel zeichnet die Befehle einer Sitzung auf. Ein Fehler aus deinem Playtest lässt sich damit exakt nachspielen.
|
||||
- **Generator-Regeln.** Regeln wie „Das erste Vorkommen liegt nah am Start" werden über viele Startzahlen geprüft.
|
||||
- **Speicherstand.** Er hält Startzahl und Änderungen fest. Ein Gast bekommt ihn beim Beitritt geschickt.
|
||||
- **Speicherstand.** Er enthält den vollständigen Zustand der Welt samt Startzahl und bleibt deshalb gültig, wenn sich der Kartengenerator später ändert. Ein Gast bekommt ihn beim Beitritt geschickt.
|
||||
|
||||
### 7.4 Grafik und Ton
|
||||
|
||||
@@ -196,7 +196,7 @@ Geprüft am 30. September 2026:
|
||||
| Rust | 1.98.1 |
|
||||
| Rust-Anbindung für Godot | `godot` 0.5.5, unterstützt Godot 4.7, verlangt Rust ab 1.94 |
|
||||
| Git | 2.54.0 |
|
||||
| Git-Archiv | `https://git.race-cave.cloud/steven/Grovewright.git`, erreichbar und noch leer |
|
||||
| Git-Archiv | `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea) |
|
||||
|
||||
Es sind keine weiteren Installationen nötig.
|
||||
|
||||
@@ -227,7 +227,8 @@ Zwei Meilensteine sind Entscheidungspunkte:
|
||||
| Plattform | Windows |
|
||||
| Scheitern | Kein Game Over, kein Verlust von Fortschritt |
|
||||
| Speichern | Automatisch in festen Abständen und zusätzlich von Hand |
|
||||
| Versionsverwaltung | Lokales Git-Archiv mit einem Eintrag pro Arbeitsschritt. Hochgeladen wird nur mit deiner Freigabe. |
|
||||
| Versionsverwaltung | Git-Archiv mit einem Eintrag pro Arbeitsschritt, hochgeladen nach jedem Schritt. Freigegeben sind auch Branches, Issues und Pull Requests. |
|
||||
| Rückkehrer | Nach der ersten Ruine kommen Schmiedin und Hirte, nach der zweiten die Gärtnerin |
|
||||
|
||||
## 10. Backlog für die Steam-Option
|
||||
|
||||
@@ -250,10 +251,8 @@ Offen, zu klären im jeweiligen Meilenstein:
|
||||
- **Wachstumsdauern.** Die Richtwerte aus 3.1 werden im Playtest eingestellt.
|
||||
- **Verhältnis Werkstatt zu Hain.** Wie viel Hain ein Ofen braucht, entscheidet, ob die Bodenregel reizt oder nervt.
|
||||
- **Andocken.** Wie Ranken, Ladetiere, Kisten und Ruinen genau aneinander anschließen, wird in den Meilensteinen 2 und 3 festgelegt.
|
||||
- **Reihenfolge der Rückkehrer.** Vorschlag: Nach der ersten Ruine kommen Schmiedin und Hirte, nach der zweiten die Gärtnerin. Das ist noch nicht besprochen.
|
||||
- **Grafikstil.** Du wählst im ersten Meilenstein aus drei Stilproben.
|
||||
- **Namen.** Alle Namen für Arten, Tiere und Waren sind Arbeitstitel.
|
||||
- **Hochladen.** Ob nach jedem Arbeitsschritt ins Git-Archiv hochgeladen wird, ist noch nicht freigegeben.
|
||||
- **Titel.** Eine Websuche fand kein Spiel namens „Grovewright". Vor einem Steam-Release ist eine Markenrecherche nötig.
|
||||
|
||||
Risiken:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user