diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..397b4a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.log diff --git a/README.md b/README.md index b011aaa..071a206 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# learn-pub-sub-starter \ No newline at end of file +# learn-pub-sub-starter (Peril) + +This is the starter code used in Boot.dev's [Learn Pub/Sub](https://learn.boot.dev/learn-pub-sub) course. diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..74de144 --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Starting Peril client...") +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..58622c6 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Starting Peril server...") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6bfacb3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/bootdotdev/learn-pub-sub-starter + +go 1.22.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/internal/gamelogic/gamedata.go b/internal/gamelogic/gamedata.go new file mode 100644 index 0000000..e361108 --- /dev/null +++ b/internal/gamelogic/gamedata.go @@ -0,0 +1,52 @@ +package gamelogic + +type Player struct { + Username string + Units map[int]Unit +} + +type UnitRank string + +const ( + RankInfantry = "infantry" + RankCavalry = "cavalry" + RankArtillery = "artillery" +) + +type Unit struct { + ID int + Rank UnitRank + Location Location +} + +type ArmyMove struct { + Player Player + Units []Unit + ToLocation Location +} + +type RecognitionOfWar struct { + Attacker Player + Defender Player +} + +type Location string + +func getAllRanks() map[UnitRank]struct{} { + return map[UnitRank]struct{}{ + RankInfantry: {}, + RankCavalry: {}, + RankArtillery: {}, + } +} + +func getAllLocations() map[Location]struct{} { + return map[Location]struct{}{ + "americas": {}, + "europe": {}, + "africa": {}, + "asia": {}, + "australia": {}, + "antarctica": {}, + } +} diff --git a/internal/gamelogic/gamelogic.go b/internal/gamelogic/gamelogic.go new file mode 100644 index 0000000..ec617f8 --- /dev/null +++ b/internal/gamelogic/gamelogic.go @@ -0,0 +1,92 @@ +package gamelogic + +import ( + "bufio" + "errors" + "fmt" + "math/rand" + "os" + "strings" +) + +func PrintClientHelp() { + fmt.Println("Possible commands:") + fmt.Println("* move ...") + fmt.Println(" example:") + fmt.Println(" move asia 1") + fmt.Println("* spawn ") + fmt.Println(" example:") + fmt.Println(" spawn europe infantry") + fmt.Println("* status") + fmt.Println("* spam ") + fmt.Println(" example:") + fmt.Println(" spam 5") + fmt.Println("* quit") + fmt.Println("* help") +} + +func ClientWelcome() (string, error) { + fmt.Println("Welcome to the Peril client!") + fmt.Println("Please enter your username:") + words := GetInput() + if len(words) == 0 { + return "", errors.New("you must enter a username. goodbye") + } + username := words[0] + fmt.Printf("Welcome, %s!\n", username) + PrintClientHelp() + return username, nil +} + +func PrintServerHelp() { + fmt.Println("Possible commands:") + fmt.Println("* pause") + fmt.Println("* resume") + fmt.Println("* quit") + fmt.Println("* help") +} + +func GetInput() []string { + fmt.Print("> ") + scanner := bufio.NewScanner(os.Stdin) + scanned := scanner.Scan() + if !scanned { + return nil + } + line := scanner.Text() + line = strings.TrimSpace(line) + return strings.Fields(line) +} + +func GetMaliciousLog() string { + possibleLogs := []string{ + "Never interrupt your enemy when he is making a mistake.", + "The hardest thing of all for a soldier is to retreat.", + "A soldier will fight long and hard for a bit of colored ribbon.", + "It is well that war is so terrible, otherwise we should grow too fond of it.", + "The art of war is simple enough. Find out where your enemy is. Get at him as soon as you can. Strike him as hard as you can, and keep moving on.", + "All warfare is based on deception.", + } + randomIndex := rand.Intn(len(possibleLogs)) + msg := possibleLogs[randomIndex] + return msg +} + +func PrintQuit() { + fmt.Println("I hate this game! (╯°□°)╯︵ ┻━┻") +} + +func (gs *GameState) CommandStatus() { + if gs.isPaused() { + fmt.Println("The game is paused.") + return + } else { + fmt.Println("The game is not paused.") + } + + p := gs.GetPlayerSnap() + fmt.Printf("You are %s, and you have %d units.\n", p.Username, len(p.Units)) + for _, unit := range p.Units { + fmt.Printf("* %v: %v, %v\n", unit.ID, unit.Location, unit.Rank) + } +} diff --git a/internal/gamelogic/gamestate.go b/internal/gamelogic/gamestate.go new file mode 100644 index 0000000..698f37a --- /dev/null +++ b/internal/gamelogic/gamestate.go @@ -0,0 +1,96 @@ +package gamelogic + +import ( + "sync" +) + +type GameState struct { + Player Player + Paused bool + mu *sync.RWMutex +} + +func NewGameState(username string) *GameState { + return &GameState{ + Player: Player{ + Username: username, + Units: map[int]Unit{}, + }, + Paused: false, + mu: &sync.RWMutex{}, + } +} + +func (gs *GameState) resumeGame() { + gs.mu.Lock() + defer gs.mu.Unlock() + gs.Paused = false +} + +func (gs *GameState) pauseGame() { + gs.mu.Lock() + defer gs.mu.Unlock() + gs.Paused = true +} + +func (gs *GameState) isPaused() bool { + gs.mu.RLock() + defer gs.mu.RUnlock() + return gs.Paused +} + +func (gs *GameState) addUnit(u Unit) { + gs.mu.Lock() + defer gs.mu.Unlock() + gs.Player.Units[u.ID] = u +} + +func (gs *GameState) removeUnitsInLocation(loc Location) { + gs.mu.Lock() + defer gs.mu.Unlock() + for k, v := range gs.Player.Units { + if v.Location == loc { + delete(gs.Player.Units, k) + } + } +} + +func (gs *GameState) UpdateUnit(u Unit) { + gs.mu.Lock() + defer gs.mu.Unlock() + gs.Player.Units[u.ID] = u +} + +func (gs *GameState) GetUsername() string { + return gs.Player.Username +} + +func (gs *GameState) getUnitsSnap() []Unit { + gs.mu.RLock() + defer gs.mu.RUnlock() + Units := []Unit{} + for _, v := range gs.Player.Units { + Units = append(Units, v) + } + return Units +} + +func (gs *GameState) GetUnit(id int) (Unit, bool) { + gs.mu.RLock() + defer gs.mu.RUnlock() + u, ok := gs.Player.Units[id] + return u, ok +} + +func (gs *GameState) GetPlayerSnap() Player { + gs.mu.RLock() + defer gs.mu.RUnlock() + Units := map[int]Unit{} + for k, v := range gs.Player.Units { + Units[k] = v + } + return Player{ + Username: gs.Player.Username, + Units: Units, + } +} diff --git a/internal/gamelogic/logs.go b/internal/gamelogic/logs.go new file mode 100644 index 0000000..f9af79f --- /dev/null +++ b/internal/gamelogic/logs.go @@ -0,0 +1,32 @@ +package gamelogic + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/bootdotdev/learn-pub-sub-starter/internal/routing" +) + +const logsFile = "game.log" + +const writeToDiskSleep = 1 * time.Second + +func WriteLog(gamelog routing.GameLog) error { + log.Printf("received game log...") + time.Sleep(writeToDiskSleep) + + f, err := os.OpenFile(logsFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("could not open logs file: %v", err) + } + defer f.Close() + + str := fmt.Sprintf("%v %v: %v\n", gamelog.CurrentTime.Format(time.RFC3339), gamelog.Username, gamelog.Message) + _, err = f.WriteString(str) + if err != nil { + return fmt.Errorf("could not write to logs file: %v", err) + } + return nil +} diff --git a/internal/gamelogic/move.go b/internal/gamelogic/move.go new file mode 100644 index 0000000..02497c0 --- /dev/null +++ b/internal/gamelogic/move.go @@ -0,0 +1,90 @@ +package gamelogic + +import ( + "errors" + "fmt" + "strconv" +) + +type MoveOutcome int + +const ( + MoveOutcomeSamePlayer MoveOutcome = iota + MoveOutcomeSafe + MoveOutcomeMakeWar +) + +func (gs *GameState) HandleMove(move ArmyMove) MoveOutcome { + defer fmt.Println("------------------------") + player := gs.GetPlayerSnap() + + fmt.Println() + fmt.Println("==== Move Detected ====") + fmt.Printf("%s is moving %v unit(s) to %s\n", move.Player.Username, len(move.Units), move.ToLocation) + for _, unit := range move.Units { + fmt.Printf("* %v\n", unit.Rank) + } + + if player.Username == move.Player.Username { + return MoveOutcomeSamePlayer + } + + overlappingLocation := getOverlappingLocation(player, move.Player) + if overlappingLocation != "" { + fmt.Printf("You have units in %s! You are at war with %s!\n", overlappingLocation, move.Player.Username) + return MoveOutcomeMakeWar + } + fmt.Printf("You are safe from %s's units.\n", move.Player.Username) + return MoveOutcomeSafe +} + +func getOverlappingLocation(p1 Player, p2 Player) Location { + for _, u1 := range p1.Units { + for _, u2 := range p2.Units { + if u1.Location == u2.Location { + return u1.Location + } + } + } + return "" +} + +func (gs *GameState) CommandMove(words []string) (ArmyMove, error) { + if gs.isPaused() { + return ArmyMove{}, errors.New("the game is paused, you can not move units") + } + if len(words) < 3 { + return ArmyMove{}, errors.New("usage: move etc") + } + newLocation := Location(words[1]) + locations := getAllLocations() + if _, ok := locations[newLocation]; !ok { + return ArmyMove{}, fmt.Errorf("error: %s is not a valid location", newLocation) + } + unitIDs := []int{} + for _, word := range words[2:] { + id := word + unitID, err := strconv.Atoi(id) + if err != nil { + return ArmyMove{}, fmt.Errorf("error: %s is not a valid unit ID", id) + } + unitIDs = append(unitIDs, unitID) + } + + for _, unitID := range unitIDs { + unit, ok := gs.GetUnit(unitID) + if !ok { + return ArmyMove{}, fmt.Errorf("error: unit with ID %v not found", unitID) + } + unit.Location = newLocation + gs.UpdateUnit(unit) + } + + mv := ArmyMove{ + ToLocation: newLocation, + Units: gs.getUnitsSnap(), + Player: gs.GetPlayerSnap(), + } + fmt.Printf("Moved %v units to %s\n", len(mv.Units), mv.ToLocation) + return mv, nil +} diff --git a/internal/gamelogic/pause.go b/internal/gamelogic/pause.go new file mode 100644 index 0000000..5874f3c --- /dev/null +++ b/internal/gamelogic/pause.go @@ -0,0 +1,19 @@ +package gamelogic + +import ( + "fmt" + + "github.com/bootdotdev/learn-pub-sub-starter/internal/routing" +) + +func (gs *GameState) HandlePause(ps routing.PlayingState) { + defer fmt.Println("------------------------") + fmt.Println() + if ps.IsPaused { + fmt.Println("==== Pause Detected ====") + gs.pauseGame() + } else { + fmt.Println("==== Resume Detected ====") + gs.resumeGame() + } +} diff --git a/internal/gamelogic/spawn.go b/internal/gamelogic/spawn.go new file mode 100644 index 0000000..dd68d41 --- /dev/null +++ b/internal/gamelogic/spawn.go @@ -0,0 +1,34 @@ +package gamelogic + +import ( + "errors" + "fmt" +) + +func (gs *GameState) CommandSpawn(words []string) error { + if len(words) < 3 { + return errors.New("usage: spawn ") + } + + locationName := words[1] + locations := getAllLocations() + if _, ok := locations[Location(locationName)]; !ok { + return fmt.Errorf("error: %s is not a valid location", locationName) + } + + rank := words[2] + units := getAllRanks() + if _, ok := units[UnitRank(rank)]; !ok { + return fmt.Errorf("error: %s is not a valid unit", rank) + } + + id := len(gs.getUnitsSnap()) + 1 + gs.addUnit(Unit{ + ID: id, + Rank: UnitRank(rank), + Location: Location(locationName), + }) + + fmt.Printf("Spawned a(n) %s in %s with id %v\n", rank, locationName, id) + return nil +} diff --git a/internal/gamelogic/war.go b/internal/gamelogic/war.go new file mode 100644 index 0000000..39e9419 --- /dev/null +++ b/internal/gamelogic/war.go @@ -0,0 +1,105 @@ +package gamelogic + +import ( + "fmt" +) + +type WarOutcome int + +const ( + WarOutcomeNotInvolved WarOutcome = iota + WarOutcomeNoUnits + WarOutcomeYouWon + WarOutcomeOpponentWon + WarOutcomeDraw +) + +func (gs *GameState) HandleWar(rw RecognitionOfWar) (outcome WarOutcome, winner string, loser string) { + defer fmt.Println("------------------------") + fmt.Println() + fmt.Println("==== War Declared ====") + fmt.Printf("%s has declared war on %s!\n", rw.Attacker.Username, rw.Defender.Username) + + player := gs.GetPlayerSnap() + + if player.Username == rw.Defender.Username { + fmt.Printf("%s, you published the war.\n", player.Username) + return WarOutcomeNotInvolved, "", "" + } + + if player.Username != rw.Attacker.Username { + fmt.Printf("%s, you are not involved in this war.\n", player.Username) + return WarOutcomeNotInvolved, "", "" + } + + overlappingLocation := getOverlappingLocation(rw.Attacker, rw.Defender) + if overlappingLocation == "" { + fmt.Printf("Error! No units are in the same location. No war will be fought.\n") + return WarOutcomeNoUnits, "", "" + } + + attackerUnits := []Unit{} + defenderUnits := []Unit{} + for _, unit := range rw.Attacker.Units { + if unit.Location == overlappingLocation { + attackerUnits = append(attackerUnits, unit) + } + } + for _, unit := range rw.Defender.Units { + if unit.Location == overlappingLocation { + defenderUnits = append(defenderUnits, unit) + } + } + + fmt.Printf("%s's units:\n", rw.Attacker.Username) + for _, unit := range attackerUnits { + fmt.Printf(" * %v\n", unit.Rank) + } + fmt.Printf("%s's units:\n", rw.Defender.Username) + for _, unit := range defenderUnits { + fmt.Printf(" * %v\n", unit.Rank) + } + attackerPower := unitsToPowerLevel(attackerUnits) + defenderPower := unitsToPowerLevel(defenderUnits) + fmt.Printf("Attacker has a power level of %v\n", attackerPower) + fmt.Printf("Defender has a power level of %v\n", defenderPower) + if attackerPower > defenderPower { + fmt.Printf("%s has won the war!\n", rw.Attacker.Username) + if player.Username == rw.Defender.Username { + fmt.Println("You have lost the war!") + gs.removeUnitsInLocation(overlappingLocation) + fmt.Printf("Your units in %s have been killed.\n", overlappingLocation) + return WarOutcomeOpponentWon, rw.Attacker.Username, rw.Defender.Username + } + return WarOutcomeYouWon, rw.Attacker.Username, rw.Defender.Username + } else if defenderPower > attackerPower { + fmt.Printf("%s has won the war!\n", rw.Defender.Username) + if player.Username == rw.Attacker.Username { + fmt.Println("You have lost the war!") + gs.removeUnitsInLocation(overlappingLocation) + fmt.Printf("Your units in %s have been killed.\n", overlappingLocation) + return WarOutcomeOpponentWon, rw.Defender.Username, rw.Attacker.Username + } + return WarOutcomeYouWon, rw.Defender.Username, rw.Attacker.Username + } + fmt.Println("The war ended in a draw!") + fmt.Printf("Your units in %s have been killed.\n", overlappingLocation) + gs.removeUnitsInLocation(overlappingLocation) + return WarOutcomeDraw, rw.Attacker.Username, rw.Defender.Username +} + +func unitsToPowerLevel(units []Unit) int { + power := 0 + for _, unit := range units { + if unit.Rank == RankArtillery { + power += 10 + } + if unit.Rank == RankCavalry { + power += 5 + } + if unit.Rank == RankInfantry { + power += 1 + } + } + return power +} diff --git a/internal/routing/models.go b/internal/routing/models.go new file mode 100644 index 0000000..5f85551 --- /dev/null +++ b/internal/routing/models.go @@ -0,0 +1,13 @@ +package routing + +import "time" + +type PlayingState struct { + IsPaused bool +} + +type GameLog struct { + CurrentTime time.Time + Message string + Username string +} diff --git a/internal/routing/routing.go b/internal/routing/routing.go new file mode 100644 index 0000000..6784374 --- /dev/null +++ b/internal/routing/routing.go @@ -0,0 +1,16 @@ +package routing + +const ( + ArmyMovesPrefix = "army_moves" + + WarRecognitionsPrefix = "war" + + PauseKey = "pause" + + GameLogSlug = "game_logs" +) + +const ( + ExchangePerilDirect = "peril_direct" + ExchangePerilTopic = "peril_topic" +) diff --git a/multiserver.sh b/multiserver.sh new file mode 100644 index 0000000..8b785b9 --- /dev/null +++ b/multiserver.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Check if the number of instances was provided +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +num_instances=$1 + +# Array to store process IDs +declare -a pids + +# Function to kill all processes when Ctrl+C is pressed +cleanup() { + echo "Terminating all instances of ./cmd/server..." + for pid in "${pids[@]}"; do + kill -SIGTERM "$pid" + done + exit +} + +# Setup trap for SIGINT +trap 'cleanup' SIGINT + +# Start the specified number of instances of the program in the background +for (( i=0; i