dkforest

A forum and chat platform (onion)
git clone https://git.dasho.dev/n0tr1v/dkforest.git
Log | Files | Refs | LICENSE

battleship.go (14385B)


      1 package interceptors
      2 
      3 import (
      4 	"bytes"
      5 	"dkforest/pkg/database"
      6 	dutils "dkforest/pkg/database/utils"
      7 	"dkforest/pkg/utils"
      8 	"dkforest/pkg/web/handlers/interceptors/command"
      9 	"encoding/base64"
     10 	"errors"
     11 	"fmt"
     12 	"github.com/fogleman/gg"
     13 	"github.com/sirupsen/logrus"
     14 	"html/template"
     15 	"image/color"
     16 	"strconv"
     17 	"sync"
     18 	"time"
     19 )
     20 
     21 // Carrier 5
     22 // Battleship 4
     23 // Cruiser 3
     24 // Submarine 3
     25 // Destroyer 2
     26 
     27 /**
     28 ◀■■▶
     29      30      31      32      33 */
     34 
     35 var BattleshipInstance *Battleship
     36 
     37 type BSCoordinate struct {
     38 	x, y int
     39 }
     40 
     41 type BSPlayer struct {
     42 	id        database.UserID
     43 	username  database.Username
     44 	userStyle string
     45 	card      *BSCard
     46 	shots     map[int]struct{}
     47 }
     48 
     49 func newPlayer(player database.User) *BSPlayer {
     50 	p := new(BSPlayer)
     51 	p.id = player.ID
     52 	p.username = player.Username
     53 	p.userStyle = player.GenerateChatStyle()
     54 	p.card = generateCard()
     55 	p.shots = make(map[int]struct{})
     56 	return p
     57 }
     58 
     59 type Direction int
     60 
     61 const (
     62 	vertical Direction = iota + 1
     63 	horizontal
     64 )
     65 
     66 type BSShip struct {
     67 	name       string
     68 	x, y, size int
     69 	direction  Direction
     70 	reversed   bool
     71 	health     int
     72 }
     73 
     74 func newShip(name string, x, y, size int, dir Direction, reversed bool) BSShip {
     75 	return BSShip{name: name, x: x, y: y, size: size, direction: dir, health: size, reversed: reversed}
     76 }
     77 
     78 func (s BSShip) contains(pos int) bool {
     79 	for _, el := range s.getPos() {
     80 		if pos == el {
     81 			return true
     82 		}
     83 	}
     84 	return false
     85 }
     86 
     87 func (s BSShip) getPos() (out []int) {
     88 	for i := 0; i < s.size; i++ {
     89 		incr := i
     90 		if s.direction == vertical {
     91 			incr *= 10
     92 		}
     93 		out = append(out, s.y*10+s.x+incr)
     94 	}
     95 	return
     96 }
     97 
     98 type BSCard struct {
     99 	carrier    BSShip
    100 	battleShip BSShip
    101 	cruiser    BSShip
    102 	submarine  BSShip
    103 	destroyer  BSShip
    104 }
    105 
    106 func (c *BSCard) collide(newShip BSShip) bool {
    107 	for _, p := range newShip.getPos() {
    108 		if c.carrier.contains(p) ||
    109 			c.battleShip.contains(p) ||
    110 			c.cruiser.contains(p) ||
    111 			c.submarine.contains(p) ||
    112 			c.destroyer.contains(p) {
    113 			return true
    114 		}
    115 	}
    116 	return false
    117 }
    118 
    119 func (c *BSCard) shot(pos int) {
    120 	if c.carrier.contains(pos) {
    121 		c.carrier.health -= 1
    122 	} else if c.battleShip.contains(pos) {
    123 		c.battleShip.health -= 1
    124 	} else if c.cruiser.contains(pos) {
    125 		c.cruiser.health -= 1
    126 	} else if c.submarine.contains(pos) {
    127 		c.submarine.health -= 1
    128 	} else if c.destroyer.contains(pos) {
    129 		c.destroyer.health -= 1
    130 	}
    131 }
    132 
    133 func (c BSCard) allShipsDead() bool {
    134 	return c.carrier.health == 0 &&
    135 		c.battleShip.health == 0 &&
    136 		c.cruiser.health == 0 &&
    137 		c.submarine.health == 0 &&
    138 		c.destroyer.health == 0
    139 }
    140 
    141 func (c BSCard) shipAt(pos int) (string, bool) {
    142 	if c.carrier.contains(pos) {
    143 		return "carrier", c.carrier.health == 0
    144 	} else if c.battleShip.contains(pos) {
    145 		return "battleShip", c.battleShip.health == 0
    146 	} else if c.cruiser.contains(pos) {
    147 		return "cruiser", c.cruiser.health == 0
    148 	} else if c.submarine.contains(pos) {
    149 		return "submarine", c.submarine.health == 0
    150 	} else if c.destroyer.contains(pos) {
    151 		return "destroyer", c.destroyer.health == 0
    152 	}
    153 	return "", false
    154 }
    155 
    156 func (c BSCard) hasShipAt(pos int) bool {
    157 	var allPos []int
    158 	allPos = append(allPos, c.carrier.getPos()...)
    159 	allPos = append(allPos, c.battleShip.getPos()...)
    160 	allPos = append(allPos, c.cruiser.getPos()...)
    161 	allPos = append(allPos, c.submarine.getPos()...)
    162 	allPos = append(allPos, c.destroyer.getPos()...)
    163 	for _, el := range allPos {
    164 		if pos == el {
    165 			return true
    166 		}
    167 	}
    168 	return false
    169 }
    170 
    171 type BSGame struct {
    172 	lastUpdated time.Time
    173 	turn        int
    174 	player1     *BSPlayer
    175 	player2     *BSPlayer
    176 }
    177 
    178 func newGame(player1, player2 database.User) *BSGame {
    179 	g := new(BSGame)
    180 	g.lastUpdated = time.Now()
    181 	g.player1 = newPlayer(player1)
    182 	g.player2 = newPlayer(player2)
    183 	return g
    184 }
    185 
    186 func (g BSGame) IsPlayerTurn(playerID database.UserID) bool {
    187 	return g.turn == 0 && g.player1.id == playerID ||
    188 		g.turn == 1 && g.player2.id == playerID
    189 }
    190 
    191 func (g *BSGame) Shot(pos string) (shipStr string, shipDead, gameEnded bool, err error) {
    192 	g.lastUpdated = time.Now()
    193 	rowStr := pos[0]
    194 	row := int(rowStr - 'A')
    195 	col, _ := strconv.Atoi(string(pos[1]))
    196 	p := row*10 + col
    197 
    198 	ent1 := g.player1
    199 	ent2 := g.player2
    200 	if g.turn == 1 {
    201 		ent1, ent2 = ent2, ent1
    202 	}
    203 	if _, ok := ent1.shots[p]; ok {
    204 		return "", false, false, errors.New("position already hit")
    205 	}
    206 	ent1.shots[p] = struct{}{}
    207 	ent2.card.shot(p)
    208 	shipStr, shipDead = ent2.card.shipAt(p)
    209 	gameEnded = ent2.card.allShipsDead()
    210 
    211 	g.turn = (g.turn + 1) % 2
    212 	return
    213 }
    214 
    215 type Battleship struct {
    216 	sync.Mutex
    217 	db     *database.DkfDB
    218 	zeroID database.UserID
    219 	games  map[string]*BSGame
    220 }
    221 
    222 func NewBattleship(db *database.DkfDB) *Battleship {
    223 	zeroUser := dutils.GetZeroUser(db)
    224 	b := &Battleship{db: db, zeroID: zeroUser.ID}
    225 	b.games = make(map[string]*BSGame)
    226 
    227 	// Thread that cleanup inactive games
    228 	go func() {
    229 		for {
    230 			time.Sleep(time.Minute)
    231 			b.Lock()
    232 			for k, g := range b.games {
    233 				if time.Since(g.lastUpdated) > 5*time.Minute {
    234 					delete(b.games, k)
    235 				}
    236 			}
    237 			b.Unlock()
    238 		}
    239 	}()
    240 
    241 	return b
    242 }
    243 
    244 func generateCard() *BSCard {
    245 	c := new(BSCard)
    246 	genTmpShip := func(name string, size int) (out BSShip) {
    247 		reversed := utils.RandBool()
    248 		dir := utils.RandChoice([]Direction{horizontal, vertical})
    249 		val1 := utils.RandInt(0, 9)
    250 		val2 := utils.RandInt(0, 9-size)
    251 		if dir == horizontal {
    252 			val1, val2 = val2, val1
    253 		}
    254 		out = newShip(name, val1, val2, size, dir, reversed)
    255 		return
    256 	}
    257 	for _, i := range []int{0, 1, 2, 3, 4} { // iterate 5 times (for each boat)
    258 		names := []string{"carrier", "battleship", "cruiser", "submarine", "destroyer"}
    259 		sizes := []int{5, 4, 3, 3, 2} // respective boat size
    260 		for {
    261 			tmpShip := genTmpShip(names[i], sizes[i])
    262 			// If boat collide with another boat, we need to generate a new position for that boat
    263 			if c.collide(tmpShip) {
    264 				continue
    265 			}
    266 			// boat position is valid, assign it
    267 			switch i {
    268 			case 0:
    269 				c.carrier = tmpShip
    270 			case 1:
    271 				c.battleShip = tmpShip
    272 			case 2:
    273 				c.cruiser = tmpShip
    274 			case 3:
    275 				c.submarine = tmpShip
    276 			case 4:
    277 				c.destroyer = tmpShip
    278 			}
    279 			break
    280 		}
    281 	}
    282 	return c
    283 }
    284 
    285 func (g *BSGame) drawCardFor(tmp int, roomName string, isNewGame, shipDead, gameEnded bool, shipStr, pos string) (out string) {
    286 	you := g.player1
    287 	enemy := g.player2
    288 	if tmp == 1 {
    289 		you = g.player2
    290 		enemy = g.player1
    291 	}
    292 
    293 	imgB64Fn := func(myCard bool) string {
    294 		ent1 := enemy
    295 		ent2 := you
    296 		if myCard {
    297 			ent1 = you
    298 			ent2 = enemy
    299 		}
    300 
    301 		c := gg.NewContext(177, 177)
    302 
    303 		c.Push()
    304 		c.SetColor(color.White)
    305 		c.DrawRectangle(0, 0, 177, 177)
    306 		c.Fill()
    307 		c.Pop()
    308 
    309 		c.Push()
    310 		c.SetColor(color.Black)
    311 		x := 22.0
    312 		y := 13.0
    313 		c.DrawString("0", x, y)
    314 		c.DrawString("1", x+16, y)
    315 		c.DrawString("2", x+16+16, y)
    316 		c.DrawString("3", x+16+16+16, y)
    317 		c.DrawString("4", x+16+16+16+16, y)
    318 		c.DrawString("5", x+16+16+16+16+16, y)
    319 		c.DrawString("6", x+16+16+16+16+16+16, y)
    320 		c.DrawString("7", x+16+16+16+16+16+16+16, y)
    321 		c.DrawString("8", x+16+16+16+16+16+16+16+16, y)
    322 		c.DrawString("9", x+16+16+16+16+16+16+16+16+16, y)
    323 		x = 6
    324 		y = 29.0
    325 		c.DrawString("A", x, y)
    326 		c.DrawString("B", x, y+16)
    327 		c.DrawString("C", x, y+16+16)
    328 		c.DrawString("D", x, y+16+16+16)
    329 		c.DrawString("E", x, y+16+16+16+16)
    330 		c.DrawString("F", x, y+16+16+16+16+16)
    331 		c.DrawString("G", x, y+16+16+16+16+16+16)
    332 		c.DrawString("H", x, y+16+16+16+16+16+16+16)
    333 		c.DrawString("I", x, y+16+16+16+16+16+16+16+16)
    334 		c.DrawString("J", x, y+16+16+16+16+16+16+16+16+16)
    335 		c.Pop()
    336 
    337 		c.Push()
    338 		c.SetLineWidth(1)
    339 		c.SetColor(color.RGBA{R: 90, G: 90, B: 90, A: 255})
    340 		for col := 0.0; col < 12; col++ {
    341 			c.MoveTo(0.5+col*16, 0)
    342 			c.LineTo(0.5+col*16, 176)
    343 			c.Stroke()
    344 		}
    345 		for row := 0.0; row < 12; row++ {
    346 			c.MoveTo(0, 0.5+row*16)
    347 			c.LineTo(176, 0.5+row*16)
    348 			c.Stroke()
    349 		}
    350 		c.Pop()
    351 
    352 		drawShip := func(s BSShip) {
    353 			if !myCard && s.health != 0 && !gameEnded {
    354 				return
    355 			}
    356 			//fmt.Println(s.name, s.x, s.y, s.direction, s.reversed)
    357 			c.Push()
    358 			c.Translate(0.5, 0.5)
    359 			c.Translate(16, 16)
    360 			c.Translate(float64(s.x)*16, float64(s.y)*16)
    361 			if s.direction == horizontal {
    362 				if s.reversed {
    363 					c.Translate(float64(s.size)*16, 0)
    364 					c.Rotate(gg.Radians(90))
    365 				} else {
    366 					c.Translate(0, 16)
    367 					c.Rotate(gg.Radians(-90))
    368 				}
    369 			} else {
    370 				if s.reversed {
    371 					c.Translate(16, float64(s.size)*16)
    372 					c.Rotate(gg.Radians(180))
    373 				}
    374 			}
    375 			// Front of the ship
    376 			c.MoveTo(1, 11)
    377 			c.QuadraticTo(8, -10, 15, 11)
    378 			// Length of the ship
    379 			c.Translate(0, float64(s.size-1)*16)
    380 			// back of the ship
    381 			c.LineTo(15, 11)
    382 			c.QuadraticTo(8, 17, 1, 11)
    383 			c.ClosePath()
    384 			if s.health == 0 {
    385 				c.SetColor(color.RGBA{R: 100, G: 100, B: 100, A: 200})
    386 				c.Fill()
    387 			} else if !myCard && gameEnded {
    388 				c.SetColor(color.RGBA{R: 100, G: 130, B: 100, A: 200})
    389 				c.Fill()
    390 			} else {
    391 				c.SetColor(color.RGBA{R: 100, G: 100, B: 100, A: 255})
    392 				c.Fill()
    393 			}
    394 			c.Pop()
    395 		}
    396 		drawShip(ent1.card.carrier)
    397 		drawShip(ent1.card.battleShip)
    398 		drawShip(ent1.card.cruiser)
    399 		drawShip(ent1.card.submarine)
    400 		drawShip(ent1.card.destroyer)
    401 
    402 		c.Push()
    403 		c.Translate(0.5, 0.5)
    404 		c.Translate(16, 16)
    405 		for shot := range ent2.shots {
    406 			shotRow := shot / 10
    407 			shotCol := shot % 10
    408 			c.Push()
    409 			c.Translate(float64(shotCol)*16, float64(shotRow)*16)
    410 
    411 			if ent1.card.hasShipAt(shot) {
    412 				c.DrawCircle(8, 8, 4)
    413 				c.SetColor(color.RGBA{R: 255, G: 200, B: 0, A: 255})
    414 				c.Fill()
    415 
    416 				c.DrawCircle(8, 8, 3)
    417 				c.SetColor(color.RGBA{R: 255, G: 0, B: 0, A: 255})
    418 				c.Fill()
    419 			} else {
    420 				c.DrawCircle(8, 8, 3)
    421 				c.SetColor(color.RGBA{R: 60, G: 200, B: 0, A: 255})
    422 				c.Fill()
    423 
    424 				c.DrawCircle(8, 8, 2)
    425 				c.SetColor(color.RGBA{R: 100, G: 0, B: 0, A: 255})
    426 				c.Fill()
    427 			}
    428 
    429 			c.Pop()
    430 		}
    431 		c.Pop()
    432 
    433 		var buf bytes.Buffer
    434 		_ = c.EncodePNG(&buf)
    435 		imgB64 := base64.StdEncoding.EncodeToString(buf.Bytes())
    436 		return imgB64
    437 	}
    438 
    439 	imgB64 := imgB64Fn(true)
    440 	img1B64 := imgB64Fn(false)
    441 
    442 	htmlTmpl := `
    443 Against <span {{ .EnemyUserStyle | HTMLAttr }}>@{{ .EnemyUsername }}</span><br />
    444 {{ if not .IsNewGame }}
    445 	{{ if .YourTurn }}
    446 		<span {{ .EnemyUserStyle | HTMLAttr }}>@{{ .EnemyUsername }}</span> played {{ .Pos }}
    447 	{{ else }}
    448 		you played {{ .Pos }}
    449 	{{ end }}
    450 	;
    451 	{{ if .ShipStr }}
    452 		{{ .ShipStr }} hit
    453 		{{ if .ShipDead }}
    454  			and sunk
    455 		{{ end }}
    456 	{{ else }}
    457 		miss
    458 	{{ end }}
    459 	;
    460 {{ end }}
    461 {{ if .GameEnded }}
    462 	{{ if .YourTurn }}
    463 		You lost!<br />
    464 	{{ else }}
    465 		You win!<br />
    466 	{{ end }}
    467 {{ else }}
    468 	{{ if .YourTurn }}
    469 		now is your turn<br />
    470 	{{ else }}
    471 		waiting for opponent<br />
    472 	{{ end }}
    473 {{ end }}
    474 <table>
    475 	<tr>
    476 		<td><img src="data:image/png;base64,{{ .ImgB64 }}" alt="" /></td>
    477 		<td style="vertical-align: top;">
    478 			<form method="post" style="margin-left: 10px;" action="/api/v1/battleship">
    479 				<input type="hidden" name="room" value="{{ .RoomName }}" />
    480 				<input type="hidden" name="enemyUsername" value="{{ .EnemyUsername }}" />
    481 				<table style="width: 177px; height: 177px; background-image: url(data:image/png;base64,{{ .Img1B64 }})">
    482 					<tr style="height: 16px;"><td colspan="11">&nbsp;</td></tr>
    483 					{{- range $row := .Rows -}}
    484 						<tr style="height: 16px;">
    485 							<td style="width: 16px;"></td>
    486 							{{- range $col := $.Cols -}}
    487 								{{- if NotShot $row $col -}}
    488 									{{- if and $.YourTurn (not $.GameEnded) -}}
    489 										<td style="width: 16px;">
    490 											<button style="height: 15px; width: 15px;" name="move" value="{{ GetRune $row }}{{ $col }}"></button>
    491 										</td>
    492 									{{- else -}}
    493 										<td style="width: 16px;"></td>
    494 									{{- end -}}
    495 								{{- else -}}
    496 									<td style="width: 16px;"></td>
    497 								{{- end -}}
    498 							{{- end -}}
    499 						</tr>
    500 					{{- end -}}
    501 				</table>
    502 			</form>
    503 		</td>
    504 	</tr>
    505 </table>
    506 `
    507 	data := map[string]any{
    508 		"RoomName":       roomName,
    509 		"EnemyUserStyle": enemy.userStyle,
    510 		"EnemyUsername":  enemy.username,
    511 		"IsNewGame":      isNewGame,
    512 		"YourTurn":       g.turn == tmp,
    513 		"Pos":            pos,
    514 		"ShipStr":        shipStr,
    515 		"ShipDead":       shipDead,
    516 		"GameEnded":      gameEnded,
    517 		"ImgB64":         imgB64,
    518 		"Img1B64":        img1B64,
    519 		"Rows":           []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
    520 		"Cols":           []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
    521 	}
    522 	fns := template.FuncMap{
    523 		"GetRune": func(i int) string {
    524 			return string(rune('A' + i))
    525 		},
    526 		"NotShot": func(i, j int) bool {
    527 			_, ok := you.shots[i*10+j]
    528 			return !ok
    529 		},
    530 		"HTMLAttr": func(in string) template.HTMLAttr {
    531 			return template.HTMLAttr(in)
    532 		},
    533 	}
    534 	var buf bytes.Buffer
    535 	_ = utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf, data)
    536 	return buf.String()
    537 }
    538 
    539 func (b *Battleship) InterceptMsg(cmd *command.Command) {
    540 	m := bsRgx.FindStringSubmatch(cmd.Message)
    541 	if len(m) != 3 {
    542 		return
    543 	}
    544 	enemyUsername := database.Username(m[1])
    545 	pos := m[2]
    546 	if err := b.PlayMove(cmd.Room.Name, cmd.Room.ID, cmd.RoomKey, *cmd.AuthUser, enemyUsername, pos); err != nil {
    547 		cmd.Err = err
    548 		return
    549 	}
    550 	cmd.Err = command.ErrStop
    551 	return
    552 }
    553 
    554 func (b *Battleship) PlayMove(roomName string, roomID database.RoomID, roomKey string, authUser database.User, enemyUsername database.Username, pos string) error {
    555 	b.Lock()
    556 	defer b.Unlock()
    557 
    558 	user, err := b.db.GetUserByUsername(enemyUsername)
    559 	if err != nil {
    560 		return errors.New("invalid username")
    561 	}
    562 
    563 	var gameKey string
    564 	if authUser.ID < user.ID {
    565 		gameKey = fmt.Sprintf("%d_%d", authUser.ID, user.ID)
    566 	} else {
    567 		gameKey = fmt.Sprintf("%d_%d", user.ID, authUser.ID)
    568 	}
    569 
    570 	var shipStr string
    571 	var isNewGame, shipDead, gameEnded bool
    572 	g, ok := b.games[gameKey]
    573 	if ok {
    574 		if !g.IsPlayerTurn(authUser.ID) {
    575 			return errors.New("not your turn")
    576 		}
    577 		shipStr, shipDead, gameEnded, err = g.Shot(pos)
    578 		if err != nil {
    579 			return err
    580 		}
    581 	} else {
    582 		if pos != "" {
    583 			return errors.New("no Game ongoing")
    584 		}
    585 		g = newGame(user, authUser)
    586 		b.games[gameKey] = g
    587 		isNewGame = true
    588 	}
    589 
    590 	// Delete old messages sent by "0" to the players
    591 	if err := b.db.DB().
    592 		Where("room_id = ? AND user_id = ? AND (to_user_id = ? OR to_user_id = ?)", roomID, b.zeroID, g.player1.id, g.player2.id).
    593 		Delete(&database.ChatMessage{}).Error; err != nil {
    594 		logrus.Error(err)
    595 	}
    596 
    597 	card1 := g.drawCardFor(0, roomName, isNewGame, shipDead, gameEnded, shipStr, pos)
    598 	_, _ = b.db.CreateMsg(card1, card1, roomKey, roomID, b.zeroID, &g.player1.id, false)
    599 
    600 	card2 := g.drawCardFor(1, roomName, isNewGame, shipDead, gameEnded, shipStr, pos)
    601 	_, _ = b.db.CreateMsg(card2, card2, roomKey, roomID, b.zeroID, &g.player2.id, false)
    602 
    603 	if gameEnded {
    604 		delete(b.games, gameKey)
    605 	}
    606 
    607 	return nil
    608 }