From 2fd6c5e854869a0f8c208d10aa786fb41c260879 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Mon, 3 Aug 2026 20:01:17 +0200 Subject: [PATCH] Soften offline hemlighet drain: grace period + per-session cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/data/version.ts | 11 ++++++++++- src/engine.test.ts | 17 +++++++++++++++++ src/engine.ts | 19 ++++++++++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/data/version.ts b/src/data/version.ts index 559f5ba..6073a35 100644 --- a/src/data/version.ts +++ b/src/data/version.ts @@ -1,4 +1,4 @@ -export const VERSION = '0.7.0'; +export const VERSION = '0.7.1'; export interface ChangelogEntry { version: string; @@ -7,6 +7,15 @@ export interface 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', date: '2026-08-01', diff --git a/src/engine.test.ts b/src/engine.test.ts index acac7ac..7593d2d 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -246,6 +246,23 @@ describe('applyOfflineProgress', () => { 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', () => { const s = createState(); const frozenAndakt = s.andakt; diff --git a/src/engine.ts b/src/engine.ts index 597639c..95b474f 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -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 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 // 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; 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.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; 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 dreadDrain = DREAD_HEMLIGHET_DRAIN * (s.dread / (s.dread + 150)); 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 // 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); } + // 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 = awaySec < 3600 ? `${Math.round(awaySec / 60)} minuter` : awaySec < MAX_OFFLINE_SECONDS ? `${(awaySec / 3600).toFixed(1)} timmar` :