mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
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:
@@ -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"`
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & 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 & 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();
|
||||
|
||||
Reference in New Issue
Block a user