Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion components/KeyHelp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,11 @@ class KeyHelp extends HTMLElement {
});

const perkHint = this.#archetypeInfo
? `${this.#archetypeInfo.perkLabel} — activate it, then pick a direction.`
? `${this.#archetypeInfo.perkLabel} — ${
this.#archetypeInfo.perkAim === 'self'
? 'activate it immediately.'
: 'activate it, then pick a direction.'
}`
: '';
const archetypeInfo = h('p', {
textContent: `Every player archetype has a special ability that can be used to move, attack, or both. ${perkHint}`,
Expand Down
32 changes: 30 additions & 2 deletions components/TouchPad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
import { h } from '/src/domUtils.js';
import { AIM_KIND, MODE, aimKindLabel } from '/src/input/keymap.js';
import { dispatchTouchAction, TOUCHPAD_DIRECTIONS } from '/src/input/touchpad.js';
import type { AimKind, Mode } from '/src/input/keymap.js';
import type { AimKind, Mode, PerkAim } from '/src/input/keymap.js';

const FORCE_SHOW_PARAM = 'touch';
const FORCE_SHOW_VALUE = 'force';
Expand Down Expand Up @@ -308,6 +308,14 @@ class TouchPad extends HTMLElement {
*/
#isBlocked: IsBlockedPredicate = () => false;

/**
* Resolve the live archetype's perk-aim so the SPECIAL button fires a
* self-centered perk (Decker EMP, future self-buffs) immediately instead of
* entering aim mode — mirrors `KeyboardController.getSpecialAim`. Defaults to
* `'directional'` so tests and non-combat callers need not wire it.
*/
#getSpecialAim: () => PerkAim = () => 'directional';

static get observedAttributes() {
return ['force-show'];
}
Expand Down Expand Up @@ -399,6 +407,21 @@ class TouchPad extends HTMLElement {
this.#isBlocked = predicate;
}

/**
* Install (or replace) the perk-aim resolver. Pass `null` to reset to the
* default `'directional'`. Validated so a typo'd assignment crashes loudly.
*/
setSpecialAim(resolver: (() => PerkAim) | null): void {
if (resolver === null || resolver === undefined) {
this.#getSpecialAim = () => 'directional';
return;
}
if (typeof resolver !== 'function') {
throw new TypeError('<touch-pad>.setSpecialAim: expected a function or null');
}
this.#getSpecialAim = resolver;
}

#shouldForceShow() {
if (this.hasAttribute('force-show')) return true;
try {
Expand Down Expand Up @@ -566,7 +589,12 @@ class TouchPad extends HTMLElement {
#dispatchButtonPress(buttonId: string) {
const previousMode = this.#mode;
const previousAimKind = this.#aimKind;
const { intent, nextMode, aimKind } = dispatchTouchAction(buttonId, this.#mode, this.#aimKind);
const { intent, nextMode, aimKind } = dispatchTouchAction(
buttonId,
this.#mode,
this.#aimKind,
this.#getSpecialAim()
);

if (nextMode !== previousMode || aimKind !== previousAimKind) {
this.#mode = nextMode;
Expand Down
234 changes: 202 additions & 32 deletions docs/phase-3.5-plan.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kernel-panic",
"version": "0.3.2-balance",
"version": "0.3.5",
"description": "Turn-based cyberpunk roguelike PWA (ASCII-plus terminal aesthetic)",
"main": "index.html",
"type": "module",
Expand Down
104 changes: 100 additions & 4 deletions src/DataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
type CampaignSummary,
} from './game/campaignSummary.js';
import { archiveScoreableItem, normalizeUnlockedScoreableItems } from './game/scoreableUnlocks.js';
import { archiveUnlockedArchetype, normalizeUnlockedArchetypes } from './game/archetypeUnlocks.js';
import { normalizePendingArchetypeShowcase } from './game/archetypeShowcase.js';

const STORAGE_KEY = 'kp:data';
let instance: DataStore | null = null;
Expand All @@ -25,6 +27,15 @@ type KPData = {
campaign: Campaign | null;
campaignHistory: CampaignSummary[];
unlockedScoreableItems: string[];
/** P3.5.M7: cross-campaign archetype unlocks (Berserk/Adept/Chimera). */
unlockedArchetypes: string[];
/**
* Showcase-slot follow-up (2026-07-14): the archetype id, if any, the next
* campaign start should reserve crew candidate slot 0 for. Set atomically
* alongside `unlockedArchetypes` by `archiveUnlockedArchetype`; cleared by
* `clearPendingArchetypeShowcase` once a campaign-start recruitment commits.
*/
pendingArchetypeShowcase: string | null;
};
type KPDataObject =
| string
Expand All @@ -42,6 +53,8 @@ class DataStore extends EventTarget {
#campaign: KPData['campaign'] = null;
#campaignHistory: KPData['campaignHistory'] = [];
#unlockedScoreableItems: KPData['unlockedScoreableItems'] = [];
#unlockedArchetypes: KPData['unlockedArchetypes'] = [];
#pendingArchetypeShowcase: KPData['pendingArchetypeShowcase'] = null;

constructor() {
if (instance) {
Expand Down Expand Up @@ -72,6 +85,8 @@ class DataStore extends EventTarget {
campaign: null,
campaignHistory: [],
unlockedScoreableItems: [],
unlockedArchetypes: [],
pendingArchetypeShowcase: null,
})
);
} catch (storageError) {
Expand All @@ -83,6 +98,8 @@ class DataStore extends EventTarget {
campaign: null,
campaignHistory: [],
unlockedScoreableItems: [],
unlockedArchetypes: [],
pendingArchetypeShowcase: null,
};
}
// The meta-progression store crashes loudly on structural corruption — a
Expand All @@ -95,6 +112,8 @@ class DataStore extends EventTarget {
campaign: (data.campaign as KPData['campaign']) ?? null,
campaignHistory,
unlockedScoreableItems: normalizeUnlockedScoreableItems(data.unlockedScoreableItems),
unlockedArchetypes: normalizeUnlockedArchetypes(data.unlockedArchetypes),
pendingArchetypeShowcase: normalizePendingArchetypeShowcase(data.pendingArchetypeShowcase),
};
}

Expand All @@ -107,27 +126,47 @@ class DataStore extends EventTarget {
campaign: null,
campaignHistory: [],
unlockedScoreableItems: [],
unlockedArchetypes: [],
pendingArchetypeShowcase: null,
});
window.localStorage.setItem(STORAGE_KEY, savedDataJson);
}
const { prefs, runs, campaign, campaignHistory, unlockedScoreableItems } =
this.#loadDataFromJson(savedDataJson);
const {
prefs,
runs,
campaign,
campaignHistory,
unlockedScoreableItems,
unlockedArchetypes,
pendingArchetypeShowcase,
} = this.#loadDataFromJson(savedDataJson);
this.#prefs = prefs;
this.#runs = runs;
this.#campaign = campaign;
this.#campaignHistory = campaignHistory;
this.#unlockedScoreableItems = unlockedScoreableItems;
this.#unlockedArchetypes = unlockedArchetypes;
this.#pendingArchetypeShowcase = pendingArchetypeShowcase;
this.#emitChangeEvent('init', '*');
}

import(jsonData: string): void {
const { prefs, runs, campaign, campaignHistory, unlockedScoreableItems } =
this.#loadDataFromJson(jsonData);
const {
prefs,
runs,
campaign,
campaignHistory,
unlockedScoreableItems,
unlockedArchetypes,
pendingArchetypeShowcase,
} = this.#loadDataFromJson(jsonData);
this.#prefs = prefs;
this.#runs = runs;
this.#campaign = campaign;
this.#campaignHistory = campaignHistory;
this.#unlockedScoreableItems = unlockedScoreableItems;
this.#unlockedArchetypes = unlockedArchetypes;
this.#pendingArchetypeShowcase = pendingArchetypeShowcase;
this.#emitChangeEvent('import', '*');
}

Expand All @@ -140,6 +179,8 @@ class DataStore extends EventTarget {
campaign: this.#campaign,
campaignHistory: this.#campaignHistory,
unlockedScoreableItems: this.#unlockedScoreableItems,
unlockedArchetypes: this.#unlockedArchetypes,
pendingArchetypeShowcase: this.#pendingArchetypeShowcase,
})
);
}
Expand Down Expand Up @@ -201,6 +242,61 @@ class DataStore extends EventTarget {
return { added };
}

/**
* Cross-campaign meta-progression store (P3.5.M7): archetype IDs
* (Berserk/Adept/Chimera) unlocked via clean Score heists, in acquisition
* order. Wholly independent of `unlockedScoreableItems` — a save that
* unlocked every item under the pre-M7 system starts with an empty
* archetype list. Returns a defensive copy.
*/
get unlockedArchetypes(): string[] {
return [...this.#unlockedArchetypes];
}

/**
* Record an archetype as unlocked. Idempotent: re-archiving an
* already-unlocked id is a no-op (no event, no save). Returns whether the
* store changed. Written only on `score-complete` when the drawn payload
* is an archetype reward.
*
* A genuinely new unlock also arms `pendingArchetypeShowcase` with the same
* id, atomically — the next campaign start reserves crew candidate slot 0
* for it (see `archetypeShowcase.ts`). A duplicate/no-op archive leaves any
* already-pending showcase untouched.
*/
archiveUnlockedArchetype(id: string): { added: boolean } {
const { list, added } = archiveUnlockedArchetype(this.#unlockedArchetypes, id);
if (added) {
this.#unlockedArchetypes = list;
this.#pendingArchetypeShowcase = id;
this.#emitChangeEvent('add', 'unlockedArchetypes', [...list]);
this.#saveData();
}
return { added };
}

/**
* The archetype id, if any, the next campaign start should showcase in
* crew candidate slot 0 (see `archetypeShowcase.ts`). `null` when nothing
* is pending.
*/
get pendingArchetypeShowcase(): string | null {
return this.#pendingArchetypeShowcase;
}

/**
* Clear the pending showcase. Idempotent (no event, no save if already
* `null`). Called once a campaign-start recruitment actually commits
* (`Campaign.recruitInitial`) — regardless of which candidate was picked,
* the showcase's job (guaranteeing the player *saw* the option) is done.
*/
clearPendingArchetypeShowcase(): void {
if (this.#pendingArchetypeShowcase === null) return;
this.#pendingArchetypeShowcase = null;
this.#emitChangeEvent('delete', 'pendingArchetypeShowcase');
this.#saveData();
}

archiveCampaign(summary: CampaignSummary): CampaignSummary {
const archived = archiveCampaignSummary(this.#campaignHistory, summary);
this.#campaignHistory = archived.history;
Expand Down
Loading
Loading