Fix silently-dropped log entries past the 40-line cap, add pause, clarify locations panel
All checks were successful
Docker / build-and-push (push) Successful in 42s

- The log view only re-rendered when state.log.length changed, but the
  log is capped at 40 entries — so once a run had logged 40+ things
  (easy in a normal session), every entry after that point, including
  choice-event outcomes, upgrade purchases, and location visits,
  stopped appearing even though the underlying state updated. Fixed
  by tracking a monotonic logSeq counter that increments on every
  push regardless of the cap, and rendering off that instead of
  array length. Consolidated the repeated unshift+cap logic into one
  pushLog() helper so future call sites can't reintroduce the bug.
- Added a pause button (and "PAUSAD" indicator) that freezes the tick
  loop without touching game state, so a hectic run can be paused to
  think without it counting as a save-scummy state mutation. Manual
  actions (buying upgrades, visiting locations) still work while
  paused, since that's usually the point of pausing.
- The "Aktiva områden" sidebar only ever listed the 3 story-gated
  core areas, while the map has 7 clickable locations — making it
  look like 4 of the map pins didn't really exist. Split the panel
  into "Ritualens kärnplatser" (the 3 gated ones, unlock status shown
  as before) and "Övriga platser" (the 4 always-open flavor
  locations), so the sidebar and map agree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-13 16:49:37 +02:00
parent 4b4e5c14aa
commit b7c052a11f
5 changed files with 71 additions and 31 deletions

View File

@@ -31,6 +31,7 @@ export interface State {
nextEventTick: number;
nextKaosTick: number;
pendingEvent: PendingEvent | null;
logSeq: number; // increments on every log push — log.length alone can't detect changes once capped at 40
}
export interface LogEntry {
@@ -75,6 +76,7 @@ export function createState(): State {
nextEventTick: EVENT_INTERVAL_TICKS,
nextKaosTick: EVENT_INTERVAL_TICKS * 3,
pendingEvent: null,
logSeq: 0,
};
}
@@ -84,11 +86,10 @@ export function startGame(state: State): State {
}
export function abandonGame(state: State): State {
return {
...state,
phase: 'abandoned',
log: [{ tick: state.tick, text: 'Ni lämnar källaren. Cirkeln bryts. Den Sovande sover vidare, ovetande, ofärdig.', kind: 'system' }, ...state.log],
};
const s = { ...state, log: [...state.log] };
s.phase = 'abandoned';
pushLog(s, { tick: s.tick, text: 'Ni lämnar källaren. Cirkeln bryts. Den Sovande sover vidare, ovetande, ofärdig.', kind: 'system' });
return s;
}
function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
@@ -114,6 +115,12 @@ function pickEvent(pool: GameEvent[], state: State): GameEvent | null {
return available[available.length - 1];
}
function pushLog(state: State, entry: LogEntry): void {
state.log.unshift(entry);
state.logSeq += 1;
if (state.log.length > 40) state.log = state.log.slice(0, 40);
}
function applyEffect(state: State, effect: GameEvent['effect']): void {
if (effect.andakt) state.andakt = Math.max(0, state.andakt + effect.andakt);
if (effect.hemlighet) state.hemlighet = Math.max(0, Math.min(100, state.hemlighet + effect.hemlighet));
@@ -159,15 +166,14 @@ export function tick(state: State): State {
if (ev) {
s.usedEventIds.add(ev.id);
if (ev.choices?.length) {
s.log.unshift({ tick: s.tick, text: ev.text, kind: 'event' });
pushLog(s, { tick: s.tick, text: ev.text, kind: 'event' });
s.pendingEvent = { event: ev, kind: 'event' };
} else {
s.log.unshift({ tick: s.tick, text: ev.text, kind: 'event' });
pushLog(s, { tick: s.tick, text: ev.text, kind: 'event' });
applyEffect(s, ev.effect);
}
}
s.nextEventTick = s.tick + EVENT_INTERVAL_TICKS + Math.floor(Math.random() * 60);
if (s.log.length > 40) s.log = s.log.slice(0, 40);
}
// Kaos event
@@ -176,26 +182,25 @@ export function tick(state: State): State {
if (ev) {
s.usedEventIds.add(ev.id);
if (ev.choices?.length) {
s.log.unshift({ tick: s.tick, text: `${ev.text}`, kind: 'kaos' });
pushLog(s, { tick: s.tick, text: `${ev.text}`, kind: 'kaos' });
s.pendingEvent = { event: ev, kind: 'kaos' };
} else {
s.log.unshift({ tick: s.tick, text: `${ev.text}`, kind: 'kaos' });
pushLog(s, { tick: s.tick, text: `${ev.text}`, kind: 'kaos' });
applyEffect(s, ev.effect);
}
}
s.kaos = Math.max(0, s.kaos - 80);
s.nextKaosTick = s.tick + EVENT_INTERVAL_TICKS * 5 + Math.floor(Math.random() * 120);
if (s.log.length > 40) s.log = s.log.slice(0, 40);
}
// Win / lose checks
if (s.ritual >= 100) {
s.phase = 'won';
s.log.unshift({ tick: s.tick, text: 'Det är gjort. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
pushLog(s, { tick: s.tick, text: 'Det är gjort. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
} else if (s.hemlighet <= 0) {
s.phase = 'lost';
s.lostReason = 'Utredarna slog till vid gryningen. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.';
s.log.unshift({ tick: s.tick, text: s.lostReason, kind: 'system' });
pushLog(s, { tick: s.tick, text: s.lostReason, kind: 'system' });
}
return s;
@@ -209,17 +214,16 @@ export function resolveEventChoice(state: State, choiceIndex: number): 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) };
applyEffect(s, choice.effect);
if (choice.flag) s.flags.add(choice.flag);
s.log.unshift({ tick: s.tick, text: `${choice.resultText}`, kind: s.pendingEvent!.kind });
if (s.log.length > 40) s.log = s.log.slice(0, 40);
pushLog(s, { tick: s.tick, text: `${choice.resultText}`, kind: s.pendingEvent!.kind });
s.pendingEvent = null;
if (s.hemlighet <= 0) {
s.phase = 'lost';
s.lostReason = 'Utredarna slog till vid gryningen. Källaren är tom. Mattan ligger kvar. Under mattan finns fortfarande bordet.';
s.log.unshift({ tick: s.tick, text: s.lostReason, kind: 'system' });
pushLog(s, { tick: s.tick, text: s.lostReason, kind: 'system' });
} else if (s.ritual >= 100) {
s.phase = 'won';
s.log.unshift({ tick: s.tick, text: 'Det är gjort. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
pushLog(s, { tick: s.tick, text: 'Det är gjort. Turning Torso pekar rakt ned nu. Havet är stilla. Alltför stilla.', kind: 'system' });
}
return s;
@@ -323,8 +327,7 @@ export function visitLocation(state: State, id: string, cooldowns: Map<string, n
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);
pushLog(s, { tick: now, text: `${text}`, kind: 'system' });
const newCooldowns = new Map(cooldowns);
newCooldowns.set(id, now + VISIT_COOLDOWN_TICKS);
@@ -355,6 +358,7 @@ export function loadState(): State | null {
upgrades: new Set<string>(d.upgrades ?? []),
usedEventIds: new Set<string>(d.usedEventIds ?? []),
flags: new Set<string>(d.flags ?? []),
logSeq: d.logSeq ?? 0,
} as State;
} catch (_) {
return null;
@@ -380,7 +384,6 @@ export function buyUpgrade(state: State, id: string): State {
if (cost.inflytande) s.inflytande -= cost.inflytande;
upgrade.effect(s);
if (upgrade.once) s.upgrades.add(id);
s.log.unshift({ tick: s.tick, text: `${upgrade.name}: ${upgrade.desc}`, kind: 'upgrade' });
if (s.log.length > 40) s.log = s.log.slice(0, 40);
pushLog(s, { tick: s.tick, text: `${upgrade.name}: ${upgrade.desc}`, kind: 'upgrade' });
return s;
}

View File

@@ -628,7 +628,7 @@ export const STANDARD_EVENTS: GameEvent[] = [
export const KAOS_EVENTS: GameEvent[] = [
{
id: 'k_goran_dog',
text: 'Göran (från Discord-incidenten) har tagit med sig sin hund "Bamse" till ritualen. Bamse verkar inte störd av chantingen. Tvärtom. Bamse leder nu det andra kvartalets böner.',
text: 'Göran (från Discord-incidenten) har tagit med sig sin hund "Bamse" till ritualen. Bamse verkar inte störd av chantandet. Tvärtom. Bamse leder nu det andra kvartalets böner.',
effect: { kaos: -40, andakt: 20, troende: 1 },
weight: 10,
},
@@ -746,7 +746,7 @@ export const KAOS_EVENTS: GameEvent[] = [
},
{
id: 'k_malmofestivalen',
text: 'Malmöfestivalen har av misstag bokat oss för en scen vid Gustav Adolfs Torg. Chantingen tolkas som experimentell körmusik. Vi vinner publikens pris. Ingen av juryn minns att de röstade.',
text: 'Malmöfestivalen har av misstag bokat oss för en scen vid Gustav Adolfs Torg. Chantandet tolkas som experimentell körmusik. Vi vinner publikens pris. Ingen av juryn minns att de röstade.',
effect: { kaos: -35, andakt: 40, troende: 2, hemlighet: -12 },
weight: 7,
},

View File

@@ -1,10 +1,11 @@
import './style.css';
import { createState, tick, buyUpgrade, visitLocation, resolveEventChoice, startGame, abandonGame, saveState, loadState, clearSave } from './engine.ts';
import { initUI, renderState } from './ui.ts';
import { initUI, renderState, setPausedUI } from './ui.ts';
import { initMap, updateMap } from './map.ts';
let state = loadState() ?? createState();
let visitCooldowns = new Map<string, number>();
let paused = false;
let lastTime = 0;
const TARGET_FPS = 30;
const FRAME_MS = 1000 / TARGET_FPS;
@@ -25,7 +26,8 @@ function start(): void {
},
(index) => { state = resolveEventChoice(state, index); renderState(state); saveState(state); },
() => { state = startGame(state); renderState(state); saveState(state); },
() => { state = abandonGame(state); renderState(state); saveState(state); }
() => { state = abandonGame(state); renderState(state); saveState(state); },
() => { paused = !paused; setPausedUI(paused); }
);
initMap('map', (locationId) => {
@@ -42,7 +44,7 @@ function start(): void {
function loop(now: number): void {
if (now - lastTime >= FRAME_MS) {
lastTime = now;
if (state.phase === 'playing') {
if (state.phase === 'playing' && !paused) {
state = tick(state);
renderState(state);
updateMap(state.dread, state.areas);

View File

@@ -355,6 +355,22 @@ body {
.phase-tag.mid { color: var(--accent); border-color: var(--accent); }
.phase-tag.late { color: var(--dread); border-color: var(--dread); }
.pause-tag {
margin-left: 8px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--kaos);
}
/* Dim the world a touch while paused, so it reads as "held" rather than "broken" */
#app.paused #map-container,
#app.paused #panel-left,
#app.paused #panel-right {
filter: grayscale(0.5) brightness(0.75);
transition: filter 0.3s ease;
}
/* ── Help / header buttons ── */
.header-actions {
margin-left: auto;

View File

@@ -9,12 +9,14 @@ export function initUI(
onEventChoice: (index: number) => void,
onStart: () => void,
onAbandon: () => void,
onTogglePause: () => 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>
<div class="brand-sub">Malmö, Sverige · <span id="phase-tag" class="phase-tag early">Tidigt skede</span><span id="pause-tag" class="pause-tag"></span></div>
<div class="header-actions">
<button id="pause-btn" class="help-btn" title="Pausa spelet">Pausa</button>
<button id="abandon-btn" class="help-btn" title="Överge kulten">Avbryt</button>
<button id="help-btn" class="help-btn" title="Hjälp &amp; Ändringslogg">? v${VERSION}</button>
</div>
@@ -126,12 +128,21 @@ export function initUI(
</div>
<div class="panel" id="panel-right">
<div class="panel-title">Aktiva områden</div>
<div class="panel-title">Ritualens kärnplatser</div>
<div id="areas" style="font-size:11px;color:var(--dim);line-height:2;">
<div id="area-rosengard">○ Rosengård</div>
<div id="area-varnhem">○ Värnhemstorget</div>
<div id="area-torso">○ Turning Torso</div>
</div>
<div style="margin-top:16px;">
<div class="panel-title">Övriga platser</div>
<div style="font-size:11px;color:var(--dim);line-height:2;">
<div>Øresundsbron</div>
<div>Ribersborgsstranden</div>
<div>Davidshallsgatan</div>
<div>Stortorget</div>
</div>
</div>
<div style="margin-top:20px;" id="investigator-section">
<div class="panel-title">Utredarna</div>
<div id="investigator-status" style="font-size:11px;color:var(--safe);">Inaktiva</div>
@@ -163,6 +174,7 @@ export function initUI(
document.getElementById('abandon-btn')!.addEventListener('click', () => {
if (window.confirm('Överge kulten? Cirkeln bryts och ni går skilda vägar.')) onAbandon();
});
document.getElementById('pause-btn')!.addEventListener('click', onTogglePause);
document.getElementById('event-modal-choices')!.addEventListener('click', e => {
const btn = (e.target as Element).closest('[data-choice]') as HTMLElement | null;
@@ -320,12 +332,12 @@ function renderUpgrades(state: State): void {
}).join('');
}
let lastLogLength = 0;
let lastLogSeq = -1;
function renderLog(state: State): void {
const log = document.getElementById('log')!;
if (state.log.length === lastLogLength) return;
lastLogLength = state.log.length;
if (state.logSeq === lastLogSeq) return;
lastLogSeq = state.logSeq;
// state.log is newest-first (unshift); render as-is, scroll to top
log.innerHTML = state.log.map(e =>
`<div class="log-entry ${e.kind}">${e.text}</div>`
@@ -363,6 +375,13 @@ function showOverlay(state: State): void {
}
}
export function setPausedUI(paused: boolean): void {
const btn = document.getElementById('pause-btn')!;
btn.textContent = paused ? 'Fortsätt' : 'Pausa';
document.getElementById('app')!.classList.toggle('paused', paused);
setText('pause-tag', paused ? '⏸ Pausad' : '');
}
function setText(id: string, val: string): void {
const el = document.getElementById(id);
if (el) el.textContent = val;