tangled
alpha
login
or
join now
eldridge.cam
/
cartography
0
fork
atom
Trading card city builder game?
0
fork
atom
overview
issues
pulls
pipelines
configure everything for unit testing
eldridge.cam
1 month ago
20d521d3
9b0fb2ae
verified
This commit was signed with the committer's
known signature
.
eldridge.cam
SSH Key Fingerprint:
SHA256:MAgO4sya2MgvdgUjSGKAO0lQ9X2HQp1Jb+x/Tpeeims=
+536
-185
35 changed files
expand all
collapse all
unified
split
.env.example
Cargo.lock
Cargo.toml
Justfile
app
.prettierignore
generated
schema.sql
packages
cartography
Cargo.toml
fixtures
account.sql
seed.sql
src
actor
field_state
mod.rs
mod.rs
player_socket
authenticate.rs
mod.rs
unsubscribe.rs
watch_field.rs
api
errors
banner_not_found.rs
mod.rs
mod.rs
operations
get_banner.rs
list_banners.rs
list_card_types.rs
list_fields.rs
mod.rs
pull_banner.rs
ws.rs
app.rs
bus.rs
db.rs
dto
mod.rs
main.rs
test.rs
cartography-macros
Cargo.toml
src
lib.rs
migrate.rs
src
main.rs
+1
.env.example
···
1
1
RUST_LOG=info
2
2
3
3
DATABASE_URL="postgres://postgres:postgres@localhost:5432/cartography"
4
4
+
TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5432/cartography-test"
4
5
SHADOW_DATABASE_URL="postgres://postgres:postgres@localhost:5432/shadow"
5
6
ROOT_DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres"
6
7
+29
-2
Cargo.lock
···
292
292
dependencies = [
293
293
"anyhow",
294
294
"axum 0.8.8",
295
295
+
"cartography-macros",
295
296
"clap",
296
297
"derive_more",
297
298
"futures",
298
299
"futures-rx",
300
300
+
"hex",
301
301
+
"http-body-util",
299
302
"json-patch",
300
303
"kameo",
301
304
"rand 0.10.0",
···
307
310
"time",
308
311
"tokio",
309
312
"tokio-stream",
313
313
+
"tower",
310
314
"tower-http",
311
315
"tracing",
312
316
"tracing-subscriber",
313
317
"utoipa 5.4.0 (git+https://github.com/foxfriends/utoipa)",
314
318
"uuid",
319
319
+
]
320
320
+
321
321
+
[[package]]
322
322
+
name = "cartography-macros"
323
323
+
version = "0.1.0"
324
324
+
dependencies = [
325
325
+
"hex",
326
326
+
"proc-macro2",
327
327
+
"quote",
328
328
+
"regex",
329
329
+
"syn",
315
330
]
316
331
317
332
[[package]]
···
1589
1604
]
1590
1605
1591
1606
[[package]]
1607
1607
+
name = "regex"
1608
1608
+
version = "1.12.3"
1609
1609
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1610
1610
+
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
1611
1611
+
dependencies = [
1612
1612
+
"aho-corasick",
1613
1613
+
"memchr",
1614
1614
+
"regex-automata",
1615
1615
+
"regex-syntax",
1616
1616
+
]
1617
1617
+
1618
1618
+
[[package]]
1592
1619
name = "regex-automata"
1593
1620
version = "0.4.14"
1594
1621
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2117
2144
2118
2145
[[package]]
2119
2146
name = "syn"
2120
2120
-
version = "2.0.114"
2147
2147
+
version = "2.0.115"
2121
2148
source = "registry+https://github.com/rust-lang/crates.io-index"
2122
2122
-
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
2149
2149
+
checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
2123
2150
dependencies = [
2124
2151
"proc-macro2",
2125
2152
"quote",
+10
-33
Cargo.toml
···
1
1
-
[package]
2
2
-
name = "cartography"
1
1
+
[workspace]
2
2
+
resolver = "3"
3
3
+
members = [
4
4
+
"packages/cartography",
5
5
+
"packages/cartography-macros",
6
6
+
]
7
7
+
8
8
+
[workspace.package]
3
9
version = "0.1.0"
4
4
-
authors = ["Cameron Eldridge <cameldridge@gmail.com>"]
5
5
-
edition = "2021"
6
6
-
description = """
7
7
-
Cartography game server.
8
8
-
"""
10
10
+
edition = "2024"
9
11
license = "MIT"
10
10
-
11
11
-
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
12
12
-
13
13
-
[dependencies]
14
14
-
anyhow = "1.0.101"
15
15
-
axum = { version = "0.8.8", features = ["ws"] }
16
16
-
clap = { version = "4.5.58", features = ["derive"] }
17
17
-
derive_more = { version = "2.1.1", features = ["error", "display"] }
18
18
-
futures = "0.3.31"
19
19
-
futures-rx = "0.2.1"
20
20
-
json-patch = { version = "4.1.0", features = ["utoipa"] }
21
21
-
kameo = "0.19.2"
22
22
-
rand = { version = "0.10.0", features = ["alloc"] }
23
23
-
rmp-serde = "1.3.1"
24
24
-
scalar_api_reference = { version = "0.1.0", features = ["axum"] }
25
25
-
serde = { version = "1.0.228", features = ["derive"] }
26
26
-
serde_json = "1.0.149"
27
27
-
sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "time"] }
28
28
-
time = { version = "0.3.47", features = ["serde", "serde-human-readable"] }
29
29
-
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
30
30
-
tokio-stream = { version = "0.1.18", features = ["sync"] }
31
31
-
tower-http = { version = "0.6.8", features = ["trace"] }
32
32
-
tracing = "0.1.44"
33
33
-
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
34
34
-
utoipa = { git = "https://github.com/foxfriends/utoipa", features = ["time"] } # { version = "5.4.0", features = ["time"] }
35
35
-
uuid = { version = "1.20.0", features = ["serde", "v7"] }
12
12
+
authors = ["Cameron Eldridge <cameldridge@gmail.com>"]
+6
-6
Justfile
···
65
65
66
66
[group: "dev"]
67
67
generate:
68
68
-
cargo sqlx prepare
68
68
+
cargo sqlx prepare --workspace
69
69
cargo run -- --openapi > openapi.json
70
70
cd app && npx orval
71
71
cd app && npx svelte-kit sync
···
77
77
cd app && npx prettier --write .
78
78
79
79
[group: "dev"]
80
80
-
test:
81
81
-
cargo test
80
80
+
test: up
81
81
+
SQLX_OFFLINE=true DATABASE_URL={{ROOT_DATABASE_URL}} cargo test
82
82
cd app && npm test
83
83
84
84
[group: "database"]
···
90
90
npx graphile-migrate watch
91
91
92
92
[group: "database"]
93
93
-
migration:
93
93
+
migration message:
94
94
npx prettier migrations -w
95
95
-
npx graphile-migrate commit
95
95
+
npx graphile-migrate commit -m {{message}}
96
96
97
97
[group: "database"]
98
98
unmigration:
···
112
112
113
113
[group: "database"]
114
114
seed:
115
115
-
trilogy run ./seeds/seed.tri
115
115
+
RUST_LOG=off trilogy run ./seeds/seed.tri
116
116
117
117
[group: "database"]
118
118
apply-seed:
+2
-22
app/.prettierignore
···
1
1
-
.DS_Store
2
1
node_modules
3
2
/build
4
3
/.svelte-kit
5
5
-
/package
6
6
-
.env
7
7
-
.env.*
8
8
-
!.env.example
4
4
+
/src/lib/appserver/dto
5
5
+
/src/lib/appserver/api.ts
9
6
10
10
-
# Ignore files for PNPM, NPM and YARN
11
11
-
pnpm-lock.yaml
12
7
package-lock.json
13
13
-
yarn.lock
14
14
-
15
15
-
/src-tauri/target
16
16
-
/src-tauri/gen/schemas
17
17
-
18
18
-
/migrations/committed
19
19
-
/generated
20
20
-
21
21
-
/server/deps
22
22
-
/server/_build
23
23
-
/server/build
24
24
-
25
25
-
/api/build
26
26
-
27
27
-
/server-ex
+9
-1
generated/schema.sql
···
170
170
CREATE TABLE public.citizens (
171
171
species_id text NOT NULL,
172
172
name text NOT NULL,
173
173
-
home_tile_id bigint,
174
173
id bigint NOT NULL,
174
174
+
home_tile_id bigint,
175
175
CONSTRAINT citizens_name_check CHECK (((0 < length(name)) AND (length(name) < 64)))
176
176
);
177
177
···
722
722
723
723
ALTER TABLE ONLY public.cards
724
724
ADD CONSTRAINT cards_card_type_id_fkey FOREIGN KEY (card_type_id) REFERENCES public.card_types(id) ON UPDATE CASCADE ON DELETE RESTRICT;
725
725
+
726
726
+
727
727
+
--
728
728
+
-- Name: citizens citizens_home_tile_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
729
729
+
--
730
730
+
731
731
+
ALTER TABLE ONLY public.citizens
732
732
+
ADD CONSTRAINT citizens_home_tile_id_fkey FOREIGN KEY (home_tile_id) REFERENCES public.tiles(id) ON UPDATE CASCADE ON DELETE SET NULL;
725
733
726
734
727
735
--
+21
packages/cartography-macros/Cargo.toml
···
1
1
+
[package]
2
2
+
name = "cartography-macros"
3
3
+
description = """
4
4
+
Cartography macros (internal usage only).
5
5
+
"""
6
6
+
version.workspace = true
7
7
+
edition.workspace = true
8
8
+
license.workspace = true
9
9
+
authors.workspace = true
10
10
+
11
11
+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
12
12
+
13
13
+
[lib]
14
14
+
proc-macro = true
15
15
+
16
16
+
[dependencies]
17
17
+
hex = "0.4.3"
18
18
+
proc-macro2 = "1.0.106"
19
19
+
quote = "1.0.44"
20
20
+
regex = "1.12.3"
21
21
+
syn = "2.0.115"
+22
packages/cartography-macros/src/lib.rs
···
1
1
+
use proc_macro::TokenStream;
2
2
+
use quote::quote;
3
3
+
4
4
+
mod migrate;
5
5
+
6
6
+
#[proc_macro]
7
7
+
pub fn graphile_migrate(input: TokenStream) -> TokenStream {
8
8
+
use syn::LitStr;
9
9
+
10
10
+
let input = syn::parse_macro_input!(input as LitStr);
11
11
+
match migrate::expand(input) {
12
12
+
Ok(ts) => ts.into(),
13
13
+
Err(e) => {
14
14
+
if let Some(parse_err) = e.downcast_ref::<syn::Error>() {
15
15
+
parse_err.to_compile_error().into()
16
16
+
} else {
17
17
+
let msg = e.to_string();
18
18
+
quote!(::std::compile_error!(#msg)).into()
19
19
+
}
20
20
+
}
21
21
+
}
22
22
+
}
+64
packages/cartography-macros/src/migrate.rs
···
1
1
+
use proc_macro2::TokenStream;
2
2
+
use quote::quote;
3
3
+
use regex::Regex;
4
4
+
use std::sync::LazyLock;
5
5
+
use syn::LitStr;
6
6
+
7
7
+
static HASH_REGEX: LazyLock<Regex> =
8
8
+
LazyLock::new(|| Regex::new(r"(?m)^--! Hash: sha1:([0-9a-fA-F]+)$").unwrap());
9
9
+
static FILE_REGEX: LazyLock<Regex> =
10
10
+
LazyLock::new(|| Regex::new(r"^(\d+)(?:-(.*))?\.sql$").unwrap());
11
11
+
12
12
+
pub fn expand(path_arg: LitStr) -> Result<TokenStream, Box<dyn std::error::Error>> {
13
13
+
let workspace_root = std::process::Command::new(env!("CARGO"))
14
14
+
.arg("locate-project")
15
15
+
.arg("--workspace")
16
16
+
.arg("--message-format=plain")
17
17
+
.output()
18
18
+
.expect("failed to locate Cargo.toml")
19
19
+
.stdout;
20
20
+
let path =
21
21
+
std::path::PathBuf::from(String::from_utf8(workspace_root).expect("cargo output not utf8"))
22
22
+
.parent()
23
23
+
.expect("Cargo.toml should be a proper path")
24
24
+
.join(path_arg.value());
25
25
+
let dir = std::fs::read_dir(&path)
26
26
+
.map_err(|error| format!("{error}\nmigrations directory: {}", path.display()))?;
27
27
+
let mut migrations = dir.filter_map(|item| item.ok()).collect::<Vec<_>>();
28
28
+
migrations.sort_by_key(|item| item.file_name());
29
29
+
let migrations = migrations
30
30
+
.into_iter()
31
31
+
.map(|item| {
32
32
+
let sql = std::fs::read_to_string(item.path())?;
33
33
+
let name = item.file_name();
34
34
+
let name = name.to_str().expect("name is not valid");
35
35
+
let file_captures = FILE_REGEX.captures(name).expect("filename is not valid");
36
36
+
let version: i64 = file_captures.get(1).unwrap().as_str().parse()?;
37
37
+
let description = file_captures
38
38
+
.get(2)
39
39
+
.map(|c| c.as_str().to_owned())
40
40
+
.unwrap_or(name.to_string());
41
41
+
let captures = HASH_REGEX
42
42
+
.captures(&sql)
43
43
+
.expect("failed to find checksum hash in file");
44
44
+
let checksum: Vec<_> = hex::decode(captures.get(1).unwrap().as_str())?;
45
45
+
Ok(quote! {
46
46
+
sqlx::migrate::Migration {
47
47
+
version: #version,
48
48
+
description: std::borrow::Cow::Borrowed(#description),
49
49
+
migration_type: sqlx::migrate::MigrationType::Simple,
50
50
+
sql: std::borrow::Cow::Borrowed(#sql),
51
51
+
checksum: std::borrow::Cow::Borrowed(&[#(#checksum),*]),
52
52
+
no_tx: false,
53
53
+
}
54
54
+
})
55
55
+
})
56
56
+
.collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?;
57
57
+
58
58
+
Ok(quote! {
59
59
+
sqlx::migrate::Migrator {
60
60
+
migrations: std::borrow::Cow::Borrowed(&[#(#migrations,)*]),
61
61
+
..sqlx::migrate::Migrator::DEFAULT
62
62
+
}
63
63
+
})
64
64
+
}
+41
packages/cartography/Cargo.toml
···
1
1
+
[package]
2
2
+
name = "cartography"
3
3
+
description = """
4
4
+
Cartography game server.
5
5
+
"""
6
6
+
version.workspace = true
7
7
+
edition.workspace = true
8
8
+
license.workspace = true
9
9
+
authors.workspace = true
10
10
+
11
11
+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
12
12
+
13
13
+
[dependencies]
14
14
+
anyhow = "1.0.101"
15
15
+
axum = { version = "0.8.8", features = ["ws"] }
16
16
+
clap = { version = "4.5.58", features = ["derive"] }
17
17
+
derive_more = { version = "2.1.1", features = ["error", "display"] }
18
18
+
futures = "0.3.31"
19
19
+
futures-rx = "0.2.1"
20
20
+
json-patch = { version = "4.1.0", features = ["utoipa"] }
21
21
+
kameo = "0.19.2"
22
22
+
rand = { version = "0.10.0", features = ["alloc"] }
23
23
+
rmp-serde = "1.3.1"
24
24
+
scalar_api_reference = { version = "0.1.0", features = ["axum"] }
25
25
+
serde = { version = "1.0.228", features = ["derive"] }
26
26
+
serde_json = "1.0.149"
27
27
+
sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "time"] }
28
28
+
time = { version = "0.3.47", features = ["serde", "serde-human-readable"] }
29
29
+
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
30
30
+
tokio-stream = { version = "0.1.18", features = ["sync"] }
31
31
+
tower-http = { version = "0.6.8", features = ["trace"] }
32
32
+
tracing = "0.1.44"
33
33
+
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
34
34
+
utoipa = { git = "https://github.com/foxfriends/utoipa", features = ["time"] } # { version = "5.4.0", features = ["time"] }
35
35
+
uuid = { version = "1.20.0", features = ["serde", "v7"] }
36
36
+
37
37
+
[dev-dependencies]
38
38
+
hex = "0.4.3"
39
39
+
http-body-util = "0.1.3"
40
40
+
tower = "0.5.3"
41
41
+
cartography-macros = { path = "../cartography-macros/" }
+1
packages/cartography/fixtures/account.sql
···
1
1
+
INSERT INTO accounts (id) VALUES ('foxfriends');
+122
packages/cartography/fixtures/seed.sql
···
1
1
+
INSERT INTO resources (id)
2
2
+
VALUES
3
3
+
('bread'),
4
4
+
('flour'),
5
5
+
('grain'),
6
6
+
('water'),
7
7
+
('lettuce'),
8
8
+
('carrot'),
9
9
+
('tomato'),
10
10
+
('salad')
11
11
+
ON CONFLICT DO NOTHING;
12
12
+
13
13
+
INSERT INTO card_sets (id, release_date)
14
14
+
VALUES
15
15
+
('base', '2026-01-01T00:00:00Z')
16
16
+
ON CONFLICT (id) DO UPDATE
17
17
+
SET release_date = EXCLUDED.release_date;
18
18
+
19
19
+
INSERT INTO card_types (id, card_set_id, class)
20
20
+
VALUES
21
21
+
('rabbit', 'base', 'citizen'),
22
22
+
('cat', 'base', 'citizen'),
23
23
+
('bird', 'base', 'citizen'),
24
24
+
('cat-colony', 'base', 'tile'),
25
25
+
('rabbit-warren', 'base', 'tile'),
26
26
+
('bird-nest', 'base', 'tile'),
27
27
+
('water-well', 'base', 'tile'),
28
28
+
('carrot-farm', 'base', 'tile'),
29
29
+
('tomato-farm', 'base', 'tile'),
30
30
+
('lettuce-farm', 'base', 'tile'),
31
31
+
('grain-farm', 'base', 'tile'),
32
32
+
('flour-mill', 'base', 'tile'),
33
33
+
('bread-bakery', 'base', 'tile'),
34
34
+
('salad-shop', 'base', 'tile')
35
35
+
ON CONFLICT (id) DO UPDATE
36
36
+
SET class = EXCLUDED.class,
37
37
+
card_set_id = EXCLUDED.card_set_id;
38
38
+
39
39
+
INSERT INTO species (id)
40
40
+
VALUES
41
41
+
('rabbit'),
42
42
+
('cat'),
43
43
+
('bird')
44
44
+
ON CONFLICT DO NOTHING;
45
45
+
46
46
+
INSERT INTO pack_banners (id, start_date, end_date)
47
47
+
VALUES
48
48
+
('default', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
49
49
+
('base-standard', '2026-01-01T00:00:00Z', NULL),
50
50
+
('halloween-2026', '2026-10-01T00:00:00Z', '2026-10-31T23:59:59Z')
51
51
+
ON CONFLICT (id) DO UPDATE
52
52
+
SET start_date = EXCLUDED.start_date,
53
53
+
end_date = EXCLUDED.end_date;
54
54
+
55
55
+
INSERT INTO pack_banner_cards (pack_banner_id, card_type_id, frequency)
56
56
+
VALUES
57
57
+
('base-standard', 'cat-colony', 3),
58
58
+
('base-standard', 'rabbit-warren', 3),
59
59
+
('base-standard', 'bird-nest', 3),
60
60
+
('base-standard', 'water-well', 3),
61
61
+
('base-standard', 'carrot-farm', 3),
62
62
+
('base-standard', 'tomato-farm', 3),
63
63
+
('base-standard', 'lettuce-farm', 3),
64
64
+
('base-standard', 'grain-farm', 3),
65
65
+
('base-standard', 'flour-mill', 3),
66
66
+
('base-standard', 'bread-bakery', 3),
67
67
+
('base-standard', 'salad-shop', 3),
68
68
+
('base-standard', 'rabbit', 1),
69
69
+
('base-standard', 'cat', 1),
70
70
+
('base-standard', 'bird', 1)
71
71
+
ON CONFLICT (pack_banner_id, card_type_id) DO UPDATE
72
72
+
SET frequency = EXCLUDED.frequency;
73
73
+
74
74
+
INSERT INTO species_needs (species_id, resource_id, quantity)
75
75
+
VALUES
76
76
+
('rabbit', 'salad', 1),
77
77
+
('cat', 'bread', 1),
78
78
+
('bird', 'grain', 1)
79
79
+
ON CONFLICT (species_id, resource_id) DO UPDATE
80
80
+
SET quantity = EXCLUDED.quantity;
81
81
+
82
82
+
INSERT INTO tile_types (id, category, houses, employs)
83
83
+
VALUES
84
84
+
('cat-colony', 'residential', 3, 0),
85
85
+
('rabbit-warren', 'residential', 3, 0),
86
86
+
('bird-nest', 'residential', 3, 0),
87
87
+
('water-well', 'source', 0, 1),
88
88
+
('carrot-farm', 'production', 0, 3),
89
89
+
('tomato-farm', 'production', 0, 3),
90
90
+
('lettuce-farm', 'production', 0, 3),
91
91
+
('grain-farm', 'production', 0, 3),
92
92
+
('flour-mill', 'production', 0, 2),
93
93
+
('bread-bakery', 'amenity', 0, 2),
94
94
+
('salad-shop', 'amenity', 0, 2)
95
95
+
ON CONFLICT (id) DO UPDATE
96
96
+
SET category = EXCLUDED.category,
97
97
+
houses = EXCLUDED.houses,
98
98
+
employs = EXCLUDED.employs;
99
99
+
100
100
+
INSERT INTO tile_type_consumes (tile_type_id, resource_id, quantity)
101
101
+
VALUES
102
102
+
('flour-mill', 'grain', 1),
103
103
+
('bread-bakery', 'flour', 3),
104
104
+
('bread-bakery', 'water', 2),
105
105
+
('salad-shop', 'tomato', 1),
106
106
+
('salad-shop', 'lettuce', 1),
107
107
+
('salad-shop', 'carrot', 1)
108
108
+
ON CONFLICT (tile_type_id, resource_id) DO UPDATE
109
109
+
SET quantity = EXCLUDED.quantity;
110
110
+
111
111
+
INSERT INTO tile_type_produces (tile_type_id, resource_id, quantity)
112
112
+
VALUES
113
113
+
('water-well', 'water', 10),
114
114
+
('carrot-farm', 'carrot', 5),
115
115
+
('tomato-farm', 'tomato', 5),
116
116
+
('lettuce-farm', 'lettuce', 5),
117
117
+
('grain-farm', 'grain', 5),
118
118
+
('flour-mill', 'flour', 10),
119
119
+
('bread-bakery', 'bread', 5),
120
120
+
('salad-shop', 'salad', 3)
121
121
+
ON CONFLICT (tile_type_id, resource_id) DO UPDATE
122
122
+
SET quantity = EXCLUDED.quantity;
+94
packages/cartography/src/app.rs
···
1
1
+
use crate::api::{operations, ws};
2
2
+
use crate::bus::Bus;
3
3
+
use axum::Router;
4
4
+
use kameo::actor::Spawn as _;
5
5
+
use utoipa::OpenApi as _;
6
6
+
7
7
+
#[derive(utoipa::OpenApi)]
8
8
+
#[openapi(
9
9
+
paths(
10
10
+
operations::get_banner,
11
11
+
operations::list_banners,
12
12
+
operations::pull_banner,
13
13
+
14
14
+
operations::list_card_types,
15
15
+
16
16
+
operations::list_fields,
17
17
+
),
18
18
+
components(
19
19
+
schemas(
20
20
+
crate::dto::AccountIdOrMe
21
21
+
)
22
22
+
),
23
23
+
tags(
24
24
+
(name = "Global", description = "Publicly available global data about the Cartography game."),
25
25
+
(name = "Player", description = "Player specific data; typically requires authorization."),
26
26
+
(name = "Game", description = "Actions with effects on gameplay."),
27
27
+
),
28
28
+
)]
29
29
+
pub struct ApiDoc;
30
30
+
31
31
+
pub struct Config {
32
32
+
pool: sqlx::PgPool,
33
33
+
}
34
34
+
35
35
+
impl Config {
36
36
+
pub async fn from_env() -> anyhow::Result<Self> {
37
37
+
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL is required");
38
38
+
39
39
+
let pool = sqlx::postgres::PgPoolOptions::new()
40
40
+
.max_connections(10)
41
41
+
.connect(&db_url)
42
42
+
.await?;
43
43
+
44
44
+
Ok(Self { pool })
45
45
+
}
46
46
+
47
47
+
#[cfg(test)]
48
48
+
pub fn test(pool: sqlx::PgPool) -> Self {
49
49
+
Self { pool }
50
50
+
}
51
51
+
52
52
+
pub fn into_router(self) -> Router {
53
53
+
let bus = Bus::spawn(());
54
54
+
55
55
+
// let scalar_config = serde_json::json!({
56
56
+
// "url": "/api/openapi.json",
57
57
+
// "agent": scalar_api_reference::config::AgentOptions::disabled()
58
58
+
// });
59
59
+
60
60
+
axum::Router::new()
61
61
+
.route(
62
62
+
"/api/v1/cardtypes",
63
63
+
axum::routing::get(operations::list_card_types),
64
64
+
)
65
65
+
.route(
66
66
+
"/api/v1/banners",
67
67
+
axum::routing::post(operations::list_banners),
68
68
+
)
69
69
+
.route(
70
70
+
"/api/v1/banners/{banner_id}",
71
71
+
axum::routing::get(operations::get_banner),
72
72
+
)
73
73
+
.route(
74
74
+
"/api/v1/banners/{banner_id}/pull",
75
75
+
axum::routing::post(operations::pull_banner),
76
76
+
)
77
77
+
.route(
78
78
+
"/api/v1/players/{player_id}/fields",
79
79
+
axum::routing::get(operations::list_fields),
80
80
+
)
81
81
+
.route("/play/ws", axum::routing::any(ws::v1))
82
82
+
.route(
83
83
+
"/api/openapi.json",
84
84
+
axum::routing::get(axum::response::Json(ApiDoc::openapi())),
85
85
+
)
86
86
+
// .merge(scalar_api_reference::axum::router("/docs", &scalar_config)) // TODO: waiting on scalar_api_reference to re-publish
87
87
+
.layer(axum::Extension(bus))
88
88
+
.layer(axum::Extension(self.pool))
89
89
+
}
90
90
+
}
91
91
+
92
92
+
pub async fn new() -> anyhow::Result<Router> {
93
93
+
Ok(Config::from_env().await?.into_router())
94
94
+
}
+52
packages/cartography/src/main.rs
···
1
1
+
mod actor;
2
2
+
mod api;
3
3
+
mod app;
4
4
+
mod bus;
5
5
+
mod db;
6
6
+
mod dto;
7
7
+
#[cfg(test)]
8
8
+
mod test;
9
9
+
10
10
+
use clap::Parser as _;
11
11
+
use tracing_subscriber::prelude::*;
12
12
+
use utoipa::OpenApi as _;
13
13
+
14
14
+
#[derive(clap::Parser)]
15
15
+
struct Args {
16
16
+
#[clap(long)]
17
17
+
openapi: bool,
18
18
+
}
19
19
+
20
20
+
#[tokio::main]
21
21
+
async fn main() -> anyhow::Result<()> {
22
22
+
let args = Args::parse();
23
23
+
24
24
+
if args.openapi {
25
25
+
println!("{}", app::ApiDoc::openapi().to_pretty_json().unwrap());
26
26
+
std::process::exit(0);
27
27
+
}
28
28
+
29
29
+
tracing_subscriber::registry()
30
30
+
.with(tracing_subscriber::EnvFilter::from_default_env())
31
31
+
.with(tracing_subscriber::fmt::layer().pretty())
32
32
+
.init();
33
33
+
34
34
+
let host: std::net::IpAddr = std::env::var("HOST")
35
35
+
.as_deref()
36
36
+
.unwrap_or("0.0.0.0")
37
37
+
.parse()
38
38
+
.expect("HOST must be a valid IP address");
39
39
+
let port = std::env::var("PORT")
40
40
+
.as_deref()
41
41
+
.unwrap_or("12000")
42
42
+
.parse()
43
43
+
.expect("PORT must be a valid u16");
44
44
+
45
45
+
let app = app::new()
46
46
+
.await?
47
47
+
.layer(tower_http::trace::TraceLayer::new_for_http());
48
48
+
49
49
+
let listener = tokio::net::TcpListener::bind((host, port)).await?;
50
50
+
axum::serve(listener, app).await?;
51
51
+
Ok(())
52
52
+
}
+21
packages/cartography/src/test.rs
···
1
1
+
use http_body_util::BodyExt;
2
2
+
3
3
+
pub trait ResponseExt {
4
4
+
async fn json<T: serde::de::DeserializeOwned>(self) -> anyhow::Result<T>;
5
5
+
}
6
6
+
7
7
+
impl ResponseExt for axum::response::Response {
8
8
+
async fn json<T: serde::de::DeserializeOwned>(self) -> anyhow::Result<T> {
9
9
+
Ok(serde_json::from_slice(
10
10
+
&self.into_body().collect().await.unwrap().to_bytes(),
11
11
+
)?)
12
12
+
}
13
13
+
}
14
14
+
15
15
+
pub mod prelude {
16
16
+
pub use super::ResponseExt as _;
17
17
+
pub use tower::ServiceExt as _;
18
18
+
19
19
+
pub const MIGRATOR: sqlx::migrate::Migrator =
20
20
+
cartography_macros::graphile_migrate!("migrations/committed");
21
21
+
}
+1
-1
src/actor/field_state/mod.rs
packages/cartography/src/actor/field_state/mod.rs
···
1
1
-
use super::player_socket::Response;
2
1
use super::Unsubscribe;
2
2
+
use super::player_socket::Response;
3
3
use kameo::prelude::*;
4
4
use serde::{Deserialize, Serialize};
5
5
use sqlx::PgPool;
src/actor/mod.rs
packages/cartography/src/actor/mod.rs
src/actor/player_socket/authenticate.rs
packages/cartography/src/actor/player_socket/authenticate.rs
+1
-1
src/actor/player_socket/mod.rs
packages/cartography/src/actor/player_socket/mod.rs
···
8
8
use serde::{Deserialize, Serialize};
9
9
use sqlx::PgPool;
10
10
use std::collections::HashMap;
11
11
-
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
11
11
+
use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
12
12
use tokio_stream::wrappers::UnboundedReceiverStream;
13
13
use uuid::Uuid;
14
14
src/actor/player_socket/unsubscribe.rs
packages/cartography/src/actor/player_socket/unsubscribe.rs
+1
-1
src/actor/player_socket/watch_field.rs
packages/cartography/src/actor/player_socket/watch_field.rs
···
1
1
-
use super::super::field_state::FieldWatcher;
2
1
use super::super::Unsubscribe;
2
2
+
use super::super::field_state::FieldWatcher;
3
3
use super::{PlayerSocket, Response};
4
4
use kameo::actor::Spawn;
5
5
use tokio::sync::mpsc::UnboundedSender;
src/api/errors/banner_not_found.rs
packages/cartography/src/api/errors/banner_not_found.rs
src/api/errors/mod.rs
packages/cartography/src/api/errors/mod.rs
src/api/mod.rs
packages/cartography/src/api/mod.rs
+2
-2
src/api/operations/get_banner.rs
packages/cartography/src/api/operations/get_banner.rs
···
1
1
use crate::api::errors::{
2
2
-
internal_server_error, BannerNotFoundError, ErrorDetailResponse, JsonError,
2
2
+
BannerNotFoundError, ErrorDetailResponse, JsonError, internal_server_error,
3
3
};
4
4
use crate::dto::*;
5
5
-
use axum::extract::Path;
6
5
use axum::Json;
6
6
+
use axum::extract::Path;
7
7
8
8
#[derive(serde::Serialize, utoipa::ToSchema)]
9
9
pub struct GetBannerResponse {
src/api/operations/list_banners.rs
packages/cartography/src/api/operations/list_banners.rs
src/api/operations/list_card_types.rs
packages/cartography/src/api/operations/list_card_types.rs
+1
-1
src/api/operations/list_fields.rs
packages/cartography/src/api/operations/list_fields.rs
···
1
1
-
use axum::extract::Path;
2
1
use axum::Json;
2
2
+
use axum::extract::Path;
3
3
4
4
use crate::api::errors::{internal_server_error, respond_internal_server_error};
5
5
use crate::dto::*;
src/api/operations/mod.rs
packages/cartography/src/api/operations/mod.rs
+34
-3
src/api/operations/pull_banner.rs
packages/cartography/src/api/operations/pull_banner.rs
···
1
1
use crate::api::errors::{
2
2
-
internal_server_error, BannerNotFoundError, ErrorDetailResponse, JsonError,
2
2
+
BannerNotFoundError, ErrorDetailResponse, JsonError, internal_server_error,
3
3
};
4
4
use crate::db::CardClass;
5
5
use crate::dto::*;
6
6
-
use axum::extract::Path;
7
6
use axum::Json;
7
7
+
use axum::extract::Path;
8
8
use rand::distr::weighted::WeightedIndex;
9
9
use rand::rngs::Xoshiro256PlusPlus;
10
10
-
use rand::{rng, Rng, RngExt, SeedableRng};
10
10
+
use rand::{Rng, RngExt, SeedableRng, rng};
11
11
12
12
#[derive(serde::Serialize, utoipa::ToSchema)]
13
13
+
#[cfg_attr(test, derive(serde::Deserialize))]
13
14
pub struct PullBannerResponse {
14
15
pack: Pack,
15
16
pack_cards: Vec<Card>,
···
173
174
.0,
174
175
}))
175
176
}
177
177
+
178
178
+
#[cfg(test)]
179
179
+
mod tests {
180
180
+
use crate::test::prelude::*;
181
181
+
use axum::body::Body;
182
182
+
use axum::http::Request;
183
183
+
use sqlx::PgPool;
184
184
+
185
185
+
use super::PullBannerResponse;
186
186
+
187
187
+
#[sqlx::test(
188
188
+
migrator = "MIGRATOR",
189
189
+
fixtures(path = "../../../fixtures", scripts("seed", "account"))
190
190
+
)]
191
191
+
async fn pull_banner_standard(pool: PgPool) {
192
192
+
let app = crate::app::Config::test(pool).into_router();
193
193
+
194
194
+
let request = Request::post("/api/v1/banners/base-standard/pull")
195
195
+
.body(Body::empty())
196
196
+
.unwrap();
197
197
+
198
198
+
let Ok(response) = app.oneshot(request).await;
199
199
+
assert!(response.status().is_success(), "{}", response.json::<serde_json::Value>().await.unwrap());
200
200
+
let response: PullBannerResponse = response.json().await.unwrap();
201
201
+
assert_eq!(response.pack.pack_banner_id, "base-standard");
202
202
+
assert_eq!(response.pack.account_id, "foxfriends");
203
203
+
assert_eq!(response.pack.opened_at, None);
204
204
+
assert_eq!(response.pack_cards.len(), 5);
205
205
+
}
206
206
+
}
+1
-1
src/api/ws.rs
packages/cartography/src/api/ws.rs
···
1
1
use crate::actor::player_socket::{PlayerSocket, Request, Response};
2
2
-
use axum::extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade};
3
2
use axum::Extension;
3
3
+
use axum::extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade};
4
4
use futures::StreamExt;
5
5
use kameo::prelude::*;
6
6
use serde::{Deserialize, Serialize};
src/bus.rs
packages/cartography/src/bus.rs
src/db.rs
packages/cartography/src/db.rs
src/dto/mod.rs
packages/cartography/src/dto/mod.rs
-111
src/main.rs
···
1
1
-
mod actor;
2
2
-
mod api;
3
3
-
mod bus;
4
4
-
mod db;
5
5
-
mod dto;
6
6
-
7
7
-
use clap::Parser as _;
8
8
-
use kameo::actor::Spawn as _;
9
9
-
use tracing_subscriber::prelude::*;
10
10
-
use utoipa::OpenApi as _;
11
11
-
12
12
-
#[derive(utoipa::OpenApi)]
13
13
-
#[openapi(
14
14
-
paths(
15
15
-
api::operations::get_banner,
16
16
-
api::operations::list_banners,
17
17
-
api::operations::pull_banner,
18
18
-
19
19
-
api::operations::list_card_types,
20
20
-
21
21
-
api::operations::list_fields,
22
22
-
),
23
23
-
components(
24
24
-
schemas(
25
25
-
crate::dto::AccountIdOrMe
26
26
-
)
27
27
-
),
28
28
-
tags(
29
29
-
(name = "Global", description = "Publicly available global data about the Cartography game."),
30
30
-
(name = "Player", description = "Player specific data; typically requires authorization."),
31
31
-
(name = "Game", description = "Actions with effects on gameplay."),
32
32
-
),
33
33
-
)]
34
34
-
struct ApiDoc;
35
35
-
36
36
-
#[derive(clap::Parser)]
37
37
-
struct Args {
38
38
-
#[clap(long)]
39
39
-
openapi: bool,
40
40
-
}
41
41
-
42
42
-
#[tokio::main]
43
43
-
async fn main() -> anyhow::Result<()> {
44
44
-
let args = Args::parse();
45
45
-
46
46
-
if args.openapi {
47
47
-
println!("{}", ApiDoc::openapi().to_pretty_json().unwrap());
48
48
-
std::process::exit(0);
49
49
-
}
50
50
-
51
51
-
tracing_subscriber::registry()
52
52
-
.with(tracing_subscriber::EnvFilter::from_default_env())
53
53
-
.with(tracing_subscriber::fmt::layer().pretty())
54
54
-
.init();
55
55
-
56
56
-
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL is required");
57
57
-
let host: std::net::IpAddr = std::env::var("HOST")
58
58
-
.as_deref()
59
59
-
.unwrap_or("0.0.0.0")
60
60
-
.parse()
61
61
-
.expect("HOST must be a valid IP address");
62
62
-
let port = std::env::var("PORT")
63
63
-
.as_deref()
64
64
-
.unwrap_or("12000")
65
65
-
.parse()
66
66
-
.expect("PORT must be a valid u16");
67
67
-
let pool = sqlx::postgres::PgPoolOptions::new()
68
68
-
.max_connections(10)
69
69
-
.connect(&db_url)
70
70
-
.await?;
71
71
-
72
72
-
// let scalar_config = serde_json::json!({
73
73
-
// "url": "/api/openapi.json",
74
74
-
// "agent": scalar_api_reference::config::AgentOptions::disabled()
75
75
-
// });
76
76
-
77
77
-
let bus = bus::Bus::spawn(());
78
78
-
let app = axum::Router::new()
79
79
-
.route(
80
80
-
"/api/v1/cardtypes",
81
81
-
axum::routing::get(api::operations::list_card_types),
82
82
-
)
83
83
-
.route(
84
84
-
"/api/v1/banners",
85
85
-
axum::routing::post(api::operations::list_banners),
86
86
-
)
87
87
-
.route(
88
88
-
"/api/v1/banners/{banner_id}",
89
89
-
axum::routing::get(api::operations::get_banner),
90
90
-
)
91
91
-
.route(
92
92
-
"/api/v1/banners/{banner_id}/pull",
93
93
-
axum::routing::post(api::operations::pull_banner),
94
94
-
)
95
95
-
.route(
96
96
-
"/api/v1/players/{player_id}/fields",
97
97
-
axum::routing::get(api::operations::list_fields),
98
98
-
)
99
99
-
.route("/play/ws", axum::routing::any(api::ws::v1))
100
100
-
.route(
101
101
-
"/api/openapi.json",
102
102
-
axum::routing::get(axum::response::Json(ApiDoc::openapi())),
103
103
-
)
104
104
-
// .merge(scalar_api_reference::axum::router("/docs", &scalar_config)) // TODO: waiting on scalar_api_reference to re-publish
105
105
-
.layer(axum::Extension(bus))
106
106
-
.layer(axum::Extension(pool))
107
107
-
.layer(tower_http::trace::TraceLayer::new_for_http());
108
108
-
let listener = tokio::net::TcpListener::bind((host, port)).await?;
109
109
-
axum::serve(listener, app).await?;
110
110
-
Ok(())
111
111
-
}