Skip to content
Open
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
}
}
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, never evicts users

userMessageTimestamps retains an entry for every userId that ever sent a message and is never cleaned up, even after a user's timestamps all age out of the window. On a long-running multiplayer server this map grows without bound (the exact buffer saturation the limiter is meant to prevent). Add eviction: after filtering, delete the key when the array is empty, and/or periodically sweep stale entries.

Persist the filtered array so stale timestamps don't accumulate; consider a periodic sweep that deletes keys whose arrays are empty to bound memory.:

const timestamps = (this.userMessageTimestamps.get(userId) || []).filter(t => now - t < this.windowMs);

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

timestamps.push(now);
this.userMessageTimestamps.set(userId, timestamps);
return { allowed: true, remaining: this.maxMessagesPerWindow - timestamps.length };
  • Apply fix

Check the box to apply the fix or reply for a change | 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 };
}
}
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 };
}
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;
}
46 changes: 46 additions & 0 deletions api/src/repositories/mapNpcPlacement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Bitcoindefi/OpenAO - Map NPC Placement & Patrol Generator (Issue #8)
*/
export interface NpcPlacementConfig {
npcId: number;
mapId: number;
x: number;
y: number;
heading: number;
patrolRadius?: number;
}

export interface PatrolCoordinate {
x: number;
y: number;
step: number;
}

export function generatePatrolWaypoints(
startX: number,
startY: number,
radius: number = 3,
mapBounds: { minX: number; maxX: number; minY: number; maxY: number } = { minX: 1, maxX: 100, minY: 1, maxY: 100 }
): PatrolCoordinate[] {
const waypoints: PatrolCoordinate[] = [];
const offsets = [
{ dx: 0, dy: 0 },
{ dx: radius, dy: 0 },
{ dx: radius, dy: radius },
{ dx: 0, dy: radius },
{ dx: -radius, dy: radius },
{ dx: -radius, dy: 0 },
{ dx: -radius, dy: -radius },
{ dx: 0, dy: -radius },
{ dx: radius, dy: -radius }
];

let step = 0;
for (const off of offsets) {
const targetX = Math.min(Math.max(startX + off.dx, mapBounds.minX), mapBounds.maxX);
const targetY = Math.min(Math.max(startY + off.dy, mapBounds.minY), mapBounds.maxY);
waypoints.push({ x: targetX, y: targetY, step: step++ });
}

return waypoints;
}