diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 7c0b178..3921922 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,6 +22,7 @@ jobs: # * 2 clients # * 1 server GOMAXPROCS: 4 + IS_CI: true runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 diff --git a/Makefile b/Makefile index 73dc689..637a63f 100644 --- a/Makefile +++ b/Makefile @@ -8,4 +8,4 @@ build: .PHONY: test test: ## Run the tests - @xvfb-run go test -v ./... + @xvfb-run go test ./... diff --git a/client/camera.go b/client/camera.go index 79d2858..6f72990 100644 --- a/client/camera.go +++ b/client/camera.go @@ -114,7 +114,7 @@ func (cs *CameraStore) Reduce(state, a interface{}) interface{} { cstate.W = float64(act.WindowResizing.Width) cstate.H = float64(act.WindowResizing.Height) case action.GoHome: - cp := cs.Store.Players.GetCurrentPlayer() + cp := cs.Store.Players.FindCurrent() cstate.X, cstate.Y = cs.Store.Map.GetHomeCoordinates(cp.LineID) } diff --git a/client/hud.go b/client/hud.go index ee9caca..3a8a198 100644 --- a/client/hud.go +++ b/client/hud.go @@ -106,8 +106,8 @@ func (hs *HUDStore) Update() error { cs := hs.game.Camera.GetState().(CameraState) hst := hs.GetState().(HUDState) x, y := hs.input.CursorPosition() - cp := hs.game.Store.Players.GetCurrentPlayer() - tws := hs.game.Store.Towers.GetTowers() + cp := hs.game.Store.Players.FindCurrent() + tws := hs.game.Store.Towers.List() // Only send a CursorMove when the curso has actually moved if hst.LastCursorPosition.X != float64(x) || hst.LastCursorPosition.Y != float64(y) { actionDispatcher.CursorMove(x, y) @@ -131,13 +131,13 @@ func (hs *HUDStore) Update() error { } // Check what the user has just clicked for _, u := range hst.Units { - if cp.Gold >= u.Unit.Gold && u.Object.IsColliding(click) { + if cp.CanSummonUnit(u.Unit.Type.String()) && u.Object.IsColliding(click) { actionDispatcher.SummonUnit(u.Unit.Type.String(), cp.ID, cp.LineID, hs.game.Store.Map.GetNextLineID(cp.LineID)) return nil } } for _, t := range hst.Towers { - if cp.Gold >= t.Tower.Gold && t.Object.IsColliding(click) { + if cp.CanPlaceTower(t.Tower.Type.String()) && t.Object.IsColliding(click) { actionDispatcher.SelectTower(t.Tower.Type.String(), x, y) return nil } @@ -276,7 +276,7 @@ func (hs *HUDStore) Update() error { func (hs *HUDStore) Draw(screen *ebiten.Image) { hst := hs.GetState().(HUDState) cs := hs.game.Camera.GetState().(CameraState) - cp := hs.game.Store.Players.GetCurrentPlayer() + cp := hs.game.Store.Players.FindCurrent() if cp.Lives == 0 { text.Draw(screen, "YOU LOST", smallFont, int(cs.W/2), int(cs.H/2), color.White) @@ -289,7 +289,7 @@ func (hs *HUDStore) Draw(screen *ebiten.Image) { for _, u := range hst.Units { op := &ebiten.DrawImageOptions{} op.GeoM.Translate(u.Object.X, u.Object.Y) - if cp.Gold < u.Unit.Gold { + if cp.CanSummonUnit(u.Unit.Type.String()) { op.ColorM.Scale(2, 0.5, 0.5, 0.9) } screen.DrawImage(u.Unit.Faceset.(*ebiten.Image), op) @@ -298,7 +298,7 @@ func (hs *HUDStore) Draw(screen *ebiten.Image) { for _, t := range hst.Towers { op := &ebiten.DrawImageOptions{} op.GeoM.Translate(t.Object.X, t.Object.Y) - if cp.Gold < t.Tower.Gold { + if cp.CanPlaceTower(t.Tower.Type.String()) { op.ColorM.Scale(2, 0.5, 0.5, 0.9) } else if hst.SelectedTower != nil && hst.SelectedTower.Type == t.Tower.Type.String() { // Once the tower is selected we gray it out @@ -324,7 +324,7 @@ func (hs *HUDStore) Draw(screen *ebiten.Image) { } psit := hs.game.Store.Players.GetState().(store.PlayersState).IncomeTimer - players := hs.game.Store.Players.GetPlayers() + players := hs.game.Store.Players.List() text.Draw(screen, fmt.Sprintf("Income Timer: %ds", psit), smallFont, 0, 15, color.White) var pcount = 2 var sortedPlayers = make([]*store.Player, 0, 0) @@ -365,7 +365,7 @@ func (hs *HUDStore) Reduce(state, a interface{}) interface{} { } case action.SelectTower: hs.GetDispatcher().WaitFor(hs.game.Store.Players.GetDispatcherToken()) - cp := hs.game.Store.Players.GetCurrentPlayer() + cp := hs.game.Store.Players.FindCurrent() hstate.SelectedTower = &SelectedTower{ Tower: store.Tower{ Object: utils.Object{ diff --git a/client/lobby.go b/client/lobby.go index 0aa123c..b6bfba8 100644 --- a/client/lobby.go +++ b/client/lobby.go @@ -70,12 +70,12 @@ func (ls *LobbyStore) Update() error { W: 1, H: 1, } if lst.YesBtn.IsColliding(obj) { - cp := ls.Store.Players.GetCurrentPlayer() + cp := ls.Store.Players.FindCurrent() actionDispatcher.PlayerReady(cp.ID) } } - players := ls.Store.Players.GetPlayers() + players := ls.Store.Players.List() if len(players) > 1 { allReady := true for _, p := range players { @@ -95,7 +95,7 @@ func (ls *LobbyStore) Update() error { func (ls *LobbyStore) Draw(screen *ebiten.Image) { cs := ls.Camera.GetState().(CameraState) - ps := ls.Store.Players.GetPlayers() + ps := ls.Store.Players.List() lst := ls.GetState().(LobbyState) text.Draw(screen, "LOBBY", normalFont, int(cs.W/2), int(cs.H/2), color.White) var pcount = 1 diff --git a/client/router.go b/client/router.go index 88c2612..be7064f 100644 --- a/client/router.go +++ b/client/router.go @@ -29,8 +29,8 @@ func NewRouterStore(d *flux.Dispatcher, g *Game, l *LobbyStore) *RouterStore { } rs.ReduceStore = flux.NewReduceStore(d, rs.Reduce, RouterState{ - //Route: LobbyRoute, - Route: GameRoute, + Route: LobbyRoute, + //Route: GameRoute, }) return rs diff --git a/client/towers.go b/client/towers.go index f4653f4..8f4b92d 100644 --- a/client/towers.go +++ b/client/towers.go @@ -31,9 +31,9 @@ func NewTowers(g *Game) (*Towers, error) { } func (ts *Towers) Update() error { - uts := ts.game.Store.Units.GetUnits() - tws := ts.game.Store.Towers.GetTowers() - cp := ts.game.Store.Players.GetCurrentPlayer() + uts := ts.game.Store.Units.List() + tws := ts.game.Store.Towers.List() + cp := ts.game.Store.Players.FindCurrent() for _, t := range tws { if t.PlayerID != cp.ID { continue @@ -66,7 +66,7 @@ func (ts *Towers) Update() error { } func (ts *Towers) Draw(screen *ebiten.Image) { - for _, t := range ts.game.Store.Towers.GetTowers() { + for _, t := range ts.game.Store.Towers.List() { ts.DrawTower(screen, ts.game.Camera, t) } } diff --git a/client/units.go b/client/units.go index 0de4415..dc371fd 100644 --- a/client/units.go +++ b/client/units.go @@ -49,21 +49,21 @@ func NewUnits(g *Game) (*Units, error) { func (us *Units) Update() error { actionDispatcher.MoveUnit() - cp := us.game.Store.Players.GetCurrentPlayer() + cp := us.game.Store.Players.FindCurrent() - for _, u := range us.game.Store.Units.GetUnits() { + for _, u := range us.game.Store.Units.List() { // Only do the events as the owern of the unit if not the actionDispatcher // will also dispatch it to the server and the event will be done len(players) // amount of times if cp.ID == u.PlayerID { if u.Health == 0 { - p := us.game.Store.Players.GetByLineID(u.CurrentLineID) + p := us.game.Store.Players.FindByLineID(u.CurrentLineID) actionDispatcher.UnitKilled(p.ID, u.Type) actionDispatcher.RemoveUnit(u.ID) continue } if us.game.Store.Map.IsAtTheEnd(u.Object, u.CurrentLineID) { - p := us.game.Store.Players.GetByLineID(u.CurrentLineID) + p := us.game.Store.Players.FindByLineID(u.CurrentLineID) actionDispatcher.StealLive(p.ID, u.PlayerID) nlid := us.game.Store.Map.GetNextLineID(u.CurrentLineID) if nlid == u.PlayerLineID { @@ -81,7 +81,7 @@ func (us *Units) Update() error { } func (us *Units) Draw(screen *ebiten.Image) { - for _, u := range us.game.Store.Units.GetUnits() { + for _, u := range us.game.Store.Units.List() { us.DrawUnit(screen, us.game.Camera, u) } } diff --git a/integration/main_test.go b/integration/main_test.go index 88d276c..36fc4c3 100644 --- a/integration/main_test.go +++ b/integration/main_test.go @@ -2,6 +2,7 @@ package integration_test import ( "context" + "os" "os/exec" "runtime" "testing" @@ -31,6 +32,9 @@ var ( ) func TestRun(t *testing.T) { + if os.Getenv("IS_CI") == "true" { + t.Skip("This test are skipped for now on the CI") + } var ( err error room = "room" @@ -153,7 +157,7 @@ func TestRun(t *testing.T) { ros := rooms.GetState().(server.RoomsState) - for len(rooms.GetState().(server.RoomsState).Rooms) != 1 || len(ros.Rooms[room].Game.Players.GetPlayers()) != 2 { + for len(rooms.GetState().(server.RoomsState).Rooms) != 1 || len(ros.Rooms[room].Game.Players.List()) != 2 { if tries == 10 { t.Fatal(t, "Could not initialize the players") } @@ -161,7 +165,7 @@ func TestRun(t *testing.T) { time.Sleep(time.Second) tries++ } - for _, p := range ros.Rooms[room].Game.Players.GetPlayers() { + for _, p := range ros.Rooms[room].Game.Players.List() { players[p.Name] = p } @@ -176,7 +180,7 @@ func TestRun(t *testing.T) { resetDefault() wait(serverGameTick) - for _, p := range rooms.GetState().(server.RoomsState).Rooms[room].Game.Players.GetPlayers() { + for _, p := range rooms.GetState().(server.RoomsState).Rooms[room].Game.Players.List() { if p.Name == p1n { assert.True(t, p.Ready) } @@ -186,7 +190,7 @@ func TestRun(t *testing.T) { // We mark 2 players as ready sad.Dispatch(action.NewPlayerReady(players[p2n].ID)) - for _, p := range rooms.GetState().(server.RoomsState).Rooms[room].Game.Players.GetPlayers() { + for _, p := range rooms.GetState().(server.RoomsState).Rooms[room].Game.Players.List() { assert.True(t, p.Ready) } diff --git a/server/action.go b/server/action.go index f3bc12c..d15afb5 100644 --- a/server/action.go +++ b/server/action.go @@ -66,7 +66,7 @@ func (ac *ActionDispatcher) UpdateState(rooms *RoomsStore) { // Towers towers := make(map[string]*action.UpdateStateTowerPayload) - ts := r.Game.Towers.GetTowers() + ts := r.Game.Towers.List() for _, t := range ts { ustp := action.UpdateStateTowerPayload(*t) towers[t.ID] = &ustp @@ -74,7 +74,7 @@ func (ac *ActionDispatcher) UpdateState(rooms *RoomsStore) { // Units units := make(map[string]*action.UpdateStateUnitPayload) - us := r.Game.Units.GetUnits() + us := r.Game.Units.List() for _, u := range us { usup := action.UpdateStateUnitPayload(*u) units[u.ID] = &usup diff --git a/store/helper_test.go b/store/helper_test.go new file mode 100644 index 0000000..a35b5d2 --- /dev/null +++ b/store/helper_test.go @@ -0,0 +1,51 @@ +package store_test + +import ( + "fmt" + + "github.com/gofrs/uuid" + "github.com/gorilla/websocket" + "github.com/xescugc/go-flux" + "github.com/xescugc/ltw/action" + "github.com/xescugc/ltw/store" + "github.com/xescugc/ltw/tower" + "github.com/xescugc/ltw/unit" +) + +func initStore() *store.Store { + d := flux.NewDispatcher() + return store.NewStore(d) +} + +func addPlayer(s *store.Store) store.Player { + sid := "sid" + id := uuid.Must(uuid.NewV4()) + name := fmt.Sprintf("name-%s", id.String()) + lid := 2 + ws := &websocket.Conn{} + s.Dispatch(action.NewAddPlayer(sid, id.String(), name, lid, ws)) + + return s.Players.FindByID(id.String()) +} + +func summonUnit(s *store.Store, p store.Player) (store.Player, store.Unit) { + clid := 2 + s.Dispatch(action.NewSummonUnit(unit.Spirit.String(), p.ID, p.LineID, clid)) + + // We know the Summon does this and as 'p' is not a pointer + // we need to do it manually + p.Gold -= unit.Units[unit.Spirit.String()].Gold + p.Income += unit.Units[unit.Spirit.String()].Income + + return p, *s.Units.List()[0] +} + +func placeTower(s *store.Store, p store.Player) (store.Player, store.Tower) { + s.Dispatch(action.NewPlaceTower(tower.Soldier.String(), p.ID, 10, 20)) + + // We know the PlaceTower does this and as 'p' is not a pointer + // we need to do it manually + p.Gold -= tower.Towers[tower.Soldier.String()].Gold + + return p, *s.Towers.List()[0] +} diff --git a/store/map.go b/store/map.go index 26c3773..23fe6f5 100644 --- a/store/map.go +++ b/store/map.go @@ -173,7 +173,7 @@ func (m *Map) Reduce(state, a interface{}) interface{} { switch act.Type { case action.StartGame: - players := m.store.Players.GetPlayers() + players := m.store.Players.List() if len(players) > 1 { allReady := true for _, p := range players { diff --git a/store/players.go b/store/players.go index b133bca..682092c 100644 --- a/store/players.go +++ b/store/players.go @@ -16,6 +16,8 @@ const ( type Players struct { *flux.ReduceStore + store *Store + mxPlayers sync.RWMutex } @@ -38,8 +40,17 @@ type Player struct { Ready bool } -func NewPlayers(d *flux.Dispatcher) *Players { - p := &Players{} +func (p Player) CanSummonUnit(ut string) bool { + return (p.Gold - unit.Units[ut].Gold) >= 0 +} +func (p Player) CanPlaceTower(tt string) bool { + return (p.Gold - tower.Towers[tt].Gold) >= 0 +} + +func NewPlayers(d *flux.Dispatcher, s *Store) *Players { + p := &Players{ + store: s, + } p.ReduceStore = flux.NewReduceStore(d, p.Reduce, PlayersState{ IncomeTimer: incomeTimer, Players: make(map[string]*Player), @@ -49,7 +60,7 @@ func NewPlayers(d *flux.Dispatcher) *Players { } // GetPlayers returns the players list and it's meant for reading only purposes -func (ps *Players) GetPlayers() []*Player { +func (ps *Players) List() []*Player { ps.mxPlayers.RLock() defer ps.mxPlayers.RUnlock() mplayers := ps.GetState().(PlayersState) @@ -60,7 +71,7 @@ func (ps *Players) GetPlayers() []*Player { return players } -func (ps *Players) GetCurrentPlayer() Player { +func (ps *Players) FindCurrent() Player { ps.mxPlayers.RLock() defer ps.mxPlayers.RUnlock() for _, p := range ps.GetState().(PlayersState).Players { @@ -71,14 +82,17 @@ func (ps *Players) GetCurrentPlayer() Player { return Player{} } -func (ps *Players) GetPlayerByID(id string) Player { +func (ps *Players) FindByID(id string) Player { ps.mxPlayers.RLock() defer ps.mxPlayers.RUnlock() - p, _ := ps.GetState().(PlayersState).Players[id] + p, ok := ps.GetState().(PlayersState).Players[id] + if !ok { + return Player{} + } return *p } -func (ps *Players) GetByLineID(lid int) Player { +func (ps *Players) FindByLineID(lid int) Player { ps.mxPlayers.RLock() defer ps.mxPlayers.RUnlock() for _, p := range ps.GetState().(PlayersState).Players { @@ -105,6 +119,18 @@ func (ps *Players) Reduce(state, a interface{}) interface{} { ps.mxPlayers.Lock() defer ps.mxPlayers.Unlock() + var found bool + for _, p := range pstate.Players { + if p.Name == act.AddPlayer.Name { + found = true + break + } + } + + if found { + break + } + pstate.Players[act.AddPlayer.ID] = &Player{ ID: act.AddPlayer.ID, Name: act.AddPlayer.Name, @@ -123,14 +149,15 @@ func (ps *Players) Reduce(state, a interface{}) interface{} { defer ps.mxPlayers.Unlock() fp := pstate.Players[act.StealLive.FromPlayerID] + tp := pstate.Players[act.StealLive.ToPlayerID] + fp.Lives -= 1 if fp.Lives < 0 { fp.Lives = 0 + } else { + tp.Lives += 1 } - tp := pstate.Players[act.StealLive.ToPlayerID] - tp.Lives += 1 - var stillPlayersLeft bool for _, p := range pstate.Players { if stillPlayersLeft { @@ -145,9 +172,15 @@ func (ps *Players) Reduce(state, a interface{}) interface{} { tp.Winner = true } case action.SummonUnit: + // We need to wait for the units if not the units Store cannot check + // if the unit can be summoned if the Gold has already been removed + ps.GetDispatcher().WaitFor(ps.store.Units.GetDispatcherToken()) ps.mxPlayers.Lock() defer ps.mxPlayers.Unlock() + if !pstate.Players[act.SummonUnit.PlayerID].CanSummonUnit(act.SummonUnit.Type) { + break + } pstate.Players[act.SummonUnit.PlayerID].Income += unit.Units[act.SummonUnit.Type].Income pstate.Players[act.SummonUnit.PlayerID].Gold -= unit.Units[act.SummonUnit.Type].Gold case action.IncomeTick: @@ -162,9 +195,18 @@ func (ps *Players) Reduce(state, a interface{}) interface{} { } } case action.PlaceTower: + ps.GetDispatcher().WaitFor( + ps.store.Towers.GetDispatcherToken(), + ps.store.Units.GetDispatcherToken(), + ) + ps.mxPlayers.Lock() defer ps.mxPlayers.Unlock() + if !pstate.Players[act.PlaceTower.PlayerID].CanPlaceTower(act.PlaceTower.Type) { + break + } + pstate.Players[act.PlaceTower.PlayerID].Gold -= tower.Towers[act.PlaceTower.Type].Gold case action.RemoveTower: ps.mxPlayers.Lock() diff --git a/store/players_test.go b/store/players_test.go new file mode 100644 index 0000000..decbc03 --- /dev/null +++ b/store/players_test.go @@ -0,0 +1,144 @@ +package store_test + +import ( + "testing" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/xescugc/go-flux" + "github.com/xescugc/ltw/action" + "github.com/xescugc/ltw/store" +) + +func TestNewPlayers(t *testing.T) { + d := flux.NewDispatcher() + st := store.NewStore(d) + ps := store.NewPlayers(d, st) + pstate := ps.GetState().(store.PlayersState) + epstate := store.PlayersState{ + Players: make(map[string]*store.Player), + IncomeTimer: 15, + } + assert.Equal(t, epstate, pstate) +} + +func TestPlayers_List(t *testing.T) { + d := flux.NewDispatcher() + st := store.NewStore(d) + ps := store.NewPlayers(d, st) + sid := "sid" + id := "id" + name := "name" + lid := 2 + ws := &websocket.Conn{} + // To have any player we have to add it first + d.Dispatch(action.NewAddPlayer(sid, id, name, lid, ws)) + + playes := ps.List() + eplayers := []*store.Player{ + &store.Player{ + ID: id, + Name: name, + LineID: lid, + Lives: 20, + Income: 25, + Gold: 40, + }, + } + + assert.Equal(t, eplayers, playes) +} + +func TestPlayers_FindCurrent(t *testing.T) { + d := flux.NewDispatcher() + st := store.NewStore(d) + ps := store.NewPlayers(d, st) + sid := "sid" + id := "id" + name := "name" + lid := 2 + ws := &websocket.Conn{} + // To have any player we have to add it first + d.Dispatch(action.NewAddPlayer(sid, id, name, lid, ws)) + + cp := ps.FindCurrent() + + assert.Empty(t, cp) + + pstate := ps.GetState().(store.PlayersState) + // NOTE: There is no way to set the current value, + // it's set directly from the server when sending + // the state back + pstate.Players[id].Current = true + + cp = ps.FindCurrent() + ecp := store.Player{ + ID: id, + Name: name, + LineID: lid, + Lives: 20, + Income: 25, + Gold: 40, + Current: true, + } + + assert.Equal(t, ecp, cp) +} + +func TestPlayers_FindByID(t *testing.T) { + d := flux.NewDispatcher() + st := store.NewStore(d) + ps := store.NewPlayers(d, st) + sid := "sid" + id := "id" + name := "name" + lid := 2 + ws := &websocket.Conn{} + // To have any player we have to add it first + d.Dispatch(action.NewAddPlayer(sid, id, name, lid, ws)) + + cp := ps.FindByID("none") + + assert.Empty(t, cp) + + cp = ps.FindByID(id) + ecp := store.Player{ + ID: id, + Name: name, + LineID: lid, + Lives: 20, + Income: 25, + Gold: 40, + } + + assert.Equal(t, ecp, cp) +} + +func TestPlayers_FindByLineID(t *testing.T) { + d := flux.NewDispatcher() + st := store.NewStore(d) + ps := store.NewPlayers(d, st) + sid := "sid" + id := "id" + name := "name" + lid := 2 + ws := &websocket.Conn{} + // To have any player we have to add it first + d.Dispatch(action.NewAddPlayer(sid, id, name, lid, ws)) + + cp := ps.FindByLineID(99) + + assert.Empty(t, cp) + + cp = ps.FindByLineID(lid) + ecp := store.Player{ + ID: id, + Name: name, + LineID: lid, + Lives: 20, + Income: 25, + Gold: 40, + } + + assert.Equal(t, ecp, cp) +} diff --git a/store/reduce_test.go b/store/reduce_test.go new file mode 100644 index 0000000..69e317a --- /dev/null +++ b/store/reduce_test.go @@ -0,0 +1,480 @@ +package store_test + +import ( + "testing" + + "github.com/gofrs/uuid" + "github.com/gorilla/websocket" + "github.com/hajimehoshi/ebiten/v2" + "github.com/stretchr/testify/assert" + "github.com/xescugc/ltw/action" + "github.com/xescugc/ltw/store" + "github.com/xescugc/ltw/tower" + "github.com/xescugc/ltw/unit" + "github.com/xescugc/ltw/utils" +) + +// This test are meant to check which Stores interact with Actions +// so we'll dispatch and action and expect changes to the stores +// that we want to have changes on and expect no changes to the rest +// +// Each test will require no preset data, it'll be independent. +// Each test block is for one action in case we want to have multiple conditions +// Not all action Types are for the 'store' to deal with so some may not have any +// relevance + +var ( + playersInitialState = func() store.PlayersState { + return store.PlayersState{ + IncomeTimer: 15, + Players: make(map[string]*store.Player), + } + } + + towersInitialState = func() store.TowersState { + return store.TowersState{ + Towers: make(map[string]*store.Tower), + } + } + + unitsInitialState = func() store.UnitsState { + return store.UnitsState{ + Units: make(map[string]*store.Unit), + } + } +) + +func TestActions(t *testing.T) { + assert.Len(t, action.TypeValues(), 27, "This is a hard check to remember that if a new action Type is added it should also have a test on this file") +} + +func TestEmpty(t *testing.T) { + s := initStore() + equalStore(t, s) +} + +func TestSummonUnit(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + clid := 2 + + eu := &store.Unit{ + // As the ID is a UUID we cannot guess it + //ID: units[0].ID, + MovingObject: utils.MovingObject{ + Object: utils.Object{ + // This is also random + //X: units[0].X, Y: units[0].Y, + W: 16, H: 16, + }, + Facing: ebiten.KeyS, + }, + Type: unit.Spirit.String(), + PlayerID: p.ID, + PlayerLineID: p.LineID, + CurrentLineID: clid, + Health: unit.Units[unit.Spirit.String()].Health, + } + + a := action.NewSummonUnit(eu.Type, p.ID, p.LineID, clid) + s.Dispatch(a) + + units := s.Units.List() + + // As this are random assigned we cannot expect them + eu.ID, eu.X, eu.Y = units[0].ID, units[0].X, units[0].Y + + // We need to set the path after the X, Y are set + eu.Path = s.Units.Astar(s.Map, clid, eu.MovingObject, nil) + + // AS the Unit is created we remove it from the gold + // and add more income + p.Gold -= unit.Units[unit.Spirit.String()].Gold + p.Income += unit.Units[unit.Spirit.String()].Income + + ps := playersInitialState() + ps.Players[p.ID] = &p + + us := unitsInitialState() + us.Units[eu.ID] = eu + + equalStore(t, s, ps, us) + }) + t.Run("Do not reach negative gold", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + clid := 2 + + // We start with 40 gold, each Spirit + // takes 10 gold so with that we can only + // create 4 so we'll try to create 5 + for i := 0; i < 5; i++ { + a := action.NewSummonUnit(unit.Spirit.String(), p.ID, p.LineID, clid) + s.Dispatch(a) + } + + // I don't want to EXPECT with Units just with Players + us := s.Units.GetState() + + assert.Equal(t, 4, len(s.Units.List())) + + // We could only create 4 of the 5 so -40 + p.Gold -= 40 + // Only 4 can be created not 5 + p.Income += 4 + + ps := playersInitialState() + ps.Players[p.ID] = &p + + equalStore(t, s, ps, us) + }) +} + +func TestMoveUnit(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + p, u := summonUnit(s, p) + + s.Dispatch(action.NewMoveUnit()) + + ps := playersInitialState() + ps.Players[p.ID] = &p + + u.Path = u.Path[1:] + u.MovingCount++ + us := unitsInitialState() + us.Units[u.ID] = &u + + equalStore(t, s, ps, us) + }) +} + +func TestRemoveUnit(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + p, u := summonUnit(s, p) + + s.Dispatch(action.NewRemoveUnit(u.ID)) + + ps := playersInitialState() + ps.Players[p.ID] = &p + + equalStore(t, s, ps) + }) +} + +func TestStealLive(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := initStore() + p1 := addPlayer(s) + p2 := addPlayer(s) + + s.Dispatch(action.NewStealLive(p1.ID, p2.ID)) + + p1.Lives-- + p2.Lives++ + + ps := playersInitialState() + ps.Players[p1.ID] = &p1 + ps.Players[p2.ID] = &p2 + + equalStore(t, s, ps) + }) + t.Run("DeclareWinner", func(t *testing.T) { + s := initStore() + p1 := addPlayer(s) + p2 := addPlayer(s) + + // The lives of a Player are 20 so we go on 30 + // to see it cannot overflow the lives + for i := 0; i <= 30; i++ { + s.Dispatch(action.NewStealLive(p1.ID, p2.ID)) + } + + // It should only be 20 + p1.Lives = 0 + p2.Lives += 20 + p2.Winner = true + + ps := playersInitialState() + ps.Players[p1.ID] = &p1 + ps.Players[p2.ID] = &p2 + + equalStore(t, s, ps) + }) +} + +func TestPlaceTower(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + + s.Dispatch(action.NewPlaceTower(tower.Soldier.String(), p.ID, 10, 20)) + + p.Gold -= tower.Towers[tower.Soldier.String()].Gold + ps := playersInitialState() + ps.Players[p.ID] = &p + + tw := store.Tower{ + ID: s.Towers.List()[0].ID, + Object: utils.Object{ + X: 10, Y: 20, + W: 32, H: 32, + }, + Type: tower.Soldier.String(), + LineID: p.LineID, + PlayerID: p.ID, + } + ts := towersInitialState() + ts.Towers[tw.ID] = &tw + + equalStore(t, s, ps, ts) + }) + t.Run("Do not reach negative gold", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + + // The Player gold is 40 so it should only create 4 + // towers and not 10 + for i := 0; i <= 10; i++ { + s.Dispatch(action.NewPlaceTower(tower.Soldier.String(), p.ID, 10, 20)) + } + + p.Gold = 0 + ps := playersInitialState() + ps.Players[p.ID] = &p + + // I don't want to EXPECT with Towers just with Players + ts := s.Towers.GetState() + + assert.Equal(t, 4, len(s.Towers.List())) + + equalStore(t, s, ps, ts) + }) + t.Run("Change unit course", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + p, u := summonUnit(s, p) + + // We place it in the 10th (any place would be fine) path position so we can force the + // unit to recalculate the path + s.Dispatch(action.NewPlaceTower(tower.Soldier.String(), p.ID, int(u.Path[10].X), int(u.Path[10].Y))) + + p.Gold -= tower.Towers[tower.Soldier.String()].Gold + ps := playersInitialState() + ps.Players[p.ID] = &p + + tw := store.Tower{ + ID: s.Towers.List()[0].ID, + Object: utils.Object{ + X: u.Path[10].X, Y: u.Path[10].Y, + W: 32, H: 32, + }, + Type: tower.Soldier.String(), + LineID: p.LineID, + PlayerID: p.ID, + } + ts := towersInitialState() + ts.Towers[tw.ID] = &tw + + u.Path = s.Units.Astar(s.Map, u.CurrentLineID, u.MovingObject, []utils.Object{tw.Object}) + us := unitsInitialState() + us.Units[u.ID] = &u + + equalStore(t, s, ps, ts, us) + }) +} + +func TestRemoveTower(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + p, tw := placeTower(s, p) + + s.Dispatch(action.NewRemoveTower(p.ID, tw.ID, tw.Type)) + + p.Gold += tower.Towers[tw.Type].Gold / 2 + + ps := playersInitialState() + ps.Players[p.ID] = &p + + equalStore(t, s, ps) + }) +} + +func TestIncomeTick(t *testing.T) { + t.Run("NormalTick", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + + s.Dispatch(action.NewIncomeTick()) + + ps := playersInitialState() + ps.Players[p.ID] = &p + ps.IncomeTimer = 14 + + equalStore(t, s, ps) + }) + t.Run("TicksToTriggerIncome", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + + for i := 0; i <= 14; i++ { + s.Dispatch(action.NewIncomeTick()) + } + p.Gold += p.Income + + ps := playersInitialState() + ps.Players[p.ID] = &p + ps.IncomeTimer = 15 + + equalStore(t, s, ps) + }) +} + +func TestTowerAttack(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + p, tw := placeTower(s, p) + p, u := summonUnit(s, p) + + s.Dispatch(action.NewTowerAttack(u.ID, tw.Type)) + u.Health -= tower.Towers[tw.Type].Damage + + ps := playersInitialState() + ps.Players[p.ID] = &p + + us := unitsInitialState() + us.Units[u.ID] = &u + + ts := towersInitialState() + ts.Towers[tw.ID] = &tw + + equalStore(t, s, ps, us, ts) + }) +} + +func TestUnitKilled(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + p, u := summonUnit(s, p) + + s.Dispatch(action.NewUnitKilled(p.ID, u.Type)) + p.Gold += unit.Units[u.Type].Income + + ps := playersInitialState() + ps.Players[p.ID] = &p + + us := unitsInitialState() + us.Units[u.ID] = &u + + equalStore(t, s, ps, us) + }) +} + +func TestPlayerReady(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + + s.Dispatch(action.NewPlayerReady(p.ID)) + p.Ready = true + + ps := playersInitialState() + ps.Players[p.ID] = &p + + equalStore(t, s, ps) + }) +} + +func TestAddPlayer(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := initStore() + sid := "sid" + id := uuid.Must(uuid.NewV4()) + name := "name" + lid := 2 + ws := &websocket.Conn{} + s.Dispatch(action.NewAddPlayer(sid, id.String(), name, lid, ws)) + + p := store.Player{ + ID: id.String(), + Name: name, + LineID: lid, + Lives: 20, + Income: 25, + Gold: 40, + } + + ps := playersInitialState() + ps.Players[p.ID] = &p + + equalStore(t, s, ps) + }) + t.Run("AlreadyExists", func(t *testing.T) { + s := initStore() + sid := "sid" + sid2 := "sid2" + id := uuid.Must(uuid.NewV4()) + id2 := uuid.Must(uuid.NewV4()) + name := "name" + lid := 2 + ws := &websocket.Conn{} + s.Dispatch(action.NewAddPlayer(sid, id.String(), name, lid, ws)) + s.Dispatch(action.NewAddPlayer(sid2, id2.String(), name, lid+1, ws)) + + p := store.Player{ + ID: id.String(), + Name: name, + LineID: lid, + Lives: 20, + Income: 25, + Gold: 40, + } + + ps := playersInitialState() + ps.Players[p.ID] = &p + + equalStore(t, s, ps) + }) +} + +func TestRemovePlayer(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s := initStore() + p := addPlayer(s) + p, _ = placeTower(s, p) + p, _ = summonUnit(s, p) + + s.Dispatch(action.NewRemovePlayer("room", p.ID)) + + equalStore(t, s) + }) +} + +func equalStore(t *testing.T, sto *store.Store, states ...interface{}) { + pis := playersInitialState() + tis := towersInitialState() + uis := unitsInitialState() + for _, st := range states { + switch s := st.(type) { + case store.PlayersState: + pis = s + case store.TowersState: + tis = s + case store.UnitsState: + uis = s + default: + t.Fatalf("State with type %T is unknown", st) + } + } + + assert.Equal(t, pis, sto.Players.GetState().(store.PlayersState)) + assert.Equal(t, tis, sto.Towers.GetState().(store.TowersState)) + assert.Equal(t, uis, sto.Units.GetState().(store.UnitsState)) +} diff --git a/store/store.go b/store/store.go index f6383ec..3e7afca 100644 --- a/store/store.go +++ b/store/store.go @@ -16,10 +16,9 @@ type Store struct { func NewStore(d *flux.Dispatcher) *Store { s := &Store{ - Players: NewPlayers(d), - dispatcher: d, } + s.Players = NewPlayers(d, s) s.Map = NewMap(d, s) s.Towers = NewTowers(d, s) s.Units = NewUnits(d, s) diff --git a/store/store_test.go b/store/store_test.go new file mode 100644 index 0000000..01053af --- /dev/null +++ b/store/store_test.go @@ -0,0 +1,16 @@ +package store_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/xescugc/go-flux" + "github.com/xescugc/ltw/store" +) + +func TestNewStore(t *testing.T) { + d := flux.NewDispatcher() + s := store.NewStore(d) + + assert.NotNil(t, s) +} diff --git a/store/towers.go b/store/towers.go index ae68a3e..0c470c6 100644 --- a/store/towers.go +++ b/store/towers.go @@ -49,8 +49,8 @@ func NewTowers(d *flux.Dispatcher, s *Store) *Towers { return t } -// GetTowers returns the towers list and it's meant for reading only purposes -func (ts *Towers) GetTowers() []*Tower { +// List returns the towers list and it's meant for reading only purposes +func (ts *Towers) List() []*Tower { ts.mxTowers.RLock() defer ts.mxTowers.RUnlock() mtowers := ts.GetState().(TowersState) @@ -77,7 +77,11 @@ func (ts *Towers) Reduce(state, a interface{}) interface{} { ts.mxTowers.Lock() defer ts.mxTowers.Unlock() - p := ts.store.Players.GetPlayerByID(act.PlaceTower.PlayerID) + p := ts.store.Players.FindByID(act.PlaceTower.PlayerID) + + if !p.CanPlaceTower(act.PlaceTower.Type) { + break + } var w, h float64 = 16 * 2, 16 * 2 tid := uuid.Must(uuid.NewV4()) diff --git a/store/towers_test.go b/store/towers_test.go new file mode 100644 index 0000000..745cf8a --- /dev/null +++ b/store/towers_test.go @@ -0,0 +1,49 @@ +package store_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/xescugc/go-flux" + "github.com/xescugc/ltw/action" + "github.com/xescugc/ltw/store" + "github.com/xescugc/ltw/tower" + "github.com/xescugc/ltw/utils" +) + +func TestNewTowers(t *testing.T) { + d := flux.NewDispatcher() + st := store.NewStore(d) + ts := store.NewTowers(d, st) + tstate := ts.GetState().(store.TowersState) + etstate := store.TowersState{ + Towers: make(map[string]*store.Tower), + } + assert.Equal(t, etstate, tstate) +} + +func TestTowers_List(t *testing.T) { + d := flux.NewDispatcher() + st := store.NewStore(d) + ts := store.NewTowers(d, st) + + player := addPlayer(st) + + d.Dispatch(action.NewPlaceTower(tower.Soldier.String(), player.ID, 10, 20)) + + towers := ts.List() + etowers := []*store.Tower{ + &store.Tower{ + // As the ID is a UUID we cannot guess it + ID: towers[0].ID, + Object: utils.Object{ + X: 10, Y: 20, + W: 16 * 2, H: 16 * 2, + }, + Type: tower.Soldier.String(), + LineID: player.LineID, + PlayerID: player.ID, + }, + } + assert.Equal(t, etowers, towers) +} diff --git a/store/units.go b/store/units.go index ab64de7..a235ded 100644 --- a/store/units.go +++ b/store/units.go @@ -60,8 +60,8 @@ func NewUnits(d *flux.Dispatcher, s *Store) *Units { return u } -// GetUnits returns the units list and it's meant for reading only purposes -func (us *Units) GetUnits() []*Unit { +// List returns the units list and it's meant for reading only purposes +func (us *Units) List() []*Unit { us.mxUnits.RLock() defer us.mxUnits.RUnlock() munits := us.GetState().(UnitsState) @@ -85,11 +85,17 @@ func (us *Units) Reduce(state, a interface{}) interface{} { switch act.Type { case action.SummonUnit: + us.GetDispatcher().WaitFor( + us.store.Towers.GetDispatcherToken(), + ) us.mxUnits.Lock() defer us.mxUnits.Unlock() + p := us.store.Players.FindByID(act.SummonUnit.PlayerID) + if !p.CanSummonUnit(act.SummonUnit.Type) { + break + } // We wait for the towers store as we need to interact with it - us.GetDispatcher().WaitFor(us.store.Towers.GetDispatcherToken()) var w, h float64 = 16, 16 var x, y float64 = us.store.Map.GetRandomSpawnCoordinatesForLineID(act.SummonUnit.CurrentLineID) uid := uuid.Must(uuid.NewV4()) @@ -108,7 +114,7 @@ func (us *Units) Reduce(state, a interface{}) interface{} { CurrentLineID: act.SummonUnit.CurrentLineID, Health: unit.Units[act.SummonUnit.Type].Health, } - ts := us.store.Towers.GetTowers() + ts := us.store.Towers.List() tws := make([]utils.Object, 0, 0) for _, t := range ts { if t.LineID == u.CurrentLineID { @@ -132,13 +138,18 @@ func (us *Units) Reduce(state, a interface{}) interface{} { } } case action.PlaceTower: + // We wait for the towers store as we need to interact with it + us.GetDispatcher().WaitFor(us.store.Towers.GetDispatcherToken()) + us.mxUnits.Lock() defer us.mxUnits.Unlock() - // We wait for the towers store as we need to interact with it - us.GetDispatcher().WaitFor(us.store.Towers.GetDispatcherToken()) ts := us.store.Towers.GetState().(TowersState) - p := us.store.Players.GetPlayerByID(act.PlaceTower.PlayerID) + p := us.store.Players.FindByID(act.PlaceTower.PlayerID) + + if !p.CanPlaceTower(act.PlaceTower.Type) { + break + } for _, u := range ustate.Units { // Only need to recalculate path for each unit when the placed tower // is on the same LineID as the unit @@ -159,8 +170,8 @@ func (us *Units) Reduce(state, a interface{}) interface{} { // We wait for the towers store as we need to interact with it us.GetDispatcher().WaitFor(us.store.Towers.GetDispatcherToken()) - ts := us.store.Towers.GetTowers() - p := us.store.Players.GetPlayerByID(act.RemoveTower.PlayerID) + ts := us.store.Towers.List() + p := us.store.Players.FindByID(act.RemoveTower.PlayerID) for _, u := range ustate.Units { // Only need to recalculate path for each unit when the placed tower // is on the same LineID as the unit diff --git a/store/units_test.go b/store/units_test.go new file mode 100644 index 0000000..62364ee --- /dev/null +++ b/store/units_test.go @@ -0,0 +1,59 @@ +package store_test + +import ( + "testing" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/stretchr/testify/assert" + "github.com/xescugc/go-flux" + "github.com/xescugc/ltw/action" + "github.com/xescugc/ltw/store" + "github.com/xescugc/ltw/unit" + "github.com/xescugc/ltw/utils" +) + +func TestNewUnits(t *testing.T) { + d := flux.NewDispatcher() + st := store.NewStore(d) + us := store.NewUnits(d, st) + ustate := us.GetState().(store.UnitsState) + eustate := store.UnitsState{ + Units: make(map[string]*store.Unit), + } + assert.Equal(t, eustate, ustate) +} + +func TestUnits_List(t *testing.T) { + d := flux.NewDispatcher() + st := store.NewStore(d) + us := store.NewUnits(d, st) + + player := addPlayer(st) + clid := 2 + + d.Dispatch(action.NewSummonUnit(unit.Spirit.String(), player.ID, player.LineID, clid)) + + units := us.List() + eunits := []*store.Unit{ + &store.Unit{ + // As the ID is a UUID we cannot guess it + ID: units[0].ID, + MovingObject: utils.MovingObject{ + Object: utils.Object{ + // This is also random + X: units[0].X, Y: units[0].Y, + W: 16, H: 16, + }, + Facing: ebiten.KeyS, + }, + Type: unit.Spirit.String(), + PlayerID: player.ID, + PlayerLineID: player.LineID, + CurrentLineID: clid, + Health: unit.Units[unit.Spirit.String()].Health, + }, + } + // We calculate the path also + eunits[0].Path = us.Astar(st.Map, clid, eunits[0].MovingObject, nil) + assert.Equal(t, eunits, units) +}