aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAleksi Heikkila <aleksi.e.heikkila@aalto.fi>2024-11-23 22:33:53 +0200
committerAleksi Heikkila <aleksi.e.heikkila@aalto.fi>2024-11-23 22:33:53 +0200
commit27dd937617cce1e43df1c16e12050f6e88763d54 (patch)
tree8247736f7c4d5b1a0c1541c25f477e73e5783944
parentb11c02e8f1f4dea09847da999c2acb2f96df8a58 (diff)
parent28b83db50f33cb704311ffe608dcd8c4412635cf (diff)
downloadscalevalapokalypsi-27dd937617cce1e43df1c16e12050f6e88763d54.tar.gz
scalevalapokalypsi-27dd937617cce1e43df1c16e12050f6e88763d54.zip
Merge branch 'master' of ssh://cron4.fi/~/O1-game
-rw-r--r--.gitignore1
-rw-r--r--README.txt95
-rw-r--r--src/scalevalapokalypsi/Client/Client.scala87
-rw-r--r--src/scalevalapokalypsi/Client/GameEvent.scala11
-rw-r--r--src/scalevalapokalypsi/Client/ReceivedLineParser.scala7
-rw-r--r--src/scalevalapokalypsi/Client/RoomState.scala (renamed from src/scalevalapokalypsi/Client/Turn.scala)4
-rw-r--r--src/scalevalapokalypsi/Model/Action.scala70
-rw-r--r--src/scalevalapokalypsi/Model/Adventure.scala23
-rw-r--r--src/scalevalapokalypsi/Model/Area.scala27
-rw-r--r--src/scalevalapokalypsi/Model/Entities/Entity.scala6
-rw-r--r--src/scalevalapokalypsi/Model/Entities/NPCs/NPC.scala89
-rw-r--r--src/scalevalapokalypsi/Model/Entities/Player.scala5
-rw-r--r--src/scalevalapokalypsi/Server/Client.scala64
-rw-r--r--src/scalevalapokalypsi/Server/Server.scala12
-rw-r--r--src/scalevalapokalypsi/UI/.bsp/scala.json45
-rw-r--r--src/scalevalapokalypsi/UI/Printer.scala78
-rw-r--r--src/scalevalapokalypsi/UI/StdinLineReader.scala (renamed from src/scalevalapokalypsi/Client/StdinLineReader.scala)6
-rw-r--r--src/scalevalapokalypsi/UI/main.scala132
-rw-r--r--src/scalevalapokalypsi/constants/constants.scala11
-rw-r--r--src/scalevalapokalypsi/main.scala25
-rw-r--r--src/scalevalapokalypsi/utils/utils.scala45
21 files changed, 664 insertions, 179 deletions
diff --git a/.gitignore b/.gitignore
index 1fcb152..296d678 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
out
+.scala-build
diff --git a/README.txt b/README.txt
index 5f4f277..4ada829 100644
--- a/README.txt
+++ b/README.txt
@@ -23,18 +23,99 @@ ne olennaisesti tehdä mitä tahansa, sen sijaan että Z-kone millään lailla
rajoittaisi niitä.
+Järjestelmävaatimukset (TÄRKEÄÄ)
+--------------------------------
+
+INTELLIJ IDEA:n OLETUSTERMINAALI EI OLE TUETTU!!! Sinun on pelattava tätä
+jossain tuetussa terminaalissa. Muuten tuloste tulee näyttämään oudolta
+(todennäköisesti rivin alku tulostuu toistuvasti siihen, mihin yrität
+kirjoittaa tai kursorisi ei pääse pois rivin alusta). Onneksi tuetut
+terminaalit ovat varsin yleisiä: nopealla testillä kaikki keksityt
+terminaalit olivat tuettuja IDEAa lukuun ottamatta.
+
+Tuettuja olivat ainakin:
+* cmd.exe (dokumentaation perusteella - ei ollut saatavilla testiin)
+* alacritty (toimii myös Windowsilla)
+* xterm
+* gnome-terminal
+* konsole
+* kitty
+* xfce4-terminal
+* jopa st (miten sekin on OOB parempi kuin IDEA?)
+
+Tarkemmin määriteltynä, jos uuden terminaalin lataamista ennen tahdot varmistua
+kyseisen terminaalin sopivuudesta: terminaalisi on tuettava ANSI-koodeja `ESC7`,
+`ESC8` ja `ESC[0E` (jossa 0 voisi muissa sovelluksissa olla jokin muukin luku).
+Näiden koodien bitit ovat samassa järjestyksessä 0x001b 0x0037, 0x001b 0x0038 ja
+0x001b 0x005b 0x0045, mikäli edelliset merkinnät olivat epäselvät.
+
+
Pelin käynnistäminen
--------------------
-[yksittäisen käyttäjän käynnistäminen]
+Saat käynnistettyä pelin seuraavasti:
+```
+1) Liity viralliselle palvelimelle (cron4.fi)
+2) Käynnistä palvelin laitteellasi ja liity sille
+3) Liity mielivaltaiselle palvelimelle
+> 1
+Valitse itsellesi pelin sisäinen alter ego.
+> [nimi]
+[liityt peliin]
+```
+Huomaa, että olet liittynyt julkiselle palvelimelle, jolla saattaa olla muita
+pelaajia. Jos olet assari arvioimassa, kovin moni tuskin vielä tietää pelistä
+taikka palvelimesta, joten pelin tila lienee jotakuinkin ennallaan.
Moninpeliominaisuudesta saat eniten iloa irti, joten jaa [pelin nimi]
-ystävillesi ja ala pelaamaan! Jos sinulla ei ole ystäviä, voit myös kokea
-moninpeliominaisuuden mahtavuuden hieman vaisumpana käynnistämällä toisen
-käyttäjän ylläolevien ohjeiden mukaisesti ja liittymällä samaan peliin
-(eli samalle serverille), johon edellisellä käyttäjällä liityit. Toista
-tämä niin monta kertaa kuin tahdot, kunnes serveri valittaa liian suuresta
-pelaajamäärästä.
+ystävillesi ja ala pelaamaan! Jos sinulla ei ole ystäviä, onnistuu moninpeli-
+ominaisuuden testaaminen joko liittymällä useamman kerran julkiselle palveli-
+melle tai pystyttämällä palvelin omalle koneelle seuraavasti `n` pelaajaa
+varten.
+
+Prosessi 1:
+```
+Miten tahdot pelata?
+1) Liity viralliselle palvelimelle (cron4.fi)
+2) Käynnistä palvelin laitteellasi ja liity sille
+3) Liity mielivaltaiselle palvelimelle
+> 2
+Valitse portti palvelimelle. (Jätä tyhjäksi oletusta 2267 varten)
+>
+Kuinka monta pelaajaa pelissä saa olla samanaikaisesti?
+> [n]
+Syötä vuorojen aikaraja yksiköttömänä sekunneissa odottelun vähentämiseksi. Syötä 0 aikarajan poistamiseksi. (Jätä tyhjäksi oletusta 30 varten)
+>
+Palvelin käynnistetty taustalla.
+Valitse itsellesi pelin sisäinen alter ego.
+> [nimi]
+[liityt peliin]
+```
+
+n x muu prosessi: (huom. en ole prosessiteekkari)
+```
+Miten tahdot pelata?
+1) Liity viralliselle palvelimelle (cron4.fi)
+2) Käynnistä palvelin laitteellasi ja liity sille
+3) Liity mielivaltaiselle palvelimelle
+> 3
+Syötä palvelimen verkkotunnus.
+> 127.0.0.1
+Valitse portti palvelimelle. (Jätä tyhjäksi oletusta 2267 varten)
+>
+Valitse itsellesi pelin sisäinen alter ego.
+> Bob
+```
+
+Huomaa, että pelin ajamista varten samalla koneella useampaan kertaan, on
+IntelliJ IDEA:n ajokonfiguraatiota muokattava. Olettaen, että ohjelma on kerran
+ajettu main-funktion play-napista, valitse play-napin vierestä valikko, jossa
+lukee `main` tai `[pelin nimi]` -jotain. Sieltä valitse "Edit Configurations".
+Valitse oikea kohde avautuvan ikkunan vasemmasta palkista, klikkaa kohtaa
+"Modify options" ja valitse "Allow multiple instances". Sitten OK/Apply.
+Jos tästä muodostuu ongelma, kannattaa ensin vähän googlata tai lukea seuraava
+StackOverflow:
+=> https://stackoverflow.com/questions/41226555/how-do-i-run-the-same-application-twice-in-intellij
Pelin tavoite
diff --git a/src/scalevalapokalypsi/Client/Client.scala b/src/scalevalapokalypsi/Client/Client.scala
index aab6bc3..e94fdb0 100644
--- a/src/scalevalapokalypsi/Client/Client.scala
+++ b/src/scalevalapokalypsi/Client/Client.scala
@@ -1,12 +1,11 @@
package scalevalapokalypsi.Client
-import java.lang.Thread.sleep
-import java.net.Socket
+import java.net.{Socket,InetSocketAddress}
import scala.io.Source
import scala.sys.process.stdout
import scalevalapokalypsi.constants.*
import scalevalapokalypsi.utils.{stringToByteArray,getNCharsFromSocket}
-import scalevalapokalypsi.Client.{ReceivedLineParser,StdinLineReader,Turn}
+import scalevalapokalypsi.Client.{ReceivedLineParser,RoomState}
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{Try, Success, Failure}
@@ -34,7 +33,8 @@ enum ServerLineState:
* @return the client created, if all was successful
*/
def newClient(name: String, ip: String, port: Int): Option[Client] =
- val socket = Socket(ip, port)
+ val socket = Socket()
+ socket.connect(new InetSocketAddress(ip, port), INITIAL_CONN_TIMEOUT)
val output = socket.getOutputStream
val input = socket.getInputStream
val initMsg = s"$GAME_VERSION\r\n$name\r\n"
@@ -61,7 +61,6 @@ class Client(socket: Socket):
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
@@ -70,41 +69,49 @@ class Client(socket: Socket):
private var timeLimit: Long = 0
private var lastTurnStart: Long = 0
private var lastExecutedTurn: Long = 0
- private var isSinging: Boolean = false
+ private var lineToSing: Option[String] = None
private val bufferedActions: Buffer[String] = Buffer.empty
assert(
lastTurnStart <= lastExecutedTurn,
"don't initialize with unexecuted turn"
)
- private val turnInfo = Turn()
+ private val turnInfo = RoomState()
-
- /** Starts the client. This shouldn't terminate. */
- def startClient(): Unit =
-
- stdinReader.startReading()
-
- while true do
-
- sleep(POLL_INTERVAL)
+ /** Takes a client step and optionally returns an in-game event for UI
+ *
+ * @param clientInput one line of client input if any
+ * @return an event describing new changes in the game state
+ */
+ def clientStep(clientInput: Option[String]): GameEvent =
this.readAndParseDataFromServer()
- this.displayActions()
+ val actions = this.getNewActions()
- if
+ val roomState = if
this.lastExecutedTurn < this.lastTurnStart &&
- !this.isSinging
+ this.lineToSing.isEmpty
then
- print(this.giveTurn())
-
- // TODO: we probably want to quit at EOF
- stdinReader.newLine().foreach((s: String) =>
- this.isSinging = false
- output.write(stringToByteArray(s+"\r\n"))
+ this.giveTurn()
+ Some(this.turnInfo)
+ else
+ None
+
+ for line <- clientInput do
+ this.lineToSing = None
+ output.write(stringToByteArray(s"$line\r\n"))
+
+ val timeOfTurnEnd = this.lastTurnStart + this.timeLimit
+ val timeToTurnEnd = -currentTimeMillis()/1000 + timeOfTurnEnd
+ GameEvent(
+ actions,
+ roomState,
+ this.lineToSing,
+ this.canAct,
+ Some(timeToTurnEnd).filter(p => this.lastTurnStart != 0)
)
- end startClient
+ end clientStep
private def readAndParseDataFromServer(): Unit =
@@ -112,37 +119,27 @@ class Client(socket: Socket):
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 =
+ private def giveTurn(): Unit =
this.canAct = true
this.lastExecutedTurn = currentTimeMillis / 1000
- s"\n\n${this.turnInfo}\n${this.actionGetterIndicator}"
private def bufferAction(action: String): Unit =
this.bufferedActions += action
- private def displayActions(): Unit =
+ private def getNewActions(): Option[Vector[String]] =
val somethingToShow = this.bufferedActions.nonEmpty
- if somethingToShow then
- if !this.isSinging then
- this.bufferedActions.foreach(println(_))
- this.bufferedActions.clear()
- if !this.isSinging && this.canAct && somethingToShow then
- print(this.actionGetterIndicator)
+ if somethingToShow && this.lineToSing.isEmpty then
+ val res = this.bufferedActions.toVector
+ this.bufferedActions.clear()
+ Some(res)
+ else
+ None
private def startSong(verse: String): Unit =
- this.isSinging = true
- print(s"\nLaula: “$verse”\n> ")
-
- // TODO: this is sometimes in front of actions, use an indicator to test if newline before actions?
- private def actionGetterIndicator =
- val timeOfTurnEnd = this.lastTurnStart + this.timeLimit
- val timeToTurnEnd = -currentTimeMillis()/1000 + timeOfTurnEnd
- s"[$timeToTurnEnd]> "
-
+ this.lineToSing = Some(verse)
private def parseDataFromServer(data: Array[Byte]): Unit =
diff --git a/src/scalevalapokalypsi/Client/GameEvent.scala b/src/scalevalapokalypsi/Client/GameEvent.scala
new file mode 100644
index 0000000..8aa1e1c
--- /dev/null
+++ b/src/scalevalapokalypsi/Client/GameEvent.scala
@@ -0,0 +1,11 @@
+package scalevalapokalypsi.Client
+
+class GameEvent(
+ val actions: Option[Vector[String]],
+ val roomState: Option[RoomState],
+ val lineToSing: Option[String],
+ val playerCanAct: Boolean,
+ val timeToNextTurn: Option[Long]
+)
+
+
diff --git a/src/scalevalapokalypsi/Client/ReceivedLineParser.scala b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala
index 9337ce1..bccba59 100644
--- a/src/scalevalapokalypsi/Client/ReceivedLineParser.scala
+++ b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala
@@ -2,6 +2,7 @@ package scalevalapokalypsi.Client
import scala.collection.mutable.Buffer
import scalevalapokalypsi.constants.*
+import scalevalapokalypsi.utils.*
/** A class for checking asynchronously for received lines */
class ReceivedLineParser:
@@ -10,11 +11,11 @@ class ReceivedLineParser:
private var bufferedData: Buffer[Byte] = Buffer.empty // TODO: suboptimal DS
- /** Add received data */
+ /** Add received data */
def in(data: Array[Byte]): Unit =
this.bufferedData ++= data
- /** Read a line from the received data */
+ /** Read a line from the received data */
def nextLine(): Option[String] =
val indexOfCRLF = this.bufferedData.indexOfSlice(CRLF)
if indexOfCRLF == -1 then
@@ -22,6 +23,6 @@ class ReceivedLineParser:
else
val splitData = this.bufferedData.splitAt(indexOfCRLF)
this.bufferedData = Buffer.from(splitData(1).drop(CRLF.length))
- Some(String(splitData(0).toArray))
+ byteArrayToString(splitData(0).toArray)
end ReceivedLineParser
diff --git a/src/scalevalapokalypsi/Client/Turn.scala b/src/scalevalapokalypsi/Client/RoomState.scala
index 30101c5..02fe11b 100644
--- a/src/scalevalapokalypsi/Client/Turn.scala
+++ b/src/scalevalapokalypsi/Client/RoomState.scala
@@ -4,7 +4,7 @@ package scalevalapokalypsi.Client
* This class exists essentially so that the client has somewhere
* to store data about turns and something to format that data with.
*/
-class Turn:
+class RoomState:
/** Description of the area the player controlled by the client is in
* at the end of the turn. */
@@ -29,4 +29,4 @@ class Turn:
(s"$areaDescription\n$directionDesc\n" +
s"\n$itemDesc\n$entityDesc")
-end Turn
+end RoomState
diff --git a/src/scalevalapokalypsi/Model/Action.scala b/src/scalevalapokalypsi/Model/Action.scala
index 52ba165..c7c8a65 100644
--- a/src/scalevalapokalypsi/Model/Action.scala
+++ b/src/scalevalapokalypsi/Model/Action.scala
@@ -1,6 +1,7 @@
package scalevalapokalypsi.Model
import scalevalapokalypsi.Model.Entities.*
+import scalevalapokalypsi.Model.Entities.NPCs.*
/** The class `Action` represents actions that a player may take in a text
* adventure game. `Action` objects are constructed on the basis of textual
@@ -29,25 +30,68 @@ class Action(input: String):
val resOption: Option[(Boolean, Event)] = this.verb match
case "go" =>
val result = actor.go(this.modifiers)
- result.foreach(r => oldLocation.observeEvent(r))
+ result.foreach(r =>
+ if actor.location != oldLocation then
+ oldLocation.observeEvent(r)
+ )
result.map((true, _))
case "rest" => Some((true, actor.rest()))
case "get" => Some((false, actor.pickUp(this.modifiers)))
case "inventory" => Some((false, actor.inventory))
- 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
+ case "sano" =>
+ val entityNames = actor.location.getEntityNames.map(_.toLowerCase)
+ val recipientNamePair = entityNames.map(name =>
+ val possibleNamesWithSuffix = (0 to "ille".length).map(i =>
+ modifiers.takeRight(name.length + i)
+ )
+ possibleNamesWithSuffix.find(s =>
+ s.take(name.length) == name
+ )
+ .map(_.splitAt(name.length))
+ ).flatten.headOption
+
+ val recipient = recipientNamePair.flatMap(p =>
+ actor.location.getEntity(p(0))
)
- val message =
- modifiers.take(modifiers.length - recipient.length - 4)
- if maybeTo == to then
- recipientEntity.map(e => (false, actor.sayTo(e, message)))
- else
+
+ val message = recipientNamePair
+ .map(p => modifiers.dropRight(p(0).length + p(1).length))
+ .filter(_.takeRight(1) == " ")
+ .map(_.dropRight(1))
+
+ message.map(m =>
+ recipient.map(e => (false, actor.sayTo(e, m)))
+ ).getOrElse(
Some((false, actor.say(modifiers)))
+ )
+ case "puhu" =>
+ val recipient = modifiers
+ .indices.take("ille".length + 1)
+ .map(i => modifiers.take(modifiers.length - i))
+ .find(name => actor.location.getEntity(name).isDefined)
+ .flatMap(name => actor.location.getEntity(name))
+ val dialog = recipient match
+ case Some(npc: NPC) =>
+ s"${npc.name}: ”${npc.getDialog}”"
+ case Some(player: Player) =>
+ "Et voi puhua pelaajille, vain sanoa asioita heille."
+ case Some(other) =>
+ "Et voi puhua tälle olennolle."
+ case None =>
+ "Kyseistä puhujaa ei löytynyt."
+
+ val fromThirdPerson = recipient
+ .filter(a => a.isInstanceOf[NPC])
+ .map(a => s"${actor.name} puhuu $modifiers")
+
+ Some(
+ (
+ false,
+ Event(Vector((
+ actor, dialog
+ )).toMap, fromThirdPerson.getOrElse(""))
+ )
+ )
case "drop" => Some((false, actor.drop(this.modifiers)))
case "laula" =>
val end = modifiers.takeRight("suohon".length)
diff --git a/src/scalevalapokalypsi/Model/Adventure.scala b/src/scalevalapokalypsi/Model/Adventure.scala
index 57f0dfd..ba45abe 100644
--- a/src/scalevalapokalypsi/Model/Adventure.scala
+++ b/src/scalevalapokalypsi/Model/Adventure.scala
@@ -2,6 +2,7 @@ package scalevalapokalypsi.Model
import scala.collection.mutable.Map
import scalevalapokalypsi.Model.Entities.*
+import scalevalapokalypsi.Model.Entities.NPCs.*
/** The class `Adventure` holds data of the game world and provides methods
* for implementing a user interface for it.
@@ -20,7 +21,6 @@ class Adventure(val playerNames: Vector[String]):
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))
@@ -37,6 +37,23 @@ class Adventure(val playerNames: Vector[String]):
))
val entities: Map[String, Entity] = Map()
+
+ val npcs: Map[String, NPC] = Map()
+
+ private val zombieAttrs = Vector(
+ ("Weary zombie", clearing, 20),
+ ("Smelly zombie", home, 20),
+ ("Rotten zombie", tangle, 10)
+ )
+ zombieAttrs.foreach(z =>
+ val zombie = Zombie(z(0), z(1), z(2))
+ npcs += z(0) -> zombie
+ z(1).addEntity(zombie)
+ )
+
+ def takeNpcTurns(): Unit =
+ npcs.values.foreach(_.act())
+
private val gruu = Entity("Gruu", northForest)
northForest.addEntity(gruu)
this.entities += gruu.name -> gruu
@@ -64,7 +81,9 @@ class Adventure(val playerNames: Vector[String]):
def getPlayer(name: String): Option[Player] = this.players.get(name)
def getEntity[A >: Entity](name: String) =
- this.players.getOrElse(name, this.entities.get(name))
+ this.players.get(name)
+ .orElse(this.npcs.get(name))
+ .getOrElse(this.entities.get(name))
end Adventure
diff --git a/src/scalevalapokalypsi/Model/Area.scala b/src/scalevalapokalypsi/Model/Area.scala
index 96392ba..c07f2f9 100644
--- a/src/scalevalapokalypsi/Model/Area.scala
+++ b/src/scalevalapokalypsi/Model/Area.scala
@@ -103,33 +103,6 @@ class Area(val name: String, var description: String):
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)
diff --git a/src/scalevalapokalypsi/Model/Entities/Entity.scala b/src/scalevalapokalypsi/Model/Entities/Entity.scala
index c7bc180..336a6b1 100644
--- a/src/scalevalapokalypsi/Model/Entities/Entity.scala
+++ b/src/scalevalapokalypsi/Model/Entities/Entity.scala
@@ -59,10 +59,7 @@ class Entity(
("Olet täysin kunnossa.", s"$name näyttää kuin vastasyntyneeltä.")
/** Does nothing, except possibly in inherited classes. */
- def observeString(observation: String): Unit =
- println(" [debug] entity got observation string & discarded it")
- def observe(event: Event): Unit =
- println(" [debug] entity got observation event & discarded it")
+ def observe(event: Event): Unit = ()
/** Returns the player’s current location. */
def location = this.currentLocation
@@ -194,4 +191,5 @@ class Entity(
+
end Entity
diff --git a/src/scalevalapokalypsi/Model/Entities/NPCs/NPC.scala b/src/scalevalapokalypsi/Model/Entities/NPCs/NPC.scala
new file mode 100644
index 0000000..21709ba
--- /dev/null
+++ b/src/scalevalapokalypsi/Model/Entities/NPCs/NPC.scala
@@ -0,0 +1,89 @@
+
+package scalevalapokalypsi.Model.Entities.NPCs
+
+import scala.collection.mutable.Buffer
+import scalevalapokalypsi.Model.*
+import scalevalapokalypsi.Model.Entities.*
+import scala.util.Random
+
+/** A `NPC` object represents a non-playable in-game character controlled by
+ * the server using this objects `act` method. It can also be "talked to": it
+ * returns a dialog when asked for.
+ *
+ * A NPC object’s state is mutable: the NPC’s location and possessions can change,
+ * for instance.
+ *
+ * @param name the NPC's name
+ * @param initialLocation the NPC’s initial location
+ */
+abstract class NPC(
+ name: String,
+ initialLocation: Area,
+ initialHP: Int,
+ maxHp: Int
+) extends Entity(name, initialLocation, initialHP, maxHp):
+ def getDialog: String
+ def act(): Unit
+
+class Zombie(
+ identifier: String,
+ initialLocation: Area,
+ initialHP: Int = 20
+) extends NPC(identifier, initialLocation, initialHP, 20):
+
+ private val damage = 10
+ private val dialogs = Vector(
+ "örvlg",
+ "grr",
+ "äyyrrrgrlgb ww",
+ "aaak brzzzwff ååö",
+ "äkb glan abglum",
+ "öub gpa"
+ )
+
+ override def getDialog: String =
+ val dialogIndex = Random.between(0, this.dialogs.length)
+ this.dialogs(dialogIndex)
+
+ override def act(): Unit =
+ val possibleVictims = this.location
+ .getEntities
+ .filter(_ != this)
+ .toVector
+ val index: Int =
+ if possibleVictims.isEmpty then 0
+ else Random.between(0, possibleVictims.length)
+ if possibleVictims.isEmpty then
+ val possibleDirections = this.location.getNeighborNames.toVector
+ val directionIndex = Random.between(0, possibleDirections.length*2)
+ possibleDirections
+ .toVector
+ .lift(directionIndex)
+ .flatMap(this.go(_))
+ .map(this.location.observeEvent(_))
+ else
+ this.location.observeEvent(
+ this.attack(possibleVictims(index))
+ )
+
+
+ private def attack(entity: Entity): Event =
+ if Random.nextBoolean() then
+ entity.takeDamage(this.damage)
+ Event(
+ Map.from(Vector((
+ entity,
+ s"${this.name} puree sinua, hyi yäk!\n" +
+ s"${entity.condition(0)}"
+ ))),
+ s"${this.name} puree henkilöä ${entity.name}.\n" +
+ s"${entity.condition(1)}"
+ )
+ else
+ Event(
+ Map.from(Vector((
+ entity,
+ s"${this.name} yrittää purra sinua mutta kaatuu ohitsesi."
+ ))),
+ s"${this.name} yrittää purra henkilöä ${entity.name}, mutta epäonnistuu surkeasti."
+ )
diff --git a/src/scalevalapokalypsi/Model/Entities/Player.scala b/src/scalevalapokalypsi/Model/Entities/Player.scala
index 1dd5187..d6b3529 100644
--- a/src/scalevalapokalypsi/Model/Entities/Player.scala
+++ b/src/scalevalapokalypsi/Model/Entities/Player.scala
@@ -18,9 +18,6 @@ class Player(name: String, initialLocation: Area) extends Entity(name, initialLo
private val observedEvents: Buffer[Event] = Buffer.empty
private var pendingSingEffect: Option[SingEffect] = None
-
- override def observeString(observation: String): Unit =
- this.observations.append(observation)
override def observe(event: Event): Unit =
this.observedEvents.append(event)
@@ -30,7 +27,7 @@ class Player(name: String, initialLocation: Area) extends Entity(name, initialLo
val res = (res1 ++ res2).toVector
observations.clear()
observedEvents.clear()
- res
+ res.filter(s => !(s.isEmpty))
/** Returns whether this player has a pending sing effect. */
def isSinging: Boolean = this.pendingSingEffect.isDefined
diff --git a/src/scalevalapokalypsi/Server/Client.scala b/src/scalevalapokalypsi/Server/Client.scala
index 17c3777..5727fc6 100644
--- a/src/scalevalapokalypsi/Server/Client.scala
+++ b/src/scalevalapokalypsi/Server/Client.scala
@@ -3,6 +3,7 @@ package scalevalapokalypsi.Server
import java.net.Socket
import scala.math.{min,max}
import scalevalapokalypsi.constants.*
+import scalevalapokalypsi.utils.*
import ServerProtocolState.*
import scalevalapokalypsi.Model.Action
import scalevalapokalypsi.Model.Entities.Player
@@ -20,13 +21,15 @@ class Client(val socket: Socket):
private var nextAction: Option[Action] = None
private var turnUsed = false
private var singStartTime: Option[Long] = None
+ private var verseToSing: String = ""
def clientHasSong = this.singStartTime.isDefined
- def startSong(): Unit =
+ def startSong(verse: String): Unit =
+ this.verseToSing = verse
this.singStartTime = Some(currentTimeMillis() / 1000)
/** Calculates the amount of bytes available for future incoming messages */
- def spaceAvailable: Int = MAX_MSG_SIZE - incompleteMessageIndex
+ def spaceAvailable: Int = this.incompleteMessage.size - incompleteMessageIndex - 1
/** Tests whether the client has behaved according to protocol.
*
@@ -75,12 +78,13 @@ class Client(val socket: Socket):
* @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
+ if data.length > this.spaceAvailable then
+ false
+ else
+ for i <- 0 until min(data.length, this.spaceAvailable) do
+ this.incompleteMessage(this.incompleteMessageIndex+i) = data(i)
+ this.incompleteMessageIndex += data.length
+ true
/** Returns data that should be sent to this client.
* The data is cleared when calling.
@@ -109,8 +113,8 @@ class Client(val socket: Socket):
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))
+ this.incompleteMessageIndex = 0
+ byteArrayToString(message)
else
None
@@ -158,43 +162,35 @@ class Client(val socket: Socket):
/** Buffers the action for execution or executes it immediately if it
* doesn't take a turn */
private def executeLine(line: String) =
+
if !this.turnUsed then
this.singStartTime match
case Some(t) =>
+
val timePassed = currentTimeMillis()/1000 - t
- this.player.foreach(_.applySingEffect(
- 1 / max(5, timePassed) * 5
- ))
+
+ val quality = if line == this.verseToSing then
+ 5.0 / max(5.0, timePassed.toDouble)
+ else
+ 0.0
+
+ this.player.foreach(_.applySingEffect(quality.toFloat))
+
this.singStartTime = None
- case None =>
+
+ case None if isPrintable(line) =>
+
val action = Action(line)
val takesATurn = this.character.exists(p => action.execute(p))
if takesATurn then
this.addDataToSend(s"$ACTION_BLOCKING_INDICATOR")
this.turnUsed = true
- /*
- val takesATurn = this.character.exists(action.execute(_))
- if takesATurn then
- this.addDataToSend(s"$ACTION_BLOCKING_INDICATOR")
- if (
- this.nextAction.isEmpty &&
- this.player.exists(action.takesATurnFor(_))
- ) then
- this.nextAction = Some(action)
- else if this.nextAction.isEmpty then
- this.singStartTime match
- case Some(t) =>
- val timePassed = currentTimeMillis()/1000 - t
- this.player.foreach(_.applySingEffect(
- 1 / max(5, timePassed) * 5
- ))
- this.singStartTime = None
case None =>
- this.addDataToSend(
- s"$ACTION_NONBLOCKING_INDICATOR${this.executeAction(action)}"
- )*/
+
+ () // There were some illegal chars but whatever
+ end executeLine
end Client
diff --git a/src/scalevalapokalypsi/Server/Server.scala b/src/scalevalapokalypsi/Server/Server.scala
index bfb0893..2ea8bd4 100644
--- a/src/scalevalapokalypsi/Server/Server.scala
+++ b/src/scalevalapokalypsi/Server/Server.scala
@@ -56,8 +56,7 @@ class Server(
this.writeObservations()
if this.canExecuteTurns then
this.clients.foreach(_.giveTurn())
- //this.writeClientDataToClients()
- //this.writeObservations()
+ this.adventure.foreach(_.takeNpcTurns())
this.clients.foreach(c =>
this.writeToClient(this.turnStartInfo(c), c)
)
@@ -123,12 +122,9 @@ class Server(
val target = c.player.flatMap(_.getSingEffectTarget)
target.foreach(t =>
if c.player.exists(_.isSinging) && !c.clientHasSong then
- this.writeToClient(
- s"${SING_INDICATOR}${t.getVerseAgainst}\r\n",
- // TODO: store the verse and check how close client input is when determining sing quality
- c
- )
- c.startSong()
+ val verse = t.getVerseAgainst
+ this.writeToClient(s"${SING_INDICATOR}$verse\r\n", c)
+ c.startSong(verse)
)
)
diff --git a/src/scalevalapokalypsi/UI/.bsp/scala.json b/src/scalevalapokalypsi/UI/.bsp/scala.json
new file mode 100644
index 0000000..fe879ce
--- /dev/null
+++ b/src/scalevalapokalypsi/UI/.bsp/scala.json
@@ -0,0 +1,45 @@
+{
+ "name": "scala",
+ "argv": [
+ "/home/cron4/.cache/coursier/arc/https/github.com/scala/scala3/releases/download/3.5.0/scala3-3.5.0-x86_64-pc-linux.tar.gz/scala3-3.5.0-x86_64-pc-linux/bin/scala-cli",
+ "--cli-default-scala-version",
+ "3.5.0",
+ "--repository",
+ "file:///home/cron4/.cache/coursier/arc/https/github.com/scala/scala3/releases/download/3.5.0/scala3-3.5.0-x86_64-pc-linux.tar.gz/scala3-3.5.0-x86_64-pc-linux/maven2",
+ "--prog-name",
+ "scala",
+ "bsp",
+ "--json-options",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/.scala-build/ide-options-v2.json",
+ "--json-launcher-options",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/.scala-build/ide-launcher-options.json",
+ "--envs-file",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/.scala-build/ide-envs.json",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/main.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/Printer.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/Client.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/GameEvent.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/ReceivedLineParser.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/RoomState.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/StdinLineReader.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Action.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Adventure.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Area.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Entities",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Event.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Item.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/SingEffects.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/Client.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/Clients.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/ConnectionGetter.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/Server.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/constants/constants.scala",
+ "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/utils/utils.scala"
+ ],
+ "version": "1.4.0",
+ "bspVersion": "2.1.1",
+ "languages": [
+ "scala",
+ "java"
+ ]
+} \ No newline at end of file
diff --git a/src/scalevalapokalypsi/UI/Printer.scala b/src/scalevalapokalypsi/UI/Printer.scala
new file mode 100644
index 0000000..a33864f
--- /dev/null
+++ b/src/scalevalapokalypsi/UI/Printer.scala
@@ -0,0 +1,78 @@
+package scalevalapokalypsi.UI
+import scalevalapokalypsi.Client.GameEvent
+import java.lang.System.currentTimeMillis
+import scalevalapokalypsi.utils.isPrintable
+
+/** A singleton for printing. Keeps track about the "action query" at the start
+ * of lines and has a helper function "pryntGameEvent".
+ */
+object Printer:
+ var inputIndicatorAtStartOfLine = false
+ var queriedLineToSing = false
+ var singStartTime: Option[Long] = None
+
+ /** Prints the given game event.
+ *
+ * @param gameEvent the event to print
+ */
+ def printGameEvent(gameEvent: GameEvent): Unit =
+
+ val actions = gameEvent.actions.map(_.mkString("\n"))
+ val roomState = gameEvent.roomState.map(_.toString)
+ val lineToSing = gameEvent.lineToSing
+
+ if
+ inputIndicatorAtStartOfLine &&
+ (actions.isDefined ||
+ roomState.isDefined ||
+ lineToSing.isDefined)
+ then
+ this.printLn("")
+
+ actions.foreach(this.printLn(_))
+
+ roomState.foreach(this.printLn(_))
+
+ lineToSing match
+ case Some(l) =>
+ if this.singStartTime.isEmpty then
+ this.singStartTime = Some(currentTimeMillis() / 1000)
+ print(s"Laula: “$l”\n ")
+ val timeSpent = this.singStartTime.map((t: Long) =>
+ (currentTimeMillis / 1000 - t).toString
+ ).getOrElse("?")
+ print(this.timeIndicatorUpdater(timeSpent))
+ case None =>
+ this.singStartTime = None
+
+ val timeLeft = s"${gameEvent.timeToNextTurn.getOrElse("∞")}"
+
+ if
+ gameEvent.playerCanAct &&
+ lineToSing.isEmpty &&
+ !inputIndicatorAtStartOfLine
+ then
+ this.inputIndicatorAtStartOfLine = true
+ print(s"[$timeLeft s]> ")
+
+ if gameEvent.playerCanAct && lineToSing.isEmpty then
+ print(this.timeIndicatorUpdater(timeLeft))
+
+ end printGameEvent
+
+ /** Prints the given string with a trailing newline added. Should be used
+ * instead of ordinary println because printing outside of the Printer
+ * might cause weird-looking output.
+ *
+ * @param s the line to print
+ */
+ def printLn(s: String): Unit =
+ if isPrintable(s) then
+ println(s)
+ else
+ println("Virhe: epätavallinen merkki havaittu tulosteessa.")
+ this.inputIndicatorAtStartOfLine = false
+
+ private def timeIndicatorUpdater(t: String): String =
+ s"\u001b7\u001b[0E[$t s]> \u001b8"
+
diff --git a/src/scalevalapokalypsi/Client/StdinLineReader.scala b/src/scalevalapokalypsi/UI/StdinLineReader.scala
index 6ba8761..4d0f778 100644
--- a/src/scalevalapokalypsi/Client/StdinLineReader.scala
+++ b/src/scalevalapokalypsi/UI/StdinLineReader.scala
@@ -1,4 +1,4 @@
-package scalevalapokalypsi.Client
+package scalevalapokalypsi.UI
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
@@ -16,13 +16,15 @@ class StdinLineReader:
def newLine(): Option[String] =
this.nextLine.value match
case Some(Success(s)) =>
+ if s.contains("\u0000") then
+ println("End of stream!")
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())
diff --git a/src/scalevalapokalypsi/UI/main.scala b/src/scalevalapokalypsi/UI/main.scala
new file mode 100644
index 0000000..44ca0e4
--- /dev/null
+++ b/src/scalevalapokalypsi/UI/main.scala
@@ -0,0 +1,132 @@
+package scalevalapokalypsi.UI
+
+import scalevalapokalypsi.Client.{newClient, Client, GameEvent}
+import scalevalapokalypsi.Server.Server
+import scalevalapokalypsi.constants.*
+import scalevalapokalypsi.utils.*
+import java.lang.Thread.sleep
+import scala.util.{Try,Success,Failure}
+import scala.collection.immutable.LazyList
+
+import java.lang.Thread
+import scala.io.StdIn.readLine
+
+
+@main def main(): Unit =
+
+ val option = getValidInput[Int](
+ "Miten tahdot pelata?\n" +
+ s"1) Liity viralliselle palvelimelle ($DEFAULT_SERVER)\n" +
+ "2) Käynnistä palvelin laitteellasi ja liity sille\n" +
+ "3) Liity mielivaltaiselle palvelimelle",
+ s => s.toIntOption match
+ case None =>
+ Left("Syötä kokonaisluku.")
+ case Some(i) =>
+ if i < 1 || i > 3 then
+ Left("Syötä kokonaisluku väliltä [1,3].")
+ else
+ Right(i)
+ )
+
+
+ val serverName =
+ if option == 3 then
+ readLine("Syötä palvelimen verkkotunnus.\n> ")
+ else if option == 2 then
+ "127.0.0.1"
+ else
+ DEFAULT_SERVER
+
+ val serverPort =
+ if option == 1 then
+ DEFAULT_PORT
+ else
+ getValidInput[Int](
+ s"Valitse portti palvelimelle. (Jätä tyhjäksi oletusta $DEFAULT_PORT varten)",
+ s =>
+ if s == "" then
+ Right(DEFAULT_PORT)
+ else
+ s.toIntOption match
+ case None =>
+ Left("Syötä kokonaisluku.")
+ case Some(i) =>
+ if i > 0 && i <= 65535 then
+ Right(i)
+ else
+ Left("Syötä portti lailliselta väliltä.")
+ )
+
+ val maxClients = if option == 2 then
+ getValidInput[Int](
+ "Kuinka monta pelaajaa pelissä saa olla samanaikaisesti?",
+ s => s.toIntOption match
+ case None =>
+ Left("Syötä kokonaisluku.")
+ case Some(i) =>
+ if i > 1000 then
+ println(
+ "Aika ison määrän valitsit, mutta olkoon menneeksi."
+ )
+ if i > 0 then
+ Right(i)
+ else
+ Left("Syötä positiivinen kokonaisluku.")
+ )
+ else
+ 0
+
+ val timeLimit = if option == 2 then
+ getValidInput[Int](
+ s"Syötä vuorojen aikaraja yksiköttömänä sekunneissa odottelun vähentämiseksi. Syötä 0 aikarajan poistamiseksi. (Jätä tyhjäksi oletusta $DEFAULT_TURN_TIME_LIMIT varten)",
+ s => if s == "" then
+ Right(DEFAULT_TURN_TIME_LIMIT)
+ else
+ s.toIntOption
+ .toRight("Syötä kokonaisluku")
+ .filterOrElse(
+ _ >= 0,
+ "Syötä epänegatiivinen kokonaisluku."
+ )
+ )
+ else
+ 0
+
+ if option == 2 then
+ Thread(() => new Server(serverPort, maxClients, timeLimit, true).startServer()).start()
+ println("Palvelin käynnistetty taustalla.")
+
+ val name = readLine("Valitse itsellesi pelin sisäinen alter ego.\n> ")
+
+ Try(newClient(name, serverName, serverPort))
+ .toOption
+ .flatten match
+ case Some(client) =>
+ startClient(client)
+ case None =>
+ println(
+ "Serverille liittyminen epäonnistui. Tarkista internet-yhteytesi. Jos yhteytesi on kunnossa ja liittyminen ei pian onnistu, ota yhteyttä Joel Kronqvistiin <joel.kronqvist@iki.fi> olettaen, ettei vuodesta 2024 ole kulunut kohtuuttomasti aikaa."
+ )
+
+
+
+/** Client game loop. Handles output to and from the client in the eyes of the
+ * terminal.
+ */
+def startClient(client: Client): Unit =
+ var hasQuit = false
+ val stdinReader = StdinLineReader()
+ stdinReader.startReading()
+ while !hasQuit do
+ sleep(POLL_INTERVAL)
+ val line = stdinReader.newLine()
+ if line.map(_.length).getOrElse(0) > 1024 then
+ Printer.printLn("Virhe: Syötteesi oli liian pitkä.")
+ else if line == Some("quit") then
+ hasQuit = true
+ else
+ val gameEvent = client.clientStep(line)
+ Printer.printGameEvent(gameEvent)
+
+
diff --git a/src/scalevalapokalypsi/constants/constants.scala b/src/scalevalapokalypsi/constants/constants.scala
index cb08962..970d6f7 100644
--- a/src/scalevalapokalypsi/constants/constants.scala
+++ b/src/scalevalapokalypsi/constants/constants.scala
@@ -9,12 +9,21 @@ val TURN_INDICATOR = ">"
val SING_INDICATOR = "~"
val ACTION_BLOCKING_INDICATOR='.'
val ACTION_NONBLOCKING_INDICATOR='+'
+val INITIAL_CONN_TIMEOUT = 5000 // millisec.
+val DEFAULT_PORT: Int = 2267
+val DEFAULT_TURN_TIME_LIMIT = 30
+val DEFAULT_SERVER = "cron4.fi"
val LIST_SEPARATOR=";"
+val BYTE_ALLOWED =
+ (n: Byte) => !(n <= 31 || (n >= 127 && n <= 159))
+
+val FORBIDDEN_CHARACTERS = "\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009\u000B\u000C\u000D\u000E\u000F\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F\u007F\u0080\u0081\u0082\u0083\u0084\u0085\u0086\u0087\u0088\u0089\u008A\u008B\u008C\u008D\u008E\u008F\u0090\u0091\u0092\u0093\u0094\u0095\u0096\u0097\u0098\u0099\u009A\u009B\u009C\u009D\u009E\u009F"
+
val PROTOCOL_VERSION_GOOD = "1"
val PROTOCOL_VERSION_BAD = "0"
-//assert(PROTOCOL_VERSION_BAD.length <= PROTOCOL_VERSION_GOOD.length)
+// 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
deleted file mode 100644
index 50e89e5..0000000
--- a/src/scalevalapokalypsi/main.scala
+++ /dev/null
@@ -1,25 +0,0 @@
-package scalevalapokalypsi
-
-import scalevalapokalypsi.Client.newClient
-import scalevalapokalypsi.Server.Server
-
-import java.lang.Thread
-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).foreach(_.startClient())
- case Some(2) =>
- print("Choose a name:\n> ")
- val name = readLine()
- newClient(name, "127.0.0.1", 2267).foreach(_.startClient())
- case _ => println("Invalid input")
-
-
diff --git a/src/scalevalapokalypsi/utils/utils.scala b/src/scalevalapokalypsi/utils/utils.scala
index ab262ad..54d407b 100644
--- a/src/scalevalapokalypsi/utils/utils.scala
+++ b/src/scalevalapokalypsi/utils/utils.scala
@@ -2,6 +2,9 @@ package scalevalapokalypsi.utils
import java.io.InputStream
import java.nio.charset.StandardCharsets
+import scala.util.Try
+import scalevalapokalypsi.constants.*
+import scala.io.StdIn.readLine
/** Converts this string to an array of bytes (probably for transmission).
*
@@ -11,6 +14,16 @@ import java.nio.charset.StandardCharsets
def stringToByteArray(str: String): Array[Byte] =
str.getBytes(StandardCharsets.UTF_8)
+/** Converts the given byte array to a string if possible*.
+ * (* Doesn't convert strings with control sequences in them)
+ *
+ * @param bytes the byte array to convert
+ * @return the matching string, if possible*.
+ */
+def byteArrayToString(bytes: Array[Byte]): Option[String] =
+ Try(String(bytes, StandardCharsets.UTF_8))
+ .toOption
+
/** Reads n characters from the given InputStream blockingly.
*
* @param input the InputStream to read from
@@ -25,5 +38,33 @@ def getNCharsFromSocket(input: InputStream, n: Int): Option[String] =
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, StandardCharsets.UTF_8))
+ if failed then None else byteArrayToString(buffer)
+
+def isPrintable(s: String): Boolean =
+ FORBIDDEN_CHARACTERS.forall((c: Char) => !(s contains c))
+
+
+/** Gets input from STDIN until the line entered is appoved
+ * by a validator function.
+ *
+ * @param message A query to represent the user with when requesting input
+ * @param validator A validator function. Should return Right[A] when the
+ * input is valid and Left[String] when the input is invalid,
+ * where the string will be printed to the user to notify
+ * their input was invalid.
+ * @return The first encountered valid string, see `validator`.
+ */
+def getValidInput[A](
+ message: String,
+ validator: String => Either[String, A]
+): A =
+ LazyList.continually(readLine(s"$message\n> "))
+ .flatMap(input =>
+ validator(input) match
+ case Left(s) =>
+ println(s)
+ None
+ case Right(a) =>
+ Some(a)
+ ).head
+