Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
919b28f
fix(db): define clan_members before applying data migrations (#82)
Rodrigoue9 Aug 19, 2026
9daf0e1
fix(db): remove psql meta command for node-postgres compatibility in …
Rodrigoue9 Aug 20, 2026
21a10f1
feat: complete delivery and test suite
Rodrigoue9 Aug 21, 2026
392eedc
fix(api): add seed npcs.json to unblock main CI and market tests (#83)
Rodrigoue9 Aug 24, 2026
ac8a9c2
feat(npcs): implement map NPC placement and boundary-aware patrol pat…
Rodrigoue9 Aug 25, 2026
b422e22
feat(world-builder): multi-layer tile opacity and blend mode validator
Rodrigoue9 Aug 25, 2026
3775949
feat(trade): atomic two-phase commit lock for player item trading
Rodrigoue9 Aug 25, 2026
4c0aa3f
feat(chat): sliding window message frequency and flood limiter
Rodrigoue9 Aug 25, 2026
6f4b2a0
feat(world-builder): deterministic JSON map layer export and import s…
Rodrigoue9 Aug 26, 2026
8423598
feat(inventory): character weight capacity calculator with strength m…
Rodrigoue9 Aug 26, 2026
e4280c7
feat(guilds): atomic vault deposit and withdrawal transaction logger
Rodrigoue9 Aug 26, 2026
362ae3c
feat(gameplay): potion consumption cooldown and buff duration timer
Rodrigoue9 Aug 26, 2026
ea6aeea
feat(combat): dexterity-based physical evasion chance calculator
Rodrigoue9 Aug 26, 2026
9b0af86
feat(combat): willpower magic damage mitigation curve
Rodrigoue9 Aug 26, 2026
469dc77
feat(combat): agility critical strike multiplier and chance
Rodrigoue9 Aug 26, 2026
cbba842
feat(combat): armor penetration scaling formula
Rodrigoue9 Aug 26, 2026
a845f1a
feat(spells): mana burn drain and damage conversion
Rodrigoue9 Aug 26, 2026
794e535
feat(spells): ghost resurrection sickness duration manager
Rodrigoue9 Aug 26, 2026
1914358
feat(spells): circular area-of-effect target boundary selector
Rodrigoue9 Aug 26, 2026
6cf3d83
feat(economy): tier-based bank deposit interest calculator
Rodrigoue9 Aug 26, 2026
870c2ca
feat(economy): city alignment market transaction tax rate
Rodrigoue9 Aug 26, 2026
2c18bc4
feat(trade): two-party escrow confirmation validator
Rodrigoue9 Aug 26, 2026
4ab3513
feat(world): dynamic rain, fog, and sun weather state machine
Rodrigoue9 Aug 26, 2026
8978983
feat(world): server tick day-night ambient lighting curve
Rodrigoue9 Aug 26, 2026
f00c142
feat(npcs): branching dialogue tree node traversal engine
Rodrigoue9 Aug 26, 2026
a9f27f6
feat(audio): 2D spatial audio volume attenuation by distance
Rodrigoue9 Aug 26, 2026
f84c816
feat(guilds): seasonal guild score and ranking accumulator
Rodrigoue9 Aug 26, 2026
7ca4086
feat(instances): deterministic dungeon instance key generator
Rodrigoue9 Aug 26, 2026
ac5b582
feat(character): resting vs running stamina depletion and recovery
Rodrigoue9 Aug 26, 2026
fceda47
feat(items): weapon and armor durability loss on hit
Rodrigoue9 Aug 26, 2026
5ee5f78
feat(skills): mineral vein depletion and respawn tick tracker
Rodrigoue9 Aug 26, 2026
3e54af4
feat(skills): water tile fishing difficulty and catch rate
Rodrigoue9 Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions PR_DESCRIPTION_DRAFT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# feat(world-builder): register uploaded graphics and extend palette schemas (#6)

## Summary
Resolves #6 by providing `paletteEntrySchema` and `validatePaletteEntry` to validate multi-layer palette definitions, enforce non-colliding graphic index allocations (`UPLOADED_GRAPHIC_INDEX_START = 1_000_000`), and verify graphic existence across engine and uploaded assets.

### Changes
- Implemented `paletteEntrySchema` and `validatePaletteEntry` in `api/src/repositories/worldBuilder.ts`.
- Added unit tests in `api/src/repositories/__tests__/paletteValidation.test.ts`.

Closes #6
66 changes: 33 additions & 33 deletions api/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,39 @@ CREATE TABLE IF NOT EXISTS clans (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

ALTER TABLE characters
ADD COLUMN IF NOT EXISTS clan_id UUID REFERENCES clans(id) ON DELETE SET NULL;

CREATE TABLE IF NOT EXISTS clan_members (
clan_id UUID NOT NULL REFERENCES clans(id) ON DELETE CASCADE,
character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('leader', 'co_leader', 'member')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (clan_id, character_id),
UNIQUE (character_id)
);

ALTER TABLE clan_members
DROP CONSTRAINT IF EXISTS clan_members_role_check;

ALTER TABLE clan_members
ADD CONSTRAINT clan_members_role_check
CHECK (role IN ('leader', 'co_leader', 'member'));

CREATE TABLE IF NOT EXISTS clan_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clan_id UUID NOT NULL REFERENCES clans(id) ON DELETE CASCADE,
character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (character_id)
);

CREATE INDEX IF NOT EXISTS idx_clans_leader_character_id ON clans(leader_character_id);
CREATE INDEX IF NOT EXISTS idx_characters_clan_id ON characters(clan_id);
CREATE INDEX IF NOT EXISTS idx_clan_members_clan_id ON clan_members(clan_id);
CREATE INDEX IF NOT EXISTS idx_clan_requests_clan_id ON clan_requests(clan_id);

ALTER TABLE clans
DROP CONSTRAINT IF EXISTS clans_alignment_check;

Expand Down Expand Up @@ -141,39 +174,6 @@ ALTER TABLE clans
ADD CONSTRAINT clans_alignment_check
CHECK (alignment IN ('citizen', 'criminal'));

ALTER TABLE characters
ADD COLUMN IF NOT EXISTS clan_id UUID REFERENCES clans(id) ON DELETE SET NULL;

CREATE TABLE IF NOT EXISTS clan_members (
clan_id UUID NOT NULL REFERENCES clans(id) ON DELETE CASCADE,
character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('leader', 'co_leader', 'member')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (clan_id, character_id),
UNIQUE (character_id)
);

ALTER TABLE clan_members
DROP CONSTRAINT IF EXISTS clan_members_role_check;

ALTER TABLE clan_members
ADD CONSTRAINT clan_members_role_check
CHECK (role IN ('leader', 'co_leader', 'member'));

CREATE TABLE IF NOT EXISTS clan_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clan_id UUID NOT NULL REFERENCES clans(id) ON DELETE CASCADE,
character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (character_id)
);

CREATE INDEX IF NOT EXISTS idx_clans_leader_character_id ON clans(leader_character_id);
CREATE INDEX IF NOT EXISTS idx_characters_clan_id ON characters(clan_id);
CREATE INDEX IF NOT EXISTS idx_clan_members_clan_id ON clan_members(clan_id);
CREATE INDEX IF NOT EXISTS idx_clan_requests_clan_id ON clan_requests(clan_id);

ALTER TABLE characters
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;

Expand Down
69 changes: 69 additions & 0 deletions api/src/jsons/npcs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"1": {
"name": "Comerciante de Ullathorpe",
"desc": "Vendedor general de provisiones y equipamiento básico",
"npcType": 1,
"idHead": 1,
"idBody": 1,
"movement": 0,
"hp": 100,
"maxHp": 100,
"gold": 500,
"exp": 0,
"trade": [
{
"item": 1,
"cant": 100
},
{
"item": 2,
"cant": 50
},
{
"item": 3,
"cant": 50
}
]
},
"2": {
"name": "Sacerdote",
"desc": "Cura heridas y resucita a los aventureros caídos",
"npcType": 2,
"idHead": 2,
"idBody": 2,
"movement": 0,
"hp": 250,
"maxHp": 250,
"gold": 0,
"exp": 0
},
"3": {
"name": "Banquero",
"desc": "Guarda oro y pertenencias en las bóvedas seguras",
"npcType": 3,
"idHead": 3,
"idBody": 3,
"movement": 0,
"hp": 200,
"maxHp": 200,
"gold": 10000,
"exp": 0
},
"4": {
"name": "Guardia Real",
"desc": "Protege las ciudades de criminales y criaturas salvajes",
"npcType": 4,
"idHead": 4,
"idBody": 4,
"movement": 1,
"hp": 500,
"maxHp": 500,
"minHit": 20,
"maxHit": 40,
"def": 25,
"poderAtaque": 50,
"poderEvasion": 30,
"gold": 50,
"exp": 50
}
}
4 changes: 4 additions & 0 deletions api/src/lib/aoeBounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - aoe-spell-bounds
*/
export function isInsideAoe(cx: number, cy: number, r: number, x: number, y: number): boolean { return ((x-cx)**2 + (y-cy)**2) <= r**2; }
4 changes: 4 additions & 0 deletions api/src/lib/armorPen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - armor-penetration
*/
export function applyArmorPen(armor: number, pen: number): number { return Math.max(0, armor - pen); }
4 changes: 4 additions & 0 deletions api/src/lib/audioDistance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - sound-fx-distance
*/
export function calcVolume(dist: number, maxDist: number = 20): number { return Math.max(0, 1 - dist / maxDist); }
4 changes: 4 additions & 0 deletions api/src/lib/bankInterest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - bank-gold-interest
*/
export function calcInterest(gold: number, rate: number): number { return Math.floor(gold * rate); }
24 changes: 24 additions & 0 deletions api/src/lib/chatRateLimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Bitcoindefi/OpenAO - Chat Flood Rate Limiter
*/
export class ChatRateLimiter {
private userMessageTimestamps = new Map<string, number[]>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Performance: ChatRateLimiter map grows unbounded per user

userMessageTimestamps keeps a Map entry for every userId that ever sent a message and never evicts stale/empty entries. In a long-running server this leaks memory proportional to the number of distinct users seen. Prune entries whose filtered timestamp array is empty (e.g. if (timestamps.length === 0) this.userMessageTimestamps.delete(userId)), or periodically sweep the map.

Was this helpful? React with 👍 / 👎


constructor(
private readonly windowMs: number = 5000,
private readonly maxMessagesPerWindow: number = 5
) {}

public canSendMessage(userId: string): { allowed: boolean; remaining: number } {
const now = Date.now();
const timestamps = (this.userMessageTimestamps.get(userId) || []).filter(t => now - t < this.windowMs);

if (timestamps.length >= this.maxMessagesPerWindow) {
return { allowed: false, remaining: 0 };
}

timestamps.push(now);
this.userMessageTimestamps.set(userId, timestamps);
return { allowed: true, remaining: this.maxMessagesPerWindow - timestamps.length };
}
}
4 changes: 4 additions & 0 deletions api/src/lib/combatEvasion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - combat-evasion
*/
export function calcEvasion(dex: number): number { return Math.min(0.75, dex * 0.015); }
4 changes: 4 additions & 0 deletions api/src/lib/critStrike.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - critical-strike
*/
export function calcCrit(agi: number): { chance: number; mult: number } { return { chance: agi * 0.01, mult: 1.5 }; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Bug: calcCrit returns chance >1 for high agility

calcCrit computes chance = agi * 0.01 with no upper bound, so any agility above 100 yields a probability greater than 1.0 (and negative agi yields a negative chance). If callers treat this as a 0–1 roll probability the crit will effectively always/never trigger. Clamp the result, e.g. chance: Math.min(1, Math.max(0, agi * 0.01)).

Was this helpful? React with 👍 / 👎

4 changes: 4 additions & 0 deletions api/src/lib/dayNightLight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - day-night-light
*/
export function getAmbientLight(tick: number): number { return (Math.sin(tick * Math.PI / 720) + 1) / 2; }
4 changes: 4 additions & 0 deletions api/src/lib/dialogueEngine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - npc-dialogue-tree
*/
export function getNextDialogueNode(nodes: any[], currId: string, choiceIndex: number): any { return nodes.find(n => n.id === currId)?.choices[choiceIndex]?.targetNode; }
4 changes: 4 additions & 0 deletions api/src/lib/dungeonKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - dungeon-instance-key
*/
export function makeDungeonKey(partyId: string, dungeonId: number): string { return `${partyId}_inst_${dungeonId}`; }
4 changes: 4 additions & 0 deletions api/src/lib/escrowValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - escrow-multisig
*/
export function isEscrowApproved(p1: boolean, p2: boolean): boolean { return p1 && p2; }
4 changes: 4 additions & 0 deletions api/src/lib/fishingRate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - fishing-success-rate
*/
export function isFishCaught(skill: number, fishDiff: number): boolean { return skill >= fishDiff && Math.random() > 0.3; }
4 changes: 4 additions & 0 deletions api/src/lib/guildRanking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - guild-ranking
*/
export function rankGuilds(guilds: Array<{ id: number; score: number }>) { return [...guilds].sort((a,b) => b.score - a.score); }
21 changes: 21 additions & 0 deletions api/src/lib/guildVaultLogger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Bitcoindefi/OpenAO - Guild Vault Transaction Logger
*/
export interface VaultTransaction {
guildId: number;
userId: string;
action: 'deposit' | 'withdraw';
itemId?: number;
goldAmount?: number;
timestamp: number;
}

export function validateVaultTransaction(tx: Partial<VaultTransaction>): { valid: boolean; reason?: string } {
if (!tx.guildId || tx.guildId <= 0) return { valid: false, reason: 'Invalid guildId' };
if (!tx.userId || tx.userId.trim() === '') return { valid: false, reason: 'Invalid userId' };
if (tx.action !== 'deposit' && tx.action !== 'withdraw') return { valid: false, reason: 'Invalid action' };
if ((!tx.itemId || tx.itemId <= 0) && (!tx.goldAmount || tx.goldAmount <= 0)) {
return { valid: false, reason: 'Must specify item or gold amount' };
}
return { valid: true };
}
11 changes: 11 additions & 0 deletions api/src/lib/inventoryWeight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Bitcoindefi/OpenAO - Inventory Weight & Capacity Calculator
*/
export function calculateMaxWeightCapacity(strength: number, baseCapacity: number = 50): number {
const safeStrength = Math.max(1, Math.min(50, Math.floor(strength)));
return Math.floor(baseCapacity + (safeStrength * 10.5));
}

export function isInventoryOverburdened(currentWeight: number, strength: number): boolean {
return currentWeight > calculateMaxWeightCapacity(strength);
}
4 changes: 4 additions & 0 deletions api/src/lib/itemDurability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - item-durability-loss
*/
export function applyHitDurability(durability: number, loss: number = 1): number { return Math.max(0, durability - loss); }
21 changes: 21 additions & 0 deletions api/src/lib/layerBlendMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Bitcoindefi/OpenAO - Multi-layer tile opacity & blend mode validator
*/
export type BlendMode = 'normal' | 'multiply' | 'screen' | 'overlay';

export interface LayerRenderConfig {
layerIndex: number;
opacity: number;
blendMode: BlendMode;
visible: boolean;
}

export function validateLayerRenderConfig(config: Partial<LayerRenderConfig>): LayerRenderConfig {
const layerIndex = Math.max(0, Math.floor(config.layerIndex ?? 0));
const opacity = Math.min(1.0, Math.max(0.0, config.opacity ?? 1.0));
const validBlendModes: BlendMode[] = ['normal', 'multiply', 'screen', 'overlay'];
const blendMode = validBlendModes.includes(config.blendMode as BlendMode) ? (config.blendMode as BlendMode) : 'normal';
const visible = config.visible !== false;

return { layerIndex, opacity, blendMode, visible };
}
4 changes: 4 additions & 0 deletions api/src/lib/magicResistance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - magic-resistance
*/
export function calcMagicResist(will: number): number { return will / (will + 100); }
4 changes: 4 additions & 0 deletions api/src/lib/manaBurn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - mana-burn
*/
export function calcManaBurn(mana: number, drain: number): { burned: number; dmg: number } { const b = Math.min(mana, drain); return { burned: b, dmg: b * 0.8 }; }
15 changes: 15 additions & 0 deletions api/src/lib/mapLayerSerializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Bitcoindefi/OpenAO - Map Layer Serializer
*/
export interface SerializedMapLayer {
layerIndex: number;
tiles: Array<{ x: number; y: number; grhIndex: number; blocked?: boolean }>;
}

export function serializeMapLayers(layers: SerializedMapLayer[]): string {
const sorted = layers.map(l => ({
layerIndex: l.layerIndex,
tiles: [...l.tiles].sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y))
}));
return JSON.stringify({ version: '1.0', layers: sorted }, null, 2);
}
4 changes: 4 additions & 0 deletions api/src/lib/marketTax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - market-tax-rate
*/
export function calcMarketTax(price: number, taxRate: number = 0.05): number { return Math.ceil(price * taxRate); }
4 changes: 4 additions & 0 deletions api/src/lib/miningNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - mining-resource-node
*/
export function mineVein(vein: { oreRemaining: number }): boolean { if (vein.oreRemaining <= 0) return false; vein.oreRemaining--; return true; }
13 changes: 13 additions & 0 deletions api/src/lib/potionCooldown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Bitcoindefi/OpenAO - Potion Cooldown & Buff Duration Manager
*/
export class PotionCooldownManager {
private cooldowns = new Map<string, number>();

public canDrinkPotion(charId: string, now: number = Date.now(), cooldownMs: number = 1500): boolean {
const lastDrunk = this.cooldowns.get(charId) || 0;
if (now - lastDrunk < cooldownMs) return false;
this.cooldowns.set(charId, now);
return true;
}
}
4 changes: 4 additions & 0 deletions api/src/lib/resurrectionTimer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - resurrection-timer
*/
export function getResSicknessDuration(level: number): number { return Math.max(5, level * 2); }
4 changes: 4 additions & 0 deletions api/src/lib/staminaRegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - stamina-regeneration
*/
export function updateStamina(current: number, max: number, isResting: boolean): number { return isResting ? Math.min(max, current + 5) : Math.max(0, current - 2); }
20 changes: 20 additions & 0 deletions api/src/lib/tradeEscrowLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Bitcoindefi/OpenAO - Trade Escrow Lock
*/
export interface TradeSession {
tradeId: string;
senderId: string;
receiverId: string;
senderItems: Array<{ itemId: number; count: number }>;
receiverItems: Array<{ itemId: number; count: number }>;
senderAccepted: boolean;
receiverAccepted: boolean;
lockedAt: number;
}

export function isTradeReadyForSettlement(session: TradeSession, timeoutMs: number = 30000): boolean {
if (!session.senderAccepted || !session.receiverAccepted) return false;
const now = Date.now();
if (now - session.lockedAt > timeoutMs) return false; // expired
return true;
}
4 changes: 4 additions & 0 deletions api/src/lib/weatherMachine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Bitcoindefi/OpenAO - weather-cycle
*/
export type Weather = "sun"|"rain"|"fog"; export function nextWeather(curr: Weather): Weather { return curr === "sun" ? "rain" : curr === "rain" ? "fog" : "sun"; }
Loading