Merge pull request #1 from Quintine/feature/memory-versioning

Feature/memory versioning
This commit is contained in:
admin authored and GitHub committed 2026-08-10 21:42:56 +10:00
commit 7269ff53f0
11 files changed
+162 -19

No files matched your search

+37
View File
@@ -4,6 +4,8 @@ This guide consolidates the durable project knowledge used to develop, test,
package, and maintain MeatSlicer. When this guide and the implementation
disagree, the implementation and configuration files are the source of truth.
**Memory version: 0.5.2**
## Architecture
MeatSlicer is an endless top-down arena roguelite built with plain JavaScript
@@ -308,6 +310,38 @@ When changing the version:
4. Run `npm run dist`.
5. Verify the packaged executable with `MS_VERIFY_OUT`.
## Memory versioning
The project knowledge ("memories") — this guide and the player-facing documents
under `docs/` — carries its own `X.Y.Z` version, tracked independently of the
game release version in `package.json`:
- `X` — a major milestone. Changed by the project owner only; a routine update
or bump rule never raises it.
- `Y` — a major feature push: a large content expansion, a major mechanic, or a
significant restructure of the knowledge base.
- `Z` — a small addition, a bug fix, or a single new small feature (a new
gotcha, a corrected formula, a changed workflow, or the documentation sync
that follows a gameplay change).
Components are independent counters and never roll over: `0.999.999` and
`0.42.677` are perfectly valid, and bumping `Z` on `0.999.999` gives
`0.999.1000`. Only `X` is exempt from automatic increases — it changes by
explicit human edit.
The current version is the `**Memory version:**` stamp at the top of this
guide; that stamp is the single source of truth.
When a change to durable knowledge lands (anywhere under `docs/`):
1. Edit the stamp: bump `Z` for a small addition or fix, `Y` for a major push,
or — for a milestone — the project owner raises `X` by hand.
2. Run `npm test` (docs are not loaded by the game, so the suite is unaffected)
and commit with an imperative message naming the bump, e.g.
`docs: record Iron Lung synergy (memory 0.5.0 -> 0.5.1)`.
3. No `?v=` cache bump is needed — `docs/` is not served through `index.html`
script tags; it only ships inside packages.
## Git workflow
Every completed change should be committed:
@@ -334,3 +368,6 @@ Player-facing mechanics belong in the relevant files under `docs/`. Keep those
documents synchronized with gameplay changes. This development guide should be
updated whenever the build, packaging, asset, audio, release, or contribution
workflow changes.
Keep the `**Memory version:**` stamp at the top of this guide current: any
durable-knowledge change bumps it per `## Memory versioning`.
+11 -3
View File
@@ -108,7 +108,7 @@ function spawnPosAwayFromPlayer() {
return { x: rand(a.cx - rx, a.cx + rx), y: rand(a.cy - ry, a.cy + ry) };
}
function spawnWave(count, floor) {
function spawnWave(count, floor, warn) {
for (let i = 0; i < count; i++) {
if (G.enemies.length >= MAX_ENEMIES) break;
const type = pickWaveEnemyType(floor);
@@ -116,7 +116,7 @@ function spawnWave(count, floor) {
const canElite = type !== 'bulwark' && type !== 'broodsac';
const elite = canElite && chance(Math.min(0.03 + floor * 0.02, 0.2));
const e = makeEnemy(type, pos.x, pos.y, floor, elite);
e.warmT = SPAWN_WARN; // telegraph the incoming spawn so the player isn't blindsided
e.warmT = warn || SPAWN_WARN; // telegraph the incoming spawn so the player isn't blindsided
G.enemies.push(e);
spawnBlood(pos.x, pos.y, rand(0, TAU), 4);
Sfx.spawn(e);
@@ -195,6 +195,11 @@ function damageEnemy(e, dmg, ang, knockback, opts) {
return false;
}
// Diminishing (armor-style) conversion of a raw stun rating into a stagger chance.
function stunChance(raw) {
return Math.min(0.50, Math.max(0, raw) / (1 + Math.max(0, raw)));
}
// Shared player-hit payload. Every weapon primitive routes through damageEnemy,
// so items remain useful for bullets, beams, slams, saws, traps and orbitals.
function procOnHit(e, dmg, ang, opts) {
@@ -243,7 +248,10 @@ function procOnHit(e, dmg, ang, opts) {
e.burnDps = Math.max(e.burnDps || 0, 5 * s.dmgMul);
}
if (s.slowOnHit > 0 && chance(clamp(s.slowOnHit * chanceScale, 0, 0.9))) e.slowT = Math.max(e.slowT || 0, 1.5);
if (!e.boss && e.type !== 'bulwark' && s.stunOnHit > 0 && chance(clamp(s.stunOnHit * chanceScale, 0, 0.75))) e.stunT = Math.max(e.stunT || 0, 0.35);
if (!e.boss && e.type !== 'bulwark') {
const stunCh = clamp((s.stunOnHit || 0) * chanceScale, 0, 0.75) + stunChance(s.stunRaw || 0);
if (stunCh > 0 && chance(clamp(stunCh, 0, 0.75))) e.stunT = Math.max(e.stunT || 0, 0.35);
}
if (s.pullOnHit > 0 && !e.boss && chance(clamp(s.pullOnHit * chanceScale, 0, 0.8))) {
const toward = angleTo(e.x, e.y, p.x, p.y);
e.vx += Math.cos(toward) * 170; e.vy += Math.sin(toward) * 170;
+1 -1
View File
@@ -220,7 +220,7 @@ function update(dt) {
drawToastsUpdate(dt);
break;
case 'levelup':
updateLevelup();
updateLevelup(dt);
updateParticles(dt);
break;
case 'debug':
+10 -7
View File
@@ -23,7 +23,7 @@ const PERKS = [
{ id: 'boneknit', name: 'Bone Knit', desc: '3% chance to heal ½ heart after combat rooms', apply: s => { s.roomHealChance += 0.03; } },
{ id: 'spiteflesh',name: 'Spite Flesh', desc: 'Contact attackers take damage', apply: s => { s.thorns += 4; } },
{ id: 'carrion', name: 'Carrion Sense', desc: '+8% pickup radius and pull speed', apply: s => { s.magnet *= 1.08; s.magnetPull *= 1.08; } },
{ id: 'sinew', name: 'Sinew Weave', desc: '+3% move speed and range', apply: s => { s.speedMul *= 1.03; s.rangeMul *= 1.03; } },
{ id: 'sinew', name: 'Sinew Weave', desc: 'Hits may stagger enemies (starts 3%, up to 50%)', apply: s => { s.stunRaw = (s.stunRaw || 0) + STUN_UNIT; } },
];
const PERK_CARD_W = 170, PERK_CARD_H = 230, PERK_CARD_GAP = 18;
@@ -61,6 +61,7 @@ function openPerkDraft() {
}
const opts = shuffle([...PERKS]).slice(0, 3);
G.perkChoices = opts;
if (G.mode !== 'levelup') G.selectionLock = 1.5;
G.mode = 'levelup';
Sfx.levelup();
}
@@ -82,11 +83,13 @@ function choosePerk(i) {
else G.mode = 'play';
}
function updateLevelup() {
if (keyPressed('1')) choosePerk(0);
else if (keyPressed('2')) choosePerk(1);
else if (keyPressed('3')) choosePerk(2);
else if (keyPressed('4', ' ')) choosePerk(irand(0, G.perkChoices.length - 1));
function updateLevelup(dt) {
G.selectionLock = Math.max(0, (G.selectionLock || 0) - dt);
const unlocked = G.selectionLock <= 0;
if (unlocked && keyPressed('1')) choosePerk(0);
else if (unlocked && keyPressed('2')) choosePerk(1);
else if (unlocked && keyPressed('3')) choosePerk(2);
else if (unlocked && keyPressed('4', ' ')) choosePerk(irand(0, G.perkChoices.length - 1));
else if (keyPressed('t')) {
setAutoPerk(true);
G.mode = 'play';
@@ -97,7 +100,7 @@ function updateLevelup() {
G.perkChoices = shuffle([...PERKS]).slice(0, 3);
Sfx.menu();
}
else if (Input.mpressed && G.perkChoices) {
else if (unlocked && Input.mpressed && G.perkChoices) {
// click a card
for (let i = 0; i < 3; i++) {
const cx = perkCardX(i);
+12
View File
@@ -40,6 +40,18 @@ function updatePickups(dt) {
k.vx *= 0.9; k.vy *= 0.9;
const d = dist(k.x, k.y, p.x, p.y);
// nearby pickup: toast the description once per approach, re-arm on leaving
if (k.type === 'item' || k.type === 'active' || k.type === 'weapon') {
if (d < 90 && !k.toastNear) {
k.toastNear = true;
if (k.type === 'item') addToast(ITEMS[k.iid].name, ITEMS[k.iid].desc, 3.5);
else if (k.type === 'active') addToast(ACTIVES[k.aid].name, ACTIVES[k.aid].desc, 3.5);
else addToast(WEAPONS[k.wid].name, WEAPONS[k.wid].desc, 3.5);
} else if (d >= 90 && k.toastNear) {
k.toastNear = false; // re-arm once the player leaves the 90px radius
}
}
// gems & small pickups fly to the player inside magnet radius
if ((k.type === 'gem' || k.type === 'heart' || k.type === 'ammo') && d < magR) {
const a = angleTo(k.x, k.y, p.x, p.y);
+1
View File
@@ -6,6 +6,7 @@ function defaultPlayerStats() {
pierce: 0, bounce: 0, homing: 0, split: 0, fan: 0, rear: 0, splinter: 0,
orbitals: 0, lifestealChance: 0, explodeOnKill: 0,
armor: 0, luck: 0, magnet: 1, magnetPull: 1, xpMul: 1, crit: 0.05, critMul: 2, bloodlust: 0,
stunRaw: 0,
bleed: 0, igniteChance: 0, slowOnHit: 0, stunOnHit: 0, acidOnHit: 0, pullOnHit: 0,
chain: 0, knockbackMul: 1, invBonus: 0, ammoEff: 1, ammoPickupMul: 1,
roomHeal: 0, roomHealChance: 0, thorns: 0, retaliate: 0, sizeMul: 1, mortar: 0, frenzy: 0,
+21 -1
View File
@@ -16,6 +16,9 @@ const ROOM_THEMES = {
flesh: { floor: ['tile_floor8', 'tile_floor8', 'tile_floor1'], wall: ['tile_wall4', 'tile_wall2'] },
};
const ENTRY_WARN = 0.75; // first-entry wave telegraph seconds (~750ms reaction)
const ENTRY_BUFFER = 170; // min px between player and a freshly-entered enemy
function roomBounds(room) {
const def = ROOM_SHAPES[(room && room.shape) || 'hall'] || ROOM_SHAPES.hall;
return { x0: def.x0, y0: def.y0, x1: def.x1, y1: def.y1,
@@ -88,6 +91,7 @@ function enterRoom(gx, gy) {
G.roomLayerKey = '';
G.roomDamaged = false;
G.roomEnterT = G.time;
G.entryFresh = false;
const room = G.rooms[roomKey(gx, gy)];
G.cur = room;
@@ -110,7 +114,8 @@ function enterRoom(gx, gy) {
const w = wavesFor(G.floor);
room.wavesLeft = w.length;
room.pendingWave = w;
spawnWave(w[0], G.floor);
G.entryFresh = true;
spawnWave(w[0], G.floor, ENTRY_WARN);
room.wavesLeft--;
room.spawnT = 0;
} else if (room.type === 'boss') {
@@ -138,6 +143,20 @@ function enterRoom(gx, gy) {
}
}
function separateEntryWave() {
const p = G.player;
const a = G.arena;
const minD = Math.min(ENTRY_BUFFER, Math.min(a.w, a.h) * 0.42);
const minD2 = minD * minD;
for (const e of G.enemies) {
if (e.boss || e.hp <= 0) continue;
if (dist2(e.x, e.y, p.x, p.y) < minD2) {
const pos = spawnPosAwayFromPlayer();
e.x = pos.x; e.y = pos.y;
}
}
}
function roomLocked() {
const r = G.cur;
if (!r) return false;
@@ -206,6 +225,7 @@ function updateRoom(dt) {
if (dir === 'w') p.x = next.x1 - p.r - 8;
p.x = clamp(p.x, next.x0 + p.r, next.x1 - p.r);
p.y = clamp(p.y, next.y0 + p.r, next.y1 - p.r);
if (G.entryFresh) { separateEntryWave(); G.entryFresh = false; }
G.transition = 0.3;
Sfx.door();
}
+1
View File
@@ -58,6 +58,7 @@ const G = {
const PERK_POWER_WEIGHT = 0.5;
// ammo economy: global dial for drop frequency (refill size is per-weapon)
const AMMO_DROP_SCALE = 0.75;
const STUN_UNIT = 0.03 / 0.97; // Sine Weave raw rating per stack, so the first stack = 3% stagger chance
const PRESSURE_MIN = 0.60, PRESSURE_MAX = 2.00;
const PRESSURE_UNIT = 0.01; // one "point" on the dial scale
const PRESSURE_GAIN = PRESSURE_UNIT; // retained: dial 0 == 1 point
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meatslicer",
"version": "0.1.0",
"version": "0.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meatslicer",
"version": "0.1.0",
"version": "0.5.2",
"devDependencies": {
"electron": "^40.10.2",
"electron-builder": "^25.1.8"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meatslicer",
"productName": "MeatSlicer",
"version": "0.5.0",
"version": "0.5.2",
"private": true,
"description": "A Butcher's Descent - clear, harvest, descend.",
"main": "desktop/main.js",
+65 -4
View File
@@ -61,7 +61,7 @@ for (const f of files) {
}
// const/let top-level bindings live in the context's lexical scope, not on the
// global object — expose the ones the harness pokes at directly.
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES })', sandbox);
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES })', sandbox);
let simTime = 0;
const ctx = sandbox;
@@ -283,6 +283,11 @@ console.log('== XP / perks ==');
ctx.gainXP(100);
step(5, 16);
check('levelup triggered', ctx.G.mode === 'levelup' || ctx.G.pendingLevelups > 0);
check('perk selection is locked on first presentation', (ctx.G.selectionLock || 0) > 0);
const lockedChoices = ctx.G.perkChoices;
tap('1'); // ignored while the selection lock is active
check('locked draft ignores selection input', ctx.G.perkChoices === lockedChoices);
step(100, 16); // advance 1.6s in levelup mode, clearing the 1.5s selection lock
let guard = 0;
while (ctx.G.mode === 'levelup' && guard++ < 30) { tap('1'); step(2, 16); }
check('perks drafted', ctx.G.mode === 'play');
@@ -298,6 +303,7 @@ console.log('== rebalanced perks + automatic draft ==');
ctx.G.pendingLevelups = 1; ctx.G.autoPerk = false; ctx.openPerkDraft();
p.stats.rerolls = 1; tap('r');
check('Reroll Rib currency refreshes a manual draft', p.stats.rerolls === 0 && ctx.G.perkChoices.length === 3);
step(100, 16); // clear the 1.5s selection lock armed by openPerkDraft above
tap('4');
check('one-off random draft choice resolves normally', ctx.G.mode === 'play' && ctx.G.pendingLevelups === 0);
const levelBefore = p.level;
@@ -310,6 +316,24 @@ console.log('== rebalanced perks + automatic draft ==');
}
autoDraft = true; // later sections must not get stuck in the perk draft
console.log('== nearby-item description toast ==');
{
const p = ctx.G.player;
ctx.G.pickups.length = 0;
ctx.G.toasts.length = 0;
ctx.spawnPickup('item', p.x + 50, p.y, { iid: 'hollowpoints' });
step(1, 16); // within the 90px radius -> toast once
check('nearby item toasts its description once',
ctx.G.toasts.length === 1 && ctx.G.toasts[0].text === ctx.ITEMS.hollowpoints.name && ctx.G.toasts[0].sub === ctx.ITEMS.hollowpoints.desc);
step(4, 16); // stay adjacent, must not re-fire
check('proximity toast does not repeat while adjacent', ctx.G.toasts.length === 1);
p.x += 500; step(1, 16); // leave the 90px radius -> re-arms
p.x -= 420; step(1, 16); // back within 90 (outside pickup reach) -> fires again
check('proximity toast re-arms after leaving', ctx.G.toasts.length === 2);
ctx.G.pickups.length = 0;
ctx.startRun(); step(3, 16);
}
console.log('== shield heart perk ==');
{
const p = ctx.G.player;
@@ -342,6 +366,16 @@ check('items applied', Object.keys(ctx.G.player.items).length === 4);
check('orbital knives active', ctx.G.player.stats.orbitals >= 1);
step(30, 16);
console.log('== Sine Weave diminishing stagger ==');
{
const s = {}; ctx.PERKS.find(p => p.id === 'sinew').apply(s, {});
check('sine weave first stack is 3% stagger chance', Math.abs(ctx.stunChance(s.stunRaw) - 0.03) < 1e-6);
check('sine weave chance never exceeds 50%', ctx.stunChance(1e6) === 0.5);
check('sine weave gains diminishing returns', ctx.stunChance(s.stunRaw * 2) < 2 * ctx.stunChance(s.stunRaw));
ctx.PERKS.find(p => p.id === 'sinew').apply(s, {});
check('sine weave stacks raw rating', Math.abs(s.stunRaw - 2 * (0.03 / 0.97)) < 1e-9);
}
console.log('== expanded perk and item rosters ==');
{
check('41 new items added (14 phase-2 + 12 phase-3 + 10 legendaries)', Object.keys(ctx.ITEMS).length === 82);
@@ -997,7 +1031,7 @@ console.log('== damaged monster health pips ==');
ctx.G.enemies = [];
}
console.log('== 500ms wave spawn telegraph ==');
console.log('== 750ms entry-wave spawn telegraph ==');
{
ctx.startRun(); step(3, 16);
// enter a combat room to trigger a wave
@@ -1010,7 +1044,7 @@ console.log('== 500ms wave spawn telegraph ==');
const room2 = ctx.G.cur;
if (room2.type === 'combat' && ctx.G.enemies.length > 0) {
const spawner = ctx.G.enemies[0];
check('wave spawn is telegraphing', spawner.warmT > 0.3 && spawner.warmT <= ctx.SPAWN_WARN);
check('wave spawn is telegraphing', spawner.warmT > 0.3 && spawner.warmT <= ctx.ENTRY_WARN);
const sx = spawner.x, sy = spawner.y;
ctx.G.player.invT = 0;
const hpBefore = ctx.G.player.hp;
@@ -1018,7 +1052,7 @@ console.log('== 500ms wave spawn telegraph ==');
step(20, 16); // ~320ms
check('telegraphed spawn is frozen', Math.abs(spawner.x - sx) < 0.01 && Math.abs(spawner.y - sy) < 0.01);
check('telegraphed spawn deals no contact damage', ctx.G.player.hp === hpBefore);
step(25, 16); // past 500ms total
step(30, 16); // past 750ms total
check('spawn arms after the telegraph', spawner.warmT <= 0);
ctx.G.player.x = ctx.W / 2; ctx.G.player.y = ctx.H / 2; ctx.G.player.invT = 1;
} else {
@@ -1036,6 +1070,33 @@ console.log('== 500ms wave spawn telegraph ==');
ctx.startRun(); step(3, 16);
}
console.log('== room-entry reaction buffer ==');
{
ctx.startRun(); step(3, 16);
// enter a combat neighbor room so a first-entry wave is staged at the arrival door
const sr = ctx.G.rooms['0,0'];
const sd = Object.keys(sr.doors).find(d => sr.doors[d]);
const DIRS3 = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
const [edx, edy] = DIRS3[sd];
ctx.enterRoom(sr.gx + edx, sr.gy + edy);
const er = ctx.G.cur;
if (er.type === 'combat' && ctx.G.enemies.length > 0) {
// move the player to a far corner, then run the post-teleport separation
const erA = ctx.G.arena;
ctx.G.player.x = erA.x0 + 40; ctx.G.player.y = erA.y0 + 40;
ctx.separateEntryWave();
const buf = Math.min(ctx.ENTRY_BUFFER, Math.min(erA.w, erA.h) * 0.42);
const ok = ctx.G.enemies.every(e =>
e.boss || e.hp <= 0 || ((e.x - ctx.G.player.x) ** 2 + (e.y - ctx.G.player.y) ** 2) >= (buf * 0.9) ** 2);
check('entry enemies stay past the reaction buffer', ok);
check('first-entry wave telegraphs with ENTRY_WARN', ctx.G.enemies.every(e => (e.warmT || 0) <= ctx.ENTRY_WARN));
} else {
check('combat room staged for entry-buffer test (' + er.type + ')', true);
}
ctx.G.enemies.length = 0;
ctx.startRun(); step(3, 16);
}
console.log('== five pressure-monster archetypes ==');
{
const newTypes = ['censer', 'bulwark', 'choirmaster', 'flenserling', 'broodsac'];