feat: restore Runiczny Pancerz with Mana; NBP rates module with latest-rate fallback

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 16:34:58 +02:00
co-authored by Claude Sonnet 5.5
parent 601f154b75
commit e4dc1842d3
10 changed files with 200 additions and 33 deletions
+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 };
}