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
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
29
src/ui.ts
29
src/ui.ts
@@ -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 & Ä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;
|
||||
|
||||
Reference in New Issue
Block a user