Skip to content

Commit

Permalink
feat: use golang to generate connect4 image
Browse files Browse the repository at this point in the history
  • Loading branch information
AiroPi committed Jan 7, 2025
1 parent 5b0777f commit c679692
Show file tree
Hide file tree
Showing 16 changed files with 380 additions and 152 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Application
draft/
data/

.DS_Store
Expand Down
30 changes: 24 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,32 +1,50 @@
# syntax=docker/dockerfile-upstream:master-labs

FROM python:3.12-alpine AS build
ARG EXTRA_DEBUG=""
ARG UV_EXTRA_ARGS=""
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apk \
--mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=python/uv.lock,target=uv.lock \
--mount=type=bind,source=python/pyproject.toml,target=pyproject.toml \
: \
&& apk update && apk add gcc musl-dev linux-headers git \
&& uv sync --no-dev --locked $EXTRA_DEBUG \
&& uv sync --no-dev --locked $UV_EXTRA_ARGS \
&& :

FROM golang:1.23-alpine AS buildgo
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apk \
--mount=type=cache,target=/go/pkg/mod/ \
--mount=type=bind,source=golang/go.sum,target=go.sum \
--mount=type=bind,source=golang/go.mod,target=go.mod \
--mount=type=bind,source=golang/src,target=src \
: \
&& apk update && apk add gcc musl-dev \
&& go mod download -x \
&& CGO_ENABLED=1 go build -o connect4img.so -buildmode=c-shared src/connect4img.go \
&& :

FROM python:3.12-alpine AS base
# https://docs.docker.com/reference/dockerfile/#copy---parents
RUN apk add --no-cache musl-dev libc6-compat file
WORKDIR /app
COPY --from=buildgo /app/connect4img.so /app/shared/
COPY --parents --from=build /app/.venv /
COPY --parents ./resources ./
COPY ./src ./
COPY ./python/src ./
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=0

FROM base AS production
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]

FROM base AS debug
COPY ./debug_pages /app/debug_pages
COPY --parents ./scripts/readme_generator.py ./templates/README.md ./
RUN python3 scripts/readme_generator.py http://localhost/
ENV DEBUG=1
ENV LOG_LEVEL=DEBUG
RUN ls shared
RUN file shared/connect4img.so
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80", "--reload"]
2 changes: 1 addition & 1 deletion compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ services:
dockerfile: ./Dockerfile
target: debug
args:
EXTRA_DEBUG: "--extra debug"
UV_EXTRA_ARGS: "--extra debug"
develop:
watch:
- action: sync
Expand Down
14 changes: 14 additions & 0 deletions golang/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module readmeconnect4

go 1.23

toolchain go1.23.3

require (
git.sr.ht/~sbinet/gg v0.6.0 // indirect
github.com/campoy/embedmd v1.0.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/text v0.21.0 // indirect
)
12 changes: 12 additions & 0 deletions golang/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
git.sr.ht/~sbinet/gg v0.6.0 h1:RIzgkizAk+9r7uPzf/VfbJHBMKUr0F5hRFxTUGMnt38=
git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94=
github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY=
github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
116 changes: 116 additions & 0 deletions golang/src/connect4img.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package main

import (
"image/color"
"log"

"git.sr.ht/~sbinet/gg"
"golang.org/x/image/font"

"C"
)

const TOKEN_DIAMETER int = 70
const INTERSPACE int = 10
const BOX_HEIGHT int = 140
const BOX_BORDER int = 10

const FONT_SIZE int = 40

var BG_COLOR = color.RGBA{100, 100, 255, 255}
var BOX_COLOR = color.RGBA{70, 70, 255, 255}
var TOKENS_COLORS = []color.RGBA{
{255, 255, 255, 255},
{255, 100, 100, 255},
{255, 255, 100, 255},
}
var HIGHLIGHT_WIN_COLOR = color.RGBA{100, 255, 100, 255}

var FACE font.Face

func init() {
var err error
FACE, err = gg.LoadFontFace("./resources/fonts/Roboto-Regular.ttf", float64(FONT_SIZE))
if err != nil {
log.Fatalf("could not load font face: %+v", err)
}
}

//export GenerateBoard
func GenerateBoard(board [][]int, isOver bool, winner int, turn int, winPositions [][]int) {
log.Print(winPositions)
const width = 7*(TOKEN_DIAMETER+INTERSPACE) + INTERSPACE
const heigh = 6*(TOKEN_DIAMETER+INTERSPACE) + INTERSPACE + BOX_HEIGHT
dc := gg.NewContext(
width, heigh,
)
dc.SetColor(BG_COLOR)
dc.DrawRectangle(0, 0, float64(width), float64(heigh))
dc.Fill()

// Draw message
var msg string
if isOver {
if winner == 0 {
msg = "It's a tie!"
} else {
if turn == 1 {
msg = "Winner : red"
} else {
msg = "Winner : yellow"
}
}
} else {
if turn == 1 {
msg = "Player turn : red"
} else {
msg = "Player turn : yellow"
}
}

dc.SetColor(color.Black)
dc.SetFontFace(FACE)
dc.DrawStringAnchored(
msg,
float64((7*(TOKEN_DIAMETER+INTERSPACE)+INTERSPACE)/2),
float64(6*(TOKEN_DIAMETER+INTERSPACE)+INTERSPACE+(BOX_HEIGHT)/2),
0.5,
0.5,
)

// Draw tokens
for i := 0; i < 6; i++ {
for j := 0; j < 7; j++ {
caseVal := board[i][j]
x := float64(INTERSPACE + j*(TOKEN_DIAMETER+INTERSPACE))
y := float64(INTERSPACE + i*(TOKEN_DIAMETER+INTERSPACE))

if contains(winPositions, [2]int{i, j}) {
log.Println(true)
dc.SetColor(HIGHLIGHT_WIN_COLOR)
dc.DrawCircle(x+float64(TOKEN_DIAMETER)/2, y+float64(TOKEN_DIAMETER)/2, float64(TOKEN_DIAMETER)/2+float64(INTERSPACE)/2)
dc.Fill()
}

dc.SetColor(TOKENS_COLORS[caseVal])
dc.DrawCircle(x+float64(TOKEN_DIAMETER)/2, y+float64(TOKEN_DIAMETER)/2, float64(TOKEN_DIAMETER)/2)
dc.Fill()
}
}

err := dc.SavePNG("./data/connect4.png") // TODO
if err != nil {
log.Fatalf("could not save to file: %+v", err)
}
}

func contains(points [][]int, point [2]int) bool {
for _, p := range points {
if [2]int(p) == point {
return true
}
}
return false
}

func main() {}
File renamed without changes.
4 changes: 2 additions & 2 deletions src/main.py → python/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ def read_root():
return RedirectResponse(GITHUB_PROFILE_URL)


if os.getenv("DEBUG"):
if os.environ.get("DEBUG", False):

@app.get("/readme.md")
def get_readme():
from markdown import markdown

with open("./debug_pages/minesweeper.md") as f:
with open("./README.out.md") as f:
markdown_text = f.read()
return HTMLResponse(markdown(markdown_text))

Expand Down
48 changes: 48 additions & 0 deletions python/src/routers/connect4_router/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import os
from functools import partial
from pathlib import Path
from time import time

from connect4 import ColumnFull, Connect4
from fastapi import APIRouter, Query
from fastapi.responses import FileResponse, RedirectResponse

from .go_library import generate_board

GITHUB_PROFILE_URL = os.environ["GITHUB_PROFILE_URL"]
IMAGE_PATH = Path("./data/connect4.png")


last_play_time = time()
c4 = Connect4()
router = APIRouter(prefix="/connect4", on_startup=[partial(generate_board, c4)])


@router.get("/image")
def get_image():
return FileResponse(IMAGE_PATH, headers={"Cache-Control": "no-cache, max-age=0"})


@router.get("/play")
def play(column: int = Query(title="The column ID you want to play to.", ge=0, le=6)):
global last_play_time

if not c4.is_over:
try:
c4.play(column)
except ColumnFull:
pass
else:
generate_board(c4)
last_play_time = time()
return RedirectResponse(GITHUB_PROFILE_URL)


@router.get("/reset")
def reset():
if not c4.is_over and time() - last_play_time < 300:
return RedirectResponse(GITHUB_PROFILE_URL)

c4.reset()
generate_board(c4)
return RedirectResponse(GITHUB_PROFILE_URL)
83 changes: 83 additions & 0 deletions python/src/routers/connect4_router/go_library.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from __future__ import annotations

import os
from collections.abc import Sequence
from ctypes import CDLL, POINTER, Structure, c_longlong, c_uint8, c_void_p
from typing import TYPE_CHECKING, ClassVar

if TYPE_CHECKING:
from connect4 import Connect4


_path = os.path.abspath("./golang/connect4img.so")

try:
_lib = CDLL(_path, mode=0x8)
print("Library loaded successfully!")
except OSError as e:
print(f"Failed to load library: {e}")
raise
except UnicodeDecodeError as e:
print(f"Unicode decode error: {e}")
raise


def _slice_type(type: type):
class GoSlice(Structure):
_fields_: ClassVar = [("data", POINTER(type)), ("len", c_longlong), ("cap", c_longlong)]

return GoSlice


_GoIntSlice = _slice_type(c_void_p)
_GoIntSliceSlice = _slice_type(_GoIntSlice)


_lib.GenerateBoard.argtypes = [_GoIntSliceSlice, c_uint8, c_longlong, c_longlong, _GoIntSliceSlice]
_lib.GenerateBoard.restype = None


def _to_go_int_slice_slice(value: Sequence[Sequence[int]]):
inner_length = len(value[0])
if not all(len(row) == inner_length for row in value):
raise ValueError("All rows must have the same length")

rows = [_to_go_int_slice(row) for row in value]

length = len(value)
return _GoIntSliceSlice((_GoIntSlice * length)(*rows), length, length)


def _to_go_int_slice(value: Sequence[int]):
length = len(value)
return _GoIntSlice((c_void_p * length)(*value), length, length)


def _to_uint8(value: bool):
return (c_uint8)(value)


def _to_longlong(value: int):
return (c_longlong)(value)


def _connect4_export_types(c4: Connect4):
board = _to_go_int_slice_slice([[p.value for p in row] for row in c4.board])

is_over = _to_uint8(c4.is_over)
winner = _to_longlong(-1 if c4.winner is None else c4.winner.value)
turn = _to_longlong(c4.turn.value)

win_points = c4._win_points # type: ignore
win_pos = len(win_points)
winner_positions = _GoIntSliceSlice(
(_GoIntSlice * win_pos)(*[_GoIntSlice((c_void_p * 2)(*xy), 2, 2) for xy in win_points]),
win_pos,
win_pos,
)

return board, is_over, winner, turn, winner_positions


def generate_board(c4: Connect4):
_lib.GenerateBoard(*_connect4_export_types(c4))
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit c679692

Please sign in to comment.