From 4dc15a1e86ef1ae985fdf36f1a84d07b1de99ea7 Mon Sep 17 00:00:00 2001 From: metamuffin Date: Tue, 25 Jun 2024 23:34:10 +0200 Subject: server can change map at runtime --- .gitignore | 2 - Cargo.lock | 60 ++++++++++++ data/demands.yaml | 26 ----- data/demands/default.yaml | 26 +++++ data/demands/none.yaml | 0 data/index.yaml | 14 +++ data/makefile | 4 + data/map.yaml | 77 --------------- data/maps/big.yaml | 77 +++++++++++++++ data/maps/lobby.yaml | 55 +++++++++++ data/maps/small.yaml | 78 +++++++++++++++ data/recipes.ts | 143 --------------------------- data/recipes/.gitignore | 1 + data/recipes/default.ts | 143 +++++++++++++++++++++++++++ data/recipes/none.ts | 1 + server/Cargo.toml | 2 + server/src/bin/graph.rs | 63 ++++++------ server/src/customer/mod.rs | 78 +++++++++------ server/src/data.rs | 239 ++++++++++++++++++++++++++++----------------- server/src/game.rs | 52 +++++++--- server/src/lib.rs | 21 ++-- server/src/main.rs | 42 ++++---- server/src/protocol.rs | 16 +-- server/src/state.rs | 68 +++++++++++++ test-client/index.html | 6 ++ test-client/main.ts | 32 +++++- test-client/protocol.ts | 6 +- 27 files changed, 872 insertions(+), 460 deletions(-) delete mode 100644 data/demands.yaml create mode 100644 data/demands/default.yaml create mode 100644 data/demands/none.yaml create mode 100644 data/index.yaml create mode 100644 data/makefile delete mode 100644 data/map.yaml create mode 100644 data/maps/big.yaml create mode 100644 data/maps/lobby.yaml create mode 100644 data/maps/small.yaml delete mode 100644 data/recipes.ts create mode 100644 data/recipes/.gitignore create mode 100644 data/recipes/default.ts create mode 100644 data/recipes/none.ts create mode 100644 server/src/state.rs diff --git a/.gitignore b/.gitignore index eef26db4..f3b6d89e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ /target -.vscode/ /test-client/*.js /specs/*.html -/data/recipes.yaml .godot undercooked.pot *.mo diff --git a/Cargo.lock b/Cargo.lock index 41ef5e82..c5f45322 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,6 +141,46 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "clap" +version = "4.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" + [[package]] name = "colorchoice" version = "1.0.1" @@ -303,6 +343,12 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -652,6 +698,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -686,6 +738,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.67" @@ -788,6 +846,7 @@ name = "undercooked" version = "0.1.0" dependencies = [ "anyhow", + "clap", "env_logger", "futures-util", "glam", @@ -796,6 +855,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "shlex", "tokio", "tokio-tungstenite", ] diff --git a/data/demands.yaml b/data/demands.yaml deleted file mode 100644 index f6be0911..00000000 --- a/data/demands.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Undercooked - a game about cooking -# Copyright 2024 metamuffin -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, version 3 of the License only. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# -- { from: bread-slice-plate, to: dirty-plate, duration: 10 } -- { from: steak-plate, to: dirty-plate, duration: 10 } -- { from: sliced-tomato-plate, to: dirty-plate, duration: 10 } - -- { from: bread-slice-steak-plate, to: dirty-plate, duration: 15 } -- { from: bread-slice-sliced-tomato-plate, to: dirty-plate, duration: 15 } -- { from: sliced-tomato-steak-plate, to: dirty-plate, duration: 15 } - -- { from: bread-slice-sliced-tomato-steak-plate, to: dirty-plate, duration: 20 } - -- { from: tomato-soup-plate, to: dirty-plate, duration: 20 } diff --git a/data/demands/default.yaml b/data/demands/default.yaml new file mode 100644 index 00000000..f6be0911 --- /dev/null +++ b/data/demands/default.yaml @@ -0,0 +1,26 @@ +# Undercooked - a game about cooking +# Copyright 2024 metamuffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License only. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +- { from: bread-slice-plate, to: dirty-plate, duration: 10 } +- { from: steak-plate, to: dirty-plate, duration: 10 } +- { from: sliced-tomato-plate, to: dirty-plate, duration: 10 } + +- { from: bread-slice-steak-plate, to: dirty-plate, duration: 15 } +- { from: bread-slice-sliced-tomato-plate, to: dirty-plate, duration: 15 } +- { from: sliced-tomato-steak-plate, to: dirty-plate, duration: 15 } + +- { from: bread-slice-sliced-tomato-steak-plate, to: dirty-plate, duration: 20 } + +- { from: tomato-soup-plate, to: dirty-plate, duration: 20 } diff --git a/data/demands/none.yaml b/data/demands/none.yaml new file mode 100644 index 00000000..e69de29b diff --git a/data/index.yaml b/data/index.yaml new file mode 100644 index 00000000..e00ff9f2 --- /dev/null +++ b/data/index.yaml @@ -0,0 +1,14 @@ +initial: [null, lobby, null] + +demands: + - none + - default + +maps: + - lobby + - big + - small + +recipes: + - none + - default diff --git a/data/makefile b/data/makefile new file mode 100644 index 00000000..b5984dd7 --- /dev/null +++ b/data/makefile @@ -0,0 +1,4 @@ + +all: $(patsubst %.ts,%.yaml,$(wildcard recipes/*.ts)) +recipes/%.yaml: recipes/%.ts + deno run $< > $@ diff --git a/data/map.yaml b/data/map.yaml deleted file mode 100644 index 207a4f90..00000000 --- a/data/map.yaml +++ /dev/null @@ -1,77 +0,0 @@ -# Undercooked - a game about cooking -# Copyright 2024 metamuffin -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, version 3 of the License only. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# -map: - - "'''''*'''*'''''*'''*'''*'''*''*'" - - "'''*''''*'*'**'''*''**''**''*'''" - - "''██v███v███v███v███v███v██v██*'" - - "''vctc.ctc.ctc.ctc.ctc█⌷....sv**" - - "''█.....c.............█⌷....s█''" - - "'*vc...c...████www██dd█⌷.⌷.⌷⌷v*'" - - "*'█tc.ctc..█⌷.C...ff..d..⌷..H█''" - - "''█c...c...█⌷.C.....~.d..⌷..R█'*" - - "*'vc.......w..⌷⌷⌷⌷⌷⌷⌷⌷█⌷.⌷..Tv*'" - - "'*█tc......w..........d..⌷..F█*'" - - "''vc.....ct█⌷S⌷S⌷S⌷⌷oo█⌷⌷⌷..Xv*'" - - "*'██v█dd█v███v██████v███v██v██*'" - - "''''''__''''''''''''''''''''''''" - - "*'''''___________________!______" - - "''''''__________________________" - - "''''''''''''''''''''''''''''''''" - -tiles: - "⌷": counter - "f": counter - "t": table - "w": counter-window - "v": wall-window - "s": sink - "o": oven - "S": stove - "C": cuttingboard - "R": raw-steak-crate - "T": tomato-crate - "F": flour-crate - "H": leek-crate - "X": trash - - "c": chair - "~": floor - ".": floor - "'": grass - "*": tree - "!": path - "_": path - "d": door - "█": wall - -items: - "S": pot - "w": plate - "f": foodprocessor - -chef_spawn: "~" -customer_spawn: "!" - -walkable: - - door - - floor - - chair - - grass - - path - -collider: - - wall - - tree diff --git a/data/maps/big.yaml b/data/maps/big.yaml new file mode 100644 index 00000000..46527a87 --- /dev/null +++ b/data/maps/big.yaml @@ -0,0 +1,77 @@ +# Undercooked - a game about cooking +# Copyright 2024 metamuffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License only. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +map: + - "'''''*'''*'''''*'''*'''*'''*''*'" + - "'''*''''*'*'**'''*''**''**''*'''" + - "''██▒██▒██▒███▒███▒████▒██▒███*'" + - "''█ctc.ctc.ctc.ctc.ctc█⌷....s█**" + - "''█.....c.............█⌷....s█''" + - "'*▒c...c...████www██dd█⌷.⌷.⌷⌷█*'" + - "*'█tc.ctc..█⌷.C...ff..d..⌷..L█''" + - "''▒c...c...█⌷.C.....~.d..⌷..R█'*" + - "*'█c.......w..⌷⌷⌷⌷⌷⌷⌷⌷█⌷.⌷..T█*'" + - "'*▒tc......w..........d..⌷..F█''" + - "''█c.....ct█⌷S⌷S⌷S⌷⌷oo█⌷⌷⌷..X█*'" + - "*'████dd██████████▒███████▒███'*" + - "'''*''__''''''''''''''''''''''''" + - "*'''*'___________________!______" + - "'*''''__________________________" + - "*''*''''''''''''''''''''''''''''" + +tiles: + "⌷": counter + "f": counter + "t": table + "w": counter-window + "s": sink + "o": oven + "S": stove + "C": cuttingboard + "R": raw-steak-crate + "T": tomato-crate + "F": flour-crate + "L": leek-crate + "X": trash + + "c": chair + "~": floor + ".": floor + "'": grass + "*": tree + "!": path + "_": path + "d": door + "█": wall + "▒": wall-window + +items: + "S": pot + "w": plate + "f": foodprocessor + +chef_spawn: "~" +customer_spawn: "!" + +walkable: + - door + - floor + - chair + - grass + - path + +collider: + - wall + - tree diff --git a/data/maps/lobby.yaml b/data/maps/lobby.yaml new file mode 100644 index 00000000..b35d5839 --- /dev/null +++ b/data/maps/lobby.yaml @@ -0,0 +1,55 @@ +# Undercooked - a game about cooking +# Copyright 2024 metamuffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License only. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +map: + - "'''''*'''*''''" + - "'''*''''*'*'*'" + - "''██████████*'" + - "''█...ctc.c█**" + - "''█c...c.cT█''" + - "'*█tc.....c█*'" + - "*'█c..~..ct█''" + - "''█......cT█'*" + - "*'█.c.....c█*'" + - "'*█cTc..c..█'*" + - "''█.c..ctc.█*'" + - "*'██████████*'" + - "'*'''*'''*''*'" + - "*''*''**'''**'" + +tiles: + "t": table + "T": table + "s": sink + "c": chair + "~": floor + ".": floor + "'": grass + "*": tree + "█": wall + +items: + "t": tomato-soup-plate + "T": bread-slice-plate + +chef_spawn: "~" +customer_spawn: "!" + +walkable: + - floor + - chair + - grass +collider: + - wall diff --git a/data/maps/small.yaml b/data/maps/small.yaml new file mode 100644 index 00000000..c45a1d19 --- /dev/null +++ b/data/maps/small.yaml @@ -0,0 +1,78 @@ +# Undercooked - a game about cooking +# Copyright 2024 metamuffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, version 3 of the License only. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +map: + - "'''''*'''*'''''*'''*'''*'" + - "'''*''''*'*'**'''*''**'''" + - "''████▒████▒████▒████▒█*'" + - "''█ctc.ctc.ctc.ctc.ctc█**" + - "''▒.....c.............█''" + - "'*█c...c...████ww██dd██*'" + - "*'█tc.ctc..█sCC..f⌷..L█''" + - "''▒c...c...█........~R█'*" + - "*'█c.......█⌷⌷⌷⌷⌷⌷⌷..T█*'" + - "'*█tc......w.........F█'*" + - "''█c.....ct█⌷oo⌷⌷SSS⌷X█*'" + - "*'████dd██████▒████▒███*'" + - "'*''''__'''''''''''''''''" + - "*'''''___________________" + - "'''*''__________________!" + - "'*'''''''''''''''''''''''" + +tiles: + "⌷": counter + "f": counter + "t": table + "w": counter-window + "s": sink + "o": oven + "S": stove + "C": cuttingboard + "R": raw-steak-crate + "T": tomato-crate + "F": flour-crate + "L": leek-crate + "X": trash + + "c": chair + "~": floor + ".": floor + "'": grass + "*": tree + "!": path + "_": path + "d": door + "▒": wall-window + "█": wall + +items: + "S": pot + "w": plate + "f": foodprocessor + +chef_spawn: "~" +customer_spawn: "!" + +walkable: + - door + - floor + - chair + - grass + - path + +collider: + - wall + - wall-window + - tree diff --git a/data/recipes.ts b/data/recipes.ts deleted file mode 100644 index c3c764f6..00000000 --- a/data/recipes.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - Undercooked - a game about cooking - Copyright 2024 metamuffin - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, version 3 of the License only. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -*/ - -//? Is is a good idea? Probably not. - -interface Recipe { - tile?: string, - inputs: (string | null)[], - outputs: (string | null)[], - action: "instant" | "passive" | "active" - duration?: number - revert_duration?: number, - warn?: boolean -} - -const all_items = new Set() -function auto_trash() { - for (const ifull of all_items) { - let [i, ic] = get_container(ifull) - if (i == "plate") continue - if (i == "pot") continue - if (i == "foodprocessor") continue - if (i == "dirty") continue - if (ic == "plate") ic = "dirty-plate" - out({ - action: "instant", - tile: "trash", - inputs: [ifull], - outputs: [ic] - }) - } -} - -function out(r: Recipe) { - r.inputs.forEach(i => i ? all_items.add(i) : void 0) - r.outputs.forEach(i => i ? all_items.add(i) : void 0) - r.inputs = r.inputs.filter(e => e) - r.outputs = r.outputs.filter(e => e) - console.log(`- ${JSON.stringify(r).replaceAll("\"active\"", "!active").replaceAll("\"passive\"", "!passive").replaceAll("\"instant\"", "!instant")}`); -} - -function cut(from: string, to?: string, to2?: string) { - out({ action: "active", duration: 2, tile: "cuttingboard", inputs: [from], outputs: [to ?? ("sliced-" + from), to2 ?? null] }) -} -function cook(from: string, to?: string) { - const i = from.endsWith("-pot") ? from : from + "-pot" - const o = (to ?? ("cooked-" + from)) + "-pot" - if (!from.endsWith("-pot")) out({ action: "instant", inputs: ["pot", from], outputs: [i] }) - out({ action: "passive", duration: 20, revert_duration: 40, tile: "stove", inputs: [i], outputs: [o] }) - out({ action: "passive", duration: 5, revert_duration: 10, tile: "stove", inputs: [o], outputs: ["burned-pot"], warn: true }) -} -function process(from: string, to?: string) { - const i = from + "-foodprocessor" - const o = (to ?? (from + "-juice")) + "-foodprocessor" - out({ action: "instant", inputs: ["foodprocessor", from], outputs: [i] }) - out({ action: "active", duration: 3, inputs: [i], outputs: [o] }) -} -function bake(from: string, to?: string) { - const o = (to ?? ("cooked-" + from)) - out({ action: "passive", duration: 25, tile: "oven", inputs: [from], outputs: [o] }) - out({ action: "passive", duration: 15, revert_duration: 20, tile: "oven", inputs: [o], outputs: ["burned"], warn: true }) -} -function crate(item: string) { - out({ action: "instant", tile: item + "-crate", inputs: [], outputs: [item], }) -} - -function get_container(ifull: string): [string, string | null] { - const iparts = ifull.split("-") - const ic = iparts.pop() - if (ic && iparts.length && ["pot", "plate", "foodprocessor"].includes(ic)) return [iparts.join("-"), ic] - return [ifull, null] -} - -function combine(container: string, ...inputs: string[]) { - const open = inputs.map(ifull => { - const [i, ic] = get_container(ifull) - out({ action: "instant", inputs: [container, ifull], outputs: [i + "-" + container, ic] }) - return [i] - }) - const seen = new Set() - while (1) { - const cur = open.pop() - if (!cur) break; - const c = cur.join("-") + "-" + container - for (const ifull of inputs) { - const [i, ic] = get_container(ifull) - if (cur.includes(i)) continue - const rkey = cur.join("-") + "#" + i - if (seen.has(rkey)) continue - seen.add(rkey) - - const parts = [...cur, i] - parts.sort() - const o = parts.join("-") + "-" + container - open.push(parts) - out({ - action: "instant", - inputs: [c, ifull], - outputs: [o, ic] - }) - } - } -} - -out({ action: "active", duration: 2, tile: "sink", inputs: ["dirty-plate"], outputs: ["plate"] }) - -crate("tomato") -crate("raw-steak") -crate("flour") -crate("leek") - -cut("tomato") - -cook("raw-steak", "steak") - -process("flour", "dough") -out({ action: "instant", inputs: ["dough-foodprocessor"], outputs: ["foodprocessor", "dough"] }) -bake("dough", "bread") -cut("bread", "bread-slice", "bread-slice") - -process("tomato") -combine("pot", "leek", "tomato-juice-foodprocessor") -cook("leek-tomato-juice-pot", "tomato-soup") -out({ action: "instant", inputs: ["tomato-soup-pot", "plate"], outputs: ["pot", "tomato-soup-plate"] }) - -combine("plate", "steak-pot", "sliced-tomato", "bread-slice") - -auto_trash() diff --git a/data/recipes/.gitignore b/data/recipes/.gitignore new file mode 100644 index 00000000..1e82fc7d --- /dev/null +++ b/data/recipes/.gitignore @@ -0,0 +1 @@ +*.yaml diff --git a/data/recipes/default.ts b/data/recipes/default.ts new file mode 100644 index 00000000..4efdb16a --- /dev/null +++ b/data/recipes/default.ts @@ -0,0 +1,143 @@ +/* + Undercooked - a game about cooking + Copyright 2024 metamuffin + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, version 3 of the License only. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +*/ + +//? Is is a good idea? Probably not. + +interface Recipe { + tile?: string, + inputs: (string | null)[], + outputs: (string | null)[], + action: "instant" | "passive" | "active" | "demand" + duration?: number + revert_duration?: number, + warn?: boolean +} + +const all_items = new Set() +function auto_trash() { + for (const ifull of all_items) { + let [i, ic] = get_container(ifull) + if (i == "plate") continue + if (i == "pot") continue + if (i == "foodprocessor") continue + if (i == "dirty") continue + if (ic == "plate") ic = "dirty-plate" + out({ + action: "instant", + tile: "trash", + inputs: [ifull], + outputs: [ic] + }) + } +} + +function out(r: Recipe) { + r.inputs.forEach(i => i ? all_items.add(i) : void 0) + r.outputs.forEach(i => i ? all_items.add(i) : void 0) + r.inputs = r.inputs.filter(e => e) + r.outputs = r.outputs.filter(e => e) + console.log(`- ${JSON.stringify(r).replaceAll("\"active\"", "!active").replaceAll("\"passive\"", "!passive").replaceAll("\"instant\"", "!instant")}`); +} + +function cut(from: string, to?: string, to2?: string) { + out({ action: "active", duration: 2, tile: "cuttingboard", inputs: [from], outputs: [to ?? ("sliced-" + from), to2 ?? null] }) +} +function cook(from: string, to?: string) { + const i = from.endsWith("-pot") ? from : from + "-pot" + const o = (to ?? ("cooked-" + from)) + "-pot" + if (!from.endsWith("-pot")) out({ action: "instant", inputs: ["pot", from], outputs: [i] }) + out({ action: "passive", duration: 20, revert_duration: 40, tile: "stove", inputs: [i], outputs: [o] }) + out({ action: "passive", duration: 5, revert_duration: 10, tile: "stove", inputs: [o], outputs: ["burned-pot"], warn: true }) +} +function process(from: string, to?: string) { + const i = from + "-foodprocessor" + const o = (to ?? (from + "-juice")) + "-foodprocessor" + out({ action: "instant", inputs: ["foodprocessor", from], outputs: [i] }) + out({ action: "active", duration: 3, inputs: [i], outputs: [o] }) +} +function bake(from: string, to?: string) { + const o = (to ?? ("cooked-" + from)) + out({ action: "passive", duration: 25, tile: "oven", inputs: [from], outputs: [o] }) + out({ action: "passive", duration: 15, revert_duration: 20, tile: "oven", inputs: [o], outputs: ["burned"], warn: true }) +} +function crate(item: string) { + out({ action: "instant", tile: item + "-crate", inputs: [], outputs: [item], }) +} + +function get_container(ifull: string): [string, string | null] { + const iparts = ifull.split("-") + const ic = iparts.pop() + if (ic && iparts.length && ["pot", "plate", "foodprocessor"].includes(ic)) return [iparts.join("-"), ic] + return [ifull, null] +} + +function combine(container: string, ...inputs: string[]) { + const open = inputs.map(ifull => { + const [i, ic] = get_container(ifull) + out({ action: "instant", inputs: [container, ifull], outputs: [i + "-" + container, ic] }) + return [i] + }) + const seen = new Set() + while (1) { + const cur = open.pop() + if (!cur) break; + const c = cur.join("-") + "-" + container + for (const ifull of inputs) { + const [i, ic] = get_container(ifull) + if (cur.includes(i)) continue + const rkey = cur.join("-") + "#" + i + if (seen.has(rkey)) continue + seen.add(rkey) + + const parts = [...cur, i] + parts.sort() + const o = parts.join("-") + "-" + container + open.push(parts) + out({ + action: "instant", + inputs: [c, ifull], + outputs: [o, ic] + }) + } + } +} + +out({ action: "active", duration: 2, tile: "sink", inputs: ["dirty-plate"], outputs: ["plate"] }) + +crate("tomato") +crate("raw-steak") +crate("flour") +crate("leek") + +cut("tomato") + +cook("raw-steak", "steak") + +process("flour", "dough") +out({ action: "instant", inputs: ["dough-foodprocessor"], outputs: ["foodprocessor", "dough"] }) +bake("dough", "bread") +cut("bread", "bread-slice", "bread-slice") + +process("tomato") +combine("pot", "leek", "tomato-juice-foodprocessor") +cook("leek-tomato-juice-pot", "tomato-soup") +out({ action: "instant", inputs: ["tomato-soup-pot", "plate"], outputs: ["pot", "tomato-soup-plate"] }) + +combine("plate", "steak-pot", "sliced-tomato", "bread-slice") + +auto_trash() diff --git a/data/recipes/none.ts b/data/recipes/none.ts new file mode 100644 index 00000000..3338909f --- /dev/null +++ b/data/recipes/none.ts @@ -0,0 +1 @@ +// This scripts generates no recipes. Interesting, isn't it? diff --git a/server/Cargo.toml b/server/Cargo.toml index 23a34d99..9b954908 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,3 +16,5 @@ tokio-tungstenite = "0.23.1" futures-util = "0.3.30" serde_yaml = "0.9.34+deprecated" rand = "0.9.0-alpha.1" +shlex = "1.3.0" +clap = { version = "4.5.7", features = ["derive"] } diff --git a/server/src/bin/graph.rs b/server/src/bin/graph.rs index aa65e91a..94dddbc4 100644 --- a/server/src/bin/graph.rs +++ b/server/src/bin/graph.rs @@ -1,41 +1,46 @@ /* Undercooked - a game about cooking Copyright 2024 metamuffin - + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3 of the License only. - + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - + */ +use anyhow::Result; use undercooked::{ + data::DataIndex, interaction::Recipe, - load_gamedata, protocol::{ItemIndex, RecipeIndex}, }; -fn main() { - let data = load_gamedata(); +fn main() -> Result<()> { + let mut index = DataIndex::default(); + index.reload()?; println!("digraph {{"); - for i in 0..data.item_names.len() { - println!("i{i} [label=\"{}\"]", data.item_name(ItemIndex(i))) - } - for (RecipeIndex(ri), recipe) in data.recipes() { - let (kind, color) = match recipe { - Recipe::Passive { .. } => ("Passive", "#2bc493"), - Recipe::Active { .. } => ("Active", "#47c42b"), - Recipe::Instant { .. } => ("Instant", "#5452d8"), - }; - println!( + for rn in &index.recipes { + let data = index.generate(format!("default-none-{rn}"))?; + + for i in 0..data.item_names.len() { + println!("i{i} [label=\"{}\"]", data.item_name(ItemIndex(i))) + } + for (RecipeIndex(ri), recipe) in data.recipes() { + let (kind, color) = match recipe { + Recipe::Passive { .. } => ("Passive", "#2bc493"), + Recipe::Active { .. } => ("Active", "#47c42b"), + Recipe::Instant { .. } => ("Instant", "#5452d8"), + }; + println!( "r{ri} [label=\"{kind}\\non {}\" shape=box color={color:?} fillcolor={color:?} style=filled]", if let Some(tile) = recipe.tile() { data.tile_name(tile) @@ -43,23 +48,25 @@ fn main() { "anything" } ); - for ItemIndex(input) in recipe.inputs() { - println!("i{input} -> r{ri}") - } - for ItemIndex(output) in recipe.outputs() { - println!("r{ri} -> i{output}") + for ItemIndex(input) in recipe.inputs() { + println!("i{input} -> r{ri}") + } + for ItemIndex(output) in recipe.outputs() { + println!("r{ri} -> i{output}") + } } - } - for (di, d) in data.demands.iter().enumerate() { - let color = "#c4422b"; - println!( + for (di, d) in data.demands.iter().enumerate() { + let color = "#c4422b"; + println!( "d{di} [label=\"Demand\\ntakes {}s\" shape=box color={color:?} fillcolor={color:?} style=filled]", d.duration ); - println!("i{} -> d{di}", d.from.0); - println!("d{di} -> i{}", d.to.0); + println!("i{} -> d{di}", d.from.0); + println!("d{di} -> i{}", d.to.0); + } } println!("}}"); + Ok(()) } diff --git a/server/src/customer/mod.rs b/server/src/customer/mod.rs index 16275227..185133e7 100644 --- a/server/src/customer/mod.rs +++ b/server/src/customer/mod.rs @@ -1,19 +1,19 @@ /* Undercooked - a game about cooking Copyright 2024 metamuffin - + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3 of the License only. - + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - + */ pub mod movement; mod pathfinding; @@ -22,6 +22,7 @@ use crate::{ data::Gamedata, game::Game, protocol::{DemandIndex, ItemIndex, Message, PacketC, PacketS, PlayerID}, + state::State, }; use glam::{IVec2, Vec2}; use log::{debug, error}; @@ -39,6 +40,7 @@ use tokio::{ }; struct CustomerManager { + disabled: bool, walkable: HashSet, chairs: HashMap, items: HashMap, @@ -77,34 +79,21 @@ struct Customer { state: CustomerState, } -pub async fn customer(game: Arc>, mut grx: broadcast::Receiver) { +pub async fn customer(gstate: Arc>, mut grx: broadcast::Receiver) { let mut state = CustomerManager { customer_id_counter: PlayerID(0), walkable: Default::default(), chairs: Default::default(), items: Default::default(), customers: Default::default(), + disabled: true, demand: DemandState { data: Gamedata::default(), }, }; - let initial = game.write().await.prime_client(PlayerID(-1)); - for p in initial { - match p { - PacketC::Init { data, .. } => { - state.demand.data = data; - } - PacketC::UpdateMap { pos, tile, .. } => { - let tilename = state.demand.data.tile_name(tile); - if !state.demand.data.is_tile_colliding(tile) { - state.walkable.insert(pos); - } - if tilename == "chair" { - state.chairs.insert(pos, true); - } - } - _ => (), - } + let initial = gstate.write().await.game.prime_client(); + for packet in initial { + state.packet(packet); } let mut interval = interval(Duration::from_millis(40)); @@ -112,21 +101,25 @@ pub async fn customer(game: Arc>, mut grx: broadcast::Receiver { - match packet.unwrap() { + let packet = packet.unwrap(); + match packet { PacketC::PutItem { .. } | PacketC::TakeItem { .. } | PacketC::SetTileItem { .. } => { - let g = game.read().await; - update_items(&mut state, &g) + let g = gstate.read().await; + update_items(&mut state, &g.game) }, _ => () } + state.packet(packet); } _ = interval.tick() => { - state.tick(&mut packets_out, 0.04); - for (player,packet) in packets_out.drain(..) { - if let Err(e) = game.write().await.packet_in(player, packet) { - error!("customer misbehaved: {e}") + if !state.disabled { + state.tick(&mut packets_out, 0.04); + for (player,packet) in packets_out.drain(..) { + if let Err(e) = gstate.write().await.packet_in(player, packet).await { + error!("customer misbehaved: {e}") + } } } } @@ -134,6 +127,7 @@ pub async fn customer(game: Arc>, mut grx: broadcast::Receiver DemandIndex { // TODO insert sofa magic formula - DemandIndex(random::() % self.data.demands.len()) } } impl CustomerManager { + pub fn packet(&mut self, packet: PacketC) { + match packet { + PacketC::Data { data } => { + self.disabled = data.demands.is_empty(); + self.demand.data = data; + } + PacketC::RemovePlayer { id } => { + self.customers.remove(&id); + } + PacketC::UpdateMap { + tile: pos, + kind: Some(tile), + .. + } => { + let tilename = self.demand.data.tile_name(tile); + if !self.demand.data.is_tile_colliding(tile) { + self.walkable.insert(pos); + } + if tilename == "chair" { + self.chairs.insert(pos, true); + } + } + _ => (), + } + } pub fn tick(&mut self, packets_out: &mut Vec<(PlayerID, PacketS)>, dt: f32) { if self.customers.len() < self.demand.target_customer_count() { self.customer_id_counter.0 -= 1; diff --git a/server/src/data.rs b/server/src/data.rs index 64509f37..e980ccbd 100644 --- a/server/src/data.rs +++ b/server/src/data.rs @@ -19,9 +19,14 @@ use crate::{ interaction::Recipe, protocol::{DemandIndex, ItemIndex, RecipeIndex, TileIndex}, }; +use anyhow::{anyhow, bail, Result}; use glam::{IVec2, Vec2}; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, sync::RwLock}; +use std::{ + collections::{HashMap, HashSet}, + fs::File, + sync::RwLock, +}; #[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)] #[serde(rename_all = "snake_case")] @@ -78,7 +83,9 @@ pub struct Demand { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Gamedata { - recipes: Vec, + #[serde(skip)] + pub recipes: Vec, + #[serde(skip)] pub demands: Vec, pub item_names: Vec, pub tile_names: Vec, @@ -90,105 +97,153 @@ pub struct Gamedata { pub customer_spawn: Vec2, } -pub fn build_gamedata( - recipes_in: Vec, - map_in: InitialMap, - demands_in: Vec, -) -> Gamedata { - let item_names = RwLock::new(Vec::new()); - let tile_names = RwLock::new(Vec::new()); - let mut recipes = Vec::new(); - let mut demands = Vec::new(); - - for r in recipes_in { - let r2 = r.clone(); - let mut inputs = r - .inputs - .into_iter() - .map(|i| ItemIndex(register(&item_names, i))); - let mut outputs = r - .outputs - .into_iter() - .map(|o| ItemIndex(register(&item_names, o))); - let tile = r.tile.map(|t| TileIndex(register(&tile_names, t))); - match r.action { - Action::Never => {} - Action::Passive => recipes.push(Recipe::Passive { - duration: r.duration.expect("duration for passive missing"), - warn: r.warn, - tile, - revert_duration: r.revert_duration, - input: inputs.next().expect("passive recipe without input"), - output: outputs.next(), - }), - Action::Active => recipes.push(Recipe::Active { - duration: r.duration.expect("duration for active missing"), - tile, - input: inputs.next().expect("active recipe without input"), - outputs: [outputs.next(), outputs.next()], - }), - Action::Instant => { - recipes.push(Recipe::Instant { +#[derive(Debug, Deserialize, Default)] +pub struct DataIndex { + pub maps: HashSet, + pub demands: HashSet, + pub recipes: HashSet, +} + +impl DataIndex { + pub fn reload(&mut self) -> anyhow::Result<()> { + *self = serde_yaml::from_reader(File::open("data/index.yaml")?)?; + Ok(()) + } + + pub fn generate(&self, spec: String) -> anyhow::Result { + let [demands, map, recipes] = spec + .split("-") + .collect::>() + .try_into() + .map_err(|_| anyhow!("data specification malformed"))?; + + if !self.demands.contains(demands) { + bail!("unknown demands: {demands:?}"); + } + if !self.maps.contains(map) { + bail!("unknown map: {map:?}"); + } + if !self.recipes.contains(recipes) { + bail!("unknown recipes: {recipes:?}"); + } + + let demands_path = format!("data/demands/{demands}.yaml"); + let map_path = format!("data/maps/{map}.yaml"); + let recipes_path = format!("data/recipes/{recipes}.yaml"); + + let demands_in = serde_yaml::from_reader(File::open(demands_path).unwrap()).unwrap(); + let map_in = serde_yaml::from_reader(File::open(map_path).unwrap()).unwrap(); + let recipes_in = serde_yaml::from_reader(File::open(recipes_path).unwrap()).unwrap(); + + Ok(Gamedata::build(recipes_in, map_in, demands_in)?) + } +} + +impl Gamedata { + pub fn build( + recipes_in: Vec, + map_in: InitialMap, + demands_in: Vec, + ) -> Result { + let item_names = RwLock::new(Vec::new()); + let tile_names = RwLock::new(Vec::new()); + let mut recipes = Vec::new(); + let mut demands = Vec::new(); + + for r in recipes_in { + let r2 = r.clone(); + let mut inputs = r + .inputs + .into_iter() + .map(|i| ItemIndex(register(&item_names, i))); + let mut outputs = r + .outputs + .into_iter() + .map(|o| ItemIndex(register(&item_names, o))); + let tile = r.tile.map(|t| TileIndex(register(&tile_names, t))); + match r.action { + Action::Never => {} + Action::Passive => recipes.push(Recipe::Passive { + duration: r.duration.expect("duration for passive missing"), + warn: r.warn, + tile, + revert_duration: r.revert_duration, + input: inputs.next().expect("passive recipe without input"), + output: outputs.next(), + }), + Action::Active => recipes.push(Recipe::Active { + duration: r.duration.expect("duration for active missing"), tile, - inputs: [inputs.next(), inputs.next()], + input: inputs.next().expect("active recipe without input"), outputs: [outputs.next(), outputs.next()], - }); + }), + Action::Instant => { + recipes.push(Recipe::Instant { + tile, + inputs: [inputs.next(), inputs.next()], + outputs: [outputs.next(), outputs.next()], + }); + } } + assert_eq!(inputs.next(), None, "{r2:?}"); + assert_eq!(outputs.next(), None, "{r2:?}"); } - assert_eq!(inputs.next(), None, "{r2:?}"); - assert_eq!(outputs.next(), None, "{r2:?}"); - } - for d in demands_in { - demands.push(Demand { - from: ItemIndex(register(&item_names, d.from)), - to: ItemIndex(register(&item_names, d.to)), - duration: d.duration, - }) - } + for d in demands_in { + demands.push(Demand { + from: ItemIndex(register(&item_names, d.from)), + to: ItemIndex(register(&item_names, d.to)), + duration: d.duration, + }) + } - let mut chef_spawn = Vec2::new(0., 0.); - let mut customer_spawn = Vec2::new(0., 0.); - let mut initial_map = HashMap::new(); - for (y, line) in map_in.map.iter().enumerate() { - for (x, tile) in line.trim().chars().enumerate() { - let pos = IVec2::new(x as i32, y as i32); - if tile == map_in.chef_spawn { - chef_spawn = pos.as_vec2() + Vec2::splat(0.5); + let mut chef_spawn = Vec2::new(0., 0.); + let mut customer_spawn = Vec2::new(0., 0.); + let mut initial_map = HashMap::new(); + for (y, line) in map_in.map.iter().enumerate() { + for (x, tile) in line.trim().chars().enumerate() { + let pos = IVec2::new(x as i32, y as i32); + if tile == map_in.chef_spawn { + chef_spawn = pos.as_vec2() + Vec2::splat(0.5); + } + if tile == map_in.customer_spawn { + customer_spawn = pos.as_vec2() + Vec2::splat(0.5); + } + let tilename = map_in + .tiles + .get(&tile) + .ok_or(anyhow!("tile {tile} is undefined"))? + .clone(); + let itemname = map_in.items.get(&tile).cloned(); + let tile = TileIndex(register(&tile_names, tilename)); + let item = itemname.map(|i| ItemIndex(register(&item_names, i))); + initial_map.insert(pos, (tile, item)); } - if tile == map_in.customer_spawn { - customer_spawn = pos.as_vec2() + Vec2::splat(0.5); - } - let tilename = map_in.tiles[&tile].clone(); - let itemname = map_in.items.get(&tile).cloned(); - let tile = TileIndex(register(&tile_names, tilename)); - let item = itemname.map(|i| ItemIndex(register(&item_names, i))); - initial_map.insert(pos, (tile, item)); } - } - let item_names = item_names.into_inner().unwrap(); - let tile_names = tile_names.into_inner().unwrap(); - - let tile_collide = tile_names - .iter() - .map(|i| !map_in.walkable.contains(i)) - .collect(); - let tile_interact = tile_names - .iter() - .map(|i| !map_in.collider.contains(i) && !map_in.walkable.contains(i)) - .collect(); - - Gamedata { - demands, - tile_collide, - tile_interact, - recipes, - initial_map, - item_names, - tile_names, - chef_spawn, - customer_spawn, + let item_names = item_names.into_inner().unwrap(); + let tile_names = tile_names.into_inner().unwrap(); + + let tile_collide = tile_names + .iter() + .map(|i| !map_in.walkable.contains(i)) + .collect(); + let tile_interact = tile_names + .iter() + .map(|i| !map_in.collider.contains(i) && !map_in.walkable.contains(i)) + .collect(); + + Ok(Gamedata { + demands, + tile_collide, + tile_interact, + recipes, + initial_map, + item_names, + tile_names, + chef_spawn, + customer_spawn, + }) } } diff --git a/server/src/game.rs b/server/src/game.rs index c5eb8c9f..c0a03616 100644 --- a/server/src/game.rs +++ b/server/src/game.rs @@ -67,15 +67,39 @@ pub struct Game { } impl Game { - pub fn new(gamedata: Arc) -> Self { - let mut g = Self { - data: gamedata.clone(), + pub fn new() -> Self { + Self { + data: Gamedata::default().into(), packet_out: Default::default(), players: Default::default(), tiles: Default::default(), - }; - for (&p, (tile, item)) in &gamedata.initial_map { - g.tiles.insert( + } + } + + fn unload(&mut self) { + for (id, _) in self.players.drain() { + self.packet_out.push_back(PacketC::RemovePlayer { id }) + } + for (pos, _) in self.tiles.drain() { + self.packet_out.push_back(PacketC::UpdateMap { + tile: pos, + kind: None, + neighbors: [None, None, None, None], + }) + } + } + pub fn load(&mut self, gamedata: Gamedata) { + let players = self + .players + .iter() + .map(|(id, p)| (*id, (p.name.to_owned(), p.character))) + .collect::>(); + + self.unload(); + + self.data = gamedata.into(); + for (&p, (tile, item)) in &self.data.initial_map { + self.tiles.insert( p, Tile { kind: *tile, @@ -86,7 +110,12 @@ impl Game { }, ); } - g + for (id, (name, character)) in players { + self.packet_in(id, PacketS::Join { name, character }) + .unwrap(); + } + + self.packet_out.extend(self.prime_client()); } pub fn tiles(&self) -> &HashMap { @@ -97,10 +126,9 @@ impl Game { self.packet_out.pop_front() } - pub fn prime_client(&self, id: PlayerID) -> Vec { + pub fn prime_client(&self) -> Vec { let mut out = Vec::new(); - out.push(PacketC::Init { - id, + out.push(PacketC::Data { data: self.data.deref().to_owned(), }); for (&id, player) in &self.players { @@ -125,14 +153,14 @@ impl Game { } for (&tile, tdata) in &self.tiles { out.push(PacketC::UpdateMap { - pos: tile, + tile, neighbors: [ self.tiles.get(&(tile + IVec2::NEG_Y)).map(|e| e.kind), self.tiles.get(&(tile + IVec2::NEG_X)).map(|e| e.kind), self.tiles.get(&(tile + IVec2::Y)).map(|e| e.kind), self.tiles.get(&(tile + IVec2::X)).map(|e| e.kind), ], - tile: tdata.kind.clone(), + kind: Some(tdata.kind.clone()), }); if let Some(item) = &tdata.item { out.push(PacketC::SetTileItem { diff --git a/server/src/lib.rs b/server/src/lib.rs index ac0fbfa4..466defb4 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,33 +1,24 @@ /* Undercooked - a game about cooking Copyright 2024 metamuffin - + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3 of the License only. - + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -*/ -use data::{build_gamedata, Gamedata}; -use std::fs::File; +*/ +#![feature(if_let_guard)] pub mod customer; pub mod data; pub mod game; pub mod interaction; pub mod protocol; - -pub fn load_gamedata() -> Gamedata { - build_gamedata( - serde_yaml::from_reader(File::open("data/recipes.yaml").unwrap()).unwrap(), - serde_yaml::from_reader(File::open("data/map.yaml").unwrap()).unwrap(), - serde_yaml::from_reader(File::open("data/demands.yaml").unwrap()).unwrap(), - ) -} +pub mod state; diff --git a/server/src/main.rs b/server/src/main.rs index aeda9c2f..6773bf29 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,19 +1,19 @@ /* Undercooked - a game about cooking Copyright 2024 metamuffin - + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3 of the License only. - + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - + */ use anyhow::Result; use futures_util::{SinkExt, StreamExt}; @@ -23,14 +23,13 @@ use tokio::{ net::TcpListener, spawn, sync::{broadcast, mpsc::channel, RwLock}, - time::sleep, + time::interval, }; use tokio_tungstenite::tungstenite::Message; use undercooked::{ customer::customer, - game::Game, - load_gamedata, protocol::{PacketC, PacketS, PlayerID}, + state::State, }; #[tokio::main] @@ -39,29 +38,22 @@ async fn main() -> Result<()> { let ws_listener = TcpListener::bind("0.0.0.0:27032").await?; info!("listening for websockets on {}", ws_listener.local_addr()?); - let data = load_gamedata(); - let game = Arc::new(RwLock::new(Game::new(data.into()))); let (tx, rx) = broadcast::channel::(1024); + let state = Arc::new(RwLock::new(State::new(tx)?)); { - let game = game.clone(); + let state = state.clone(); spawn(async move { let dt = 1. / 25.; + let mut tick = interval(Duration::from_secs_f32(dt)); loop { - { - let mut g = game.write().await; - g.tick(dt); - while let Some(p) = g.packet_out() { - debug!("-> {p:?}"); - let _ = tx.send(p); - } - } - sleep(Duration::from_secs_f32(dt)).await; + tick.tick().await; + state.write().await.tick(dt).await; } }); } - spawn(customer(game.clone(), rx.resubscribe())); + spawn(customer(state.clone(), rx.resubscribe())); for id in (1..).map(PlayerID) { let (sock, addr) = ws_listener.accept().await?; @@ -70,11 +62,12 @@ async fn main() -> Result<()> { continue; }; let (mut write, mut read) = sock.split(); - let game = game.clone(); + let state = state.clone(); let mut rx = rx.resubscribe(); let (error_tx, mut error_rx) = channel::(8); info!("{addr} connected via ws"); - let init = game.write().await.prime_client(id); + let mut init = state.write().await.game.prime_client(); + init.insert(0, PacketC::Init { id }); spawn(async move { for p in init { if let Err(e) = write @@ -114,7 +107,7 @@ async fn main() -> Result<()> { break; }; debug!("<- {id:?} {packet:?}"); - if let Err(e) = game.write().await.packet_in(id, packet) { + if let Err(e) = state.write().await.packet_in(id, packet).await { warn!("client error: {e}"); let _ = error_tx .send(PacketC::Error { @@ -127,7 +120,8 @@ async fn main() -> Result<()> { _ => (), } } - let _ = game.write().await.packet_in(id, PacketS::Leave); + info!("{id:?} left"); + state.write().await.packet_in(id, PacketS::Leave).await.ok(); }); } Ok(()) diff --git a/server/src/protocol.rs b/server/src/protocol.rs index 8690febf..262fad8d 100644 --- a/server/src/protocol.rs +++ b/server/src/protocol.rs @@ -1,19 +1,19 @@ /* Undercooked - a game about cooking Copyright 2024 metamuffin - + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3 of the License only. - + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - + */ use crate::data::Gamedata; use glam::{IVec2, Vec2}; @@ -77,9 +77,11 @@ pub enum Message { #[serde(rename_all = "snake_case", tag = "type")] pub enum PacketC { Init { - data: Gamedata, id: PlayerID, }, + Data { + data: Gamedata, + }, AddPlayer { id: PlayerID, position: Vec2, @@ -116,8 +118,8 @@ pub enum PacketC { warn: bool, }, UpdateMap { - pos: IVec2, - tile: TileIndex, + tile: IVec2, + kind: Option, neighbors: [Option; 4], }, Collide { diff --git a/server/src/state.rs b/server/src/state.rs new file mode 100644 index 00000000..98f92a24 --- /dev/null +++ b/server/src/state.rs @@ -0,0 +1,68 @@ +use crate::{ + data::DataIndex, + game::Game, + protocol::{Message, PacketC, PacketS, PlayerID}, +}; +use anyhow::{anyhow, Result}; +use clap::Parser; +use log::debug; +use tokio::sync::broadcast::Sender; + +pub struct State { + index: DataIndex, + tx: Sender, + pub game: Game, +} + +#[derive(Parser)] +#[clap(multicall = true)] +enum Command { + Start { spec: String }, +} + +impl State { + pub fn new(tx: Sender) -> Result { + let mut index = DataIndex::default(); + index.reload()?; + + let mut game = Game::new(); + game.load(index.generate("none-lobby-none".to_string())?); + + Ok(Self { game, index, tx }) + } + + pub async fn tick(&mut self, dt: f32) { + self.game.tick(dt); + while let Some(p) = self.game.packet_out() { + debug!("-> {p:?}"); + let _ = self.tx.send(p); + } + } + pub async fn packet_in(&mut self, player: PlayerID, packet: PacketS) -> Result<()> { + match &packet { + PacketS::Communicate { + message: Some(Message::Text(message)), + } if let Some(command) = message.strip_prefix("/") => { + self.handle_command(Command::try_parse_from( + shlex::split(command) + .ok_or(anyhow!("quoting invalid"))? + .into_iter(), + )?) + .await?; + return Ok(()); + } + _ => (), + } + self.game.packet_in(player, packet)?; + Ok(()) + } + + async fn handle_command(&mut self, command: Command) -> Result<()> { + match command { + Command::Start { spec } => { + self.game.load(self.index.generate(spec)?); + } + } + Ok(()) + } +} diff --git a/test-client/index.html b/test-client/index.html index d1cd7133..63d02f9e 100644 --- a/test-client/index.html +++ b/test-client/index.html @@ -31,6 +31,12 @@ noscript { color: white; } + input[type="text"] { + position: absolute; + top: 0px; + left: 0px; + font-size: 30px; + } diff --git a/test-client/main.ts b/test-client/main.ts index 69a4ff69..0f841b06 100644 --- a/test-client/main.ts +++ b/test-client/main.ts @@ -92,7 +92,6 @@ export const items_removed = new Set() export let data: Gamedata = { item_names: [], tile_names: [], spawn: [0, 0], tile_collide: [], tile_interact: [] } - export let my_id: PlayerID = -1 export const camera: V2 = { x: 0, y: 0 } export const interact_target_anim: V2 = { x: 0, y: 0 } @@ -107,6 +106,8 @@ function packet(p: PacketC) { switch (p.type) { case "init": my_id = p.id + break; + case "data": data = p.data break; case "add_player": { @@ -172,7 +173,8 @@ function packet(p: PacketC) { break; } case "update_map": - tiles.set(p.pos.toString(), { x: p.pos[0], y: p.pos[1], kind: p.tile }) + if (p.kind !== undefined && p.kind !== null) tiles.set(p.tile.toString(), { x: p.tile[0], y: p.tile[1], kind: p.kind }) + else tiles.delete(p.tile.toString()) break; case "communicate": { const player = players.get(p.player)! @@ -188,9 +190,14 @@ function packet(p: PacketC) { } } +export let chat: null | HTMLInputElement = null; + export const keys_down = new Set(); const HANDLED_KEYS = ["KeyW", "KeyA", "KeyS", "KeyD", "Space"] function keyboard(ev: KeyboardEvent, down: boolean) { + if (down && ev.code == "Enter") return toggle_chat() + else if (down && ev.code == "Escape" && chat) return close_chat() + else if (chat) return if (HANDLED_KEYS.includes(ev.code)) ev.preventDefault() if (!keys_down.has("Space") && ev.code == "Space" && down) set_interact(true) if (keys_down.has("Space") && ev.code == "Space" && !down) set_interact(false) @@ -198,6 +205,27 @@ function keyboard(ev: KeyboardEvent, down: boolean) { else keys_down.delete(ev.code) } +function close_chat() { + if (!chat) return + chat.remove() + canvas.focus() + chat = null; +} +function toggle_chat() { + if (chat) { + if (chat.value.length) send({ type: "communicate", message: { text: chat.value } }) + chat.remove() + canvas.focus() + chat = null; + } else { + chat = document.createElement("input") + chat.type = "text" + chat.placeholder = "Message" + document.body.append(chat) + chat.focus() + } +} + export function get_interact_target(): V2 | undefined { if (interacting) return interacting const me = players.get(my_id) diff --git a/test-client/protocol.ts b/test-client/protocol.ts index 48a1cca7..f4ffba01 100644 --- a/test-client/protocol.ts +++ b/test-client/protocol.ts @@ -32,10 +32,12 @@ export type PacketS = { type: "join", name: string, character: number } // You join, sent as first packet. | { type: "position", pos: Vec2, rot: number } // Update your position and rotation in radians (0 is -y) | { type: "interact", pos: Vec2, edge: boolean } // Interact with some tile. edge is true when pressing and false when releasing interact button + | { type: "communicate", message: Message } // Send a message | { type: "collide", player: PlayerID, force: Vec2 } // Apply force to another player as a result of a collision export type PacketC = - { type: "init", id: PlayerID, data: Gamedata } // You joined + { type: "init", id: PlayerID } // You just connected. This is your id for this session. + | { type: "data", data: Gamedata } // Game data was changed | { type: "add_player", id: PlayerID, name: string, position: Vec2, character: number } // Somebody else joined (or was already in the game) | { type: "remove_player", id: PlayerID } // Somebody left | { type: "position", player: PlayerID, pos: Vec2, rot: number } // Update the position of a players (your own position is included here) @@ -44,7 +46,7 @@ export type PacketC = | { type: "set_tile_item", tile: Vec2, item?: ItemIndex } // A tile changed its item | { type: "set_player_item", player: PlayerID, item?: ItemIndex } // A player changed their item | { type: "set_active", tile: Vec2, progress?: number, warn: boolean } // A tile is doing something. progress goes from 0 to 1, then null when finished - | { type: "update_map", pos: Vec2, tile: TileIndex, neighbors: [TileIndex | null] } // A map tile was changed + | { type: "update_map", tile: Vec2, kind: TileIndex | null, neighbors: [TileIndex | null] } // A map tile was changed | { type: "communicate", player: PlayerID, message?: Message } // A player wants to communicate something, message is null when cleared | { type: "error", message?: Message } // Your client did something wrong. -- cgit v1.2.3-70-g09d2