10 changed files with 200 additions and 33 deletions
+8
View File
@@ -5,6 +5,14 @@ All notable changes to the **HbM RPG v3** Foundry VTT system.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.1] - 2026-10-20
### Added
- Runiczny Pancerz can be restored with Mana: `game.hbm.runicArmor.rechargeRunicArmor(actor, points, { partial })` – 2 Many per point, capped at the armor's maximum (Amulet Osłony, Akcja). Degradation (every 5 absorbed damage) is unchanged.
### Changed
- Money converter: NBP rates come from `src/logic/nbp.ts` – one range request per currency (first table from 1 June of the campaign year), latest published rate for future years or when no table is found, 4 s timeout, offline fallback EUR = `TRADE.eurRate` (4.36), USD = 4.00.
## [1.4.0] - 2026-10-20
Vault sync ("Foundry Sync Brief 2026-10-20").
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hbm-rpg-v3-foundry",
"version": "1.4.0",
"version": "1.4.1",
"description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3",
"private": true,
"type": "module",
+2
View File
@@ -30,6 +30,7 @@ import { castSpell } from './logic/spell-cast';
import * as bloodMagic from './logic/blood-magic';
import * as abyssMagic from './logic/abyss-magic';
import * as brewing from './logic/brewing';
import * as runicArmor from './logic/runic-armor';
import { rest } from './logic/rest';
import * as trade from './logic/trade';
@@ -137,6 +138,7 @@ Hooks.once('ready', () => {
bloodMagic,
abyssMagic,
brewing,
runicArmor,
rest,
trade,
};
+53
View File
@@ -0,0 +1,53 @@
/**
* NBP (Narodowy Bank Polski) table A mid rates for the money converter.
* Campaign year → first published rate on/after 1 June of that year; a year in the
* future (or no table found / request failed) → latest published rate.
*/
import { TRADE } from './rules';
export interface NbpRate {
rate: number;
/** Effective date (YYYY-MM-DD) of the table the rate comes from. */
date: string;
}
const NBP_FIRST_YEAR = 2002;
const NBP_TIMEOUT_MS = 4000;
/** Offline fallbacks when NBP is unreachable. */
export const FALLBACK_RATES: Record<string, number> = { eur: TRADE.eurRate, usd: 4.0 };
type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; json(): Promise<any> }>;
async function getRates(url: string, fetchImpl: FetchLike): Promise<NbpRate | null> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), NBP_TIMEOUT_MS);
try {
const res = await fetchImpl(url, { signal: ctrl.signal });
if (!res.ok) return null;
const first = (await res.json())?.rates?.[0];
return typeof first?.mid === 'number' ? { rate: first.mid, date: String(first.effectiveDate ?? '') } : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/** Fetch the rate for `currency` (ISO code, any case) valid for the given campaign year. */
export async function fetchNbpRate(
currency: string,
year: number,
now: Date = new Date(),
fetchImpl: FetchLike = (u, i) => fetch(u, i),
): Promise<NbpRate | null> {
const code = currency.toLowerCase();
const base = `https://api.nbp.pl/api/exchangerates/rates/a/${code}`;
if (year >= NBP_FIRST_YEAR && year <= now.getFullYear()) {
// A two-week window always contains a working day; take the first table in it.
const hit = await getRates(`${base}/${year}-06-01/${year}-06-14/?format=json`, fetchImpl);
if (hit) return hit;
}
return getRates(`${base}/?format=json`, fetchImpl);
}
+13
View File
@@ -133,6 +133,19 @@ export function bloodToZeal(blood: number): number {
return Math.floor(Math.max(0, blood) / 5);
}
/** Runiczny Pancerz (Amulet Osłony): recharging costs 2 Mana per point, as an Akcja. */
export const RUNIC_ARMOR_MANA_PER_POINT = 2;
/** Points of Runiczny Pancerz that can actually be restored (never above max, never negative). */
export function runicArmorRestorable(value: number, max: number, requested: number): number {
return Math.max(0, Math.min(Math.floor(requested), max - value));
}
/** Mana needed to restore `points` points of Runiczny Pancerz. */
export function runicArmorManaCost(points: number): number {
return Math.max(0, points) * RUNIC_ARMOR_MANA_PER_POINT;
}
/** Eliksir Odnowienia Many: +3 mana (capped by the pool); quick use = 1d3 as Immediate Action. */
export const MANA_ELIXIR_NORMAL = 3;
export const MANA_ELIXIR_QUICK_DIE = 3;
+46
View File
@@ -0,0 +1,46 @@
/**
* Runiczny Pancerz – restoring points with Mana (Amulet Osłony: Akcja, 2 Many za punkt).
* Degradation (every 5 absorbed damage, `runicCounter`) lives in logic/damage.ts.
*/
import type { CastableActor } from './spell-cast';
import { runicArmorManaCost, runicArmorRestorable } from './rules';
export interface RunicRestoreResult {
ok: boolean;
restored: number;
manaSpent: number;
reason?: string;
}
/**
* Spend Mana to restore up to `points` points of Runiczny Pancerz (capped at its max).
* Fails with `insufficient-mana` when the pool cannot pay for every requested point;
* pass `partial: true` to restore as many points as the pool affords instead.
*/
export async function rechargeRunicArmor(
actor: CastableActor,
points: number,
opts: { partial?: boolean } = {},
): Promise<RunicRestoreResult> {
const a = actor.system.attributes as any;
const armor = a.magicalArmor as { value: number; max: number } | undefined;
if (!armor) return { ok: false, restored: 0, manaSpent: 0, reason: 'no-runic-armor' };
let restored = runicArmorRestorable(armor.value, armor.max, points);
if (restored <= 0) return { ok: false, restored: 0, manaSpent: 0, reason: 'already-full' };
const mana = Number(a.mana?.value) || 0;
if (runicArmorManaCost(restored) > mana) {
if (!opts.partial) return { ok: false, restored: 0, manaSpent: 0, reason: 'insufficient-mana' };
restored = runicArmorRestorable(armor.value, armor.max, Math.floor(mana / runicArmorManaCost(1)));
if (restored <= 0) return { ok: false, restored: 0, manaSpent: 0, reason: 'insufficient-mana' };
}
const manaSpent = runicArmorManaCost(restored);
await actor.update({
'system.attributes.magicalArmor.value': armor.value + restored,
'system.attributes.mana.value': mana - manaSpent,
});
return { ok: true, restored, manaSpent };
}
+5 -30
View File
@@ -1,3 +1,5 @@
import { fetchNbpRate, FALLBACK_RATES } from '../logic/nbp';
const { DialogV2 } = foundry.applications.api as unknown as {
DialogV2: any;
};
@@ -55,37 +57,10 @@ class MoneyConverterDialog extends DialogV2 {
export async function showMoneyConverter(actor: any): Promise<void> {
const pln = actor.system.details?.money ?? 0;
const currentYear = actor.system.details?.currentYear ?? 2026;
const currentRealYear = new Date().getFullYear();
const fetchNBP = async (currency: string, year: number): Promise<{ rate: number; date: string } | null> => {
if (year >= 2002 && year <= currentRealYear) {
for (let day = 1; day <= 7; day++) {
const d = `${year}-06-${String(day).padStart(2, '0')}`;
try {
const res = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/${d}/?format=json`);
if (res.ok) {
const data = await res.json();
const r = data?.rates?.[0]?.mid;
if (typeof r === 'number') return { rate: r, date: d };
}
} catch (_) { /* ignore */ }
}
}
try {
const res = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/?format=json`);
if (res.ok) {
const data = await res.json();
const r = data?.rates?.[0]?.mid;
const dt = data?.rates?.[0]?.effectiveDate || '';
if (typeof r === 'number') return { rate: r, date: dt };
}
} catch (_) { /* ignore */ }
return null;
};
const [eurData, usdData] = await Promise.all([fetchNBP('eur', currentYear), fetchNBP('usd', currentYear)]);
const eurRate = eurData?.rate ?? 4.35;
const usdRate = usdData?.rate ?? 4.00;
const [eurData, usdData] = await Promise.all([fetchNbpRate('eur', currentYear), fetchNbpRate('usd', currentYear)]);
const eurRate = eurData?.rate ?? FALLBACK_RATES.eur!;
const usdRate = usdData?.rate ?? FALLBACK_RATES.usd!;
const rateDate = eurData?.date ?? usdData?.date ?? 'default';
const isLive = !!(eurData || usdData);
+2 -2
View File
@@ -2,9 +2,9 @@
"id": "hbm-rpg-v3",
"title": "Homebrew Magic: RPG v3",
"description": "System Foundry VTT dla Homebrew Magic: Role Playing Game v3 - autorska gra fabularna z mechaniką puli kości d6 (TS - Trudność:Sukcesy).",
"version": "1.4.0",
"version": "1.4.1",
"manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json",
"download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.4.0.zip",
"download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.4.1.zip",
"compatibility": {
"minimum": "13",
"verified": "14"
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect, vi } from 'vitest';
import { fetchNbpRate } from '../../src/logic/nbp';
const ok = (mid: number, effectiveDate: string) => ({ ok: true, json: async () => ({ rates: [{ mid, effectiveDate }] }) });
const notFound = { ok: false, json: async () => ({}) };
const now = new Date('2026-10-20');
describe('fetchNbpRate', () => {
it('uses the June window for a past year', async () => {
const f = vi.fn().mockResolvedValue(ok(4.4, '2024-06-03'));
expect(await fetchNbpRate('EUR', 2024, now, f)).toEqual({ rate: 4.4, date: '2024-06-03' });
expect(f.mock.calls[0]![0]).toContain('/eur/2024-06-01/2024-06-14/');
});
it('uses the latest rate for a future year without a dated request', async () => {
const f = vi.fn().mockResolvedValue(ok(4.3, '2026-10-19'));
expect(await fetchNbpRate('eur', 2031, now, f)).toEqual({ rate: 4.3, date: '2026-10-19' });
expect(f).toHaveBeenCalledTimes(1);
expect(f.mock.calls[0]![0]).not.toMatch(/\d{4}-\d{2}-\d{2}\//);
});
it('falls back to the latest rate when the dated window has no table', async () => {
const f = vi.fn().mockResolvedValueOnce(notFound).mockResolvedValueOnce(ok(4.2, '2026-10-19'));
expect((await fetchNbpRate('eur', 2026, now, f))?.rate).toBe(4.2);
});
it('returns null when NBP is unreachable', async () => {
const f = vi.fn().mockRejectedValue(new Error('offline'));
expect(await fetchNbpRate('eur', 2024, now, f)).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { rechargeRunicArmor } from '../../src/logic/runic-armor';
import { runicArmorManaCost, runicArmorRestorable } from '../../src/logic/rules';
function makeActor(armor: { value: number; max: number }, mana: number) {
const update = vi.fn().mockResolvedValue(undefined);
return { actor: { system: { attributes: { magicalArmor: armor, mana: { value: mana, max: 30 } } }, update } as any, update };
}
describe('Runiczny Pancerz restore', () => {
it('costs 2 Mana per point and never exceeds max', () => {
expect(runicArmorManaCost(3)).toBe(6);
expect(runicArmorRestorable(2, 4, 5)).toBe(2);
expect(runicArmorRestorable(4, 4, 1)).toBe(0);
});
it('restores points and spends mana', async () => {
const { actor, update } = makeActor({ value: 1, max: 4 }, 10);
const res = await rechargeRunicArmor(actor, 2);
expect(res).toEqual({ ok: true, restored: 2, manaSpent: 4 });
expect(update).toHaveBeenCalledWith({
'system.attributes.magicalArmor.value': 3,
'system.attributes.mana.value': 6,
});
});
it('refuses when mana is short, unless partial', async () => {
const { actor, update } = makeActor({ value: 0, max: 4 }, 5);
expect((await rechargeRunicArmor(actor, 3)).reason).toBe('insufficient-mana');
expect(update).not.toHaveBeenCalled();
const res = await rechargeRunicArmor(actor, 3, { partial: true });
expect(res).toEqual({ ok: true, restored: 2, manaSpent: 4 });
});
it('reports already-full armor', async () => {
const { actor } = makeActor({ value: 4, max: 4 }, 10);
expect((await rechargeRunicArmor(actor, 1)).reason).toBe('already-full');
});
});