Compare commits

...
2 Commits
Author SHA1 Message Date
Fredrik JohanssonandClaude Sonnet 5 2fd6c5e854 Soften offline hemlighet drain: grace period + per-session cap
Docker / build-and-push (push) Successful in 1m39s
Per feedback that unattended-tab game-overs felt bad specifically
because there was no chance to react — unlike a loss during active
play, which you can usually see coming.

- The first 5 minutes away accrue rewards (andakt, dread, etc.) as
  before, but cost zero hemlighet. A quick tab-out is never punished.
- A single absence, however long, can never cost more than 35
  hemlighet total. Applied as a clamp on the *combined* passive-drain
  and flavor-event effects — an earlier version of this only capped
  the passive-drain component, so a run of bad luck on the random
  away-time events could still blow past the intended cap.

Doesn't change anything about live play — only applyOfflineProgress.
A cult that was already critically exposed before you left can still
be found while you're gone; the cap bounds the damage one absence can
do, it doesn't grant immunity.

Added two tests (grace window is a true no-op below threshold and
still accrues rewards; a 24h absence can't drop hemlighet by more
than the cap regardless of which random events fire) and verified
live in a real browser: a 4-minute absence left hemlighet unchanged
by the offline step, and a 30-hour absence starting at full hemlighet
landed at 64/65 instead of the uncapped 58 from before the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 20:01:17 +02:00
Fredrik JohanssonandClaude Sonnet 5 c23bd369f1 Actually fix click-outside-to-close (previous fix only worked by luck)
Root cause: #app carries a CSS filter for the dread color effect, and
a filter on an ancestor creates a new containing block for any
position:fixed descendant. Every modal (and the overlay) lived inside
#app, so they were clipping to #app's own box — max-width: 1200px,
centered — instead of the real viewport. On screens wider than that,
clicking in the resulting side margins did nothing, because the click
never actually landed inside the fixed element's clipped box.

The earlier "fix" only added Escape support; click-outside code was
already correct and simply never got exercised at the actual edges of
a wide window, which is exactly where a real "click outside" gesture
lands.

Real fix: overlay, help-modal, characters-modal, event-modal, and the
lightbox are now appended directly to <body>, as siblings of #app,
so they're never affected by #app's filter regardless of viewport
width. Everything else (game grid, danger-vignette) stays inside
#app as before.

Verified via headless browser at the exact point that failed before
the fix (top-left corner of a 1300px-wide viewport, well outside
#app's 1200px content area): help, characters, and the lightbox all
now close correctly. Also confirmed no regression — normal gameplay
screen and the game-over overlay both render identically, and the
overlay now correctly covers the full viewport instead of leaving a
gap on wide screens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 18:57:21 +02:00
4 changed files with 114 additions and 61 deletions
+10 -1
View File
@@ -1,4 +1,4 @@
export const VERSION = '0.7.0'; export const VERSION = '0.7.1';
export interface ChangelogEntry { export interface ChangelogEntry {
version: string; version: string;
@@ -7,6 +7,15 @@ export interface ChangelogEntry {
} }
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: '0.7.1',
date: '2026-08-03',
changes: [
'Alla modaler (hjälp, sällskapet, bildvisning) går nu faktiskt att stänga genom att klicka utanför dem, inte bara med X-knappen — tidigare fungerade det bara av misstag i mitten av skärmen',
'Mjukat till hur mycket en frånvaro kan kosta: de första fem minuterna borta dränerar ingen hemlighet alls, och en enskild frånvaro kan aldrig kosta mer än 35 hemlighet totalt, hur länge den än varar',
'Fixat: Malmö universitets kartnål satt vid Hyllie, inte vid campus',
],
},
{ {
version: '0.7.0', version: '0.7.0',
date: '2026-08-01', date: '2026-08-01',
+17
View File
@@ -246,6 +246,23 @@ describe('applyOfflineProgress', () => {
expect(after.phase).toBe('lost'); expect(after.phase).toBe('lost');
}); });
it('never drains hemlighet at all within the grace window', () => {
let s = createState();
s.troende = 50; // would otherwise drain fast
s.hemlighet = 50;
const after = applyOfflineProgress(s, 4 * 60 * 1000); // 4 minutes, under the 5-minute grace
expect(after.hemlighet).toBe(50);
expect(after.andakt).toBeGreaterThan(s.andakt); // rewards still accrue during grace
});
it('caps how much hemlighet a single absence can cost, no matter how long', () => {
let s = createState();
s.troende = 50; // heavy secrecy drain
s.hemlighet = 100;
const after = applyOfflineProgress(s, 24 * 3600 * 1000); // the full 24h cap
expect(after.hemlighet).toBeGreaterThanOrEqual(100 - 35);
});
it('never mutates the input state', () => { it('never mutates the input state', () => {
const s = createState(); const s = createState();
const frozenAndakt = s.andakt; const frozenAndakt = s.andakt;
+18 -1
View File
@@ -211,6 +211,8 @@ export function tick(state: State): State {
const MAX_OFFLINE_SECONDS = 24 * 3600; // cap the catch-up so a week-long absence doesn't insta-win or insta-lose const MAX_OFFLINE_SECONDS = 24 * 3600; // cap the catch-up so a week-long absence doesn't insta-win or insta-lose
const MIN_OFFLINE_SECONDS = 60; // don't bother with a log line for a quick tab-switch const MIN_OFFLINE_SECONDS = 60; // don't bother with a log line for a quick tab-switch
const OFFLINE_GRACE_SECONDS = 5 * 60; // the first 5 minutes away accrue rewards but risk no hemlighet at all
const MAX_OFFLINE_HEMLIGHET_DROP = 35; // one absence, however long, can't cost more than this much secrecy
// Approximates what `tick()` would have done, second-by-second, without // Approximates what `tick()` would have done, second-by-second, without
// actually running potentially hundreds of thousands of frames synchronously // actually running potentially hundreds of thousands of frames synchronously
@@ -222,6 +224,7 @@ export function applyOfflineProgress(state: State, awayMs: number): State {
if (awaySec < MIN_OFFLINE_SECONDS) return state; if (awaySec < MIN_OFFLINE_SECONDS) return state;
const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) }; const s = { ...state, areas: { ...state.areas }, upgrades: new Set(state.upgrades), log: [...state.log], usedEventIds: new Set(state.usedEventIds), flags: new Set(state.flags) };
const startingHemlighet = s.hemlighet;
s.andakt += s.andaktRate * s.troende * awaySec; s.andakt += s.andaktRate * s.troende * awaySec;
s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * awaySec; s.inflytande += INFLYTANDE_BASE_RATE * s.inflytandeRate * Math.pow(s.troende, 1.2) * awaySec;
@@ -233,10 +236,16 @@ export function applyOfflineProgress(state: State, awayMs: number): State {
const kaosReleased = s.kaos + kaosGain >= 100; const kaosReleased = s.kaos + kaosGain >= 100;
s.kaos = Math.min(100, s.kaos + kaosGain); s.kaos = Math.min(100, s.kaos + kaosGain);
// A quick tab-out shouldn't be punished at all, and even a long one has
// a floor on how much it can cost — losing the game entirely to a
// stretch you weren't watching, with no chance to react, felt like a
// different (worse) kind of loss than one you saw coming during play.
const drainSec = Math.max(0, awaySec - OFFLINE_GRACE_SECONDS);
const troendeDrain = s.troende > 4 ? HEMLIGHET_DRAIN_PER_TROENDE * (s.troende - 4) : 0; const troendeDrain = s.troende > 4 ? HEMLIGHET_DRAIN_PER_TROENDE * (s.troende - 4) : 0;
const dreadDrain = DREAD_HEMLIGHET_DRAIN * (s.dread / (s.dread + 150)); const dreadDrain = DREAD_HEMLIGHET_DRAIN * (s.dread / (s.dread + 150));
const ritualDrain = s.ritualUnlocked ? RITUAL_HEMLIGHET_DRAIN * (s.ritual / 100) : 0; const ritualDrain = s.ritualUnlocked ? RITUAL_HEMLIGHET_DRAIN * (s.ritual / 100) : 0;
s.hemlighet = Math.max(0, s.hemlighet - (troendeDrain + dreadDrain + ritualDrain) * awaySec); const rawDrop = (troendeDrain + dreadDrain + ritualDrain) * drainSec;
s.hemlighet = Math.max(0, s.hemlighet - Math.min(MAX_OFFLINE_HEMLIGHET_DROP, rawDrop));
// A little flavor for the time away — non-choice events only, since nobody // A little flavor for the time away — non-choice events only, since nobody
// was there to answer. Roughly one per two hours away, capped low so the // was there to answer. Roughly one per two hours away, capped low so the
@@ -252,6 +261,14 @@ export function applyOfflineProgress(state: State, awayMs: number): State {
s.kaos = Math.max(0, s.kaos - 80); s.kaos = Math.max(0, s.kaos - 80);
} }
// Re-apply the session cap after events — those can also carry a
// hemlighet penalty, and the cap is meant to bound the *total* damage
// an absence can do, not just the passive-drain portion of it.
const totalDrop = startingHemlighet - s.hemlighet;
if (totalDrop > MAX_OFFLINE_HEMLIGHET_DROP) {
s.hemlighet = Math.max(0, startingHemlighet - MAX_OFFLINE_HEMLIGHET_DROP);
}
const awayLabel = const awayLabel =
awaySec < 3600 ? `${Math.round(awaySec / 60)} minuter` : awaySec < 3600 ? `${Math.round(awaySec / 60)} minuter` :
awaySec < MAX_OFFLINE_SECONDS ? `${(awaySec / 3600).toFixed(1)} timmar` : awaySec < MAX_OFFLINE_SECONDS ? `${(awaySec / 3600).toFixed(1)} timmar` :
+69 -59
View File
@@ -37,63 +37,6 @@ export function initUI(
</div> </div>
</div> </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 id="characters-modal" class="help-modal">
<div class="help-modal-inner" id="characters-modal-inner">
<div class="help-modal-header">
<span class="help-modal-title">Sällskapet</span>
<button id="characters-close" class="help-close">✕</button>
</div>
<div class="help-modal-body">
<div class="archive-toggle" role="group" aria-label="Visningsläge">
<button type="button" class="archive-toggle-btn active" data-archive="story">Under spelets gång</button>
<button type="button" class="archive-toggle-btn" data-archive="full">Fullständigt arkiv</button>
</div>
<p class="help-flavor" id="characters-hint" style="margin-bottom:14px;">De som suttit vid bordet. Vissa längre än andra.</p>
<div id="characters-grid" class="characters-grid"></div>
</div>
</div>
</div>
<div class="panel" id="panel-left"> <div class="panel" id="panel-left">
<div class="panel-title">Resurser</div> <div class="panel-title">Resurser</div>
@@ -168,6 +111,18 @@ export function initUI(
</div> </div>
</div> </div>
<div id="danger-vignette"></div>
`;
// Overlays live as siblings of #app, appended directly to <body>, not
// inside it. #app carries a `filter` (for the dread color effect),
// and CSS filter on an ancestor creates a new containing block for any
// `position: fixed` descendant — so these would clip to #app's own box
// (max-width: 1200px, centered) instead of the real viewport. On wider
// screens that left a dead margin on each side where "click outside to
// close" silently did nothing, because the click never actually landed
// inside the fixed element's clipped box.
document.body.insertAdjacentHTML('beforeend', `
<div id="overlay"> <div id="overlay">
<div class="overlay-scrim"> <div class="overlay-scrim">
<div class="overlay-title" id="overlay-title"></div> <div class="overlay-title" id="overlay-title"></div>
@@ -176,7 +131,62 @@ export function initUI(
</div> </div>
</div> </div>
<div id="danger-vignette"></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 id="characters-modal" class="help-modal">
<div class="help-modal-inner" id="characters-modal-inner">
<div class="help-modal-header">
<span class="help-modal-title">Sällskapet</span>
<button id="characters-close" class="help-close">✕</button>
</div>
<div class="help-modal-body">
<div class="archive-toggle" role="group" aria-label="Visningsläge">
<button type="button" class="archive-toggle-btn active" data-archive="story">Under spelets gång</button>
<button type="button" class="archive-toggle-btn" data-archive="full">Fullständigt arkiv</button>
</div>
<p class="help-flavor" id="characters-hint" style="margin-bottom:14px;">De som suttit vid bordet. Vissa längre än andra.</p>
<div id="characters-grid" class="characters-grid"></div>
</div>
</div>
</div>
<div id="event-modal" class="help-modal"> <div id="event-modal" class="help-modal">
<div class="help-modal-inner" id="event-modal-inner"> <div class="help-modal-inner" id="event-modal-inner">
@@ -193,7 +203,7 @@ export function initUI(
<div id="lightbox-caption" class="lightbox-caption"></div> <div id="lightbox-caption" class="lightbox-caption"></div>
<p id="lightbox-blurb" class="lightbox-blurb"></p> <p id="lightbox-blurb" class="lightbox-blurb"></p>
</div> </div>
`; `);
document.getElementById('overlay-btn')!.addEventListener('click', onRestart); document.getElementById('overlay-btn')!.addEventListener('click', onRestart);
document.getElementById('start-btn')!.addEventListener('click', onStart); document.getElementById('start-btn')!.addEventListener('click', onStart);