step 10.11: done
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
// Integration tests for natural HP regeneration (step 10.11).
|
||||
// IMPORTANT: time.setNow() must be called on the time module imported AFTER setup()
|
||||
// because setup() clears the require cache and recreates the time module.
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { setup, teardown, cleanup } = require('../helpers');
|
||||
|
||||
describe('Step 10.11 — Natural HP regeneration', () => {
|
||||
let baseUrl;
|
||||
|
||||
async function registerAndLogin(username) {
|
||||
await fetch(`${baseUrl}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password: 'password123' }),
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password: 'password123' }),
|
||||
});
|
||||
return res.headers.get('set-cookie');
|
||||
}
|
||||
|
||||
async function createHero(cookie, name) {
|
||||
await fetch(`${baseUrl}/api/character`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({ name, class: 'warrior' }),
|
||||
});
|
||||
}
|
||||
|
||||
async function setupHero(username, name, hp, gold) {
|
||||
const cookie = await registerAndLogin(username);
|
||||
await createHero(cookie, name);
|
||||
const db = require('../../src/db').getDb();
|
||||
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get(username).id);
|
||||
const char = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userId);
|
||||
db.prepare('UPDATE characters SET hp_current = ?, gold = ? WHERE id = ?').run(hp, gold, char.id);
|
||||
return cookie;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
baseUrl = await setup();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Reset time on the fresh module
|
||||
const time = require('../../src/utils/time');
|
||||
time.resetNow();
|
||||
await teardown();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('hero at 1 HP recovers after 30 minutes (lazy regen restores ≥20%)', async () => {
|
||||
const cookie = await setupHero('hpregen1', 'HpRegen1', 1, 100);
|
||||
|
||||
// Import time AFTER setup() cleared the cache
|
||||
const time = require('../../src/utils/time');
|
||||
|
||||
const db = require('../../src/db').getDb();
|
||||
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen1').id);
|
||||
const char = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userId);
|
||||
const maxHp = 50 + char.vit * 10 + char.level * 5;
|
||||
|
||||
// Set HP to 1 and hp_updated_at to a fixed time
|
||||
const startTime = 1000;
|
||||
db.prepare('UPDATE characters SET hp_current = 1, hp_updated_at = ? WHERE id = ?').run(startTime, char.id);
|
||||
|
||||
// Set time AFTER setup() cleared the cache
|
||||
const thirtyMinMs = 30 * 60 * 1000;
|
||||
const later = startTime + thirtyMinMs;
|
||||
time.setNow(() => later);
|
||||
|
||||
try {
|
||||
// HP should have regenerated: 30 ticks * 1% per tick = 30% of maxHp
|
||||
const expectedHp = Math.min(1 + Math.floor(30 * maxHp / 100), maxHp);
|
||||
assert.ok(expectedHp >= maxHp * 0.2, `Expected HP ${expectedHp} >= 20% of ${maxHp}`);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/character`, {
|
||||
method: 'GET',
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.data.character.hp_current, expectedHp);
|
||||
assert.equal(body.data.derived.maxHp, maxHp);
|
||||
} finally {
|
||||
time.resetNow();
|
||||
}
|
||||
});
|
||||
|
||||
it('fresh character starts with hp_updated_at set', async () => {
|
||||
const cookie = await registerAndLogin('hpregen2');
|
||||
await createHero(cookie, 'HpFresh2');
|
||||
|
||||
const db = require('../../src/db').getDb();
|
||||
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen2').id);
|
||||
const ch = db.prepare('SELECT hp_updated_at FROM characters WHERE user_id = ?').get(userId);
|
||||
assert.ok(ch, 'character row should exist');
|
||||
assert.ok(ch.hp_updated_at > 0, 'hp_updated_at should be set for new character');
|
||||
});
|
||||
|
||||
it('rest sets hp_updated_at to now', async () => {
|
||||
const cookie = await setupHero('hpregen3', 'HpRest3', 10, 100);
|
||||
|
||||
const db = require('../../src/db').getDb();
|
||||
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen3').id);
|
||||
const char = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userId);
|
||||
const maxHp = 50 + char.vit * 10 + char.level * 5;
|
||||
|
||||
// Set hp_updated_at to a fixed past time
|
||||
const pastTime = 1000;
|
||||
db.prepare('UPDATE characters SET hp_current = 10, hp_updated_at = ? WHERE id = ?').run(pastTime, char.id);
|
||||
|
||||
// Call rest directly without getCharacter in between
|
||||
const restRes = await fetch(`${baseUrl}/api/character/rest`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
assert.equal(restRes.status, 200);
|
||||
const restData = await restRes.json();
|
||||
assert.equal(restData.ok, true);
|
||||
assert.equal(restData.data.character.hp_current, maxHp);
|
||||
|
||||
// DB should have hp_updated_at updated
|
||||
const updated = db.prepare('SELECT hp_updated_at FROM characters WHERE id = ?').get(char.id);
|
||||
assert.ok(updated.hp_updated_at > pastTime, 'hp_updated_at should be updated by rest');
|
||||
});
|
||||
|
||||
it('no HP change when less than 1 minute elapsed', async () => {
|
||||
const cookie = await setupHero('hpregen4', 'HpNoRegen4', 10, 100);
|
||||
|
||||
// Import time AFTER setup() cleared the cache
|
||||
const time = require('../../src/utils/time');
|
||||
|
||||
const db = require('../../src/db').getDb();
|
||||
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen4').id);
|
||||
const char = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userId);
|
||||
|
||||
// Set hp_updated_at to now
|
||||
const now = Date.now();
|
||||
db.prepare('UPDATE characters SET hp_current = 10, hp_updated_at = ? WHERE id = ?').run(now, char.id);
|
||||
|
||||
// Simulate 30 seconds passing (less than 1 minute)
|
||||
time.setNow(() => now + 30000);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/character`, {
|
||||
method: 'GET',
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, true);
|
||||
// HP should not have changed (less than 1 minute)
|
||||
assert.equal(body.data.character.hp_current, 10);
|
||||
} finally {
|
||||
time.resetNow();
|
||||
}
|
||||
});
|
||||
|
||||
it('HP regen caps at maxHp and resets timestamp', async () => {
|
||||
const cookie = await setupHero('hpregen5', 'HpCap5', 5, 100);
|
||||
|
||||
// Import time AFTER setup() cleared the cache
|
||||
const time = require('../../src/utils/time');
|
||||
|
||||
const db = require('../../src/db').getDb();
|
||||
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen5').id);
|
||||
const char = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userId);
|
||||
const maxHp = 50 + char.vit * 10 + char.level * 5;
|
||||
|
||||
// Set HP low and hp_updated_at to a fixed time
|
||||
const startTime = 1000;
|
||||
db.prepare('UPDATE characters SET hp_current = 5, hp_updated_at = ? WHERE id = ?').run(startTime, char.id);
|
||||
|
||||
// Simulate 2 hours passing (should cap at maxHp)
|
||||
const twoHoursMs = 2 * 60 * 60 * 1000;
|
||||
const later = startTime + twoHoursMs;
|
||||
time.setNow(() => later);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/character`, {
|
||||
method: 'GET',
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.data.character.hp_current, maxHp);
|
||||
} finally {
|
||||
time.resetNow();
|
||||
}
|
||||
});
|
||||
|
||||
it('potion heal applies lazy regen before healing', async () => {
|
||||
const cookie = await setupHero('hpregen6', 'HpPotion6', 5, 200);
|
||||
|
||||
// Import time AFTER setup() cleared the cache
|
||||
const time = require('../../src/utils/time');
|
||||
|
||||
// Buy a Minor Healing Potion
|
||||
const shopRes = await fetch(`${baseUrl}/api/shop`, {
|
||||
method: 'GET',
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
assert.equal(shopRes.status, 200);
|
||||
const shopBody = await shopRes.json();
|
||||
const potion = shopBody.data.stock.find(i => i.slug === 'minor-healing-potion');
|
||||
assert.ok(potion, `Minor Healing Potion should be in shop stock. Available: ${shopBody.data.stock.map(i => i.slug).join(', ')}`);
|
||||
|
||||
const buyRes = await fetch(`${baseUrl}/api/shop/buy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({ itemId: potion.itemId, quantity: 1 }),
|
||||
});
|
||||
assert.equal(buyRes.status, 200);
|
||||
|
||||
const db = require('../../src/db').getDb();
|
||||
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen6').id);
|
||||
const char = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userId);
|
||||
const maxHp = 50 + char.vit * 10 + char.level * 5;
|
||||
|
||||
// Set HP low and hp_updated_at far in the past
|
||||
const pastTime = 1000;
|
||||
db.prepare('UPDATE characters SET hp_current = 5, hp_updated_at = ? WHERE id = ?').run(pastTime, char.id);
|
||||
|
||||
// Simulate 10 minutes passing (10% regen)
|
||||
const tenMinMs = 10 * 60 * 1000;
|
||||
const later = pastTime + tenMinMs;
|
||||
time.setNow(() => later);
|
||||
|
||||
try {
|
||||
const expectedRegenHp = Math.min(5 + Math.floor(10 * maxHp / 100), maxHp);
|
||||
|
||||
// Use potion directly without getCharacter in between
|
||||
const useRes = await fetch(`${baseUrl}/api/inventory/use`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({ itemId: potion.itemId }),
|
||||
});
|
||||
assert.equal(useRes.status, 200);
|
||||
const useBody = await useRes.json();
|
||||
assert.equal(useBody.ok, true);
|
||||
|
||||
// Check HP from DB (listInventory response doesn't include hp_current)
|
||||
const updated = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(char.id);
|
||||
// Minor Healing Potion heals 50 HP
|
||||
const expectedFinalHp = Math.min(expectedRegenHp + 50, maxHp);
|
||||
assert.equal(updated.hp_current, expectedFinalHp);
|
||||
} finally {
|
||||
time.resetNow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// Unit tests for computeHp — natural HP regeneration (+1% max HP/min, lazy computation).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { computeHp } = require('../../src/game/hp');
|
||||
|
||||
test('computeHp: no change when clock is same', () => {
|
||||
const result = computeHp(50, 100, 1000, 1000);
|
||||
assert.strictEqual(result.hp, 50);
|
||||
assert.strictEqual(result.updatedAtMs, 1000);
|
||||
});
|
||||
|
||||
test('computeHp: no change when clock went backwards', () => {
|
||||
const result = computeHp(50, 100, 1000, 500);
|
||||
assert.strictEqual(result.hp, 50);
|
||||
assert.strictEqual(result.updatedAtMs, 1000);
|
||||
});
|
||||
|
||||
test('computeHp: no change when less than 1 minute elapsed', () => {
|
||||
const result = computeHp(50, 100, 1000, 60000); // 59s elapsed
|
||||
assert.strictEqual(result.hp, 50);
|
||||
assert.strictEqual(result.updatedAtMs, 1000);
|
||||
});
|
||||
|
||||
test('computeHp: +1% max HP after 1 minute', () => {
|
||||
const result = computeHp(50, 100, 1000, 61000); // 60s later
|
||||
assert.strictEqual(result.hp, 51); // 1% of 100 = 1
|
||||
assert.strictEqual(result.updatedAtMs, 1000 + 60000);
|
||||
});
|
||||
|
||||
test('computeHp: +5% max HP after 5 minutes', () => {
|
||||
const result = computeHp(50, 100, 1000, 310000); // 300s later
|
||||
assert.strictEqual(result.hp, 55); // 5% of 100 = 5
|
||||
assert.strictEqual(result.updatedAtMs, 1000 + 5 * 60000);
|
||||
});
|
||||
|
||||
test('computeHp: caps at maxHp and resets timestamp', () => {
|
||||
const result = computeHp(99, 100, 1000, 121000); // 120s later → +2% → 101 → cap at 100
|
||||
assert.strictEqual(result.hp, 100);
|
||||
assert.strictEqual(result.updatedAtMs, 121000);
|
||||
});
|
||||
|
||||
test('computeHp: already at max HP stays at max', () => {
|
||||
const result = computeHp(100, 100, 1000, 3600000); // 1 hour later
|
||||
assert.strictEqual(result.hp, 100);
|
||||
assert.strictEqual(result.updatedAtMs, 3600000);
|
||||
});
|
||||
|
||||
test('computeHp: large gap — multiple minutes', () => {
|
||||
// 10 minutes later, maxHp=135 → +10% = 13 HP
|
||||
const result = computeHp(20, 135, 1000, 601000); // 600s later
|
||||
assert.strictEqual(result.hp, 33); // 20 + floor(10 * 135 / 100) = 20 + 13 = 33
|
||||
assert.strictEqual(result.updatedAtMs, 1000 + 10 * 60000);
|
||||
});
|
||||
|
||||
test('computeHp: large gap caps at max', () => {
|
||||
// 2 hours later, maxHp=135, started at 5 → +120% → cap at 135
|
||||
const result = computeHp(5, 135, 1000, 7201000); // 7200s later
|
||||
assert.strictEqual(result.hp, 135);
|
||||
assert.strictEqual(result.updatedAtMs, 7201000);
|
||||
});
|
||||
|
||||
test('computeHp: fractional percent floors correctly', () => {
|
||||
// maxHp=100, 1 minute → +1% = +1
|
||||
const result = computeHp(0, 100, 1000, 61000);
|
||||
assert.strictEqual(result.hp, 1);
|
||||
});
|
||||
|
||||
test('computeHp: partial tick preserved in timestamp', () => {
|
||||
// 90 seconds = 1 full minute + 30s partial
|
||||
const result = computeHp(50, 100, 1000, 91000);
|
||||
assert.strictEqual(result.hp, 51); // 1 tick
|
||||
assert.strictEqual(result.updatedAtMs, 1000 + 60000); // partial 30s preserved
|
||||
});
|
||||
|
||||
test('computeHp: zero max HP edge case', () => {
|
||||
const result = computeHp(0, 0, 1000, 1000000);
|
||||
assert.strictEqual(result.hp, 0);
|
||||
assert.strictEqual(result.updatedAtMs, 1000000);
|
||||
});
|
||||
Reference in New Issue
Block a user