Skip to content
network.ts 6.17 KiB
Newer Older
Grant's avatar
Grant committed
import { Socket, io } from "socket.io-client";
import EventEmitter from "eventemitter3";
Grant's avatar
Grant committed
import {
  AuthSession,
Grant's avatar
Grant committed
  ClientConfig,
  ClientToServerEvents,
  IAccountStanding,
Grant's avatar
Grant committed
  ServerToClientEvents,
} from "@sc07-canvas/lib/src/net";
import { toast } from "react-toastify";
import { handleAlert, handleDismiss } from "./alerts";
Grant's avatar
Grant committed
import { Recaptcha } from "./recaptcha";
Grant's avatar
Grant committed

export interface INetworkEvents {
  connected: () => void;
  disconnected: () => void;

Grant's avatar
Grant committed
  user: (user: AuthSession) => void;
  standing: (standing: IAccountStanding) => void;
Grant's avatar
Grant committed
  config: (user: ClientConfig) => void;
Grant's avatar
Grant committed
  canvas: (
    start: [x: number, y: number],
    end: [x: number, y: number],
    pixels: string[]
  ) => void;
Grant's avatar
Grant committed
  pixels: (data: { available: number }) => void;
  pixelLastPlaced: (time: number) => void;
Grant's avatar
Grant committed
  online: (count: number) => void;
  pixel: (pixel: Pixel) => void;
Grant's avatar
Grant committed
  square: (
    start: [x: number, y: number],
    end: [x: number, y: number],
    color: number
  ) => void;
Grant's avatar
Grant committed
  undo: (
    data: { available: false } | { available: true; expireAt: number }
  ) => void;
Grant's avatar
Grant committed
}

type SentEventValue<K extends keyof INetworkEvents> = EventEmitter.ArgumentMap<
  Exclude<INetworkEvents, string | symbol>
>[Extract<K, keyof INetworkEvents>];

class Network extends EventEmitter<INetworkEvents> {
  socket: Socket<ServerToClientEvents, ClientToServerEvents> = io("", {
    autoConnect: false,
    withCredentials: true,
    reconnection: true,
  });
Grant's avatar
Grant committed
  private online_count = 0;
  private stateEvents: {
Grant's avatar
Grant committed
    [key in keyof INetworkEvents]?: SentEventValue<key>;
  } = {};

Grant's avatar
Grant committed
  private canvasChunks: {
    start: [number, number];
    end: [number, number];
    pixels: string[];
  }[] = [];

Grant's avatar
Grant committed
  constructor() {
    super();

    this.socket.on("connect", () => {
      console.log("Connected to server");
      toast.success("Connected to server");
      this.emit("connected");
    });

    this.socket.on("connect_error", (err) => {
      // TODO: proper error handling
      console.error("Failed to connect to server", err);
      toast.error("Failed to connect: " + (err.message || err.name));
    });

    this.socket.on("disconnect", (reason, desc) => {
      console.log("Disconnected from server", reason, desc);
      toast.warn("Disconnected from server");
      this.emit("disconnected");
    });

Grant's avatar
Grant committed
    this.socket.io.on("reconnect", (attempt) => {
      console.log("Reconnected to server on attempt " + attempt);
    });

    this.socket.io.on("reconnect_attempt", (attempt) => {
      console.log("Reconnect attempt " + attempt);
    });

    this.socket.io.on("reconnect_error", (err) => {
      console.log("Reconnect error", err);
    });

    this.socket.io.on("reconnect_failed", () => {
      console.log("Reconnect failed");
    });

Grant's avatar
Grant committed
    this.socket.on("recaptcha", (site_key) => {
      Recaptcha.load(site_key);
    });

    this.socket.on("recaptcha_challenge", (ack) => {
      Recaptcha.executeChallenge(ack);
    });

    this.socket.on("user", (user) => {
Grant's avatar
Grant committed
      this.emit("user", user);
    });

    this.socket.on("standing", (standing) => {
      this.acceptState("standing", standing);
    });

Grant's avatar
Grant committed
    this.socket.on("config", (config) => {
      console.info("Server sent config", config);

      if (config.version !== __COMMIT_HASH__) {
        toast.info("Client version does not match server, reloading...");
        console.warn("Client version does not match server, reloading...", {
          clientVersion: __COMMIT_HASH__,
          serverVersion: config.version,
        });
        window.location.reload();
      }

Grant's avatar
Grant committed
      this.emit("config", config);
    });

Grant's avatar
Grant committed
    this.socket.on("canvas", (start, end, pixels) => {
      // this.acceptState("canvas", start, end, pixels);
      this.emit("canvas", start, end, pixels);
      this.canvasChunks.push({ start, end, pixels });
    });

    this.socket.on("clearCanvasChunks", () => {
      this.canvasChunks = [];
Grant's avatar
Grant committed
    });

Grant's avatar
Grant committed
    this.socket.on("availablePixels", (count) => {
      this.acceptState("pixels", { available: count });
Grant's avatar
Grant committed
    });

    this.socket.on("pixelLastPlaced", (time) => {
      this.acceptState("pixelLastPlaced", time);
Grant's avatar
Grant committed
    });

Grant's avatar
Grant committed
    this.socket.on("online", ({ count }) => {
      this.acceptState("online", count);
Grant's avatar
Grant committed
    });

    this.socket.on("pixel", (pixel) => {
      this.emit("pixel", pixel);
    });

Grant's avatar
Grant committed
    this.socket.on("square", (...square) => {
      this.emit("square", ...square);
    });

Grant's avatar
Grant committed
    this.socket.on("undo", (undo) => {
      this.emit("undo", undo);
    });

    this.socket.on("heatmap", (heatmap) => {
      this.emit("heatmap", heatmap);
    });

    this.socket.on("alert", handleAlert);
    this.socket.on("alert_dismiss", handleDismiss);
  }

  subscribe(subscription: Subscription) {
    this.socket.emit("subscribe", subscription);
  }

  unsubscribe(subscription: Subscription) {
    this.socket.emit("unsubscribe", subscription);
Grant's avatar
Grant committed
  /**
   * Track events that we only care about the most recent version of
   *
   * Used by #waitFor
   *
   * @param event
   * @param args
   * @returns
   */
  acceptState: typeof this.emit = (event, ...args) => {
    this.stateEvents[event] = args;
Grant's avatar
Grant committed
    return this.emit(event, ...args);
  };

  /**
   * Discard the existing state-like event, if it exists in cache
   * @param ev
   */
  clearPreviousState<Ev extends keyof INetworkEvents & (string | symbol)>(
    ev: Ev
  ) {
    delete this.stateEvents[ev];
Grant's avatar
Grant committed
  getCanvasChunks() {
    return this.canvasChunks;
  }

  /**
   * Wait for event, either being already sent, or new one
   *
   * Used for state-like events
   *
   * @param ev
   * @returns
   */
  waitForState<Ev extends keyof INetworkEvents & (string | symbol)>(
Grant's avatar
Grant committed
    ev: Ev
  ): Promise<SentEventValue<Ev>> {
    return new Promise((res) => {
      if (this.stateEvents[ev]) return res(this.stateEvents[ev]!);
Grant's avatar
Grant committed

      this.once(ev, (...data) => {
        res(data);
      });
    });
  }

  /**
   * Get current value of state event
   * @param event
   * @returns
   */
  getState<Ev extends keyof INetworkEvents>(
    event: Ev
  ): SentEventValue<Ev> | undefined {
    return this.stateEvents[event];
  }

Grant's avatar
Grant committed
  /**
   * Get online user count
   * @returns online users count
   */
  getOnline() {
    return this.online_count;
  }
}

export default new Network() as Network;