From e4dc1842d379939347a5078d2b950db6ce1c3b3d Mon Sep 17 00:00:00 2001 From: Octoturge Date: Tue, 20 Oct 2026 16:34:58 +0200 Subject: [PATCH] feat: restore Runiczny Pancerz with Mana; NBP rates module with latest-rate fallback Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 +++++ package.json | 2 +- src/hbm.ts | 2 ++ src/logic/nbp.ts | 53 ++++++++++++++++++++++++++++ src/logic/rules.ts | 13 +++++++ src/logic/runic-armor.ts | 46 ++++++++++++++++++++++++ src/sheets/money-converter-dialog.ts | 35 +++--------------- system.json | 4 +-- tests/logic/nbp.test.ts | 31 ++++++++++++++++ tests/logic/runic-armor.test.ts | 39 ++++++++++++++++++++ 10 files changed, 200 insertions(+), 33 deletions(-) create mode 100644 src/logic/nbp.ts create mode 100644 src/logic/runic-armor.ts create mode 100644 tests/logic/nbp.test.ts create mode 100644 tests/logic/runic-armor.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b34ccb6..3b5886f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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"). diff --git a/package.json b/package.json index eab59d8..6e7e20b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/hbm.ts b/src/hbm.ts index abbb121..0bc5313 100644 --- a/src/hbm.ts +++ b/src/hbm.ts @@ -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, }; diff --git a/src/logic/nbp.ts b/src/logic/nbp.ts new file mode 100644 index 0000000..30d468b --- /dev/null +++ b/src/logic/nbp.ts @@ -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 = { eur: TRADE.eurRate, usd: 4.0 }; + +type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; json(): Promise }>; + +async function getRates(url: string, fetchImpl: FetchLike): Promise { + 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 { + 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); +} diff --git a/src/logic/rules.ts b/src/logic/rules.ts index 9b12094..4258a61 100644 --- a/src/logic/rules.ts +++ b/src/logic/rules.ts @@ -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; diff --git a/src/logic/runic-armor.ts b/src/logic/runic-armor.ts new file mode 100644 index 0000000..a144da7 --- /dev/null +++ b/src/logic/runic-armor.ts @@ -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 { + 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 }; +} diff --git a/src/sheets/money-converter-dialog.ts b/src/sheets/money-converter-dialog.ts index ad083e1..c6c1659 100644 --- a/src/sheets/money-converter-dialog.ts +++ b/src/sheets/money-converter-dialog.ts @@ -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 { 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); diff --git a/system.json b/system.json index bd9b078..c5eb8e6 100644 --- a/system.json +++ b/system.json @@ -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" diff --git a/tests/logic/nbp.test.ts b/tests/logic/nbp.test.ts new file mode 100644 index 0000000..c0e9df7 --- /dev/null +++ b/tests/logic/nbp.test.ts @@ -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(); + }); +}); diff --git a/tests/logic/runic-armor.test.ts b/tests/logic/runic-armor.test.ts new file mode 100644 index 0000000..b0e5331 --- /dev/null +++ b/tests/logic/runic-armor.test.ts @@ -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'); + }); +}); -- 2.54.0