Skip to content

Commit

Permalink
feat(WIP): add sologame room, state, logic commands
Browse files Browse the repository at this point in the history
TODO:
- implement service layer for persistence, recovery(?), and retrieval

ref virtualcommons#856
  • Loading branch information
sgfost committed Jun 14, 2023
1 parent 389dad5 commit 0629194
Show file tree
Hide file tree
Showing 8 changed files with 311 additions and 1 deletion.
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"yamljs": "^0.3.0"
},
"dependencies": {
"@colyseus/command": "^0.2.1",
"@colyseus/schema": "^1.0.25",
"@sentry/node": "~5.29.2",
"@types/luxon": "^3.0.2",
Expand Down
2 changes: 1 addition & 1 deletion server/src/rooms/game/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ export class GameRoom extends Room<GameState> implements Game {
logger.debug(`GameRoom.onAuth found user ${username}`);
// save user ip address
const ip = (
(request.headers["x-forwarded-for"] || request.connection.remoteAddress) ??
(request.headers["x-forwarded-for"] || request.socket.remoteAddress) ??
""
).toString();
await getServices().account.setLastPlayerIp(user.id, ip);
Expand Down
161 changes: 161 additions & 0 deletions server/src/rooms/sologame/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import _ from "lodash";
import { Command } from "@colyseus/command";
import { User } from "@port-of-mars/server/entity";
import { SoloGameRoom } from "@port-of-mars/server/rooms/sologame";
import { getServices } from "@port-of-mars/server/services";
import { getRandomIntInclusive } from "@port-of-mars/server/util";

const { sologame: service } = getServices();

abstract class Cmd<Payload> extends Command<SoloGameRoom, Payload> {}
abstract class CmdWithoutPayload extends Cmd<Record<string, never>> {}

export class InitGameCmd extends Cmd<{ user: User }> {
execute({ user } = this.payload) {
return [
new SetPlayerCmd().setPayload({ user }),
new CreateDeckCmd(),
new SetTreatmentParamsCmd().setPayload({ user }),
new SetFirstRoundCmd(),
];
}
}

export class SetPlayerCmd extends Cmd<{ user: User }> {
execute({ user } = this.payload) {
this.state.player.assign({
username: user.username,
points: 0,
});
}
}

export class CreateDeckCmd extends CmdWithoutPayload {
async execute() {
const cards = await service.drawEventCardDeck();
this.state.eventCardDeck.concat(_.shuffle(cards));
}
}

export class SetTreatmentParamsCmd extends Cmd<{ user: User }> {
async execute({ user } = this.payload) {
// pick a random (unseen, if user hasn't been through them all) treatment configuration
const treatmentIds = await service.getUserRemainingTreatments(user.id);
if (treatmentIds.length > 0) {
const treatmentId = treatmentIds[Math.floor(Math.random() * treatmentIds.length)];
this.state.treatmentParams = await service.getTreatmentById(treatmentId);
} else {
this.state.treatmentParams = await service.getRandomTreatment();
}
}
}

export class SetFirstRoundCmd extends CmdWithoutPayload {
execute() {
// FIXME: pull these from defaults somewhere
this.state.maxRound = getRandomIntInclusive(6, 14);
this.state.twoCardThreshold = getRandomIntInclusive(12, 20);
// this formula may need tweaking
const threeCardThresholdMax = Math.min(15, this.state.twoCardThreshold - 3);
this.state.threeCardThreshold = getRandomIntInclusive(5, threeCardThresholdMax);
this.state.round = 1;
this.state.systemHealth = 25;
this.state.timeRemaining = 30;
this.state.player.resources = 7;
}
}

export class ApplyCardCmd extends Cmd<{ playerSkipped: boolean }> {
validate() {
return (
this.state.activeRoundCardIndex >= 0 &&
this.state.activeRoundCardIndex < this.state.roundEventCards.length
);
}

execute({ playerSkipped } = this.payload) {
if (playerSkipped) {
this.room.eventTimeout?.clear();
}
this.state.player.points += this.state.activeRoundCard!.pointsDelta;
this.state.player.resources += this.state.activeRoundCard!.resourcesDelta;
this.state.systemHealth += this.state.activeRoundCard!.systemHealthDelta;
if (this.state.systemHealth <= 0) {
return new EndGameCmd().setPayload({ status: "defeat" });
}

// if we still have cards left, prepare the next one
if (this.state.activeRoundCardIndex < this.state.roundEventCards.length - 1) {
this.state.activeRoundCardIndex += 1;
return new StartEventTimerCmd();
}
}
}

export class StartEventTimerCmd extends CmdWithoutPayload {
execute() {
this.room.eventTimeout = this.clock.setTimeout(() => {
return new ApplyCardCmd().setPayload({ playerSkipped: false });
}, 10 * 1000);
}
}

export class DrawCardsCmd extends CmdWithoutPayload {
execute() {
let drawCount = 1;
if (this.state.systemHealth > this.state.twoCardThreshold) {
drawCount = 1;
} else if (this.state.systemHealth > this.state.threeCardThreshold) {
drawCount = 2;
} else {
drawCount = 3;
}
this.state.roundEventCards.concat(this.state.eventCardDeck.splice(0, drawCount));
// draw 2 more if murphy's law is in play
if (this.state.roundEventCards.some(card => card.isMurphysLaw)) {
this.state.roundEventCards.concat(this.state.eventCardDeck.splice(0, 2));
}
this.state.timeRemaining += 10 * drawCount;
this.state.activeRoundCardIndex = 0;
return new StartEventTimerCmd();
}
}

export class InvestCmd extends Cmd<{
systemHealthInvestment: number;
pointsInvestment: number;
}> {
validate({ systemHealthInvestment, pointsInvestment } = this.payload) {
return this.state.resources === systemHealthInvestment + pointsInvestment;
}

execute({ systemHealthInvestment, pointsInvestment } = this.payload) {
this.state.systemHealth = Math.min(25, this.state.systemHealth + systemHealthInvestment);
this.state.player.points += pointsInvestment;
return new SetNextRoundCmd();
}
}

export class SetNextRoundCmd extends CmdWithoutPayload {
execute() {
// TODO: persist round
this.state.round += 1;
if (this.state.round > this.state.maxRound) {
return new EndGameCmd().setPayload({ status: "victory" });
}
if (this.state.systemHealth <= 5) {
return new EndGameCmd().setPayload({ status: "defeat" });
}
this.state.systemHealth -= 5; // pull these from defaults somewhere
this.state.player.resources = 7;
this.state.timeRemaining = 30;
}
}

export class EndGameCmd extends Cmd<{ status: string }> {
execute({ status } = this.payload) {
// do any cleanup
this.state.status = status;
this.room.disconnect();
}
}
73 changes: 73 additions & 0 deletions server/src/rooms/sologame/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Client, Delayed, Room } from "colyseus";
import { Dispatcher } from "@colyseus/command";
import * as http from "http";
import { SoloGameState } from "@port-of-mars/server/rooms/sologame/state";
import { settings } from "@port-of-mars/server/settings";
import { getServices } from "@port-of-mars/server/services";
import { ApplyCardCmd, InitGameCmd, InvestCmd, SetNextRoundCmd } from "./commands";
import { User } from "@port-of-mars/server/entity";

const logger = settings.logging.getLogger(__filename);

export class SoloGameRoom extends Room<SoloGameState> {
public static get NAME() {
return "solo_game_room";
}
autoDispose = true;
maxClients = 1;
patchRate = 1000 / 5; // sends state to client 5 times per second

dispatcher = new Dispatcher(this);
eventTimeout: Delayed | null = null;

onCreate(options: any) {
// do we need any options? most things are set up after onJoin is called
logger.trace("SoloGameRoom '%s' created", this.roomId);
this.setState(new SoloGameState());
this.setPrivate(true);
this.registerAllHandlers();
this.clock.setInterval(() => {
this.state.timeRemaining -= 1;
if (this.state.timeRemaining <= 0) {
this.dispatcher.dispatch(new SetNextRoundCmd());
}
}, 1000);
}

async onAuth(client: Client, options: any, request?: http.IncomingMessage) {
try {
const user = await getServices().account.findUserById((request as any).session.passport.user);
if (user.isBanned) {
logger.info("Banned user %s attempted to join", user.username);
return false;
}
return user;
} catch (e) {
logger.fatal("Unable to authenticate user: %s", e);
}
return false;
}

onJoin(client: Client, options: any, auth: User) {
logger.trace("Client %s joined SoloGameRoom %s", auth.username, this.roomId);
this.dispatcher.dispatch(new InitGameCmd());
}

onDispose() {
this.dispatcher.stop();
}

registerAllHandlers() {
this.onMessage("event-continue", (client, message) => {
this.dispatcher.dispatch(new ApplyCardCmd().setPayload({ playerSkipped: true }));
});
this.onMessage("invest", (client, message) => {
this.dispatcher.dispatch(
new InvestCmd().setPayload({
systemHealthInvestment: message.systemHealthInvestment,
pointsInvestment: message.pointsInvestment,
})
);
});
}
}
56 changes: 56 additions & 0 deletions server/src/rooms/sologame/state/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Schema, ArraySchema, type } from "@colyseus/schema";

export class EventCard extends Schema {
@type("string") codeName = "";
@type("string") displayName = "";
@type("string") description = "";
@type("int8") pointsDelta = 0;
@type("int8") resourcesDelta = 0;
@type("int8") systemHealthDelta = 0;

get isMurphysLaw() {
return this.codeName === "murphys-law";
}
}

export class Player extends Schema {
@type("string") username = "";
@type("uint8") resources = 0;
@type("uint8") points = 0;
}

export class TreatmentParams extends Schema {
@type("boolean") isKnownNumberOfRounds = false;
@type("boolean") isEventDeckKnown = false;
@type("string") thresholdInformation: "unknown" | "range" | "known" = "unknown";
}

export class SoloGameState extends Schema {
// FIXME: these should come from defaults as well
@type("string") status = "incomplete";
@type("int8") systemHealth = 25;
@type("uint8") twoCardThreshold = 15;
@type("uint8") threeCardThreshold = 8;
@type("uint8") timeRemaining = 30;
@type("uint8") round = 1;
@type("uint8") maxRound = 0;
@type(TreatmentParams) treatmentParams = new TreatmentParams();
@type(Player) player!: Player;
@type([EventCard]) eventCardDeck = new ArraySchema<EventCard>();
@type([EventCard]) roundEventCards = new ArraySchema<EventCard>();
@type("uint8") activeRoundCardIndex = -1;

get points() {
if (this.player) return this.player.points;
}

get resources() {
if (this.player) return this.player.resources;
}

get activeRoundCard() {
if (this.activeRoundCardIndex >= 0 && this.activeRoundCardIndex < this.roundEventCards.length) {
return this.roundEventCards[this.activeRoundCardIndex];
}
}
}
9 changes: 9 additions & 0 deletions server/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { StatsService } from "@port-of-mars/server/services/stats";
import { getConnection } from "@port-of-mars/server/util";
import { TimeService } from "@port-of-mars/server/services/time";
import { GameService } from "@port-of-mars/server/services/game";
import { SoloGameService } from "@port-of-mars/server/services/sologame";
import { RedisSettings } from "@port-of-mars/server/services/settings";
import { createClient, RedisClient } from "redis";

Expand Down Expand Up @@ -39,6 +40,14 @@ export class ServiceProvider {
return this._game;
}

private _sologame?: SoloGameService;
get sologame(): SoloGameService {
if (!this._sologame) {
this._sologame = new SoloGameService(this);
}
return this._sologame;
}

private _quiz?: QuizService;
get quiz() {
if (!this._quiz) {
Expand Down
3 changes: 3 additions & 0 deletions server/src/services/sologame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { BaseService } from "@port-of-mars/server/services/db";

export class SoloGameService extends BaseService {}
7 changes: 7 additions & 0 deletions server/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,13 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==

"@colyseus/command@^0.2.1":
version "0.2.1"
resolved "https://registry.yarnpkg.com/@colyseus/command/-/command-0.2.1.tgz#3d287da435e0286f93b164a5985bfff3224901bd"
integrity sha512-kwNvJTNDRshuxfcKZyFoO0hpcaUQKTpncl551eLsZr8xgAiu2uyhmuQTcsPYmrDZ1fV8MxcbBlEfu8H+4JtbhA==
dependencies:
debug "^4.1.1"

"@colyseus/core@^0.14.20", "@colyseus/core@^0.14.33":
version "0.14.36"
resolved "https://registry.yarnpkg.com/@colyseus/core/-/core-0.14.36.tgz#651c1a13ee72b781798e29daa3af050c32bff113"
Expand Down

0 comments on commit 0629194

Please sign in to comment.