commit 28b026a6147a3e2e1c4af4c2967ad382131ecc9c Author: Matthieu Jolimaitre Date: Tue Apr 9 02:05:02 2024 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..688e137 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/deno.lock diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cbac569 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "deno.enable": true +} diff --git a/client/main.ts b/client/main.ts new file mode 100755 index 0000000..362af52 --- /dev/null +++ b/client/main.ts @@ -0,0 +1,125 @@ +#!/bin/env -S deno run --allow-net --allow-run +import { MsgToClient, MsgToServer, mtc } from "../common/mod.ts"; +import { run } from "../common/utils.ts"; +import { + channel, + launch_caught, + log_from, + parsed_stream, + Receiver, + Sender, + serialized_stream, + wait, +} from "../common/utils.ts"; +const log = log_from(import.meta); + +async function main() { + log("Client starting."); + const interface_ = await ServerInterface.connect(9999); + new InputHandler(interface_.outputs).spin(); + const display = new DisplayHandler(interface_.outputs); + display.spin(); + new MsgHandler(interface_.inputs, display).spin(); + log("Connected."); + + interface_.outputs.send({ kind: "ping", content: { message: "Machin." } }); + log("Sent ping."); +} + +class MsgHandler { + receiver; + display; + + constructor(receiver: Receiver, display: DisplayHandler) { + this.receiver = receiver; + this.display = display; + } + + async spin() { + for await (const event of this.receiver.iter()) { + if (event.kind === "ping_response") log("Received ping response :", event.content); + if (event.kind === "display") this.display.refresh(event.content.raw); + } + } +} + +class InputHandler { + outputs; + + constructor(outputs: Sender) { + this.outputs = outputs; + } + + async read_one_char() { + const args = ["-i0", "sh", "-c", "read -n 1 OUT; echo -n $OUT"]; + const command = new Deno.Command("stdbuf", { args, stdin: "inherit", stdout: "piped" }); + const output = await command.output(); + const result = new TextDecoder().decode(output.stdout); + console.log(`${String.fromCharCode(0x08)} `); + return result[0]; + } + + async spin() { + while (true) { + const read = await this.read_one_char(); + if (read === "z") this.outputs.send({ kind: "input", content: { control: "up" } }); + if (read === "s") this.outputs.send({ kind: "input", content: { control: "down" } }); + if (read === "q") this.outputs.send({ kind: "input", content: { control: "left" } }); + if (read === "d") this.outputs.send({ kind: "input", content: { control: "right" } }); + } + } +} + +class DisplayHandler { + outputs; + + constructor(outputs: Sender) { + this.outputs = outputs; + } + + async spin() { + while (true) { + await wait(100); + const [width, height] = await this.get_res(); + this.outputs.send({ kind: "request_display", content: { width, height } }); + } + } + + async get_res() { + const width = parseInt(await run("tput", "cols")) / 2 - 2; // note : tiles are 2 char wide + const height = parseInt(await run("tput", "lines")) - 2; + return [width, height]; + } + + refresh(raw: string) { + const clear_sequence = String.fromCharCode(0o033) + String.fromCharCode(0o143); + console.log(clear_sequence + raw); + } +} + +class ServerInterface { + inputs; + outputs; + + constructor(inputs: Receiver, outputs: Sender) { + this.inputs = inputs; + this.outputs = outputs; + } + + static async connect(port: number) { + const connection = await Deno.connect({ port }); + return await ServerInterface.init(connection); + } + + // deno-lint-ignore require-await + static async init(connection: Deno.Conn) { + // TODO : handshake ? + const [input_sender, input_receiver] = channel(); + const [output_sender, output_receiver] = channel(); + launch_caught(() => input_sender.send_all(parsed_stream(mtc.message_to_client_parser())(connection.readable))); + launch_caught(() => serialized_stream(output_receiver.iter())(connection.writable)); + return new ServerInterface(input_receiver, output_sender); + } +} + +if (import.meta.main) await main(); diff --git a/client/watch.sh b/client/watch.sh new file mode 100755 index 0000000..65b3ae5 --- /dev/null +++ b/client/watch.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e +cd "$(dirname "$(realpath "$0")")" + +nodemon -w ../common -w . -e ts -x ' +while true; +do + ./main.ts; + sleep 1s; +done' diff --git a/common/message/message.ts b/common/message/message.ts new file mode 100644 index 0000000..e69de29 diff --git a/common/message/to_client.ts b/common/message/to_client.ts new file mode 100644 index 0000000..46081c8 --- /dev/null +++ b/common/message/to_client.ts @@ -0,0 +1,32 @@ +import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; +import { ParserOutput } from "../utils.ts"; + +export type MsgToClient = ParserOutput; +export function message_to_client_parser() { + return ping_response_parser() + .or(display_parser()); +} + +export type MsgPingResponse = ParserOutput; +export function ping_response_parser() { + return z.object({ + kind: z.literal("ping_response"), + content: z.object({ + message: z.string(), + }), + }); +} + +export type MsgDisplay = ParserOutput; +export function display_parser() { + return z.object({ + kind: z.literal("display"), + content: z.object({ + raw: z.string(), + }), + }); +} + +function char_parser() { + return z.string().length(1); +} diff --git a/common/message/to_server.ts b/common/message/to_server.ts new file mode 100644 index 0000000..d09e52b --- /dev/null +++ b/common/message/to_server.ts @@ -0,0 +1,58 @@ +import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; +import { ParserOutput } from "../utils.ts"; + +export type MsgToServer = ParserOutput; +export function message_to_server_parser() { + return ping_parser() + .or(request_display_parser()) + .or(input_parser()) + .or(exit_parser()); +} + +export type MsgPing = ParserOutput; +export function ping_parser() { + return z.object({ + kind: z.literal("ping"), + content: z.object({ + message: z.string(), + }), + }); +} + +export type MsgReqDisplay = ParserOutput; +export function request_display_parser() { + return z.object({ + kind: z.literal("request_display"), + content: z.object({ + width: z.number(), + height: z.number(), + }), + }); +} + +export type MsgInput = ParserOutput; +export function input_parser() { + return z.object({ + kind: z.literal("input"), + content: z.object({ + control: control_parser(), + }), + }); +} + +export type ClientInput = ParserOutput; +function control_parser() { + return z.literal("up") + .or(z.literal("down")) + .or(z.literal("left")) + .or(z.literal("right")) + .or(z.literal("interact")) + .or(z.literal("attack")); +} + +export type MsgExit = ParserOutput; +export function exit_parser() { + return z.object({ + kind: z.literal("exit"), + }); +} diff --git a/common/mod.ts b/common/mod.ts new file mode 100644 index 0000000..c7dcae7 --- /dev/null +++ b/common/mod.ts @@ -0,0 +1,4 @@ +export * as mtc from "./message/to_client.ts"; +export * as mts from "./message/to_server.ts"; +export type { MsgToClient } from "./message/to_client.ts"; +export type { MsgToServer } from "./message/to_server.ts"; diff --git a/common/utils.ts b/common/utils.ts new file mode 100644 index 0000000..159d4e8 --- /dev/null +++ b/common/utils.ts @@ -0,0 +1,322 @@ +import { assert, assertEquals, assertInstanceOf } from "https://deno.land/std@0.221.0/assert/mod.ts"; +import * as path from "https://deno.land/std@0.221.0/path/mod.ts"; +import { TextLineStream } from "https://deno.land/std@0.221.0/streams/mod.ts"; +import { z, ZodType } from "https://deno.land/x/zod@v3.22.4/mod.ts"; + +export function parsed_stream(parser: ZodType) { + return async function* (readable: ReadableStream) { + const line_stream = readable + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream()); + for await (const line of line_stream) yield parser.parse(JSON.parse(line)); + }; +} + +export function serialized_stream(generator: AsyncIterable) { + return async function (writable: WritableStream) { + const stream = new TextEncoderStream(); + stream.readable.pipeTo(writable); + for await (const item of generator) { + const writer = stream.writable.getWriter(); + await writer.write(JSON.stringify(item) + "\n"); + writer.releaseLock(); + } + }; +} + +export type Consumer = (item: T) => void; + +// deno-lint-ignore no-explicit-any +export type ParserOutput ZodType> = z.infer>; + +export type PromiseSplit = ReturnType>; +export function split_promise() { + let resolver_ = null as Consumer | null; + const promise = new Promise((r) => { + resolver_ = r; + }); + assert(resolver_ !== null); + const resolver = resolver_ as Consumer; + return { promise, resolver }; +} + +export class AsyncQueue { + private items; + private awaiting; + + public constructor() { + this.items = [] as T[]; + this.awaiting = [] as Consumer[]; + } + + public push(item: T) { + this.items.push(item); + const [first] = this.awaiting.splice(0, 1); + if (first !== undefined) first(); + } + + public async pull() { + let first = this.take_first_item(); + if (first !== undefined) return first; + const { promise, resolver } = split_promise(); + this.awaiting.push(resolver); + await promise; + first = this.take_first_item(); + assert(first !== undefined); + return first as T; + } + + private take_first_item() { + const [first] = this.items.splice(0, 1); + return first as T | undefined; + } +} + +export function channel() { + const queue = new AsyncQueue(); + return [new Sender(queue), new Receiver(queue)] as const; +} + +export function channel_generator(generator: AsyncIterable) { + const [sender, receiver] = channel(); + (async () => { + for await (const item of generator) sender.send(item); + })(); + return receiver; +} + +export class Sender { + queue; + + public constructor(queue: AsyncQueue) { + this.queue = queue; + } + + public send(item: T) { + this.queue.push(item); + } + + public async send_all(iter: AsyncGenerator) { + for await (const item of iter) this.send(item); + } +} + +export class Receiver { + queue; + + public constructor(queue: AsyncQueue) { + this.queue = queue; + } + + public async receive() { + return await this.queue.pull(); + } + + public async *iter() { + while (true) yield this.receive(); + } +} + +export function range(from: number, to: number) { + return new Range(from, to); +} + +export class Range { + from; + to; + + constructor(from: number, to: number) { + this.from = from; + this.to = to; + } + + includes(n: number) { + return (n >= this.from) && (n < this.to); + } + + [Symbol.iterator]() { + let index = this.from; + const to = this.to; + return (function* () { + while (index < to) yield index++; + })(); + } +} + +export async function launch_caught( + operation: () => Promise | T, + handler: null | ((error: unknown) => void) = null, +) { + try { + return await operation(); + } catch (error) { + if (handler !== null) handler(error); + else console.error("Lanched Uncaught :", error); + } +} + +export function log_from(meta: ImportMeta) { + return function (...args: unknown[]) { + const mod = path.basename(new URL(meta.url).pathname); + console.log(`[${mod}]`, ...args); + }; +} + +export class Vec2 { + private x_; + private y_; + + public constructor(x: number, y: number) { + this.x_ = Math.round(x); + this.y_ = Math.round(y); + } + + public x() { + return this.x_; + } + + public y() { + return this.y_; + } + + public scale(factor: number) { + return new Vec2(this.x_ * factor, this.y_ * factor); + } + + public neg() { + return this.scale(-1); + } + + public add(other: Vec2) { + return new Vec2(this.x_ + other.x_, this.y_ + other.y_); + } + + public sub(other: Vec2) { + return this.add(other.neg()); + } + + public inside(corner_tl: Vec2, corner_br: Vec2) { + if (!range(corner_tl.x(), corner_br.x()).includes(this.x())) return false; + if (!range(corner_tl.y(), corner_br.y()).includes(this.y())) return false; + return true; + } + + public eq(other: Vec2) { + return (this.x() === other.x()) && (this.y() === other.y()); + } + + public neq(other: Vec2) { + return !this.eq(other); + } + + public len() { + return Math.sqrt(this.x() ** 2 + this.y() ** 2); + } + + public distance(other: Vec2) { + return this.sub(other).len(); + } + + public overlaps(other: Vec2) { + return this.distance(other) < 1; + } +} + +export function v2(x: number, y: number) { + return new Vec2(x, y); +} + +export function* enumerate(iterator: Iterable) { + let index = 0; + for (const item of iterator) yield [index++, item] as [number, T]; +} + +export function* chunks(iterator: Iterable, size: number) { + let current = []; + for (const item of iterator) { + current.push(item); + if (current.length < size) continue; + yield current; + current = []; + } +} + +Deno.test("test_chunk", () => { + assertEquals( + [...chunks([1, 2, 3, 4, 5, 6, 7], 3)], + [[1, 2, 3], [4, 5, 6]], + ); +}); + +export function wait(ms: number) { + return new Promise((res) => setTimeout(res, ms)); +} + +export async function run(cmd: string, ...args: string[]) { + const command = new Deno.Command(cmd, { args, stdout: "piped" }); + const output = await command.output(); + if (!output.success) throw new Error(); + return new TextDecoder().decode(output.stdout); +} + +export type Prototyped

= { constructor: { prototype: P } }; +export type Constructible = new (...args: unknown[]) => I; +export class ClassMap { + private inner; + + constructor() { + this.inner = new Map(); + } + + insert(object: V) { + const prototype = object.constructor.prototype; + if (this.inner.has(prototype)) throw new Error(); + this.inner.set(prototype, object); + } + + get>(class_: C) { + const value = this.inner.get(class_.prototype); + if (value === undefined) return null; + assertInstanceOf(value, class_); + return value; + } +} + +Deno.test("test_classmap", () => { + { + class Truc {} + class Machin {} + class Chose {} + + const map = new ClassMap(); + const truc = new Truc(); + map.insert(truc); + const machin = new Machin(); + map.insert(machin); + + assert(map.get(Truc) === truc); + assert(map.get(Machin) !== truc); + assert(map.get(Machin) === machin); + assert(map.get(Chose) === null); + } + { + interface Machinable { + machin(): number; + } + // deno-lint-ignore no-unused-vars + class Truc {} + class Machin implements Machinable { + machin = () => 35; + } + const map = new ClassMap(); + map.insert(new Machin()); + // map.insert(new Truc()); // note : Fails with improper typing. + + map.get(Machin); + // map.get(Truc); // note : Fails with improper typing. + } +}); + +export function maybe(item: T) { + return item as T | undefined; +} diff --git a/data/structures/houses.txt b/data/structures/houses.txt new file mode 100644 index 0000000..29306e1 --- /dev/null +++ b/data/structures/houses.txt @@ -0,0 +1,8 @@ +[][][] [][] +[] [] +[] [] +[] [][][] +[][][][][][] [] + [] + [] [] + [][][][][] diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..92b61b1 --- /dev/null +++ b/deno.json @@ -0,0 +1,6 @@ +{ + "fmt": { + "useTabs": true, + "lineWidth": 120 + } +} diff --git a/server/engine.ts b/server/engine.ts new file mode 100644 index 0000000..bf13550 --- /dev/null +++ b/server/engine.ts @@ -0,0 +1,153 @@ +import { assertEquals } from "https://deno.land/std@0.221.0/assert/mod.ts"; +import { chunks, enumerate, log_from, Prototyped, range, Vec2 } from "../common/utils.ts"; +import { ClientInterface } from "./network.ts"; +import { Session } from "./entities/player.ts"; +import { ClassMap } from "../common/utils.ts"; +import { maybe } from "../common/utils.ts"; +const log = log_from(import.meta); + +export class WorldEntity { + display; + world; + identifier; + position; + compontents; + + constructor(world: World, identifier: number, display: string, position: Vec2, ...components: Prototyped[]) { + this.display = display; + this.world = world; + this.identifier = identifier; + this.position = position; + this.compontents = new ClassMap(); + for (const c of components) this.compontents.insert(c); + } +} + +export class World { + next_id; + entities; + + constructor() { + this.next_id = 0; + this.entities = new Map(); + } + + spawn_entity(display: string, pos: Vec2) { + const entity = new WorldEntity(this, this.next_id++, display, pos); + this.entities.set(entity.identifier, entity); + return entity; + } + + async spawn_structure(file_path: string, origin: Vec2) { + const content = await Deno.readTextFile(file_path); + let count = 0; + for (const [y, line] of enumerate(content.split("\n"))) { + for (const [x, chars] of enumerate(chunks(line, 2))) { + const display = chars[0] + chars[1]; + if (display === " ") continue; + this.spawn_entity(display, origin.add(new Vec2(x, y))); + count += 1; + } + } + log("Spawned", count, "tiles from", file_path); + } + + render(center: Vec2, size: Vec2) { + const radius = size.scale(0.5); + const result = Array.from(range(0, size.y())).map(() => Array.from(range(0, size.x())).map(() => " ")); + const top_left = center.sub(radius); + const bottom_right = center.add(radius); + for (const entity of this.entities.values()) { + if (!entity.position.inside(top_left, bottom_right)) continue; + const local_pos = entity.position.sub(top_left); + result[local_pos.y()][local_pos.x()] = entity.display; + } + return result.map((line) => line.join("")).toReversed().join("\n"); + } + + move_collide(entity_id: number, displacement: Vec2) { + let result = false; + const entity = this.get_entity(entity_id); + if (entity === undefined) return result; + for (const step of displacement_steps(entity.position, displacement)) { + const collision = this.get_entity_at(step); + if (collision !== undefined) return result; + entity.position = step; + result = true; + } + return result; + } + + get_entity(entity_id: number) { + return this.entities.get(entity_id); + } + + *get_entities_at(pos: Vec2) { + for (const entity of this.entities.values()) if (entity.position.overlaps(pos)) yield entity; + } + + get_entity_at(pos: Vec2) { + return maybe([...this.get_entities_at(pos)][0]); + } + + *get_entities_in_range(min: Vec2, max: Vec2) { + for (const entity of this.entities.values()) if (entity.position.inside(min, max)) yield entity; + } + + get_entity_in_range(min: Vec2, max: Vec2) { + return maybe([...this.get_entities_in_range(min, max)][0]); + } + + kill(entity_id: number) { + this.entities.delete(entity_id); + } +} + +export class Engine { + world; + + constructor(world: World) { + this.world = world; + } + + async start() { + log("Started engine."); + } + + add_session(client: ClientInterface) { + const player_entity = this.world.spawn_entity("`/", new Vec2(0, 0)); + const session = new Session(client, this.world, player_entity); + session.spin(); + } +} + +function* displacement_steps(position: Vec2, displacement: Vec2) { + let pos = position; + let left = displacement; + while (left.len() >= 1) { + if (left.x() >= 1) { + pos = pos.add(new Vec2(1, 0)); + left = left.sub(new Vec2(1, 0)); + } + if (left.x() <= -1) { + pos = pos.add(new Vec2(-1, 0)); + left = left.sub(new Vec2(-1, 0)); + } + if (left.y() >= 1) { + pos = pos.add(new Vec2(0, 1)); + left = left.sub(new Vec2(0, 1)); + } + if (left.y() <= -1) { + pos = pos.add(new Vec2(0, -1)); + left = left.sub(new Vec2(0, -1)); + } + yield pos; + } +} + +Deno.test("test_displacement", () => { + assertEquals( + [...displacement_steps(new Vec2(1, 1), new Vec2(4, 6))], + [new Vec2(2, 2), new Vec2(3, 3), new Vec2(4, 4), new Vec2(5, 5), new Vec2(5, 6), new Vec2(5, 7)], + ); +}); diff --git a/server/entities/enemy.ts b/server/entities/enemy.ts new file mode 100644 index 0000000..cd7b461 --- /dev/null +++ b/server/entities/enemy.ts @@ -0,0 +1,33 @@ +import { World, WorldEntity } from "../engine.ts"; +import { v2, Vec2 } from "../../common/utils.ts"; +import { wait } from "../../common/utils.ts"; + +export class Enemy { + entity; + alive; + + constructor(entity: WorldEntity) { + this.entity = entity; + this.alive = true; + } + + static spawn(world: World, pos: Vec2) { + const entity = world.spawn_entity("èé", pos); + } + + async spin() { + while (this.alive) { + await wait(500); + const target = this.find_target(); + } + } + + find_target() { + const world = this.entity.world; + const local_origin = this.entity.position; + const found = world.get_entities_in_range(local_origin.sub(v2(3, 3)), local_origin.add(v2(3, 3))); + for (const entity of found) { + // if (entity.compontents.get()) + } + } +} diff --git a/server/entities/player.ts b/server/entities/player.ts new file mode 100644 index 0000000..cd9b66b --- /dev/null +++ b/server/entities/player.ts @@ -0,0 +1,61 @@ +import { mts } from "../../common/mod.ts"; +import { log_from, Vec2 } from "../../common/utils.ts"; +import { World, WorldEntity } from "../engine.ts"; +import { ClientInterface } from "../network.ts"; +const log = log_from(import.meta); + +export class Session { + client; + world; + player_entity; + + constructor(client: ClientInterface, world: World, player_entity: WorldEntity) { + this.client = client; + this.world = world; + this.player_entity = player_entity; + } + + async spin() { + try { + for await (const input of this.client.inputs.iter()) { + if (input.kind !== "request_display") log("Received", input); + + if (input.kind === "ping") this.client.outputs.send({ kind: "ping_response", content: input.content }); + if (input.kind === "request_display") this.send_display(input.content.width, input.content.height); + if (input.kind === "input") this.handle_input(input); + if (input.kind === "exit") this.world.kill(this.player_entity.identifier); + } + } catch (error) { + console.error("Session loop failed, ", error); + this.world.kill(this.player_entity.identifier); + } + } + + send_display(width: number, height: number) { + const raw = this.world.render(this.player_entity.position, new Vec2(width, height)); + this.client.outputs.send({ kind: "display", content: { raw } }); + } + + handle_input(input: mts.MsgInput) { + if (input.content.control === "up") this.world.move_collide(this.player_entity.identifier, new Vec2(0, 1)); + if (input.content.control === "down") this.world.move_collide(this.player_entity.identifier, new Vec2(0, -1)); + if (input.content.control === "left") this.world.move_collide(this.player_entity.identifier, new Vec2(-1, 0)); + if (input.content.control === "right") this.world.move_collide(this.player_entity.identifier, new Vec2(1, 0)); + } +} + +export class PlayerComponent { + entity; + + constructor(entity: WorldEntity) { + this.entity = entity; + } + + move(direction: Vec2) { + return this.entity.world.move_collide(this.entity.identifier, direction); + } + + render(res: Vec2) { + return this.entity.world.render(this.entity.position, res); + } +} diff --git a/server/main.ts b/server/main.ts new file mode 100755 index 0000000..500c20c --- /dev/null +++ b/server/main.ts @@ -0,0 +1,24 @@ +#!/bin/env -S deno run --allow-net --allow-read + +import { log_from, Vec2 } from "../common/utils.ts"; +import { Engine, World } from "./engine.ts"; +import { Enemy } from "./entities/enemy.ts"; +import { Gateway } from "./network.ts"; +const log = log_from(import.meta); + +async function main() { + log("Starting."); + const world = new World(); + await world.spawn_structure("../data/structures/houses.txt", new Vec2(2, 2)); + const engine = new Engine(world); + engine.start(); + + Enemy.spawn(world, new Vec2(-3, -3)); + + const port = 9999; + const gateway = new Gateway(port); + log("Awaiting connection."); + for await (const session of gateway.accept()) engine.add_session(session); +} + +if (import.meta.main) await main(); diff --git a/server/network.ts b/server/network.ts new file mode 100644 index 0000000..719eb3a --- /dev/null +++ b/server/network.ts @@ -0,0 +1,50 @@ +#!/bin/env -S deno run --allow-net + +import { MsgToClient, MsgToServer, mts } from "../common/mod.ts"; +import { + channel, + launch_caught, + log_from, + parsed_stream, + Receiver, + Sender, + serialized_stream, +} from "../common/utils.ts"; +const log = log_from(import.meta); + +export class Gateway { + server; + constructor(port: number) { + this.server = Deno.listen({ port }); + log("Listening on", port); + } + async *accept() { + for await (const connection of this.server) { + const session = await ClientInterface.init(connection); + log("New session."); + yield session; + } + } +} + +export class ClientInterface { + inputs; + outputs; + + constructor(inputs: Receiver, outputs: Sender) { + this.inputs = inputs; + this.outputs = outputs; + } + + // deno-lint-ignore require-await + static async init(connection: Deno.Conn) { + // TODO : handshake ? + const [input_sender, input_receiver] = channel(); + const [output_sender, output_receiver] = channel(); + input_sender.send_all(parsed_stream(mts.message_to_server_parser())(connection.readable)) + .finally(() => input_sender.send({ kind: "exit" })); + serialized_stream(output_receiver.iter())(connection.writable) + .finally(() => input_sender.send({ kind: "exit" })); + return new ClientInterface(input_receiver, output_sender); + } +} diff --git a/server/watch.sh b/server/watch.sh new file mode 100755 index 0000000..350b253 --- /dev/null +++ b/server/watch.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e +cd "$(dirname "$(realpath "$0")")" + +nodemon -w ../common -w . -e ts -x 'clear && ./main.ts'