diff --git a/package.json b/package.json index dbc73a6..de72820 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "test:seasonxp": "node scripts/test-season-xp.js", "test:exit-streak": "node scripts/test-exit-streak.js", "test:privy": "node scripts/test-privy-gate.js --serve", - "test:kills": "node scripts/test-leaderboard-writes.js" + "test:kills": "node scripts/test-leaderboard-writes.js", + "test:proploot": "node scripts/test-prop-loot.js" }, "overrides": { "permissionless": { diff --git a/public/game-engine/game.js b/public/game-engine/game.js index e9d6e21..dc2102d 100644 --- a/public/game-engine/game.js +++ b/public/game-engine/game.js @@ -1583,6 +1583,45 @@ function applyLoot(kind,amt,x,y){ log('A weathered paper, folded shut. Tap it in your inventory to read the code.', 'reward', 'scroll'); spark(x, y-10, '#c9a86a', 26, 200); } + else if(kind==='item' || kind==='food'){ + // ── THE DROP THAT NEVER ARRIVED ──────────────────────────────────────── + // + // OWNER: *"Lootingan food ko skrg kaya gaada ya?? ini serius perlu di bahas + // kenapa di hilangkan."* Nothing was removed. This branch never existed. + // + // Nine breakable props roll an `item` outcome — crate, crystal, tablet, + // statue, oak_barrel, barrel_stack, hay_pile, plaque_sword, cot — and + // applyLoot() handled hp, xp, relic, goldkey, paper and gshard. An `item` + // roll fell through every branch, played the pickup sound, and logged + // "— loot!" because the roll was not 'none'. The player got nothing. His + // own screenshot says *"Straw Bedding shattered — loot!"* over an empty + // inventory, which is this line missing. + // + // Food was never removed either: food IS items (the edible slice of the + // same library, ~16% of ids), so a broken item path took food with it. + // + // `amt` carries the tier the loot table asked for — ['item',2,30] means a + // tier-2 roll — which is how a chest yields better icons than a barrel. + const I = window.NS_ITEMS; + const it = !I ? null : (kind==='food' ? I.rollFoodDrop() : I.rollItemDrop(amt||1)); + if(it){ + const got = addItemToStash(it, 1); + if(got.added > 0){ + const edible = !!(window.NS_MARKET && NS_MARKET.isFood(it.id)); + lootText(x, y, '+' + (it.shortName || it.name), it.color || '#eafff5'); + // 'heart' and 'open' because those keys exist in NS_ICON_PATHS — + // nsIcon() returns an empty string for anything else, so an invented + // name would silently ship a message with no icon. + log((edible ? 'Found food: ' : 'Found ') + it.name + (edible ? ' — eat it from the Food tab.' : ''), + 'reward', edible ? 'heart' : 'open'); + spark(x, y-10, it.color || '#eafff5', 22, 190); + } else { + // Stack already at its cap. Say so rather than repeating the silent + // loss this whole branch exists to fix. + lootText(x, y, it.shortName + ' (stack full)', '#c9a86a'); + } + } + } else if(kind==='gshard'){ // Phase 2 (blueprint §2.2): Glitch Shard crafting material. Tier follows // the current act (early acts drop t1, mid t2, late t3) so materials @@ -6168,6 +6207,14 @@ window.__NS = { get G(){ return G; }, spawnElite(){ if(!G) return; const a=ARCHETYPES[0]; G.enemies.push(new Enemy(a, G.player.x+120, G.player.y, G.depth, false, true)); }, addDecor(t){ if(!G) return; G.decor.push(new Decor(t||'vase', G.player.x+40, G.player.y)); }, + // Debug-only, alongside addDecor above and for the same reason: driving a + // real swing into a prop from a test is all timing and hitboxes, and none of + // that is what the loot path is being asked about. Smashes prop `i` through + // the SAME smashDecor() a weapon calls, so what the test exercises is the + // production path — see scripts/test-prop-loot.js, which exists because + // applyLoot() shipped with no `item` branch and nine props dropped nothing. + smashDecorAt(i){ if(!G || !G.decor[i]) return false; + const o = G.decor[i]; o.hp = 1; smashDecor(o); return !!o.broken; }, onEnemyKilled, // debug-only: lets tests exercise act-completion without a full attack simulation get campaignActIndex(){ return campaignActIndex; }, get campaignCycle(){ return campaignCycle; }, // Null Cycles — enemy scaling diff --git a/public/game-engine/items.js b/public/game-engine/items.js index 523296b..6ae5523 100644 --- a/public/game-engine/items.js +++ b/public/game-engine/items.js @@ -115,11 +115,37 @@ return best; } + // rollFoodDrop(): an item drawn from the EDIBLE range only. + // + // Food is not a separate table — it is the slice of the same 1244-icon + // library that NS_MARKET.FOOD_RANGES marks edible (ids 321-515, about 16%). + // So a generic item roll already produces food one time in six; this exists + // for props that should reliably yield something to eat — a supply crate, a + // stocked shelf, a water pail — where "one in six" was indistinguishable + // from never. + // + // Deliberately no rarity re-roll. rollItemDrop biases toward better rarity + // for richer containers; food heals 3-5% of max HP by rarity, a spread too + // narrow to be worth the bias, and a legendary apple reads as a joke. + function rollFoodDrop(){ + var ranges = (window.NS_MARKET && window.NS_MARKET.FOOD_RANGES) || [[321, 515]]; + var span = 0, i; + for (i = 0; i < ranges.length; i++) span += (ranges[i][1] - ranges[i][0] + 1); + var pick = Math.floor(Math.random() * span); + for (i = 0; i < ranges.length; i++){ + var len = ranges[i][1] - ranges[i][0] + 1; + if (pick < len) return getItem(ranges[i][0] + pick); + pick -= len; + } + return getItem(ranges[0][0]); + } + window.NS_ITEMS = { ITEM_COUNT: ITEM_COUNT, RARITIES: RARITIES, RARITY_ORDER: RARITY_ORDER, getItem: getItem, rollItemDrop: rollItemDrop, + rollFoodDrop: rollFoodDrop, }; })(); diff --git a/public/game-engine/props.js b/public/game-engine/props.js index aa18c96..8aaaaa9 100644 --- a/public/game-engine/props.js +++ b/public/game-engine/props.js @@ -14,9 +14,33 @@ facing 'left' = against the right wall (9 o'clock, side profile) ============================================================ */ const DECOR_TYPES = { + // ---- what a loot entry means ------------------------------------------- + // ['hp', n, w] heal n + // ['xp', n, w] n experience + // ['item', t, w] one item from the library, rolled at TIER t (1-3) + // ['food', t, w] one EDIBLE item (NS_MARKET.FOOD_RANGES). t is unused for + // now — food has no rarity bias, see rollFoodDrop() + // ['relic'|'gshard'|'goldkey'|'paper', n, w] + // ['none', 0, w] nothing + // + // OWNER: *"Lootingan food ko skrg kaya gaada ya?"* Two separate reasons, and + // only one of them was a tuning problem: + // + // 1. applyLoot() in game.js had no `item` branch at all, so every item roll + // from a SMASHED prop (nine types) played the sound, logged "— loot!" + // and gave nothing. Food is the edible ~16% of the same item library, so + // a broken item path took food with it. Fixed there. + // 2. Even working, food arrived only as a sixth of an item roll, and the + // props a player smashes most had no item roll at all. So the props that + // hold PROVISIONS — pails, barrels, crates, shelves, bedding, the herb + // pot — now drop food directly, at ~20-26%. + // + // Weight is taken from `none` first and `xp` second. The hp/relic/gshard + // economies are deliberately untouched: this was meant to add food, not to + // quietly make every prop richer. // ---- breakable decorations: smash them for loot ---- vase: { hp:1, w:24, h:38, label:'Glazed Vase', loot:[['hp',6,50],['xp',12,32],['relic',1,4],['none',0,14]] }, - pot: { hp:1, w:30, h:30, label:'Herb Pot', loot:[['hp',8,58],['xp',10,26],['none',0,16]] }, + pot: { hp:1, w:30, h:30, label:'Herb Pot', loot:[['hp',8,50],['xp',10,22],['food',1,22],['none',0,6]] }, barrel: { hp:2, w:30, h:40, label:'Old Barrel', loot:[['xp',18,45],['hp',10,35],['none',0,20]] }, // Owner, again, and about the LOOTABLE crate this time (the earlier pass at // the same complaint only resized the ambient crate stacks at the bottom of @@ -26,7 +50,7 @@ const DECOR_TYPES = { // drawScale in Decor.draw(). 1.3x brings the drawn crate to ~44px, in line // with the scenery around it, and w/h below are the matching footprint so // the collision circle and contact shadow still fit what is on screen. - crate: { hp:2, w:44, h:44, drawScale:1.3, label:'Supply Crate', loot:[['xp',22,45],['hp',8,25],['item',1,20],['none',0,10]] }, + crate: { hp:2, w:44, h:44, drawScale:1.3, label:'Supply Crate', loot:[['xp',22,36],['hp',8,20],['item',1,20],['food',1,24]] }, cabinet_s:{ hp:2, w:34, h:46, label:'Forgotten Archive', loot:[['hp',14,26],['xp',24,24],['item',2,30],['relic',1,10]], interactive:true, containerMaterial:'wood', northOnly:true }, // owner: cabinets/safes hug the NORTH wall only wardrobe: { hp:3, w:48, h:64, label:'Rotten Armoire', loot:[['hp',32,24],['xp',55,24],['item',2,32]], interactive:true, containerMaterial:'wood_rotten', northOnly:true }, // ---- ancient ornaments: break them for XP, CELO, or rare relics ---- @@ -42,21 +66,21 @@ const DECOR_TYPES = { chest: { hp:2, w:42, h:34, label:'Lost Cache', loot:[['relic',1,15],['item',3,35],['xp',60,15],['hp',30,10]], interactive:true, containerMaterial:'iron', northOnly:true }, // ---- v75 sprite decor (user-supplied 4-direction art, /sprites/decor2) ---- safe: { hp:2, w:40, h:44, label:'Rusted Strongbox', loot:[['relic',1,18],['item',3,32],['xp',55,18],['hp',26,12]], interactive:true, containerMaterial:'iron', northOnly:true }, - table_w: { hp:1, w:36, h:30, label:'Wooden Table', loot:[['xp',14,45],['hp',8,25],['none',0,30]] }, + table_w: { hp:1, w:36, h:30, label:'Wooden Table', loot:[['xp',14,40],['hp',8,22],['food',1,20],['none',0,18]] }, bench: { hp:2, w:52, h:26, label:'Waiting Bench', loot:[['xp',16,45],['hp',8,25],['none',0,30]], northOnly:true }, // ---- v80 LPC sprite props (18 new PNGs in /sprites/decor2, cut from the // Liberated Pixel Cup contest tilesets — Sharm, Janna, Skyler R. Collady, // Lanea Zimmerman et al., CC-BY-SA 3.0 / GPL 3.0, see decor2/CREDITS.md). // Owner request: maps felt empty — more breakable & lootable dressing. // ---- breakables ---- - oak_barrel: { hp:2, w:30, h:40, label:'Oak Barrel', loot:[['xp',16,42],['hp',10,34],['item',1,8],['none',0,16]] }, - barrel_stack: { hp:3, w:46, h:56, label:'Barrel Stack', loot:[['xp',26,42],['hp',14,30],['item',1,14],['none',0,14]] }, - bucket: { hp:1, w:22, h:24, label:'Wooden Pail', loot:[['hp',6,50],['xp',8,26],['none',0,24]] }, - bucket_water: { hp:1, w:22, h:24, label:'Water Pail', loot:[['hp',12,62],['xp',6,18],['none',0,20]] }, + oak_barrel: { hp:2, w:30, h:40, label:'Oak Barrel', loot:[['xp',16,34],['hp',10,28],['item',1,8],['food',1,24],['none',0,6]] }, + barrel_stack: { hp:3, w:46, h:56, label:'Barrel Stack', loot:[['xp',26,34],['hp',14,24],['item',1,14],['food',2,24],['none',0,4]] }, + bucket: { hp:1, w:22, h:24, label:'Wooden Pail', loot:[['hp',6,44],['xp',8,22],['food',1,20],['none',0,14]] }, + bucket_water: { hp:1, w:22, h:24, label:'Water Pail', loot:[['hp',12,52],['xp',6,14],['food',1,26],['none',0,8]] }, boulder: { hp:2, w:32, h:30, label:'Fallen Boulder', loot:[['xp',14,45],['hp',6,20],['relic',1,4],['gshard',1,7],['none',0,31]] }, - hay_pile: { hp:1, w:44, h:50, label:'Straw Bedding', loot:[['hp',8,44],['xp',10,28],['item',1,8],['none',0,20]] }, + hay_pile: { hp:1, w:44, h:50, label:'Straw Bedding', loot:[['hp',8,36],['xp',10,22],['item',1,8],['food',1,26],['none',0,8]] }, chalice: { hp:1, w:26, h:40, label:'Ritual Chalice', loot:[['relic',1,16],['xp',24,38],['hp',10,22],['gshard',1,10],['none',0,24]] }, - basin: { hp:1, w:30, h:26, label:'Wash Basin', loot:[['hp',10,52],['xp',10,24],['none',0,24]] }, + basin: { hp:1, w:30, h:26, label:'Wash Basin', loot:[['hp',10,46],['xp',10,20],['food',1,20],['none',0,14]] }, // Owner: this one read as a framed photograph on the wall — "delete it, // put another lootable thing there instead". Same call they made about // plaque_coin, and for the same reason: a picture frame is not something a @@ -84,10 +108,10 @@ const DECOR_TYPES = { // saved floor built before this change breaks on load. plaque_coin: { hp:2, w:29, h:22, label:'Gold Chest', loot:[['relic',1,16],['item',3,34],['xp',55,20],['hp',24,14],['gshard',1,16]], interactive:true, containerMaterial:'iron', northOnly:true }, skull_heap: { hp:1, w:32, h:16, label:'Skull Heap', loot:[['xp',12,44],['relic',1,7],['hp',6,20],['gshard',1,8],['none',0,29]] }, - cot: { hp:2, w:26, h:56, label:'Rotten Cot', loot:[['hp',12,36],['xp',14,30],['item',1,12],['none',0,22]], northOnly:true }, + cot: { hp:2, w:26, h:56, label:'Rotten Cot', loot:[['hp',12,32],['xp',14,26],['item',1,12],['food',1,18],['none',0,12]], northOnly:true }, // ---- interactive containers (opened via OPEN button, like cabinet_s) ---- footlocker: { hp:2, w:34, h:30, label:'Iron Footlocker', loot:[['item',2,32],['xp',30,26],['hp',16,20],['relic',1,10]], interactive:true, containerMaterial:'iron', northOnly:true }, // boxy, front-view-only art: keep on the top wall so it never faces the wrong way on a side wall (owner: "lemari di W/E menghadap S/N") - shelf_stocked: { hp:2, w:52, h:52, label:'Stocked Shelf', loot:[['item',1,34],['hp',14,26],['xp',22,26],['relic',1,6]], interactive:true, containerMaterial:'wood', northOnly:true }, + shelf_stocked: { hp:2, w:52, h:52, label:'Stocked Shelf', loot:[['item',1,30],['food',2,24],['hp',14,20],['xp',22,20],['relic',1,6]], interactive:true, containerMaterial:'wood', northOnly:true }, dresser: { hp:2, w:48, h:40, label:'Old Dresser', loot:[['item',1,30],['xp',20,28],['hp',12,26],['relic',1,6]], interactive:true, containerMaterial:'wood', northOnly:true }, cabinet_ornate: { hp:2, w:46, h:52, label:'Ornate Cabinet', loot:[['item',2,30],['xp',26,26],['hp',14,22],['relic',1,10]], interactive:true, containerMaterial:'wood', northOnly:true }, // ---- Bunker 5 "THE LAST LIGHT" weekly Vault door (Phase 5.5 #9C/#10) ---- diff --git a/scripts/test-prop-loot.js b/scripts/test-prop-loot.js new file mode 100644 index 0000000..44f8b1b --- /dev/null +++ b/scripts/test-prop-loot.js @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/** + * test-prop-loot.js — a smashed prop must actually give you the thing. + * + * OWNER: *"Lootingan food ko skrg kaya gaada ya?? ini serius perlu di bahas + * kenapa di hilangkan sebelum2nya."* Nothing had been removed. applyLoot() in + * game.js handled hp, xp, relic, goldkey, paper and gshard — and had no `item` + * branch at all. Nine breakable props roll `item` (crate, crystal, tablet, + * statue, oak_barrel, barrel_stack, hay_pile, plaque_sword, cot); every one of + * those rolls played the pickup sound, logged "— loot!" because the roll was + * not 'none', and put nothing in the stash. + * + * Food is the edible ~16% of that same item library (NS_MARKET.FOOD_RANGES), + * so a broken item path took food with it. His own screenshot says "Straw + * Bedding shattered — loot!" over an inventory that never changed. + * + * This drives the REAL engine: it mounts the game, spawns props, and smashes + * them through the same smashDecor() a weapon swing calls. It asserts on the + * stash, not on the log line — the log line was never the thing that was + * broken. + * + * (cd public && python3 -m http.server 3180) & + * node scripts/test-prop-loot.js + * + * Requires playwright-core and a Chromium at CHROME_PATH. + */ +const { chromium } = require('playwright-core') +const fs = require('fs'), path = require('path') + +let fails = 0 +const ok = (l, c, d) => { console.log((c ? ' ✓ ' : ' ✗ FAIL: ') + l + (d !== undefined ? ' (' + d + ')' : '')); if (!c) fails++ } + +;(async () => { + const tsx = fs.readFileSync('components/game/DungeonGame.tsx', 'utf8') + const ids = [...new Set([...tsx.matchAll(/id="([A-Za-z0-9_]+)"/g)].map((m) => m[1]))].sort() + const sp = { game: '', vaultCodeInput: '' } + const scripts = ['audio.js','assets.js','story.js','story_campaign.js','dungeon.js','items.js', + 'marketplace-items.js','props.js','monster-config.js','effects.js','entities.js','outdoor.js','run-session.js','game.js'] + fs.mkdirSync('public/__loot', { recursive: true }) + fs.writeFileSync('public/__loot/index.html', + '
' + + ids.map((i) => sp[i] || `
`).join('') + '
' + + scripts.map((s) => ``).join('')) + + const b = await chromium.launch({ executablePath: process.env.CHROME_PATH || '/opt/pw-browsers/chromium', args: ['--no-sandbox'] }) + const p = await b.newPage() + const errs = [] + p.on('pageerror', (e) => errs.push(e.message.slice(0, 140))) + await p.goto((process.env.BASE_URL || 'http://127.0.0.1:3180') + '/__loot/index.html', { waitUntil: 'load' }) + + const res = await p.evaluate(async () => { + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + // Mount the shell, tear it down, then mount a real run — the same dance + // scripts/test-floor-traversable.js does. Mounting 'continue' straight on + // to a cold shell leaves the dungeon null. + window.NullStateGame.mount({ startMode: null, worldMapHub: true }); await sleep(1200) + window.NullStateGame.unmount(); await sleep(50) + window.NullStateGame.mount({ startMode: 'continue', worldMapHub: true, walletAddress: null, + energy: { trySpend: async () => ({ ok: true }), onExhausted() {} }, + savedSession: { charKey: 'knight', campaignActIndex: 0, depth: 1, maxDepthReached: 1, + xp: 0, level: 1, kills: 0, hp: 100, inventory: { keys: 0, relics: 0, shards: 0, items: {} }, + goldenKeysRemaining: 1, paperRemaining: 1, savedAt: Date.now() } }) + let g = null + for (let t = 0; t < 200 && !(g && g.dun); t++) { await sleep(50); g = window.__NS && window.__NS.G } + if (!g || !g.dun) return { err: 'engine did not mount' } + + const count = () => Object.values(g.inventory.items || {}).reduce((s, e) => s + (e.qty || 0), 0) + const foodCount = () => Object.values(g.inventory.items || {}) + .filter((e) => e.item && window.NS_MARKET.isFood(e.item.id)) + .reduce((s, e) => s + (e.qty || 0), 0) + + // Smash a lot of one type and see what actually lands in the stash. The + // rates are probabilistic, so the counts are compared against the loot + // table's own weights rather than against a hardcoded number. + const run = (type, n) => { + g.decor.length = 0 + g.inventory.items = {} + for (let i = 0; i < n; i++) window.__NS.addDecor(type) + let smashed = 0 + for (let i = g.decor.length - 1; i >= 0; i--) if (window.__NS.smashDecorAt(i)) smashed++ + return { smashed, items: count(), food: foodCount() } + } + + const N = 400 + return { + hay: run('hay_pile', N), + crate: run('crate', N), + pail: run('bucket_water', N), + vase: run('vase', N), // no item/food in its table at all + n: N, + // The table this is measured against, read from the engine itself so the + // test cannot drift from the data it is checking. + tables: ['hay_pile','crate','bucket_water','vase'].reduce((o, k) => { + const d = window.NS_PROPS.DECOR_TYPES[k] + const tot = d.loot.reduce((s, l) => s + l[2], 0) + o[k] = { item: d.loot.filter(l => l[0]==='item').reduce((s,l)=>s+l[2],0) / tot, + food: d.loot.filter(l => l[0]==='food').reduce((s,l)=>s+l[2],0) / tot } + return o + }, {}), + } + }) + + if (res.err) { console.error(' ERROR: ' + res.err); process.exit(2) } + const N = res.n + + ok('the engine smashed every prop it was given', + res.hay.smashed === N && res.crate.smashed === N, `${res.hay.smashed}/${N}`) + + // ── THE BUG ──────────────────────────────────────────────────────────────── + // Before the fix these were all 0: an item roll produced nothing at all. + ok('smashing Straw Bedding puts things in the stash', res.hay.items > 0, + `${res.hay.items} items from ${N}`) + ok('smashing a Supply Crate does too', res.crate.items > 0, + `${res.crate.items} items from ${N}`) + + // ── FOOD, which was the owner's actual report ───────────────────────────── + const expFood = (k) => (res.tables[k].food + res.tables[k].item * 0.156) * N + for (const [k, got] of [['hay_pile', res.hay.food], ['crate', res.crate.food], ['bucket_water', res.pail.food]]) { + const exp = expFood(k) + // ±45% of expectation: loose enough that 400 samples never flake, tight + // enough that a zero or a tenfold change fails loudly. + ok(`${k} yields food at roughly its table's rate`, + got > exp * 0.55 && got < exp * 1.45, `got ${got}, table says ~${exp.toFixed(0)}`) + } + ok('everything counted as food really is edible — checked via NS_MARKET', + res.pail.food <= res.pail.items) + + // ── AND NOTHING ELSE CHANGED ────────────────────────────────────────────── + // A prop with no item and no food in its table must still give none. This is + // what would fail if the fix had been "make every prop drop items". + ok('a Glazed Vase still drops no items at all', res.vase.items === 0, + String(res.vase.items)) + + ok('nothing threw', errs.length === 0, errs.slice(0, 2).join(' | ') || 'clean') + + console.log(fails ? ` ${fails} GAGAL` : ' semua lolos') + await b.close() + fs.rmSync('public/__loot', { recursive: true, force: true }) + process.exit(fails ? 1 : 0) +})().catch((e) => { + console.error('ERROR', e.message) + fs.rmSync('public/__loot', { recursive: true, force: true }) + process.exit(2) +})