Add data models

This commit is contained in:
2025-10-15 11:14:56 +02:00
parent 2433b865da
commit 8f034d6309
11 changed files with 393 additions and 116 deletions

View File

@@ -1,107 +0,0 @@
## Overview
Deploy `estus-shots` to the personal Fedora server manually—no CI/CD required. The application runs behind Nginx via a reverse proxy and is supervised by `systemd`.
## Prerequisites
- Fedora (server) with SSH access for a user that can `sudo` without a passwordless setup.
- Nim toolchain installed locally (for `nim`/`nimble`).
- Server already provisioned with Nim runtime dependencies.
- Existing Nginx site definition that proxies traffic to the `estus-shots` service port (defaults to `127.0.0.1:9000`).
## One-time server setup
1. Create deployment directories on the server:
```fish
ssh user@server 'sudo mkdir -p /opt/estus-shots/bin /opt/estus-shots/config && sudo chown ${USER}:${USER} /opt/estus-shots -R'
```
2. Copy the `systemd` unit and enable it:
```fish
printf '%s\n' "[Unit]" \
"Description=estus-shots web service" \
"After=network.target" \
"" \
"[Service]" \
"Type=simple" \
"ExecStart=/opt/estus-shots/bin/estus-shots" \
"Restart=on-failure" \
"RestartSec=5" \
"User=www-data" \
"WorkingDirectory=/opt/estus-shots" \
"Environment=LOG_LEVEL=info" \
"" \
"[Install]" \
"WantedBy=multi-user.target" \
| ssh user@server 'sudo tee /etc/systemd/system/estus-shots.service'
ssh user@server 'sudo systemctl daemon-reload && sudo systemctl enable estus-shots.service'
```
3. Ensure Nginx reverse proxy points to `127.0.0.1:9000` and reload it once configured:
```fish
ssh user@server 'sudo systemctl restart nginx'
```
## Deployment script
Save the following as `scripts/deploy.fish` (or another preferred location) and make it executable with `chmod +x scripts/deploy.fish`.
```fish
#!/usr/bin/env fish
set -e
set SERVER user@server
set TARGET_DIR /opt/estus-shots
set BIN_NAME estus-shots
echo 'Building project locally (release)...'
nimble build -y
echo 'Uploading binary...'
scp bin/$BIN_NAME $SERVER:$TARGET_DIR/bin/
echo 'Uploading static assets...'
scp -r src/static $SERVER:$TARGET_DIR/
echo 'Restarting services...'
ssh $SERVER 'sudo systemctl restart estus-shots.service'
ssh $SERVER 'sudo systemctl restart nginx'
echo 'Deployment finished.'
```
> The script prompts for the SSH password and any required `sudo` password on the server.
## Manual deployment steps
1. Build the project locally:
```fish
nimble build -y
```
2. Copy the new build output and static assets:
```fish
scp bin/estus-shots user@server:/opt/estus-shots/bin/
scp -r src/static user@server:/opt/estus-shots/
```
3. Restart the service and, if needed, Nginx:
```fish
ssh user@server 'sudo systemctl restart estus-shots.service'
ssh user@server 'sudo systemctl restart nginx'
```
## Verification
- Check service status:
```fish
ssh user@server 'systemctl status estus-shots.service --no-pager'
```
- Review recent logs:
```fish
ssh user@server 'journalctl -u estus-shots.service -n 50 --no-pager'
```
## Troubleshooting tips
- If the service fails, run it manually on the server to capture stdout/stderr: `bin/estus-shots`.
- Ensure SELinux contexts allow Nginx to proxy (`setsebool -P httpd_can_network_connect 1`).
- Validate reverse proxy config: `sudo nginx -t`.

26
README.md Normal file
View File

@@ -0,0 +1,26 @@
# Estus Shots
Tracking tool for Dark Souls drinking games.
## Game Rules
- One player actively plays the game.
- When a player dies, all must take a drink.
- The next player takes over.
- If a player beats a boss, the controller passes on, no drinks.
## Features
- Track game progress across multiple games.
- Progress is tracked per game sessions.
- A Session is compoesed of multiple Events
- Events can be start, end, boss defeated, death, etc.
- Statistics are can be viewed for players, games and bosses.
- Players can be added, removed and edited.
- Games can be added, removed and edited.
- Bosses can be added, removed and edited.
- A penalty event happens when a player dies.
- The penalty event assigns a a dink to a player
- A typical penaly would look like this:
- 20:24 Player A dies to Boss B
- Player A drinks a shot of Drink A
- Player B drinks a shot of Drinnk B
- Player C drinks a shot of Dink A

79
internal/models/models.go Normal file
View File

@@ -0,0 +1,79 @@
package models
import "time"
// Player represents a participant in the drinking game.
type Player struct {
ID int `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Game represents a Dark Souls game variant.
type Game struct {
ID int `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Boss represents a boss in a Dark Souls game.
type Boss struct {
ID int `json:"id"`
Name string `json:"name"`
GameID int `json:"game_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Drink represents a type of alcoholic beverage.
type Drink struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // e.g., "beer", "shot", "cocktail"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// EventType represents the type of event that occurred during a session.
type EventType string
const (
EventTypeStart EventType = "start"
EventTypeEnd EventType = "end"
EventTypeBossDefeated EventType = "boss_defeated"
EventTypeDeath EventType = "death"
)
// Session represents a game session with multiple players.
type Session struct {
ID int `json:"id"`
GameID int `json:"game_id"`
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Event represents an event that occurred during a session.
type Event struct {
ID int `json:"id"`
SessionID int `json:"session_id"`
EventType EventType `json:"event_type"`
PlayerID int `json:"player_id"`
BossID *int `json:"boss_id,omitempty"`
Timestamp time.Time `json:"timestamp"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PenaltyDrink represents a drink consumed as a penalty.
type PenaltyDrink struct {
ID int `json:"id"`
EventID int `json:"event_id"`
PlayerID int `json:"player_id"`
DrinkID int `json:"drink_id"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -111,6 +111,30 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
data := AdminMenuBar()
data.Title = "Admin Panel"
s.renderTemplate(w, http.StatusOK, "admin.html", data)
case "/players":
data := DefaultMenuBar()
data.Title = "Players"
s.renderTemplate(w, http.StatusOK, "players.html", data)
case "/games":
data := DefaultMenuBar()
data.Title = "Games"
s.renderTemplate(w, http.StatusOK, "games.html", data)
case "/bosses":
data := DefaultMenuBar()
data.Title = "Bosses"
s.renderTemplate(w, http.StatusOK, "bosses.html", data)
case "/sessions":
data := DefaultMenuBar()
data.Title = "Sessions"
s.renderTemplate(w, http.StatusOK, "sessions.html", data)
case "/statistics":
data := DefaultMenuBar()
data.Title = "Statistics"
s.renderTemplate(w, http.StatusOK, "statistics.html", data)
case "/drinks":
data := DefaultMenuBar()
data.Title = "Drinks"
s.renderTemplate(w, http.StatusOK, "drinks.html", data)
case "/counter":
s.handleCounter(w, r)
case "/time":

View File

@@ -40,22 +40,22 @@ func DefaultMenuBar() PageData {
ShowClock: true,
MenuGroups: []MenuGroup{
{
Label: "File",
Label: "Game",
Items: []MenuItem{
{Label: "New", URL: "#!"},
{Label: "Open", URL: "#!"},
{Label: "Save", URL: "#!"},
{Label: "Save As", URL: "#!"},
{Label: "Home", URL: "/"},
{Label: "Sessions", URL: "/sessions"},
{Label: "Statistics", URL: "/statistics"},
{IsDivider: true},
{Label: "Exit", URL: "#!"},
},
},
{
Label: "Edit",
Label: "Manage",
Items: []MenuItem{
{Label: "Cut", URL: "#!"},
{Label: "Copy", URL: "#!"},
{Label: "Paste", URL: "#!"},
{Label: "Players", URL: "/players"},
{Label: "Games", URL: "/games"},
{Label: "Bosses", URL: "/bosses"},
{Label: "Drinks", URL: "/drinks"},
},
},
{

41
web/templates/bosses.html Normal file
View File

@@ -0,0 +1,41 @@
{{template "layout" .}}
{{define "title"}}Bosses · Estus Shots{{end}}
{{define "content"}}
<fieldset class="tui-fieldset" style="padding: 2rem;">
<legend>Bosses Management</legend>
<p class="tui-text-white">
Manage bosses from Dark Souls games.
</p>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Boss List</h2>
<div
id="boss-list"
hx-get="/bosses/list"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="tui-panel">Loading bosses...</div>
</div>
</section>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Add New Boss</h2>
<form hx-post="/bosses" hx-target="#boss-list" hx-swap="innerHTML">
<div class="tui-fieldset">
<label for="boss-name">Boss Name:</label>
<input type="text" id="boss-name" name="name" required class="tui-input" />
<label for="boss-game" style="margin-top: 1rem;">Game:</label>
<select id="boss-game" name="game_id" required class="tui-input">
<option value="">Select a game...</option>
</select>
<button type="submit" class="tui-button" style="margin-top: 1rem;">Add Boss</button>
</div>
</form>
</section>
</fieldset>
{{end}}

46
web/templates/drinks.html Normal file
View File

@@ -0,0 +1,46 @@
{{template "layout" .}}
{{define "title"}}Drinks · Estus Shots{{end}}
{{define "content"}}
<fieldset class="tui-fieldset" style="padding: 2rem;">
<legend>Drinks Management</legend>
<p class="tui-text-white">
Manage the types of drinks consumed during the game.
</p>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Drink List</h2>
<div
id="drink-list"
hx-get="/drinks/list"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="tui-panel">Loading drinks...</div>
</div>
</section>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Add New Drink</h2>
<form hx-post="/drinks" hx-target="#drink-list" hx-swap="innerHTML">
<div class="tui-fieldset">
<label for="drink-name">Drink Name:</label>
<input type="text" id="drink-name" name="name" required class="tui-input" />
<label for="drink-type" style="margin-top: 1rem;">Type:</label>
<select id="drink-type" name="type" required class="tui-input">
<option value="">Select type...</option>
<option value="beer">Beer</option>
<option value="shot">Shot</option>
<option value="cocktail">Cocktail</option>
<option value="wine">Wine</option>
<option value="other">Other</option>
</select>
<button type="submit" class="tui-button" style="margin-top: 1rem;">Add Drink</button>
</div>
</form>
</section>
</fieldset>
{{end}}

35
web/templates/games.html Normal file
View File

@@ -0,0 +1,35 @@
{{template "layout" .}}
{{define "title"}}Games · Estus Shots{{end}}
{{define "content"}}
<fieldset class="tui-fieldset" style="padding: 2rem;">
<legend>Games Management</legend>
<p class="tui-text-white">
Manage Dark Souls game variants tracked by the system.
</p>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Game List</h2>
<div
id="game-list"
hx-get="/games/list"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="tui-panel">Loading games...</div>
</div>
</section>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Add New Game</h2>
<form hx-post="/games" hx-target="#game-list" hx-swap="innerHTML">
<div class="tui-fieldset">
<label for="game-name">Game Name:</label>
<input type="text" id="game-name" name="name" required class="tui-input" />
<button type="submit" class="tui-button" style="margin-top: 1rem;">Add Game</button>
</div>
</form>
</section>
</fieldset>
{{end}}

View File

@@ -0,0 +1,35 @@
{{template "layout" .}}
{{define "title"}}Players · Estus Shots{{end}}
{{define "content"}}
<fieldset class="tui-fieldset" style="padding: 2rem;">
<legend>Players Management</legend>
<p class="tui-text-white">
Manage players who participate in the drinking game.
</p>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Player List</h2>
<div
id="player-list"
hx-get="/players/list"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="tui-panel">Loading players...</div>
</div>
</section>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Add New Player</h2>
<form hx-post="/players" hx-target="#player-list" hx-swap="innerHTML">
<div class="tui-fieldset">
<label for="player-name">Name:</label>
<input type="text" id="player-name" name="name" required class="tui-input" />
<button type="submit" class="tui-button" style="margin-top: 1rem;">Add Player</button>
</div>
</form>
</section>
</fieldset>
{{end}}

View File

@@ -0,0 +1,50 @@
{{template "layout" .}}
{{define "title"}}Sessions · Estus Shots{{end}}
{{define "content"}}
<fieldset class="tui-fieldset" style="padding: 2rem;">
<legend>Sessions Management</legend>
<p class="tui-text-white">
View and manage game sessions.
</p>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Active Sessions</h2>
<div
id="active-sessions"
hx-get="/sessions/active"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="tui-panel">Loading active sessions...</div>
</div>
</section>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Session History</h2>
<div
id="session-history"
hx-get="/sessions/history"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="tui-panel">Loading session history...</div>
</div>
</section>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Start New Session</h2>
<form hx-post="/sessions" hx-target="#active-sessions" hx-swap="innerHTML">
<div class="tui-fieldset">
<label for="session-game">Game:</label>
<select id="session-game" name="game_id" required class="tui-input">
<option value="">Select a game...</option>
</select>
<button type="submit" class="tui-button" style="margin-top: 1rem;">Start Session</button>
</div>
</form>
</section>
</fieldset>
{{end}}

View File

@@ -0,0 +1,48 @@
{{template "layout" .}}
{{define "title"}}Statistics · Estus Shots{{end}}
{{define "content"}}
<fieldset class="tui-fieldset" style="padding: 2rem;">
<legend>Statistics</legend>
<p class="tui-text-white">
View statistics for players, games, and bosses.
</p>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Player Statistics</h2>
<div
id="player-stats"
hx-get="/statistics/players"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="tui-panel">Loading player statistics...</div>
</div>
</section>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Boss Statistics</h2>
<div
id="boss-stats"
hx-get="/statistics/bosses"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="tui-panel">Loading boss statistics...</div>
</div>
</section>
<section style="margin-top: 1.5rem;">
<h2 class="tui-text-green">Game Statistics</h2>
<div
id="game-stats"
hx-get="/statistics/games"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="tui-panel">Loading game statistics...</div>
</div>
</section>
</fieldset>
{{end}}