Add help modal, changelog, map visits, Dockerfile, CI

- Help modal (? button or keyboard shortcut): how-to-play in Swedish,
  flavor text, full changelog sourced from src/data/version.ts
- src/data/version.ts: versioned changelog entries, in-universe Malmö voice
- Map zone clicks: each location on the Leaflet map is now clickable.
  Unlocked cult areas give resources + log entries. Locked locations show
  lore hints. 30s cooldown per location so it's a decision, not a spam button.
- Dockerfile + nginx.conf + docker-compose.yml (port 5684)
- Gitea CI workflow (identical to 2048 — derives image from repo path, needs
  TKNTKN secret, pushes :latest and :sha tags)
- README.md with mechanics summary and docker instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-07 23:31:37 +02:00
parent f5776f269b
commit 5fcb339408
11 changed files with 420 additions and 3 deletions

View File

@@ -0,0 +1,57 @@
name: Docker
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive image name and tags
id: meta
shell: bash
run: |
set -euo pipefail
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
echo "Missing SERVER_URL/REPO/SHA env vars." >&2
echo "SERVER_URL='${SERVER_URL}' REPO='${REPO}' SHA='${SHA}'" >&2
exit 1
fi
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ steps.meta.outputs.host }}
username: ${{ github.actor }}
password: ${{ secrets.TKNTKN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
${{ steps.meta.outputs.image }}:latest
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

40
README.md Normal file
View File

@@ -0,0 +1,40 @@
# Djupet
Lovecraftian idle cult management game set in Malmö, Sweden.
You run a small cult in a basement in Rosengård. The goal is to complete the ritual before the investigators close in. The Turning Torso is involved. The ducks at Ribersborg know more than they let on.
Inspired by Anders Fager, Kult, and Call of Cthulhu.
**Play:** [djupet.games.goonk.se](https://djupet.games.goonk.se)
## Stack
Vanilla TypeScript + Vite. No framework. Leaflet for the map.
## Dev
```
npm install
npm run dev # localhost:5681
npm run typecheck
npm run build
```
## Mechanics
- **Andakt** — passive income, spend on upgrades
- **Dread** — builds as the ritual deepens, unlocks areas and events
- **Hemlighet** — your exposure level; reaches zero and you lose
- **Kaos** — triggers chaotic events at 100, resets, repeat
- **Ritual** — reach 100 to win; unlocked via the Manuscript
Click locations on the Malmö map to visit them. There is a cooldown.
## Docker
```
docker compose up -d
```
Serves on port `5684` by default. Override via `HOST_PORT` in `.env`.

6
docker-compose.yml Normal file
View File

@@ -0,0 +1,6 @@
services:
app:
image: ${IMAGE:-djupet:local}
restart: unless-stopped
ports:
- "${HOST_PORT:-5684}:80"

10
nginx.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}

35
src/data/version.ts Normal file
View File

@@ -0,0 +1,35 @@
export const VERSION = '0.2.0';
export interface ChangelogEntry {
version: string;
date: string;
changes: string[];
}
export const CHANGELOG: ChangelogEntry[] = [
{
version: '0.2.0',
date: '2026-07-07',
changes: [
'Klicka på platser på kartan för att besöka dem — varje besök lämnar spår',
'Välkomnar Anders Fager, Kult och Call of Cthulhu till händelsepoolen',
'Utökad händelsepool: ICA-kassörskor som inte blinkar, SJ-tåg som stannar i tunnlar som inte finns, en stad på Öresunds botten med belysning tänd',
'Ny uppgradering: Paktens Förnyelse (någon tecknade den redan — vi förnyar den bara)',
'Uppgraderingsbeskrivningar omskrivna till rätt register: vardagssvenska, kosmisk fasa, inga utropstecken',
'Autospar var 10:e sekund och vid varje köp, versionsnyckeln djupet_v001',
'Logg: senaste händelse överst',
'Knappklick fungerar nu (renderingen bytte ut DOM 30 gånger per sekund)',
],
},
{
version: '0.1.0',
date: '2026-07-05',
changes: [
'Källarmötet grundades i Rosengård',
'Kartan över Malmö aktiverades — Turning Torso, Värnhemstorget, Øresundsbron',
'Ritualsystemet implementerat: andakt, dread, hemlighet, kaos',
'Göran från Discord kom med av misstag och är fortfarande kvar',
'Den Sovande är ännu inte vaken',
],
},
];

View File

@@ -152,6 +152,85 @@ export function tick(state: State): State {
return s;
}
interface LocationVisit {
text: string;
effect: Partial<{ andakt: number; dread: number; kaos: number; ritual: number; hemlighet: number }>;
requiresArea?: keyof State['areas'];
lockedText: string;
}
const LOCATION_VISITS: Record<string, LocationVisit> = {
rosengard: {
text: 'Du sitter i källaren. Lamporna flimrar i takt med ditt hjärta. Eller tvärtom. +andakt',
effect: { andakt: 15 },
requiresArea: 'rosengard',
lockedText: 'En fuktig källarlokal i Rosengård. Du kan se ljuset under dörren. Ni är inte redo än.',
},
varnhem: {
text: 'Cirkeln är lite större sedan du var här sist. Du ritade den inte. +dread +ritual',
effect: { dread: 8, ritual: 2 },
requiresArea: 'varnhem',
lockedText: 'Värnhemstorget ser normalt ut. Det är ett dåligt tecken.',
},
torso: {
text: 'Byggnaden pekar. Du tittar dit den pekar. Du tittar bort igen. Det hjälper inte. +dread +ritual',
effect: { dread: 15, ritual: 5 },
requiresArea: 'torso',
lockedText: 'Turning Torso vrider sig i vinden. Inte i vinden. I något annat.',
},
oresund: {
text: 'Vattnet sjunker lite långsammare just nu. Det vet att du stod här. +dread',
effect: { dread: 5 },
lockedText: 'Öresund. Danskarna på andra sidan verkar inte märka någonting. Det är det mest oroande av allt.',
},
ribersborg: {
text: 'Änderna tittar på dig. En av dem nickar. Du nickar tillbaka. Det var ett misstag. +andakt',
effect: { andakt: 8 },
lockedText: 'Stranden är tom nu. Änderna sitter stilla. De väntar på rätt tidpunkt.',
},
davidshall: {
text: 'Källarlokalen luktar fortfarande. Du andas djupare än du borde. +andakt',
effect: { andakt: 10 },
lockedText: 'Davidshallsgatan. Kakel från 1923. Bakom det finns något som väljer att vänta.',
},
stortorget: {
text: 'Du vet inte varför du kom hit. Du vet inte hur länge du stod här. +kaos',
effect: { kaos: 8 },
lockedText: 'Stortorget är fullt av folk som inte vet vad de gör här heller. Du passar in.',
},
};
const VISIT_COOLDOWN_TICKS = 900; // 30s at 30fps
export function visitLocation(state: State, id: string, cooldowns: Map<string, number>): { state: State; cooldowns: Map<string, number> } {
const visit = LOCATION_VISITS[id];
if (!visit) return { state, cooldowns };
const now = state.tick;
if ((cooldowns.get(id) ?? 0) > now) return { state, cooldowns };
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds) };
const areaLocked = visit.requiresArea && !s.areas[visit.requiresArea];
const text = areaLocked ? visit.lockedText : visit.text;
if (!areaLocked) {
if (visit.effect.andakt) s.andakt += visit.effect.andakt;
if (visit.effect.dread) s.dread += visit.effect.dread;
if (visit.effect.kaos) s.kaos = Math.min(100, s.kaos + visit.effect.kaos);
if (visit.effect.ritual && s.ritualUnlocked) s.ritual = Math.min(100, s.ritual + visit.effect.ritual);
if (visit.effect.hemlighet) s.hemlighet = Math.max(0, Math.min(100, s.hemlighet + visit.effect.hemlighet));
}
s.log.unshift({ tick: now, text: `${text}`, kind: 'system' });
if (s.log.length > 40) s.log = s.log.slice(0, 40);
const newCooldowns = new Map(cooldowns);
newCooldowns.set(id, now + VISIT_COOLDOWN_TICKS);
return { state: s, cooldowns: newCooldowns };
}
export function saveState(state: State): void {
try {
const serial = {

View File

@@ -1,9 +1,10 @@
import './style.css';
import { createState, tick, buyUpgrade, saveState, loadState, clearSave } from './engine.ts';
import { createState, tick, buyUpgrade, visitLocation, saveState, loadState, clearSave } from './engine.ts';
import { initUI, renderState } from './ui.ts';
import { initMap, updateMap } from './map.ts';
let state = loadState() ?? createState();
let visitCooldowns = new Map<string, number>();
let lastTime = 0;
const TARGET_FPS = 30;
const FRAME_MS = 1000 / TARGET_FPS;
@@ -18,12 +19,18 @@ function start(): void {
() => {
clearSave();
state = createState();
visitCooldowns = new Map();
document.getElementById('overlay')!.classList.remove('visible');
renderState(state);
}
);
initMap('map');
initMap('map', (locationId) => {
const result = visitLocation(state, locationId, visitCooldowns);
state = result.state;
visitCooldowns = result.cooldowns;
renderState(state);
});
renderState(state);
requestAnimationFrame(loop);

View File

@@ -34,7 +34,7 @@ function dreadRadius(level: number): number {
return [6, 10, 16, 24][Math.min(level, 3)];
}
export function initMap(containerId: string): void {
export function initMap(containerId: string, onClick: (id: string) => void): void {
if (!L) return;
map = L.map(containerId, {
center: [55.597, 13.002],
@@ -55,6 +55,7 @@ export function initMap(containerId: string): void {
fillOpacity: 0.3,
weight: 1,
}).addTo(map).bindTooltip(loc.label, { className: 'map-tooltip', permanent: false });
m.on('click', () => onClick(loc.id));
markers.set(loc.id, m);
}
}

View File

@@ -269,6 +269,128 @@ body {
.phase-tag.mid { color: var(--accent); border-color: var(--accent); }
.phase-tag.late { color: var(--dread); border-color: var(--dread); }
/* ── Help button ── */
.help-btn {
margin-left: auto;
background: none;
border: 1px solid var(--border);
color: var(--dim);
font-family: inherit;
font-size: 10px;
letter-spacing: 0.08em;
padding: 3px 10px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.help-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ── Help modal ── */
.help-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(6, 6, 14, 0.88);
z-index: 200;
align-items: center;
justify-content: center;
}
.help-modal.visible { display: flex; }
.help-modal-inner {
background: var(--panel);
border: 1px solid var(--border);
width: min(640px, 92vw);
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.help-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.help-modal-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.help-close {
background: none;
border: none;
color: var(--dim);
font-size: 14px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
transition: color 0.15s;
}
.help-close:hover { color: var(--text); }
.help-modal-body {
overflow-y: auto;
padding: 18px;
display: flex;
flex-direction: column;
gap: 20px;
}
.help-section { display: flex; flex-direction: column; gap: 8px; }
.help-section-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--dim);
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.help-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 5px;
font-size: 11px;
line-height: 1.6;
color: var(--text);
}
.help-list li::before { content: '— '; color: var(--dim); }
.help-list strong { color: var(--accent); }
.help-list em { color: var(--dread); font-style: normal; }
.help-flavor {
font-size: 11px;
color: var(--dim);
line-height: 1.8;
font-style: italic;
}
.help-changelog-entry {
margin-bottom: 14px;
}
.help-changelog-version {
font-size: 11px;
font-weight: 700;
color: var(--accent);
margin-bottom: 5px;
}
.help-changelog-date {
font-weight: 400;
color: var(--dim);
margin-left: 6px;
}
@media (max-width: 900px) {
#app { grid-template-columns: 1fr; }
.panel { border-right: none; border-bottom: 1px solid var(--border); }

View File

@@ -1,11 +1,53 @@
import type { State } from './engine.ts';
import { UPGRADES } from './upgrades.ts';
import { VERSION, CHANGELOG } from './data/version.ts';
export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRestart: () => void): void {
app.innerHTML = `
<div id="header">
<div class="brand">DJUPET</div>
<div class="brand-sub">Malmö, Sverige · <span id="phase-tag" class="phase-tag early">Tidigt skede</span></div>
<button id="help-btn" class="help-btn" title="Hjälp &amp; Ändringslogg">? v${VERSION}</button>
</div>
<div id="help-modal" class="help-modal">
<div class="help-modal-inner" id="help-modal-inner">
<div class="help-modal-header">
<span class="help-modal-title">DJUPET — Hjälp &amp; Ändringslogg</span>
<button id="help-close" class="help-close">✕</button>
</div>
<div class="help-modal-body">
<section class="help-section">
<div class="help-section-title">Hur man spelar</div>
<ul class="help-list">
<li><strong>Andakt</strong> ackumuleras passivt. Använd den för att köpa åtgärder i vänsterpanelen.</li>
<li><strong>Dread</strong> ökar när ritualen fördjupas. Hög dread låser upp nya platser och händelser.</li>
<li><strong>Hemlighet</strong> sjunker när ni blir för uppenbara. Når den noll är det slut.</li>
<li><strong>Kaos</strong> byggs upp naturligt och utlöser kaotiska händelser när det når 100.</li>
<li><strong>Ritual</strong> låses upp via Skriften. Nå 100 för att fullborda ceremonin.</li>
<li>Klicka på <em>platser på kartan</em> för att besöka dem. Varje besök lämnar spår.</li>
<li>Händelser inträffar automatiskt. De flesta är dåliga. En del är värre.</li>
</ul>
</section>
<section class="help-section">
<div class="help-section-title">Om spelet</div>
<p class="help-flavor">
Djupet är ett idle-spel om en kult i Malmö, Sverige.<br>
Inspirerat av Anders Fager, Kult och Call of Cthulhu.<br>
Den Sovande är inte vaken ännu. Jobba på det.
</p>
</section>
<section class="help-section">
<div class="help-section-title">Ändringslogg</div>
${CHANGELOG.map(e => `
<div class="help-changelog-entry">
<div class="help-changelog-version">v${e.version} <span class="help-changelog-date">${e.date}</span></div>
<ul class="help-list">${e.changes.map(c => `<li>${c}</li>`).join('')}</ul>
</div>
`).join('')}
</section>
</div>
</div>
</div>
<div class="panel" id="panel-left">
@@ -77,6 +119,13 @@ export function initUI(app: HTMLElement, onUpgrade: (id: string) => void, onRest
document.getElementById('overlay-btn')!.addEventListener('click', onRestart);
const helpModal = document.getElementById('help-modal')!;
const helpInner = document.getElementById('help-modal-inner')!;
document.getElementById('help-btn')!.addEventListener('click', () => helpModal.classList.toggle('visible'));
document.getElementById('help-close')!.addEventListener('click', () => helpModal.classList.remove('visible'));
helpModal.addEventListener('click', (e) => { if (!helpInner.contains(e.target as Node)) helpModal.classList.remove('visible'); });
document.addEventListener('keydown', (e) => { if (e.key === '?' || (e.key === 'Escape' && helpModal.classList.contains('visible'))) helpModal.classList.toggle('visible'); });
// Split the map container: map on top, log below
const mapContainer = document.getElementById('map-container')!;
mapContainer.style.cssText = 'display:flex;flex-direction:column;overflow:hidden;height:100%;';