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

View File

@@ -628,7 +628,7 @@ export const STANDARD_EVENTS: GameEvent[] = [
export const KAOS_EVENTS: GameEvent[] = [ export const KAOS_EVENTS: GameEvent[] = [
{ {
id: 'k_goran_dog', 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 }, effect: { kaos: -40, andakt: 20, troende: 1 },
weight: 10, weight: 10,
}, },
@@ -746,7 +746,7 @@ export const KAOS_EVENTS: GameEvent[] = [
}, },
{ {
id: 'k_malmofestivalen', 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 }, effect: { kaos: -35, andakt: 40, troende: 2, hemlighet: -12 },
weight: 7, weight: 7,
}, },

View File

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

View File

@@ -355,6 +355,22 @@ body {
.phase-tag.mid { color: var(--accent); border-color: var(--accent); } .phase-tag.mid { color: var(--accent); border-color: var(--accent); }
.phase-tag.late { color: var(--dread); border-color: var(--dread); } .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 ── */ /* ── Help / header buttons ── */
.header-actions { .header-actions {
margin-left: auto; margin-left: auto;

View File

@@ -9,12 +9,14 @@ export function initUI(
onEventChoice: (index: number) => void, onEventChoice: (index: number) => void,
onStart: () => void, onStart: () => void,
onAbandon: () => void, onAbandon: () => void,
onTogglePause: () => void,
): void { ): void {
app.innerHTML = ` app.innerHTML = `
<div id="header"> <div id="header">
<div class="brand">DJUPET</div> <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"> <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="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> <button id="help-btn" class="help-btn" title="Hjälp &amp; Ändringslogg">? v${VERSION}</button>
</div> </div>
@@ -126,12 +128,21 @@ export function initUI(
</div> </div>
<div class="panel" id="panel-right"> <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="areas" style="font-size:11px;color:var(--dim);line-height:2;">
<div id="area-rosengard">○ Rosengård</div> <div id="area-rosengard">○ Rosengård</div>
<div id="area-varnhem">○ Värnhemstorget</div> <div id="area-varnhem">○ Värnhemstorget</div>
<div id="area-torso">○ Turning Torso</div> <div id="area-torso">○ Turning Torso</div>
</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 style="margin-top:20px;" id="investigator-section">
<div class="panel-title">Utredarna</div> <div class="panel-title">Utredarna</div>
<div id="investigator-status" style="font-size:11px;color:var(--safe);">Inaktiva</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', () => { document.getElementById('abandon-btn')!.addEventListener('click', () => {
if (window.confirm('Överge kulten? Cirkeln bryts och ni går skilda vägar.')) onAbandon(); 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 => { document.getElementById('event-modal-choices')!.addEventListener('click', e => {
const btn = (e.target as Element).closest('[data-choice]') as HTMLElement | null; const btn = (e.target as Element).closest('[data-choice]') as HTMLElement | null;
@@ -320,12 +332,12 @@ function renderUpgrades(state: State): void {
}).join(''); }).join('');
} }
let lastLogLength = 0; let lastLogSeq = -1;
function renderLog(state: State): void { function renderLog(state: State): void {
const log = document.getElementById('log')!; const log = document.getElementById('log')!;
if (state.log.length === lastLogLength) return; if (state.logSeq === lastLogSeq) return;
lastLogLength = state.log.length; lastLogSeq = state.logSeq;
// state.log is newest-first (unshift); render as-is, scroll to top // state.log is newest-first (unshift); render as-is, scroll to top
log.innerHTML = state.log.map(e => log.innerHTML = state.log.map(e =>
`<div class="log-entry ${e.kind}">${e.text}</div>` `<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 { function setText(id: string, val: string): void {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.textContent = val; if (el) el.textContent = val;