Content pass: fix a dead event/character, give eko a purpose, branch endings

Mirrors the depth pass djupet just got, but targeting this project's own
actual gaps (its Kodex/lore.ts was already well-built, confirmed via
survey -- left untouched):

- text_language required flag: 'has_language', which no choice anywhere
  ever set -- a genuinely dead, unreachable event. Worse: the character
  Maja unlocks via unlockEventId: 'text_language', so she was
  unreachable too. Added language_choice (learn to speak Thörnepherian
  vs. only understand it) to actually set the flag, fixing both at once.
- eko had exactly one spender (exert_echo, fixed 30 cost, no scaling).
  Added echo_network (unlock) -> call_in_echo (repeatable, scaling cost,
  restores fäste) plus erase_the_search, which pays off Jonna's
  disappearance thread (discord_erik_missing) by having the private
  investigator looking for her lose the trail permanently.
- researcher_joins (doktorand) was a flat effect event; converted to a
  choice (full trust vs. arm's length) mirroring djupet's Hassan arc.
- Neither new arc's flags touched the win/lose ending text -- same issue
  djupet had. showOverlay now picks a variant based on which flags are
  set, most-specific match first.
- barare past nine was flavor-only (nine_masks_carved exists but no
  event marks outgrowing it); added barare_overflow + bigger_mill,
  mirroring djupet's troende>9 branch.
- Campus (6 of 9 locations were flavor-only, same shape as djupet's
  Möllan/Universitet gap) got its first real tie: campus_regular event
  + campus_access upgrade.
- yra pool skewed entirely heavy/unsettling with no comic-relief beat
  (inverse of djupet's original all-comic kaos pool) -- added two
  lighter entries for tonal variety.

No test suite exists for this project (unlike djupet) -- verified via
typecheck, build, and real browser checks against injected save state:
all upgrade gating (echo_network -> call_in_echo/erase_the_search,
campus_regular -> campus_access) confirmed correct, call_in_echo's cost
scaling and faste restoration confirmed by inspecting state before/after
two real purchases, and all five new ending variants (plus the combined
doktorand+language case) render correctly with proper priority ordering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-08-09 21:12:45 +02:00
co-authored by Claude Sonnet 5
parent f0b123a471
commit 442c28e107
5 changed files with 179 additions and 5 deletions
+14 -1
View File
@@ -1,4 +1,4 @@
export const VERSION = '0.1.0';
export const VERSION = '0.2.0';
export interface ChangelogEntry {
version: string;
@@ -7,6 +7,19 @@ export interface ChangelogEntry {
}
export const CHANGELOG: ChangelogEntry[] = [
{
version: '0.2.0',
date: '2026-08-08',
changes: [
'Eko har äntligen fler användningsområden: ett nätverk av rykten, en repeterbar "kalla in ekot"-uppgradering, och ett sätt att sudda ut sökandet efter Jonna permanent',
'Ett dött, onåbart evenemang (och en onåbar karaktär, Maja) är fixat — ett nytt val om huruvida ni ska lära er tala Thörnepherian eller bara förstå det',
'Doktoranden vid Campus är nu ett riktigt val: fullt förtroende eller armlängds avstånd',
'Fler än nio bärare har nu en egen konsekvens: kvarnen blir för trång, och en ny uppgradering flyttar er till fler av sågverkets byggnader',
'Campus har fått en egen händelse och uppgradering, istället för att bara vara besöksbar utan vidare koppling',
'Två lättare händelser i yra-poolen, för lite tonal variation mot de tyngre',
'Segrar och nederlag har nu olika slutord beroende på språkvalet, doktorandens öde, och om sökandet efter Jonna suddades ut — tidigare påverkade valen aldrig själva slutet',
],
},
{
version: '0.1.0',
date: '2026-08-08',
+3
View File
@@ -21,6 +21,7 @@ export interface State {
vav: number; // 0-100, complete the Weave to win
vavRate: number; // per second
vavUnlocked: boolean;
echoesCalled: number; // times "Kalla in ekot" bought — drives its own cost scaling
areas: { kvarnen: boolean; bonnstan: boolean; sara: boolean };
upgrades: Set<string>;
log: LogEntry[];
@@ -67,6 +68,7 @@ export function createState(): State {
vav: 0,
vavRate: 0,
vavUnlocked: false,
echoesCalled: 0,
areas: { kvarnen: false, bonnstan: false, sara: false },
upgrades: new Set(),
log: [{ tick: 0, text: 'Kvarnen har stått tom sedan sågverket lades ner. Ni är fem. En av er räknade fel, eller så räknade ni rätt och det är en av er som inte borde räknas.', kind: 'system' }],
@@ -450,6 +452,7 @@ export function loadState(): State | null {
usedEventIds: new Set<string>(d.usedEventIds ?? []),
flags: new Set<string>(d.flags ?? []),
logSeq: d.logSeq ?? 0,
echoesCalled: d.echoesCalled ?? 0,
lastRealTime: d.lastRealTime ?? Date.now(),
} as State;
} catch (_) {
+63 -2
View File
@@ -86,6 +86,27 @@ export const STANDARD_EVENTS: GameEvent[] = [
effect: { brasch: 10, yra: 5 },
requires: { minBrasch: 40 },
},
{
id: 'language_choice',
text: 'Lingvisten skickade bara halva översättningen innan hon slutade svara. Resten går att lista ut, om ni vill anstränga er — men det är skillnad på att förstå Thörnepherian när det talas till er, och att faktiskt börja tänka i det.',
weight: 7,
effect: {},
requires: { minBrasch: 10 },
choices: [
{
text: 'Lär er tala det ordentligt',
resultText: 'Ni övar tillsammans, kväll efter kväll, tills orden slutar kännas som ord och börjar kännas som det de faktiskt beskriver. Det går snabbare än det borde.',
effect: { brasch: 8, faste: -4 },
flag: 'has_language',
},
{
text: 'Nöj er med att förstå',
resultText: 'Ni lär er lyssna utan att lära er svara. Det känns säkrare. Det känns också som att ni valt att stå utanför ett rum ni redan är inne i.',
effect: { faste: 5 },
flag: 'language_understood_only',
},
],
},
{
id: 'text_language',
text: 'Ni börjar utan att märka det tänka i Thörnepherian istället för svenska, bara för en sekund åt gången. Det är effektivare. Ord för saker ni tidigare behövde meningar för.',
@@ -93,6 +114,20 @@ export const STANDARD_EVENTS: GameEvent[] = [
effect: { sken: 25, faste: -6 },
requires: { flag: 'has_language' },
},
{
id: 'barare_overflow',
text: 'Kvarnen rymmer inte fler stolar. Ni har börjat stå längs väggarna. Ingen erbjuder sin plats till den som kom sist.',
weight: 6,
effect: { yra: 8, faste: -6 },
requires: { minBarare: 10 },
},
{
id: 'campus_regular',
text: 'Bibliotekarien vid Campus har börjat lägga undan böcker åt er innan ni ens frågar efter dem. Hon kallar det en tjänst till en god kund. Ingen av er minns att ni presenterat er.',
weight: 7,
effect: { sken: 10, brasch: 4 },
requires: { minBarare: 3 },
},
{
id: 'kvarnen_grows',
text: 'Källaren under kvarnen har fler rum än ritningarna medger. Ni hittade tre nya i förra veckan. Ni har slutat rita nya kartor — det känns oartigt att kartlägga något som fortfarande bestämmer sig.',
@@ -123,10 +158,24 @@ export const STANDARD_EVENTS: GameEvent[] = [
},
{
id: 'researcher_joins',
text: 'En doktorand vid Campus råkade översätta tre rader Thörnepherian för ett annat projekt. Hon söker medlemskap nu och säger att hon bara vill se hur det slutar. Ni tror henne inte. Ni släpper in henne ändå.',
text: 'En doktorand vid Campus råkade översätta tre rader Thörnepherian för ett annat projekt. Hon söker medlemskap nu och säger att hon bara vill se hur det slutar. Ni tror henne inte.',
weight: 4,
effect: { barare: 1, sken: 15 },
effect: {},
requires: { minBarare: 3, minBrasch: 30 },
choices: [
{
text: 'Ge henne fullt förtroende',
resultText: 'Ni släpper in henne på riktigt — visar henne kugghjulen, källaren, det ni annars inte visar nya. Hon antecknar allt. Ni har inte bett henne sluta.',
effect: { barare: 1, sken: 20, brasch: 5 },
flag: 'doktorand_trusted',
},
{
text: 'Håll henne på armlängds avstånd',
resultText: 'Ni släpper in henne, men bara till det yttre. Hon märker det, ni ser att hon märker det, och fortsätter komma ändå — som om avståndet i sig var det hon egentligen ville studera.',
effect: { barare: 1, sken: 10 },
flag: 'doktorand_distanced',
},
],
},
{
id: 'the_dagger_dream',
@@ -224,4 +273,16 @@ export const YRA_EVENTS: GameEvent[] = [
weight: 6,
effect: { yra: -20, vav: 1 },
},
{
id: 'yra_ikea_thornepherian',
text: 'En av er försöker montera en bokhylla med hjälp av Thörnepherian-ord ni råkat klottra i marginalen på instruktionen. Den blir klar på tre minuter, perfekt, utan en enda skruv över.',
weight: 5,
effect: { yra: -30, sken: 12 },
},
{
id: 'yra_moose_lobby',
text: 'En älg går vilse i kvarnens foajé och verkar mest generad över det. Ingen av er vågar be den gå. Den går själv, till slut, med viss värdighet kvar.',
weight: 6,
effect: { yra: -25, brasch: 2 },
},
];
+49 -2
View File
@@ -526,6 +526,53 @@ function renderLoreList(state: State | null): void {
: '');
}
// Both arcs (language spoken/understood-only, doktorand trusted/distanced)
// previously only rerouted flavor text on the way to the same win/lose
// copy — the choices never actually reached the ending. Picking the most
// specific match first means a run where both arcs resolved gets its own
// combined ending instead of falling through to whichever flag happened
// to be checked first.
function pickVariant(flags: Set<string>, variants: { when: string[]; text: string }[], fallback: string): string {
const scored = variants
.filter(v => v.when.every(f => flags.has(f)))
.sort((a, b) => b.when.length - a.when.length);
return scored[0]?.text ?? fallback;
}
const WIN_BASE = 'Väven sluter sig. Tornet slutar knaka. Älven är stilla under isen — alltför stilla. Skellefteå fortsätter som vanligt. Det är det värsta.';
const WIN_VARIANTS = [
{
when: ['doktorand_trusted', 'has_language'],
text: 'Väven sluter sig. Doktoranden står i cirkelns mitt och läser de sista raderna på ett Thörnepherian hon aldrig fick lära sig av er — hon lärde sig det av väven själv. Tornet slutar knaka. Ni undrar, för första gången, om ni fortfarande behövs.',
},
{
when: ['doktorand_trusted'],
text: 'Väven sluter sig, men det var doktorandens anteckningar som till slut fick ordningen rätt, inte era. Tornet slutar knaka. Älven är stilla. Hon ler och tittar förbi er, mot vattnet.',
},
{
when: ['has_language'],
text: 'Väven sluter sig. Ni talar de sista orden själva, på Thörnepherian, utan att behöva läsa dem. Tornet slutar knaka. Älven är stilla — alltför stilla för att bara vara vatten.',
},
{
when: ['search_erased'],
text: 'Väven sluter sig. Ingen kom för att leta efter er, eller efter henne — ni såg till det i god tid. Tornet slutar knaka. Älven är stilla. Ni undrar om det var en tjänst ni gjorde honom, eller tog ifrån honom.',
},
];
const LOST_BASE_FALLBACK = 'Ni tappade taget.';
const LOST_VARIANTS = [
{
when: ['doktorand_trusted'],
text: 'Ni tappar taget någonstans mellan sömn och vaka. Doktoranden var den enda som inte sprang när det märktes — hon satt kvar och skrev tills det inte längre gick att skriva. Kvarnen står tom vid gryningen. Fem par skor står prydligt i rad. Tårna pekar mot vattnet.',
},
{
when: ['has_language'],
text: 'Ni tappar taget. Ingen av er hann säga det på svenska. Kvarnen står tom vid gryningen. Fem par skor står prydligt i rad. Tårna pekar mot vattnet.',
},
];
function showOverlay(state: State): void {
const ov = document.getElementById('overlay')!;
ov.classList.add('visible');
@@ -535,7 +582,7 @@ function showOverlay(state: State): void {
ov.style.backgroundImage = "url('/img/win.png')";
title.textContent = 'VÄVEN SLUTEN';
title.className = 'overlay-title won';
body.textContent = 'Väven sluter sig. Tornet slutar knaka. Älven är stilla under isen — alltför stilla. Skellefteå fortsätter som vanligt. Det är det värsta.';
body.textContent = pickVariant(state.flags, WIN_VARIANTS, WIN_BASE);
} else if (state.phase === 'abandoned') {
ov.style.backgroundImage = "url('/img/fail.png')";
title.textContent = 'ÖVERGIVET';
@@ -545,7 +592,7 @@ function showOverlay(state: State): void {
ov.style.backgroundImage = "url('/img/fail.png')";
title.textContent = 'FÖRLORAT FÄSTE';
title.className = 'overlay-title lost';
body.textContent = state.lostReason ?? 'Ni tappade taget.';
body.textContent = pickVariant(state.flags, LOST_VARIANTS, state.lostReason ?? LOST_BASE_FALLBACK);
}
}
+50
View File
@@ -119,6 +119,56 @@ export const UPGRADES: Upgrade[] = [
available: s => s.upgrades.has('grand_weave_prep'),
once: false,
},
{
id: 'echo_network',
name: 'Ekots nätverk',
desc: 'Ryktena ni spridit har börjat sprida sig själva nu, utan att ni behöver be om det. En större sorts tjänst blir möjlig att kalla in.',
cost: () => ({ eko: 60 }),
effect: s => { s.ekoRate += 0.5; },
available: s => s.barare >= 3 && !s.upgrades.has('echo_network'),
once: true,
},
{
id: 'call_in_echo',
name: 'Kalla in ekot',
desc: 'Låt ryktet göra jobbet åt er den här gången. Fungerar, men det märks — kaos sprids en aning och bräschen krymper tillfälligt, ovilligt tystad. Dyrare för varje gång ni ber om samma sak.',
cost: s => ({ eko: Math.round(50 * Math.pow(1.4, s.echoesCalled)) }),
effect: s => {
s.faste = Math.min(100, s.faste + 20);
s.yra = Math.min(100, s.yra + 6);
s.brasch = Math.max(0, s.brasch - 5);
s.echoesCalled += 1;
},
available: s => s.upgrades.has('echo_network'),
once: false,
},
{
id: 'erase_the_search',
name: 'Sudda ut sökandet',
desc: 'Jonnas familj har anlitat en privatutredare. Han börjar komma för nära. Ekot kan göra honom osäker på vad han faktiskt letar efter, permanent — han lägger ner, officiellt av resursskäl.',
cost: () => ({ eko: 150, sken: 300 }),
effect: s => { s.faste = Math.min(100, s.faste + 25); s.flags.add('search_erased'); },
available: s => s.upgrades.has('echo_network') && s.usedEventIds.has('discord_erik_missing') && !s.upgrades.has('erase_the_search'),
once: true,
},
{
id: 'campus_access',
name: 'Fri tillgång till arkiven',
desc: 'Bibliotekarien vid Campus ger er mer än lånekort nu. Hon ber inte om förklaringar, bara om att få vara med "när det händer". Ni har inte sagt ja. Ni har inte sagt nej heller.',
cost: () => ({ sken: 250 }),
effect: s => { s.skenRate += 0.4; },
available: s => s.usedEventIds.has('campus_regular') && !s.upgrades.has('campus_access'),
once: true,
},
{
id: 'bigger_mill',
name: 'Kvarnen är för liten',
desc: 'Ni tar över fler av sågverkets gamla byggnader, officiellt för "föreningsverksamhet". Fler rum, fler dörrar, fler saker att förklara för kommunen. Den extra stolen flyttar med. Den är fortfarande varm.',
cost: () => ({ sken: 1200, brasch: 150 }),
effect: s => { s.skenRate += 2; s.yraRate += 0.25; },
available: s => s.barare >= 10 && s.upgrades.has('nine_masks_carved') && !s.upgrades.has('bigger_mill'),
once: true,
},
{
id: 'the_mask_leads',
name: 'Masken leder er',