feat: add SQLite support, setup wizard enhancements, and live dashboard

Add zero-dependency SQLite mode so users can run Erupe without
PostgreSQL. A transparent db.DB wrapper auto-translates PostgreSQL
SQL ($N placeholders, now(), ::casts, ILIKE, public. prefix,
TRUNCATE) for SQLite at runtime — all 28 repo files use the wrapper
with no per-query changes needed.

Setup wizard gains two new steps: quest file detection with download
link, and gameplay presets (solo/small/community/rebalanced). The API
server gets a /dashboard endpoint with auto-refreshing stats.

CI release workflow now builds and pushes Docker images to GHCR
alongside binary artifacts on tag push.

Key changes:
- common/db: DB/Tx wrapper with 6 SQL translation rules
- server/migrations/sqlite: full SQLite schema (0001-0005)
- config: Database.Driver field ("postgres" or "sqlite")
- main.go: SQLite connection with WAL mode, single writer
- server/setup: quest check + preset selection steps
- server/api: /dashboard with live stats
- .github/workflows: Docker in release, deduplicate docker.yml
This commit is contained in:
Houmgaor
2026-03-05 18:00:30 +01:00
parent 03adb21e99
commit ecfe58ffb4
86 changed files with 2326 additions and 356 deletions

View File

@@ -45,6 +45,15 @@ func (ws *wizardServer) handleClientModes(w http.ResponseWriter, _ *http.Request
writeJSON(w, http.StatusOK, map[string]interface{}{"modes": clientModes()})
}
func (ws *wizardServer) handleCheckQuests(w http.ResponseWriter, _ *http.Request) {
status := checkQuestFiles("")
writeJSON(w, http.StatusOK, status)
}
func (ws *wizardServer) handlePresets(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"presets": availablePresets()})
}
// testDBRequest is the JSON body for POST /api/setup/test-db.
type testDBRequest struct {
Host string `json:"host"`

View File

@@ -23,6 +23,8 @@ func Run(logger *zap.Logger, port int) error {
r.HandleFunc("/api/setup/client-modes", ws.handleClientModes).Methods("GET")
r.HandleFunc("/api/setup/test-db", ws.handleTestDB).Methods("POST")
r.HandleFunc("/api/setup/init-db", ws.handleInitDB).Methods("POST")
r.HandleFunc("/api/setup/check-quests", ws.handleCheckQuests).Methods("GET")
r.HandleFunc("/api/setup/presets", ws.handlePresets).Methods("GET")
r.HandleFunc("/api/setup/finish", ws.handleFinish).Methods("POST")
srv := &http.Server{

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"os"
"path/filepath"
"github.com/lib/pq"
)
@@ -31,6 +32,7 @@ type FinishRequest struct {
Language string `json:"language"`
ClientMode string `json:"clientMode"`
AutoCreateAccount bool `json:"autoCreateAccount"`
Preset string `json:"preset"`
}
// buildDefaultConfig produces a minimal config map with only user-provided values.
@@ -40,7 +42,7 @@ func buildDefaultConfig(req FinishRequest) map[string]interface{} {
if lang == "" {
lang = "jp"
}
return map[string]interface{}{
cfg := map[string]interface{}{
"Host": req.Host,
"Language": lang,
"ClientMode": req.ClientMode,
@@ -53,6 +55,156 @@ func buildDefaultConfig(req FinishRequest) map[string]interface{} {
"Database": req.DBName,
},
}
// Apply preset overrides. The "community" preset uses Viper defaults and
// adds nothing to the config file.
if overrides, ok := presetConfigs()[req.Preset]; ok {
for k, v := range overrides {
cfg[k] = v
}
}
return cfg
}
// presetConfigs returns config overrides keyed by preset ID.
// The "community" preset is intentionally absent — it relies entirely on
// Viper defaults.
func presetConfigs() map[string]map[string]interface{} {
return map[string]map[string]interface{}{
"solo": {
"GameplayOptions": map[string]interface{}{
"HRPMultiplier": 3.0,
"SRPMultiplier": 3.0,
"GRPMultiplier": 3.0,
"GSRPMultiplier": 3.0,
"ZennyMultiplier": 2.0,
"GZennyMultiplier": 2.0,
"MaterialMultiplier": 2.0,
"GMaterialMultiplier": 2.0,
"ExtraCarves": 2,
"GExtraCarves": 2,
},
"Entrance": map[string]interface{}{
"Entries": []map[string]interface{}{
{
"Name": "Solo",
"Type": 1,
"Channels": []map[string]interface{}{
{"Port": 54001, "MaxPlayers": 100},
},
},
},
},
},
"small": {
"Entrance": map[string]interface{}{
"Entries": []map[string]interface{}{
{
"Name": "World 1",
"Type": 1,
"Channels": []map[string]interface{}{
{"Port": 54001, "MaxPlayers": 100},
{"Port": 54002, "MaxPlayers": 100},
},
},
},
},
},
"rebalanced": {
"GameplayOptions": map[string]interface{}{
"HRPMultiplier": 2.0,
"SRPMultiplier": 2.0,
"GRPMultiplier": 2.0,
"GSRPMultiplier": 2.0,
"ExtraCarves": 1,
"GExtraCarves": 1,
},
"Entrance": map[string]interface{}{
"Entries": []map[string]interface{}{
{
"Name": "Normal",
"Type": 1,
"Channels": []map[string]interface{}{
{"Port": 54001, "MaxPlayers": 100},
{"Port": 54002, "MaxPlayers": 100},
},
},
{
"Name": "Cities",
"Type": 2,
"Channels": []map[string]interface{}{
{"Port": 54003, "MaxPlayers": 100},
{"Port": 54004, "MaxPlayers": 100},
},
},
},
},
},
}
}
// QuestStatus holds the result of a quest files check.
type QuestStatus struct {
QuestsFound bool `json:"questsFound"`
QuestCount int `json:"questCount"`
}
// checkQuestFiles checks if quest files exist in the bin/quests/ directory.
func checkQuestFiles(binPath string) QuestStatus {
if binPath == "" {
binPath = "bin"
}
questDir := filepath.Join(binPath, "quests")
entries, err := os.ReadDir(questDir)
if err != nil {
return QuestStatus{QuestsFound: false, QuestCount: 0}
}
count := 0
for _, e := range entries {
if !e.IsDir() {
count++
}
}
return QuestStatus{QuestsFound: count > 0, QuestCount: count}
}
// PresetInfo describes a gameplay preset for the wizard UI.
type PresetInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Channels int `json:"channels"`
}
// availablePresets returns the list of gameplay presets shown in the wizard.
func availablePresets() []PresetInfo {
return []PresetInfo{
{
ID: "solo",
Name: "Solo / Testing",
Description: "Single channel, boosted XP rates (3x), relaxed grind. Ideal for solo play or development testing.",
Channels: 1,
},
{
ID: "small",
Name: "Small Group (2-8 players)",
Description: "Two channels with vanilla rates. Good for friends playing together.",
Channels: 2,
},
{
ID: "community",
Name: "Community Server",
Description: "Full 8-channel topology with vanilla rates. Ready for a public community.",
Channels: 8,
},
{
ID: "rebalanced",
Name: "Rebalanced",
Description: "Community-tuned rates: 2x GRP, 2x HRP, extra carves. Addresses G-Rank grind without trivializing content.",
Channels: 4,
},
}
}
// writeConfig writes the config map to config.json with pretty formatting.

View File

@@ -87,12 +87,16 @@ h1{font-size:1.75rem;margin-bottom:.5rem;color:#e94560;text-align:center}
<div class="progress-step" id="prog-2"></div>
<div class="progress-step" id="prog-3"></div>
<div class="progress-step" id="prog-4"></div>
<div class="progress-step" id="prog-5"></div>
<div class="progress-step" id="prog-6"></div>
</div>
<div class="step-labels">
<span id="lbl-1">1. Database</span>
<span id="lbl-2">2. Schema</span>
<span id="lbl-3">3. Server</span>
<span id="lbl-4">4. Finish</span>
<span id="lbl-3">3. Quest Files</span>
<span id="lbl-4">4. Preset</span>
<span id="lbl-5">5. Server</span>
<span id="lbl-6">6. Finish</span>
</div>
<!-- Step 1: Database Connection -->
@@ -134,8 +138,39 @@ h1{font-size:1.75rem;margin-bottom:.5rem;color:#e94560;text-align:center}
</div>
</div>
<!-- Step 3: Server Settings -->
<!-- Step 3: Quest Files Check -->
<div class="card step hidden" id="step-3">
<h2>Quest Files</h2>
<p style="font-size:.85rem;color:#888;margin-bottom:1rem">Quest files are needed for gameplay. The server checks the <code>bin/quests/</code> directory.</p>
<div id="quest-status" class="status status-info">Checking quest files...</div>
<div id="quest-warning" class="hidden" style="margin-top:1rem">
<p style="font-size:.85rem;color:#e94560;margin-bottom:.7rem"><strong>No quest files found.</strong></p>
<p style="font-size:.85rem;color:#aaa;margin-bottom:.5rem">Download the quest archive:</p>
<a href="https://files.catbox.moe/xf0l7w.7z" target="_blank" rel="noopener" style="color:#4ecdc4;font-size:.9rem;word-break:break-all">https://files.catbox.moe/xf0l7w.7z</a>
<p style="font-size:.85rem;color:#aaa;margin-top:.7rem">Download the archive, extract it, and place the <code>quests/</code> and <code>scenarios/</code> folders into the <code>bin/</code> directory next to the server binary.</p>
</div>
<div style="margin-top:1rem">
<button class="btn btn-secondary" id="btn-recheck-quests" onclick="checkQuests()">Re-check</button>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="goToStep(2)">Back</button>
<button class="btn btn-primary" id="btn-step3-next" onclick="goToStep(4)">Next</button>
</div>
</div>
<!-- Step 4: Gameplay Preset -->
<div class="card step hidden" id="step-4">
<h2>Gameplay Preset</h2>
<p style="font-size:.85rem;color:#888;margin-bottom:1rem">Choose a preset that matches your intended use. This configures channels and gameplay rates.</p>
<div id="preset-cards" style="display:flex;flex-direction:column;gap:.7rem"></div>
<div class="actions">
<button class="btn btn-secondary" onclick="goToStep(3)">Back</button>
<button class="btn btn-primary" id="btn-step4-next" onclick="goToStep(5)">Next</button>
</div>
</div>
<!-- Step 5: Server Settings -->
<div class="card step hidden" id="step-5">
<h2>Server Settings</h2>
<div class="field">
<label>Host IP Address</label>
@@ -162,19 +197,19 @@ h1{font-size:1.75rem;margin-bottom:.5rem;color:#e94560;text-align:center}
</div>
<label class="checkbox" style="margin-top:1rem"><input type="checkbox" id="srv-auto-create" checked> Auto-create accounts (recommended for private servers)</label>
<div class="actions">
<button class="btn btn-secondary" onclick="goToStep(2)">Back</button>
<button class="btn btn-primary" onclick="goToStep(4)">Next</button>
<button class="btn btn-secondary" onclick="goToStep(4)">Back</button>
<button class="btn btn-primary" onclick="goToStep(6)">Next</button>
</div>
</div>
<!-- Step 4: Review & Finish -->
<div class="card step hidden" id="step-4">
<!-- Step 6: Review & Finish -->
<div class="card step hidden" id="step-6">
<h2>Review &amp; Finish</h2>
<p style="font-size:.85rem;color:#888;margin-bottom:1rem">Verify your settings before creating config.json.</p>
<table class="review-table" id="review-table"></table>
<div id="finish-status" class="hidden"></div>
<div class="actions">
<button class="btn btn-secondary" onclick="goToStep(3)">Back</button>
<button class="btn btn-secondary" onclick="goToStep(5)">Back</button>
<button class="btn btn-success" id="btn-finish" onclick="finish()">Create config &amp; Start Server</button>
</div>
</div>
@@ -184,10 +219,13 @@ h1{font-size:1.75rem;margin-bottom:.5rem;color:#e94560;text-align:center}
<script>
let currentStep = 1;
let dbTestResult = null;
let selectedPreset = 'community';
function goToStep(n) {
if (n === 4) buildReview();
if (n === 6) buildReview();
if (n === 2) updateSchemaOptions();
if (n === 3) checkQuests();
if (n === 4) loadPresets();
document.querySelectorAll('.step').forEach(el => el.classList.add('hidden'));
document.getElementById('step-' + n).classList.remove('hidden');
currentStep = n;
@@ -195,7 +233,7 @@ function goToStep(n) {
}
function updateProgress() {
for (let i = 1; i <= 4; i++) {
for (let i = 1; i <= 6; i++) {
const p = document.getElementById('prog-' + i);
const l = document.getElementById('lbl-' + i);
p.className = 'progress-step';
@@ -339,6 +377,78 @@ async function detectIP() {
btn.textContent = 'Auto-detect';
}
async function checkQuests() {
const status = document.getElementById('quest-status');
const warning = document.getElementById('quest-warning');
const btn = document.getElementById('btn-recheck-quests');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Checking...';
status.className = 'status status-info';
status.textContent = 'Checking quest files...';
warning.classList.add('hidden');
try {
const res = await fetch('/api/setup/check-quests');
const data = await res.json();
if (data.questsFound) {
status.className = 'status status-ok';
status.textContent = data.questCount + ' quest file' + (data.questCount !== 1 ? 's' : '') + ' found.';
warning.classList.add('hidden');
} else {
status.className = 'status status-warn';
status.textContent = 'No quest files found in bin/quests/.';
warning.classList.remove('hidden');
}
} catch (e) {
status.className = 'status status-warn';
status.textContent = 'Could not check quest files: ' + e.message;
warning.classList.remove('hidden');
}
btn.disabled = false;
btn.textContent = 'Re-check';
}
let presetsLoaded = false;
async function loadPresets() {
if (presetsLoaded) { highlightPreset(); return; }
try {
const res = await fetch('/api/setup/presets');
const data = await res.json();
const container = document.getElementById('preset-cards');
container.innerHTML = '';
data.presets.forEach(p => {
const card = document.createElement('div');
card.className = 'preset-card';
card.dataset.id = p.id;
card.style.cssText = 'padding:1rem 1.2rem;background:#0f3460;border:2px solid #1a3a6e;border-radius:8px;cursor:pointer;transition:border-color .2s,background .2s';
card.innerHTML = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.3rem">'
+ '<strong style="color:#e0e0e0;font-size:.95rem">' + p.name + '</strong>'
+ '<span style="font-size:.75rem;color:#888">' + p.channels + ' channel' + (p.channels !== 1 ? 's' : '') + '</span>'
+ '</div>'
+ '<div style="font-size:.82rem;color:#aaa">' + p.description + '</div>';
card.onclick = function() {
selectedPreset = p.id;
highlightPreset();
};
container.appendChild(card);
});
presetsLoaded = true;
highlightPreset();
} catch (e) { /* ignore */ }
}
function highlightPreset() {
document.querySelectorAll('.preset-card').forEach(c => {
if (c.dataset.id === selectedPreset) {
c.style.borderColor = '#e94560';
c.style.background = 'rgba(233,69,96,.1)';
} else {
c.style.borderColor = '#1a3a6e';
c.style.background = '#0f3460';
}
});
}
function buildReview() {
const table = document.getElementById('review-table');
const password = document.getElementById('db-password').value;
@@ -352,6 +462,7 @@ function buildReview() {
['Language', document.getElementById('srv-language').value],
['Client Mode', document.getElementById('srv-client-mode').value],
['Auto-create Accounts', document.getElementById('srv-auto-create').checked ? 'Yes' : 'No'],
['Gameplay Preset', selectedPreset],
];
table.innerHTML = rows.map(r => '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>').join('');
}
@@ -376,6 +487,7 @@ async function finish() {
language: document.getElementById('srv-language').value,
clientMode: document.getElementById('srv-client-mode').value,
autoCreateAccount: document.getElementById('srv-auto-create').checked,
preset: selectedPreset,
})
});
const data = await res.json();