diff options
author | Joel Kronqvist <joel.kronqvist@iki.fi> | 2024-11-17 13:45:44 +0200 |
---|---|---|
committer | Joel Kronqvist <joel.kronqvist@iki.fi> | 2024-11-17 13:45:44 +0200 |
commit | 4de67b497e0e229fe4a42f66f833640b6e50fd5a (patch) | |
tree | 34fb5b0e776f7cd3adcb4556f4d6a7c8ad66de39 /src/scalevalapokalypsi | |
parent | 8595e892abc0e0554f589ed2eb88c351a347fbd4 (diff) | |
download | scalevalapokalypsi-4de67b497e0e229fe4a42f66f833640b6e50fd5a.tar.gz scalevalapokalypsi-4de67b497e0e229fe4a42f66f833640b6e50fd5a.zip |
Moved the project to an IDEA project & wrote part of README.txt
Diffstat (limited to 'src/scalevalapokalypsi')
-rw-r--r-- | src/scalevalapokalypsi/Client/Client.scala | 178 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Client/ReceivedLineParser.scala | 27 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Client/StdinLineReader.scala | 31 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Client/Turn.scala | 32 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Model/Action.scala | 69 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Model/Adventure.scala | 79 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Model/Area.scala | 117 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Model/Entity.scala | 103 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Model/Item.scala | 20 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Server/Client.scala | 173 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Server/Clients.scala | 82 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Server/ConnectionGetter.scala | 25 | ||||
-rw-r--r-- | src/scalevalapokalypsi/Server/Server.scala | 195 | ||||
-rw-r--r-- | src/scalevalapokalypsi/constants/constants.scala | 19 | ||||
-rw-r--r-- | src/scalevalapokalypsi/main.scala | 27 | ||||
-rw-r--r-- | src/scalevalapokalypsi/utils/utils.scala | 28 |
16 files changed, 1205 insertions, 0 deletions
diff --git a/src/scalevalapokalypsi/Client/Client.scala b/src/scalevalapokalypsi/Client/Client.scala new file mode 100644 index 0000000..41b1003 --- /dev/null +++ b/src/scalevalapokalypsi/Client/Client.scala @@ -0,0 +1,178 @@ +package scalevalapokalypsi.Client + +import java.lang.Thread.sleep +import java.net.Socket +import scala.io.Source +import scala.sys.process.stdout +import scalevalapokalypsi.constants.* +import scalevalapokalypsi.utils.{stringToByteArray,getNCharsFromSocket} +import scalevalapokalypsi.Client.{ReceivedLineParser,StdinLineReader,Turn} +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global +import scala.util.{Try, Success, Failure} +import scala.collection.mutable.Buffer +import java.lang.System.currentTimeMillis + + +/** A helper enum for `Client` to keep track of communications with the server + */ +enum ServerLineState: + case WaitingForTimeLimit, + ActionDescription, + TurnIndicator, + AreaDescription, + Directions, + Items, + Entities + + +/** Creates a new client. + * + * @param name the name the client and its player should have + * @ip the ip of the server to connect to + * @port the port of the server to connect to + * @return the client created, if all was successful + */ +def newClient(name: String, ip: String, port: Int): Option[Client] = + val socket = Socket(ip, port) + val output = socket.getOutputStream + val input = socket.getInputStream + val initMsg = s"$GAME_VERSION\r\n$name\r\n" + output.write(stringToByteArray(initMsg)) + val msgLen = (PROTOCOL_VERSION_GOOD + "\r\n").length + val versionResponse = getNCharsFromSocket(input, msgLen) + if versionResponse == Some(s"$PROTOCOL_VERSION_GOOD\r\n") then + Some(Client(socket)) + else + None + + + +/** Main class for the client: handles communication with the server + * and the player. Should be initialized with `newClient`. + * + * @param socket the socket the client uses + */ +class Client(socket: Socket): + + /** Essential IO variables */ + private val input = socket.getInputStream + private val output = socket.getOutputStream + private val buffer: Array[Byte] = Array.ofDim(MAX_MSG_SIZE) + private var bufferIndex = 0 + private val serverLineParser = ReceivedLineParser() + private val stdinReader = StdinLineReader() + + private var serverLineState = ServerLineState.WaitingForTimeLimit + + /** Variables about the status of the current turn for the client */ + private var canAct = false + private var timeLimit: Long = 0 + private var lastTurnStart: Long = 0 + private var lastExecutedTurn: Long = 0 + assert( + lastTurnStart <= lastExecutedTurn, + "don't initialize with unexecuted turn" + ) + private val turnInfo = Turn() + + + /** Starts the client. This shouldn't terminate. */ + def startClient(): Unit = + + stdinReader.startReading() + + while true do + sleep(POLL_INTERVAL) + + this.readAndParseDataFromServer() + + if this.lastExecutedTurn < this.lastTurnStart then + print(this.giveTurn()) + + stdinReader.newLine().foreach((s: String) => + output.write(stringToByteArray(s+"\r\n")) + ) + + end startClient + + + private def readAndParseDataFromServer(): Unit = + var availableBytes = input.available() + while availableBytes != 0 do + val bytesRead = input.read(buffer, 0, availableBytes) + if bytesRead != -1 then + // TODO: unsafe conversion + parseDataFromServer(buffer.take(bytesRead)) + availableBytes = input.available() + + private def giveTurn(): String = + this.canAct = true + this.lastExecutedTurn = currentTimeMillis / 1000 + s"\n\n${this.turnInfo}\n${this.actionGetterIndicator}" + + private def displayAction(action: String): Unit = + println(s"$action") + if this.canAct then + print(this.actionGetterIndicator) + + private def actionGetterIndicator = + val timeOfTurnEnd = this.lastTurnStart + this.timeLimit + val timeToTurnEnd = -currentTimeMillis()/1000 + timeOfTurnEnd + s"[$timeToTurnEnd]> " + + + + private def parseDataFromServer(data: Array[Byte]): Unit = + this.serverLineParser.in(data) + var nextLine: Option[String] = Some("") + while nextLine.isDefined do + nextLine = this.serverLineParser + .nextLine() + nextLine + .foreach(this.parseLineFromServer(_)) + + + private def parseLineFromServer(line: String) = + + if line == TURN_INDICATOR then + this.serverLineState = ServerLineState.TurnIndicator + + serverLineState match + + case ServerLineState.WaitingForTimeLimit => + val time = line.toLongOption + time match + case Some(t) => this.timeLimit = t + case None => print("Invalid time limit, oh no!!!") + this.serverLineState = ServerLineState.TurnIndicator + this.lastTurnStart = currentTimeMillis / 1000 + + case ServerLineState.ActionDescription => + if line.nonEmpty && line.head == ACTION_BLOCKING_INDICATOR then + this.canAct = false + this.displayAction(line.tail) + + case ServerLineState.TurnIndicator => + this.serverLineState = ServerLineState.AreaDescription + + case ServerLineState.AreaDescription => + this.turnInfo.areaDescription = line + this.serverLineState = ServerLineState.Directions + + case ServerLineState.Directions => + this.turnInfo.possibleDirections = line.split(LIST_SEPARATOR) + this.serverLineState = ServerLineState.Items // TODO: maybe use a list instead? + + case ServerLineState.Items => + this.turnInfo.visibleItems = line.split(LIST_SEPARATOR) + this.serverLineState = ServerLineState.Entities + + case ServerLineState.Entities => + this.turnInfo.visibleEntities = line.split(LIST_SEPARATOR) + this.serverLineState = ServerLineState.ActionDescription + this.lastTurnStart = currentTimeMillis() / 1000 + + end parseLineFromServer + +end Client diff --git a/src/scalevalapokalypsi/Client/ReceivedLineParser.scala b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala new file mode 100644 index 0000000..dfcc2d2 --- /dev/null +++ b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala @@ -0,0 +1,27 @@ +package scalevalapokalypsi.Client + +import scala.collection.mutable.Buffer +import scalevalapokalypsi.constants.* + +/** A class for checking asynchronously for received lines */ +class ReceivedLineParser: + + private var serverLineState = ServerLineState.ActionDescription + + private var bufferedData: Buffer[Byte] = Buffer.empty // TODO: suboptimal DS + + /** Add received data */ + def in(data: Array[Byte]): Unit = + this.bufferedData ++= data + + /** Read a line from the received data */ + def nextLine(): Option[String] = + val indexOfCRLF = this.bufferedData.indexOfSlice(CRLF) + if indexOfCRLF == -1 then + None + else + val splitData = this.bufferedData.splitAt(indexOfCRLF) + this.bufferedData = Buffer.from(splitData(1).drop(CRLF.length)) + Some(String(splitData(0).toArray)) + +end ReceivedLineParser diff --git a/src/scalevalapokalypsi/Client/StdinLineReader.scala b/src/scalevalapokalypsi/Client/StdinLineReader.scala new file mode 100644 index 0000000..6ba8761 --- /dev/null +++ b/src/scalevalapokalypsi/Client/StdinLineReader.scala @@ -0,0 +1,31 @@ +package scalevalapokalypsi.Client + +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global +import scala.io.StdIn.readLine +import scala.util.{Try, Success, Failure} + +/** This class is for taking new lines from stdin when they are available. + * reading starts when either newLine or clear or startReading are called. + */ +class StdinLineReader: + + private var nextLine: Future[String] = Future.failed(Exception()) + + /** Returns a new line of input if there are any. */ + def newLine(): Option[String] = + this.nextLine.value match + case Some(Success(s)) => + this.startReading() + Some(s) + case Some(Failure(e)) => + this.startReading() + None + case None => None + + /** Discards the line that is currently being read and restarts reading */ + def startReading(): Unit = + this.nextLine = Future(readLine()) + + +end StdinLineReader diff --git a/src/scalevalapokalypsi/Client/Turn.scala b/src/scalevalapokalypsi/Client/Turn.scala new file mode 100644 index 0000000..30101c5 --- /dev/null +++ b/src/scalevalapokalypsi/Client/Turn.scala @@ -0,0 +1,32 @@ +package scalevalapokalypsi.Client + +/** `Turn`s represent information the client has got about a turn. + * This class exists essentially so that the client has somewhere + * to store data about turns and something to format that data with. + */ +class Turn: + + /** Description of the area the player controlled by the client is in + * at the end of the turn. */ + var areaDescription: String = "" + + /** Directions the player controlled by the client can go to. */ + var possibleDirections: Array[String] = Array.empty + + /** Items the player controlled by the client can see. */ + var visibleItems: Array[String] = Array.empty + + /** Entities the player controlled by the client can see. */ + var visibleEntities: Array[String] = Array.empty + + override def toString: String = + val itemDesc = "You can see the following items: " + + this.visibleItems.mkString(", ") + val entityDesc = "The following entities reside in the room: " + + this.visibleEntities.mkString(", ") + val directionDesc = "There are exits to " + + this.possibleDirections.mkString(", ") + (s"$areaDescription\n$directionDesc\n" + + s"\n$itemDesc\n$entityDesc") + +end Turn diff --git a/src/scalevalapokalypsi/Model/Action.scala b/src/scalevalapokalypsi/Model/Action.scala new file mode 100644 index 0000000..32f513d --- /dev/null +++ b/src/scalevalapokalypsi/Model/Action.scala @@ -0,0 +1,69 @@ +package scalevalapokalypsi.Model + +/** The class `Action` represents actions that a player may take in a text adventure game. + * `Action` objects are constructed on the basis of textual commands and are, in effect, + * parsers for such commands. An action object is immutable after creation. + * @param input a textual in-game command such as “go east” or “rest” */ +class Action(input: String): + + private val commandText = input.trim.toLowerCase + private val verb = commandText.takeWhile( _ != ' ' ) + private val modifiers = commandText.drop(verb.length).trim + + def takesATurnFor(actor: Player): Boolean = + this.verb match + case "rest" => true + case "go" => actor.location.hasNeighbor(modifiers) + case "get" => actor.location.hasItem(this.modifiers) + case "drop" => actor.canDrop(this.modifiers) + case "say" => false + case other => false + + /** Causes the given player to take the action represented by this object, assuming + * that the command was understood. Returns a description of what happened as a result + * of the action (such as “You go west.”). The description is returned in an `Option` + * wrapper; if the command was not recognized, `None` is returned. */ + def execute(actor: Player): Option[String] = + val oldLocation = actor.location + val resOption: Option[(String, String)] = this.verb match + case "go" => Some(actor.go(this.modifiers)) + case "rest" => Some(actor.rest()) + case "get" => Some(actor.pickUp(this.modifiers)) + case "say" => + val to = "to" + val recipient = modifiers.reverse.takeWhile(_ != ' ').reverse + val recipientEntity = actor.location.getEntity(recipient) + val maybeTo = modifiers.slice( + modifiers.length - recipient.length - s"$to ".length, + modifiers.length - recipient.length - 1 + ) + val message = + modifiers.take(modifiers.length - recipient.length - 4) + if maybeTo == to then + recipientEntity.map(actor.sayTo(_, message)) + else + Some(actor.say(modifiers)) + case "drop" => Some(actor.drop(this.modifiers)) + case "xyzzy" => Some(( + "The grue tastes yummy.", + s"${actor.name} tastes some grue.") + ) + case other => None + +// println(resOption) +// println(actor.location.getEntities) + resOption.map(_(1)).filter(_.length > 0) + .foreach(s => + actor.location.getEntities.filter(_ != actor).foreach(_.observe(s)) + if oldLocation != actor.location then + oldLocation.getEntities.foreach(_.observe(s)) + ) + + resOption.map(_(0)) + + + /** Returns a textual description of the action object, for debugging purposes. */ + override def toString = s"$verb (modifiers: $modifiers)" + +end Action + diff --git a/src/scalevalapokalypsi/Model/Adventure.scala b/src/scalevalapokalypsi/Model/Adventure.scala new file mode 100644 index 0000000..2bf6cfe --- /dev/null +++ b/src/scalevalapokalypsi/Model/Adventure.scala @@ -0,0 +1,79 @@ +package scalevalapokalypsi.Model + +import scala.collection.mutable.Map + +/** The class `Adventure` represents text adventure games. An adventure consists of a player and + * a number of areas that make up the game world. It provides methods for playing the game one + * turn at a time and for checking the state of the game. + * + * N.B. This version of the class has a lot of “hard-coded” information that pertains to a very + * specific adventure game that involves a small trip through a twisted forest. All newly created + * instances of class `Adventure` are identical to each other. To create other kinds of adventure + * games, you will need to modify or replace the source code of this class. */ +class Adventure(val playerNames: Vector[String]): + + private val middle = Area("Forest", "You are somewhere in the forest. There are a lot of trees here.\nBirds are singing.") + private val northForest = Area("Forest", "You are somewhere in the forest. A tangle of bushes blocks further passage north.\nBirds are singing.") + private val southForest = Area("Forest", "The forest just goes on and on.") + private val clearing = Area("Forest Clearing", "You are at a small clearing in the middle of forest.\nNearly invisible, twisted paths lead in many directions.") + private val tangle = Area("Tangle of Bushes", "You are in a dense tangle of bushes. It's hard to see exactly where you're going.") + private val home = Area("Home", "Home sweet home! Now the only thing you need is a working remote control.") + private val destination = home + + middle.setNeighbors(Vector("north" -> northForest, "east" -> tangle, "south" -> southForest, "west" -> clearing)) + northForest.setNeighbors(Vector("east" -> tangle, "south" -> middle, "west" -> clearing)) + southForest.setNeighbors(Vector("north" -> middle, "east" -> tangle, "south" -> southForest, "west" -> clearing)) + clearing.setNeighbors(Vector("north" -> northForest, "east" -> middle, "south" -> southForest, "west" -> northForest)) + tangle.setNeighbors(Vector("north" -> northForest, "east" -> home, "south" -> southForest, "west" -> northForest)) + home.setNeighbors(Vector("west" -> tangle)) + + clearing.addItem(Item("battery", "It's a small battery cell. Looks new.")) + southForest.addItem(Item( + "remote", + "It's the remote control for your TV.\n" + + "What it was doing in the forest, you have no idea.\n" + + "Problem is, there's no battery." + )) + + val players: Map[String, Player] = Map() + playerNames.foreach(this.addPlayer(_)) + + val entities: Map[String, Entity] = Map() + private val gruu = Entity("Gruu", northForest) + northForest.addEntity(gruu) + this.entities += gruu.name -> gruu + + /** Adds a player entity with the specified name to the game. + * + * @param name the name of the player entity to add + * @return the created player entity + */ + def addPlayer(name: String): Player = + val newPlayer = Player(name, middle) + middle.addEntity(newPlayer) + players += name -> newPlayer + newPlayer + + /** Gets the player entity with the specified name. + * + * @param name name of the player to find + * @return the player, if one with the name was found + */ + def getPlayer(name: String): Option[Player] = this.players.get(name) + + def getEntity[A >: Entity](name: String) = + this.players.getOrElse(name, this.entities.get(name)) + + /** Returns a message that is to be displayed to the player at the beginning of the game. */ + def welcomeMessage = "Generic welcome message" + + /** Plays a turn by executing the given in-game command, such as “go west”. Returns a textual + * report of what happened, or an error message if the command was unknown. In the latter + * case, no turns elapse. */ + def playTurnOfPlayer(playerName: String, command: String): Option[String] = + val action = Action(command) + val actor = this.players.get(playerName) + actor.flatMap(action.execute(_)) + +end Adventure + diff --git a/src/scalevalapokalypsi/Model/Area.scala b/src/scalevalapokalypsi/Model/Area.scala new file mode 100644 index 0000000..c891af8 --- /dev/null +++ b/src/scalevalapokalypsi/Model/Area.scala @@ -0,0 +1,117 @@ +package scalevalapokalypsi.Model + +import scala.collection.mutable.Map + +/** The class `Area` represents locations in a text adventure game world. A game world + * consists of areas. In general, an “area” can be pretty much anything: a room, a building, + * an acre of forest, or something completely different. What different areas have in + * common is that players can be located in them and that they can have exits leading to + * other, neighboring areas. An area also has a name and a description. + * @param name the name of the area + * @param description a basic description of the area (typically not including information about items) */ +class Area(val name: String, var description: String): + + private val neighbors = Map[String, Area]() + private val items: Map[String, Item] = Map() + private val entities: Map[String, Entity] = Map() + + /** Returns the area that can be reached from this area by moving in the given direction. The result + * is returned in an `Option`; `None` is returned if there is no exit in the given direction. */ + def neighbor(direction: String): Option[Area] = + this.neighbors.get(direction) + + def getNeighborNames: Iterable[String] = this.neighbors.keys + def getItemNames: Iterable[String] = this.items.keys + def getEntityNames: Iterable[String] = this.entities.values.map(_.name) + def getEntity(name: String): Option[Entity] = this.entities.get(name) + def getEntities: Iterable[Entity] = this.entities.values + + /** Tells whether this area has a neighbor in the given direction. + * + * @param direction the direction to check + * @return whether there is a neighbor in the direction + */ + def hasNeighbor(direction: String): Boolean = + this.neighbors.contains(direction) + + /** Adds an exit from this area to the given area. The neighboring area is reached by moving in + * the specified direction from this area. */ + def setNeighbor(direction: String, neighbor: Area) = + this.neighbors += direction -> neighbor + + /** Adds exits from this area to the given areas. Calling this method is equivalent to calling + * the `setNeighbor` method on each of the given direction–area pairs. + * @param exits contains pairs consisting of a direction and the neighboring area in that direction + * @see [[setNeighbor]] */ + def setNeighbors(exits: Vector[(String, Area)]) = + this.neighbors ++= exits + + /** Adds the specified item + * + * @param item the item to add + */ + def addItem(item: Item): Unit = this.items += item.name -> item + + /** Adds multiple items + * + * @param items a once iterable collection of items to add + */ + def addItems(items: IterableOnce[Item]) = + items.iterator.foreach(i => this.items += i.name -> i) + + def hasItem(itemName: String) = this.items.contains(itemName) + + + /** Removes the specified item if it exists. + * + * @param itemName the name of the item to remove + * @return an option containing the removed item + */ + def removeItem(itemName: String): Option[Item] = + this.items.remove(itemName) + + /** Adds the specified entity to the area. + * + * @param entity the entity to add. + */ + def addEntity(entity: Entity): Unit = + this.entities += entity.name.toLowerCase -> entity + + /** Removes the entity with the name `entityName`. + * + * @param entityName the name of the entity to remove + * @return an option containing the removed entity if it was in the area + */ + def removeEntity(entityName: String): Option[Entity] = + this.entities.remove(entityName.toLowerCase()) + + /** Returns a multi-line description of the area as a player sees it. This includes a basic + * description of the area as well as information about exits and items. If there are no + * items present, the return value has the form "DESCRIPTION\n\nExits available: + * DIRECTIONS SEPARATED BY SPACES". If there are one or more items present, the return + * value has the form "DESCRIPTION\nYou see here: ITEMS SEPARATED BY SPACES\n\nExits available: + * DIRECTIONS SEPARATED BY SPACES". The items and directions are listed in an arbitrary order. */ + def fullDescription: String = + val exitList = this.neighbors.keys.mkString(" ") + val itemList = this.items.keys.mkString(" ") + val entityList = this.getEntityNames.mkString(" ") + val itemDescription = + if this.items.nonEmpty then + s"\nYou see here: ${itemList}" + else "" + val entityDescription = + if this.entities.nonEmpty then + s"\nThere are entities: ${entityList}" + else "" + (this.description + + itemDescription + + entityDescription + + s"\n\nExits available: $exitList") + + + /** Returns a single-line description of the area for debugging purposes. */ + override def toString = + this.name + ": " + this.description.replaceAll("\n", " ").take(150) + +end Area + diff --git a/src/scalevalapokalypsi/Model/Entity.scala b/src/scalevalapokalypsi/Model/Entity.scala new file mode 100644 index 0000000..083c37f --- /dev/null +++ b/src/scalevalapokalypsi/Model/Entity.scala @@ -0,0 +1,103 @@ +package scalevalapokalypsi.Model + +import scala.collection.mutable.{Buffer,Map} + + + +/** A `Player` object represents a player character controlled by one real-life player + * of the program. + * + * A player object’s state is mutable: the player’s location and possessions can change, + * for instance. + * + * @param startingArea the player’s initial location */ +class Player(name: String, initialLocation: Area) extends Entity(name, initialLocation): + + private val observations: Buffer[String] = Buffer.empty + + override def observe(observation: String): Unit = + this.observations.append(observation) + + def readAndClearObservations(): Vector[String] = + val res = this.observations.toVector + observations.clear() + res + +end Player + +/** An in-game entity. + * + * @param name the name of the entity + * @param initialLocation the Area where the entity is instantiated + */ +class Entity(val name: String, initialLocation: Area): + private var currentLocation: Area = initialLocation + private var quitCommandGiven = false // one-way flag + private val inventory: Map[String, Item] = Map() + + /** Determines if the player has indicated a desire to quit the game. */ + def hasQuit = this.quitCommandGiven // TODO: This is probably unneccessary? + + /** Does nothing, except possibly in inherited classes. */ + def observe(observation: String): Unit = + println("no observation made.") + () + + /** Returns the player’s current location. */ + def location = this.currentLocation + + /** Attempts to move the player in the given direction. This is successful if there + * is an exit from the player’s current location towards the direction name. Returns + * a description of the result: "You go DIRECTION." or "You can't go DIRECTION." */ + def go(direction: String): (String, String) = + val destination = this.location.neighbor(direction) + if destination.isDefined then + val removeSuccess = this.currentLocation.removeEntity(this.name) + assert(removeSuccess.isDefined) // Production - assertions off + this.currentLocation = destination.getOrElse(this.currentLocation) + destination.foreach(_.addEntity(this)) + (s"You go $direction.", s"$name goes $direction") + else + ( + s"You can't go $direction.", + s"$name tries to go $direction and stumbles in their feet." + ) + + def pickUp(itemName: String): (String, String) = + this.currentLocation.removeItem(itemName) match + case Some(i) => + this.inventory += i.name -> i + (s"You pick up the ${i.name}", s"$name picks up the ${i.name}") + case None => (s"There is no $itemName here to pick up.", "WHAAAT THIS SHOULDN'T HAPPEN???") + + def drop(itemName: String): (String, String) = + this.inventory.remove(itemName) match + case Some(item) => + this.currentLocation.addItem(item) + (s"You drop the $itemName", s"$name drops the $itemName") + case None => ("You don't have that!", s"$name reaches their backpack to drop $itemName but miserably fails to find it there.") + + def sayTo(entity: Entity, message: String): (String, String) = + entity.observe(s"Alice: \"$message\"") + (s"You say so to ${entity.name}.", "") + + def say(message: String): (String, String) = + ("You say that aloud.", s"$name: \"$message\"") + + /** Tells whether this entity can drop the specified item + * (if an action were to specify so). + * + * @param itemName the name to check + * @return whether this entity has this item and can drop it + */ + def canDrop(itemName: String): Boolean = this.inventory.contains(itemName) + + /** Causes the player to rest for a short while (this has no substantial effect in game terms). + * Returns a description of what happened. */ + def rest(): (String, String) = + ("You rest for a while. Better get a move on, though.", "") + + /** Returns a brief description of the player’s state, for debugging purposes. */ + override def toString = "Now at: " + this.location.name + +end Entity diff --git a/src/scalevalapokalypsi/Model/Item.scala b/src/scalevalapokalypsi/Model/Item.scala new file mode 100644 index 0000000..7979480 --- /dev/null +++ b/src/scalevalapokalypsi/Model/Item.scala @@ -0,0 +1,20 @@ +package scalevalapokalypsi.Model + +import scala.annotation.targetName + +/** The class `Item` represents items in a text adventure game. Each item has a name + * and a longer description. (In later versions of the adventure game, items may + * have other features as well.) + * + * N.B. It is assumed, but not enforced by this class, that items have unique names. + * That is, no two items in a game world have the same name. + * + * @param name the item’s name + * @param description the item’s description */ +class Item(val name: String, val description: String): + + /** Returns a short textual representation of the item (its name, that is). */ + override def toString = this.name + +end Item + diff --git a/src/scalevalapokalypsi/Server/Client.scala b/src/scalevalapokalypsi/Server/Client.scala new file mode 100644 index 0000000..6ce2522 --- /dev/null +++ b/src/scalevalapokalypsi/Server/Client.scala @@ -0,0 +1,173 @@ +package scalevalapokalypsi.Server + +import java.net.Socket +import scala.math.min +import scalevalapokalypsi.constants.* +import ServerProtocolState.* +import scalevalapokalypsi.Model.{Action,Player,Entity} + +class Client(val socket: Socket): + private var incompleteMessage: Array[Byte] = + Array.fill(MAX_MSG_SIZE)(0.toByte) + private var incompleteMessageIndex = 0 + private var protocolState = WaitingForVersion + private var outData: String = "" + private var character: Option[Player] = None + private var protocolIsIntact = true + private var name: Option[String] = None + private var nextAction: Option[Action] = None + + /** Calculates the amount of bytes available for future incoming messages */ + def spaceAvailable: Int = MAX_MSG_SIZE - incompleteMessageIndex + + /** Tests whether the client has behaved according to protocol. + * + * @return false if there has been a protocol violation, true otherwise + */ + def isIntactProtocolWise: Boolean = this.protocolIsIntact + + /** Marks that this client misbehaved in eyes of the protocol */ + def failedProtocol(): Unit = this.protocolIsIntact = false + + /** Tests whether this client is initialized and ready to start the game + * + * @return true if the client is ready to join the game + */ + def isReadyForGameStart: Boolean = + this.protocolState == WaitingForGameStart + + /** Signals this client that it's joining the game. This is important so + * that this object knows to update its protocol state. + */ + def gameStart(): Unit = this.protocolState = InGame + + /** Returns the player this client controls in the model. + * + * @return an option containing the player + */ + def player: Option[Player] = this.character + + /** Tells this client object that it controls the specified player. + * + * @param player the player this client is to control + */ + def givePlayer(player: Player): Unit = + this.character = Some(player) + + /** Gets the name of this client, which should match the name of the player + * that is given to this client. Not very useful if the client hasn't yet + * received the name or if it already has an player. + * + * @return the name of this client + */ + def getName: Option[String] = this.name + + /** Sets `data` as received for the client. + * + * @return false means there was not enough space to receive the message + */ + def receiveData(data: Vector[Byte]): Boolean = + for i <- 0 until min(data.length, spaceAvailable) do + this.incompleteMessage(this.incompleteMessageIndex + i) = data(i) + this.incompleteMessageIndex += data.length + this.incompleteMessageIndex = + min(this.incompleteMessageIndex, MAX_MSG_SIZE) + data.length < spaceAvailable + + /** Returns data that should be sent to this client. + * The data is cleared when calling. + */ + def dataToThisClient(): String = + val a = this.outData + this.outData = "" + a + + /** Specifies that the data should be buffered for + * sending to this client + * + * @param data data to buffer for sending + */ + private def addDataToSend(data: String): Unit = + this.outData += s"$data\r\n" + + + /** Returns one line of data if there are any line breaks. + * Removes the parsed data from the message buffering area. + */ + private def nextLine(): Option[String] = + var nextCRLF = this.incompleteMessage.indexOf(CRLF(0)) + if this.incompleteMessage(nextCRLF + 1) != CRLF(1) then nextCRLF = -1 + if nextCRLF != -1 then + val message = this.incompleteMessage.take(nextCRLF) + val rest = this.incompleteMessage.drop(nextCRLF + 2) + this.incompleteMessage = rest ++ Array.fill(nextCRLF + 1)(0.toByte) + // TODO: the conversion may probably be exploited to crash the server + Some(String(message)) + else + None + + /** Makes the client play its turn */ + def act(): Unit = + this.addDataToSend(ACTION_BLOCKING_INDICATOR.toString) + this.nextAction.foreach(a => this.addDataToSend( + s"$ACTION_BLOCKING_INDICATOR${this.executeAction(a)}" + )) + this.nextAction = None + + /** Checks whether the client has chosen its next action + * + * @return whether the client is ready to act */ + def isReadyToAct: Boolean = this.nextAction.isDefined + + /** Causes the client to interpret the data it has received */ + def interpretData(): Unit = + LazyList.continually(this.nextLine()) + .takeWhile(_.isDefined) + .flatten + .foreach(s => interpretLine(s)) + + /** Makes the client execute the action specified by `line`. + * If there is a protocol error, the function changes + * the variable `protocolIsIntact` to false. + * + * @param line the line to interpret + */ + private def interpretLine(line: String): Unit = + this.protocolIsIntact = this.protocolState match + case WaitingForVersion => + if line == GAME_VERSION then + addDataToSend(s"$PROTOCOL_VERSION_GOOD") + this.protocolState = WaitingForClientName + true + else + addDataToSend(s"$PROTOCOL_VERSION_BAD") + false + case WaitingForClientName => + this.name = Some(line) + this.protocolState = WaitingForGameStart + true + case WaitingForGameStart => true + case InGame => + this.bufferAction(Action(line)) + true + + /** Buffers the action for execution or executes it immediately if it + * doesn't take a turn */ + private def bufferAction(action: Action) = + if ( + this.nextAction.isEmpty && + this.player.exists(action.takesATurnFor(_)) + ) then + this.nextAction = Some(action) + else if this.nextAction.isEmpty then + this.addDataToSend(s"$ACTION_NONBLOCKING_INDICATOR${this.executeAction(action)}") + + /** Executes the specified action and returns its description */ + private def executeAction(action: Action): String = + this.character.flatMap(action.execute(_)) match + case Some(s) => s + case None => "You can't do that" + + +end Client + diff --git a/src/scalevalapokalypsi/Server/Clients.scala b/src/scalevalapokalypsi/Server/Clients.scala new file mode 100644 index 0000000..377050d --- /dev/null +++ b/src/scalevalapokalypsi/Server/Clients.scala @@ -0,0 +1,82 @@ +package scalevalapokalypsi.Server + +import scala.util.Try +import scala.util.Random + +class Clients(maxClients: Int): + private val clients: Array[Option[Client]] = Array.fill(maxClients)(None) + + /** Adds `client` to this collection of clients. + * + * @param client the Client to add + * @return true if there was room for the client + * i.e. fewer clients than `maxClients`, false otherwise + */ + def addClient(client: Client): Boolean = + val i = this.clients.indexOf(None) + if i == -1 then + false + else + this.clients(i) = Some(client) + true + + /** Returns all the clients. + * + * @return an iterable of all the clients + */ + def allClients: Iterable[Client] = clients.toVector.flatten + + /** Applies the function `f` to all the clients for its side effects. */ + def foreach(f: Client => Any): Unit = this.clients.flatten.foreach(f) + + /** Executes the function `f` for all clients in a pseudorandom order. */ + def inRandomOrder(f: Client => Any): Unit = + Random.shuffle(this + .clients + .flatten) + .foreach(f) + + /** Returns true if the predicate `f` stands for all clients, + * false otherwise + * + * @param f the predicate to check for all clients + * @return whether `f` stands for all clients + */ + def forall(f: Client => Boolean): Boolean = this.clients.flatten.forall(f) + + /** Gets the names of all the clients stored by this object. + * + * @return the names of the clients + */ + def names: Vector[String] = this.clients.flatten.flatMap(_.getName).toVector + + def isEmpty: Boolean = this.clients.flatten.isEmpty + + /** Applies the function `f` to all the clients for its side effects + * and removes all the clients for which `f([client])` returns false. + * This is useful for doing IO with the client and removing clients + * with stale sockets. + * + * @param f the function to apply to all the clients and filter them with + */ + def removeNonSatisfying(f: Client => Boolean): Unit = + for i <- this.clients.indices do + this.clients(i) match + case Some(c) => + if !f(c) then + this.clients(i) = None + case None => + + /** Removes clients that have not behaved according to protocol */ + def removeNonCompliant(): Unit = + this.removeNonSatisfying(_.isIntactProtocolWise) + + /** Applies the function f to all clients for its side effects. + * If the function throws an exception, the client is removed. + * Probably a more concise alternative to `removeNonSatisfying`, + * but might catch exceptions unintentionally. + * + * @param f the function to apply for its side effects to each client + */ + def mapAndRemove(f: Client => Unit): Unit = + this.removeNonSatisfying(c => Try(f(c)).isSuccess) diff --git a/src/scalevalapokalypsi/Server/ConnectionGetter.scala b/src/scalevalapokalypsi/Server/ConnectionGetter.scala new file mode 100644 index 0000000..40830c7 --- /dev/null +++ b/src/scalevalapokalypsi/Server/ConnectionGetter.scala @@ -0,0 +1,25 @@ +package scalevalapokalypsi.Server + +import java.io.IOException +import java.net.{ServerSocket, Socket} +import scala.concurrent.Future +import scala.util.{Failure, Success} +import scala.concurrent.ExecutionContext.Implicits.global + +/** Small helper class for getting new connections using futures */ +class ConnectionGetter(val socket: ServerSocket): + + private var nextClient: Future[Socket] = Future.failed(IOException()) + + /** Returns a new socket to a client if there is any new connections. */ + def newClient(): Option[Socket] = + this.nextClient.value match + case Some(Success(s)) => + nextClient = Future(socket.accept()) + Some(s) + case Some(Failure(e)) => + nextClient = Future(socket.accept()) + None + case None => None + +end ConnectionGetter diff --git a/src/scalevalapokalypsi/Server/Server.scala b/src/scalevalapokalypsi/Server/Server.scala new file mode 100644 index 0000000..13ca2f5 --- /dev/null +++ b/src/scalevalapokalypsi/Server/Server.scala @@ -0,0 +1,195 @@ +package scalevalapokalypsi.Server + + +// TODO: TLS/SSL / import javax.net.ssl.SSLServerSocketFactory + +import java.lang.Thread.{currentThread, sleep} +import java.io.IOException +import java.net.{ServerSocket, Socket} +import scalevalapokalypsi.constants.* +import scalevalapokalypsi.Model.{Adventure,Entity,Player} +import scalevalapokalypsi.utils.stringToByteArray + +import java.lang.System.currentTimeMillis +import scala.util.Try + + +/** `Server` exists to initialize a server for the game + * and run it with its method `startServer`. + * + * @param port the TCP port the server should listen on + * @param maxClients the maximum number of clients that may be in the game + * simultaneously. + * @param timeLimit the time limit clients should have to execute their turns. + * @param joinAfterStart whether new clients are accepted after the game has + * been started + */ +class Server( + port: Int, + maxClients: Int, + val timeLimit: Int, + val joinAfterStart: Boolean +): + + private val socket = ServerSocket(port) + private val clientGetter = ConnectionGetter(socket) + private val clients: Clients = Clients(maxClients) + private val buffer: Array[Byte] = Array.ofDim(1024) + private var bufferIndex = 0 + private var adventure: Option[Adventure] = None + private var previousTurn = 0.0 + + /** Starts the server. Won't terminate under normal circumstances. */ + def startServer(): Unit = + while true do + this.serverStep() + sleep(POLL_INTERVAL) + + private def serverStep(): Unit = + this.clients.removeNonCompliant() + if this.adventure.isEmpty || this.joinAfterStart then + this.receiveNewClient() + this.readFromAll() + this.clients.foreach(_.interpretData()) + this.writeClientDataToClients() + this.writeObservations() + if this.canExecuteTurns then + this.clients.inRandomOrder(_.act()) + this.writeClientDataToClients() + this.writeObservations() + this.clients.foreach(c => + this.writeToClient(this.turnStartInfo(c), c) + ) + this.previousTurn = currentTimeMillis() / 1000 + if this.adventure.isDefined && this.joinAfterStart then + this.clients.foreach( c => if c.isReadyForGameStart then + this.adventure.foreach(a => + c.getName.foreach(n => a.addPlayer(n)) + ) + startGameForClient(c) + ) + else if this.adventure.isEmpty && !this.clients.isEmpty && this.clients.forall(_.isReadyForGameStart) then + this.adventure = Some(Adventure(this.clients.names)) + this.clients.foreach(startGameForClient(_)) + this.previousTurn = currentTimeMillis() / 1000 + + /** Helper function to start the game for the specified client c. + * MAY ONLY BE USED IF `this.adventure` is Some! + * Apparently guard clauses are bad because they use return or something, + * but assertions should be fine, as long as they enforce the function + * contract? + */ + private def startGameForClient(c: Client): Unit = + assert(this.adventure.isDefined) + c.gameStart() + val name = c.getName + + val playerEntity: Option[Player] = name match + case Some(n) => this.adventure match + case Some(a) => a.getPlayer(n) + case None => None + case None => None + playerEntity.foreach(c.givePlayer(_)) + + this.writeToClient( + s"$timeLimit\r\n${this.turnStartInfo(c)}", c + ) + + this.clients.foreach(c => + if c.player != playerEntity then + c.player.foreach(_.observe(s"${name.getOrElse("Unknown player")} joins the game.")) + ) + + + private def writeObservations(): Unit = + this.clients.foreach(c => + val observations = c.player.map(_.readAndClearObservations()) + observations.foreach(_.foreach((s: String) => + this.writeToClient(s"$ACTION_NONBLOCKING_INDICATOR$s\r\n", c)) + ) + ) + + /** Helper function to determine if the next turn can be taken */ + private def canExecuteTurns: Boolean = + val requirement1 = this.adventure.isDefined + val requirement2 = !this.clients.isEmpty // nice! you can just return + // to the game after everyone + // left and everything is just + // as before! + val allPlayersReady = this.clients.forall(_.isReadyToAct) + val requirement3 = (allPlayersReady + || currentTimeMillis() / 1000 >= previousTurn + timeLimit) + requirement1 && requirement2 && requirement3 + + + /** Receives a new client and stores it in `clients`. + * + * @return describes if a client was added + */ + private def receiveNewClient(): Boolean = + this.clientGetter.newClient() match + case Some(c) => + clients.addClient(Client(c)) + true + case None => + false + + private def turnStartInfo(client: Client): String = + val clientArea = client.player.map(_.location) + val areaDesc = clientArea + .map(_.description) + .getOrElse("You are floating in the middle of a soothing void.") + val directions = clientArea + .map(_.getNeighborNames.mkString(LIST_SEPARATOR)) + .getOrElse("") + val items = clientArea + .map(_.getItemNames.mkString(LIST_SEPARATOR)) + .getOrElse("") + val entities = client.player.map(c => + c.location + .getEntityNames + .filter(c.name != _) + .mkString(LIST_SEPARATOR) + ).getOrElse("") + s"$TURN_INDICATOR\r\n$areaDesc\r\n$directions\r\n$items\r\n$entities\r\n" + + /** Sends `message` to all clients + * + * @param message the message to send + */ + private def writeToAll(message: String): Unit = + this.clients.mapAndRemove(c => + val output = c.socket.getOutputStream + output.write(stringToByteArray(message)) + output.flush() + ) + + private def writeToClient(message: String, client: Client): Unit = + try { + val output = client.socket.getOutputStream + output.write(stringToByteArray(message)) + output.flush() + } catch { + case e: IOException => client.failedProtocol() + } + + /** Sends every client's `dataToThisClient` to the client */ + private def writeClientDataToClients(): Unit = + this.clients.mapAndRemove(c => + val output = c.socket.getOutputStream + val data = c.dataToThisClient() + output.write(stringToByteArray(data)) + output.flush() + ) + + /** Reads data sent by clients and stores it in the `Client`s of `clients` */ + private def readFromAll(): Unit = + clients.mapAndRemove(c => + val input = c.socket.getInputStream + while input.available() != 0 do + val bytesRead = input.read(buffer) + if bytesRead != -1 then + c.receiveData(buffer.take(bytesRead).toVector) + ) + +end Server diff --git a/src/scalevalapokalypsi/constants/constants.scala b/src/scalevalapokalypsi/constants/constants.scala new file mode 100644 index 0000000..d5abb43 --- /dev/null +++ b/src/scalevalapokalypsi/constants/constants.scala @@ -0,0 +1,19 @@ + +package scalevalapokalypsi.constants + +val MAX_MSG_SIZE = 1024 // bytes +val CRLF: Vector[Byte] = Vector(13.toByte, 10.toByte) +val POLL_INTERVAL = 100 // millisec. +val GAME_VERSION = "0.1.0" +val TURN_INDICATOR = ">" +val ACTION_BLOCKING_INDICATOR='.' +val ACTION_NONBLOCKING_INDICATOR='+' + +val LIST_SEPARATOR=";" + +val PROTOCOL_VERSION_GOOD = "1" +val PROTOCOL_VERSION_BAD = "0" +//assert(PROTOCOL_VERSION_BAD.length <= PROTOCOL_VERSION_GOOD.length) + +enum ServerProtocolState: + case WaitingForVersion, WaitingForClientName, WaitingForGameStart, InGame diff --git a/src/scalevalapokalypsi/main.scala b/src/scalevalapokalypsi/main.scala new file mode 100644 index 0000000..9633f73 --- /dev/null +++ b/src/scalevalapokalypsi/main.scala @@ -0,0 +1,27 @@ +package scalevalapokalypsi + +import scalevalapokalypsi.Client.newClient +import scalevalapokalypsi.Server.Server +import java.lang.Thread +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global + +import scala.io.StdIn.readLine + +// TODO: add proper logic for starting the game +@main def main(): Unit = + print("How do you want to play?\n1) Host and join local game\n2) Join local game\n> ") + readLine().toIntOption match + case Some(1) => + Thread(() => new Server(2267, 5, 30, true).startServer()).start() + println("Server started in background.") + print("Choose a name:\n> ") + val name = readLine() + newClient(name, "127.0.0.1", 2267).map(_.startClient()) + case Some(2) => + print("Choose a name:\n> ") + val name = readLine() + newClient(name, "127.0.0.1", 2267).map(_.startClient()) + case _ => println("Invalid input") + + diff --git a/src/scalevalapokalypsi/utils/utils.scala b/src/scalevalapokalypsi/utils/utils.scala new file mode 100644 index 0000000..b1bac4d --- /dev/null +++ b/src/scalevalapokalypsi/utils/utils.scala @@ -0,0 +1,28 @@ +package scalevalapokalypsi.utils + +import java.io.InputStream + +/** Converts this string to an array of bytes (probably for transmission). + * + * @param str the string to convert + * @return an array of bytes representing the string in UTF8. + */ +def stringToByteArray(str: String): Array[Byte] = + str.toVector.map(_.toByte).toArray + +/** Reads n characters from the given InputStream blockingly. + * + * @param input the InputStream to read from + * @param n the number of bytes to read + * @return The read result, or None in case of failure + */ +def getNCharsFromSocket(input: InputStream, n: Int): Option[String] = + val buffer: Array[Byte] = Array.ofDim(n) + var i = 0 + var failed = false + while i < n && !failed do + val res = input.read(buffer, i, n - i) + if res < 0 then failed = true + i += res + // TODO: better error handling + if failed then None else Some(String(buffer)) |