Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
76 changes: 48 additions & 28 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,32 @@ on:
- main

jobs:
protocol:
name: Protocol (typecheck, build)
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build protocol
run: pnpm --filter @openao/protocol run build

api:
name: API (typecheck, test, build)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -42,34 +68,30 @@ jobs:
with:
node-version: 24
cache: pnpm
cache-dependency-path: api/pnpm-lock.yaml
cache-dependency-path: pnpm-lock.yaml

- name: Install dependencies
working-directory: api
run: pnpm install --frozen-lockfile

- name: Typecheck
working-directory: api
run: pnpm exec tsc --noEmit
run: pnpm --filter argentumonlineweb-api exec tsc --noEmit

- name: Run migrations / schema
working-directory: api
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/aoweb
TOKEN_AUTH: test-token-secret
run: |
PGPASSWORD=postgres psql -h localhost -U postgres -d aoweb -f schema.sql
PGPASSWORD=postgres psql -h localhost -U postgres -d aoweb -f api/schema.sql

- name: Start API for integration tests
working-directory: api
env:
PORT: 3001
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/aoweb
TOKEN_AUTH: test-token-secret
NODE_ENV: test
CORS_ORIGIN: "http://localhost:3000"
run: |
pnpm exec tsx src/server.ts &
pnpm --filter argentumonlineweb-api exec tsx src/server.ts &
for i in {1..30}; do
if curl -s http://localhost:3001/health | grep -q ok:true; then
echo "API is healthy"
Expand All @@ -80,21 +102,20 @@ jobs:
done

- name: Run tests
working-directory: api
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/aoweb
TOKEN_AUTH: test-token-secret
API_TEST_URL: http://127.0.0.1:3001
API_TEST_AUTH: test-token-secret
run: pnpm test
run: pnpm --filter argentumonlineweb-api test

- name: Build API
working-directory: api
run: pnpm run build
run: pnpm --filter argentumonlineweb-api run build

server:
name: Server (typecheck, lint, build)
runs-on: ubuntu-latest
needs: protocol

steps:
- name: Checkout
Expand All @@ -110,27 +131,27 @@ jobs:
with:
node-version: 24
cache: pnpm
cache-dependency-path: server/pnpm-lock.yaml
cache-dependency-path: pnpm-lock.yaml

- name: Install dependencies
working-directory: server
run: pnpm install --frozen-lockfile

- name: Build protocol
run: pnpm --filter @openao/protocol run build

- name: Lint
working-directory: server
run: pnpm run lint
run: pnpm --filter argentumonlineweb_server run lint

- name: Typecheck
working-directory: server
run: pnpm exec tsc --noEmit
run: pnpm --filter argentumonlineweb_server exec tsc --noEmit

- name: Build
working-directory: server
run: pnpm run build
run: pnpm --filter argentumonlineweb_server run build

frontend:
name: Frontend (typecheck, lint, build)
runs-on: ubuntu-latest
needs: protocol

steps:
- name: Checkout
Expand All @@ -146,25 +167,24 @@ jobs:
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
cache-dependency-path: pnpm-lock.yaml

- name: Install dependencies
working-directory: frontend
run: pnpm install --frozen-lockfile

- name: Build protocol
run: pnpm --filter @openao/protocol run build

- name: Lint
working-directory: frontend
run: pnpm run lint
run: pnpm --filter falopita run lint

- name: Typecheck
working-directory: frontend
run: pnpm exec tsc --noEmit
run: pnpm --filter falopita exec tsc --noEmit

- name: Build
working-directory: frontend
env:
NEXT_PUBLIC_AOWEB_TEST_MODE: "true"
run: pnpm run build
run: pnpm --filter falopita run build

docker-build:
name: Docker build checks
Expand Down
220 changes: 220 additions & 0 deletions api/src/repositories/mapExits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";

export type TileExit = {
map: number;
x: number;
y: number;
};

export type TileExitConfig = TileExit | { destinations: TileExit[] };

export type SpecialsJson = {
id: number;
exits: Record<string, TileExitConfig>;
objects: Record<string, { objIndex: number; amount: number }>;
npcs: Record<string, number>;
triggers: Record<string, number>;
};

const MAPS_SOURCE_DIR = process.env.MAPS_SOURCE_DIR || path.resolve(__dirname, "../../../server/mapas_source");

const mapWriteLocks = new Map<number, Promise<unknown>>();

async function withMapLock<T>(mapNum: number, fn: () => Promise<T> | T): Promise<T> {
while (mapWriteLocks.has(mapNum)) {
try {
await mapWriteLocks.get(mapNum);
} catch {
// Ignore previous errors on lock release
}
}

let resolveLock!: () => void;
const lockPromise = new Promise<void>((resolve) => {
resolveLock = resolve;
});
mapWriteLocks.set(mapNum, lockPromise);

try {
return await fn();
} finally {
if (mapWriteLocks.get(mapNum) === lockPromise) {
mapWriteLocks.delete(mapNum);
}
resolveLock();
}
}

function getMapSpecialsPath(mapNum: number): string {
return path.join(MAPS_SOURCE_DIR, `mapa_${mapNum}`, "specials.json");
}

function readMapSpecials(mapNum: number): SpecialsJson {
const filePath = getMapSpecialsPath(mapNum);
if (!fs.existsSync(filePath)) {
return {
id: mapNum,
exits: {},
objects: {},
npcs: {},
triggers: {},
};
}

try {
const raw = fs.readFileSync(filePath, "utf8");
return JSON.parse(raw) as SpecialsJson;
} catch {
return {
id: mapNum,
exits: {},
objects: {},
npcs: {},
triggers: {},
};
}
}

function writeMapSpecials(mapNum: number, data: SpecialsJson): void {
const filePath = getMapSpecialsPath(mapNum);
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });

// Atomic write-then-rename to prevent partial/corrupted reads
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
fs.renameSync(tmpPath, filePath);
}

export const upsertExitSchema = z.object({
destMap: z.number().int().positive(),
destX: z.number().int().min(1).max(100),
destY: z.number().int().min(1).max(100),
createPaired: z.boolean().optional().default(false),
});

export type UpsertExitInput = z.infer<typeof upsertExitSchema>;

export async function getMapExits(mapNum: number): Promise<{ mapNum: number; exits: Record<string, TileExitConfig> }> {
const specials = readMapSpecials(mapNum);
return {
mapNum,
exits: specials.exits ?? {},
};
}

export async function upsertMapExit(
mapNum: number,
x: number,
y: number,
input: UpsertExitInput,
): Promise<{
mapNum: number;
x: number;
y: number;
exit: TileExit;
pairedExitCreated: boolean;
}> {
return withMapLock(mapNum, async () => {
const coordKey = `${x},${y}`;
const exitTarget: TileExit = {
map: input.destMap,
x: input.destX,
y: input.destY,
};

const sourceSpecials = readMapSpecials(mapNum);
if (!sourceSpecials.exits) {
sourceSpecials.exits = {};
}
sourceSpecials.exits[coordKey] = exitTarget;
writeMapSpecials(mapNum, sourceSpecials);

Comment thread
gitar-bot[bot] marked this conversation as resolved.
Outdated
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Outdated
let pairedExitCreated = false;

if (input.createPaired) {
await withMapLock(input.destMap, async () => {
const destSpecials = readMapSpecials(input.destMap);
if (!destSpecials.exits) {
destSpecials.exits = {};
}
const destCoordKey = `${input.destX},${input.destY}`;
destSpecials.exits[destCoordKey] = {
map: mapNum,
x,
y,
};
writeMapSpecials(input.destMap, destSpecials);
pairedExitCreated = true;
});
}

return {
mapNum,
x,
y,
exit: exitTarget,
pairedExitCreated,
};
});
}

export async function deleteMapExit(
mapNum: number,
x: number,
y: number,
deletePaired = false,
): Promise<{
mapNum: number;
x: number;
y: number;
deleted: boolean;
pairedExitDeleted: boolean;
}> {
return withMapLock(mapNum, async () => {
const coordKey = `${x},${y}`;
const sourceSpecials = readMapSpecials(mapNum);
const existingExit = sourceSpecials.exits?.[coordKey];

if (!existingExit) {
return {
mapNum,
x,
y,
deleted: false,
pairedExitDeleted: false,
};
}

delete sourceSpecials.exits[coordKey];
writeMapSpecials(mapNum, sourceSpecials);

let pairedExitDeleted = false;

if (deletePaired && "map" in existingExit) {
const destMap = existingExit.map;
const destX = existingExit.x;
const destY = existingExit.y;
const destCoordKey = `${destX},${destY}`;

await withMapLock(destMap, async () => {
const destSpecials = readMapSpecials(destMap);
if (destSpecials.exits?.[destCoordKey]) {
delete destSpecials.exits[destCoordKey];
writeMapSpecials(destMap, destSpecials);
pairedExitDeleted = true;
}
});
}

return {
mapNum,
x,
y,
deleted: true,
pairedExitDeleted,
};
});
}
Loading