Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install Convex Dependencies
shell: pwsh
run: npm ci

- name: Check Convex Types
shell: pwsh
run: npx tsc --noEmit

- name: Run Convex Tests
shell: pwsh
run: npm run test:convex

- uses: dart-lang/setup-dart@v1

- name: Cache Pub Packages
Expand Down
51 changes: 51 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Icarus

A desktop-first app for creating and sharing Valorant map strategies:
interactive map drawing, agent/ability placement, lineups, and exports.

## Language

**Strategy**:
One Valorant map plan — the top-level document a user creates, containing
pages, a map choice, and a theme. This is the domain object; do not name code
abstractions "…Strategy" (GoF sense) unless they operate on this object.
_Avoid_: plan, document, project

**Page**:
One frame of a strategy: the agents, abilities, drawings, text, images, and
utilities shown at a moment in the plan. Ordered within a strategy. In
user-facing video-export copy, a page shown in sequence is called a "step".
_Avoid_: slide, scene, frame

**Step duration**:
How long each included page is held on screen in an exported video. One
global value per export.
_Avoid_: page duration, hold time

**Page transition**:
The animated change between two pages: widgets move, morph, appear, or
disappear; freehand drawings and images fade in early.
_Avoid_: page switch animation

**Transition entry**:
One widget's role in a page transition — it moves, appears, or disappears.
_Avoid_: transition item

**Agent path**:
The curved route an agent travels along during a page transition.
_Avoid_: movement path, trajectory

**Video export**:
Rendering a chosen subset of a strategy's pages, in order, into an .mp4 —
each page held for the step duration with full-fidelity page transitions
between them.
_Avoid_: video sequencing, movie export

**Lineup**:
A saved ability setup (position/aim reference) attached to a page, grouped
into lineup groups.

**.ica file**:
Icarus's zip-based strategy interchange format for import/export of whole
strategies. Unrelated to video export.
_Avoid_: archive (ambiguous with library backups)
12 changes: 12 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* @module
*/

import type * as crons from "../crons.js";
import type * as elements from "../elements.js";
import type * as folders from "../folders.js";
import type * as health from "../health.js";
Expand All @@ -17,14 +18,19 @@ import type * as lib_auth from "../lib/auth.js";
import type * as lib_cloudProtocol from "../lib/cloudProtocol.js";
import type * as lib_entities from "../lib/entities.js";
import type * as lib_errors from "../lib/errors.js";
import type * as lib_imageAssets from "../lib/imageAssets.js";
import type * as lib_opTypes from "../lib/opTypes.js";
import type * as lib_payloadValidators from "../lib/payloadValidators.js";
import type * as lib_r2 from "../lib/r2.js";
import type * as lib_snapshotSerialization from "../lib/snapshotSerialization.js";
import type * as lineups from "../lineups.js";
import type * as maintenance from "../maintenance.js";
import type * as ops from "../ops.js";
import type * as page from "../page.js";
import type * as pages from "../pages.js";
import type * as shares from "../shares.js";
import type * as strategies from "../strategies.js";
import type * as strategy from "../strategy.js";
import type * as users from "../users.js";

import type {
Expand All @@ -34,6 +40,7 @@ import type {
} from "convex/server";

declare const fullApi: ApiFromModules<{
crons: typeof crons;
elements: typeof elements;
folders: typeof folders;
health: typeof health;
Expand All @@ -43,14 +50,19 @@ declare const fullApi: ApiFromModules<{
"lib/cloudProtocol": typeof lib_cloudProtocol;
"lib/entities": typeof lib_entities;
"lib/errors": typeof lib_errors;
"lib/imageAssets": typeof lib_imageAssets;
"lib/opTypes": typeof lib_opTypes;
"lib/payloadValidators": typeof lib_payloadValidators;
"lib/r2": typeof lib_r2;
"lib/snapshotSerialization": typeof lib_snapshotSerialization;
lineups: typeof lineups;
maintenance: typeof maintenance;
ops: typeof ops;
page: typeof page;
pages: typeof pages;
shares: typeof shares;
strategies: typeof strategies;
strategy: typeof strategy;
users: typeof users;
}>;

Expand Down
24 changes: 24 additions & 0 deletions convex/lib/canonicalValues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function normalizeComparableValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(normalizeComparableValue);
if (isRecord(value)) {
const result: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
if (value[key] !== undefined) {
result[key] = normalizeComparableValue(value[key]);
}
}
return result;
}
return value;
}

export function valuesEqual(left: unknown, right: unknown): boolean {
return (
JSON.stringify(normalizeComparableValue(left)) ===
JSON.stringify(normalizeComparableValue(right))
);
}
5 changes: 2 additions & 3 deletions convex/lib/cloudProtocol.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { clientUpgradeRequiredError } from "./errors";

export const CURRENT_CLOUD_PROTOCOL_VERSION = 1;
export const MIN_CLOUD_PROTOCOL_VERSION = 1;
export const CURRENT_CLOUD_PROTOCOL_VERSION = 2;
export const MIN_CLOUD_PROTOCOL_VERSION = 2;

export function assertSupportedCloudProtocol(clientProtocolVersion: number): void {
if (clientProtocolVersion < MIN_CLOUD_PROTOCOL_VERSION) {
throw clientUpgradeRequiredError();
}
}

13 changes: 12 additions & 1 deletion convex/lib/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,16 @@ export function sortByNumberField<T extends Record<string, unknown>>(
input: T[],
field: keyof T,
): T[] {
return [...input].sort((a, b) => Number(a[field] ?? 0) - Number(b[field] ?? 0));
return [...input].sort((a, b) => {
const fieldDifference = Number(a[field] ?? 0) - Number(b[field] ?? 0);
if (fieldDifference !== 0) return fieldDifference;

const leftPublicId = typeof a.publicId === "string" ? a.publicId : "";
const rightPublicId = typeof b.publicId === "string" ? b.publicId : "";
return leftPublicId.localeCompare(rightPublicId);
});
}

export function clampPageIndex(index: number, maximum: number): number {
return Math.max(0, Math.min(Math.trunc(index), maximum));
}
3 changes: 3 additions & 0 deletions convex/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export type ErrorCode =
| "MISSING_PAGE_PUBLIC_ID"
| "NOT_FOUND"
| "PAGE_STRATEGY_MISMATCH"
| "INVALID_PAGE_CONTENT_COUNT"
| "PAGE_DESCRIPTOR_REQUIRES_PAGE_OP"
| "PAGE_SETTINGS_REQUIRE_PAGE_CONTENT"
| "R2_OBJECT_KEY_MISMATCH"
| "SHARE_LINK_REVOKED"
| "UNAUTHENTICATED"
Expand Down
2 changes: 1 addition & 1 deletion convex/lib/opTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const opKindValidator = v.union(
export const entityTypeValidator = v.union(
v.literal("strategy"),
v.literal("page"),
v.literal("pageContent"),
v.literal("element"),
v.literal("lineup"),
);
Expand All @@ -37,5 +38,4 @@ export const strategyOpValidator = v.object({
),
sortIndex: v.optional(v.number()),
expectedRevision: v.optional(v.number()),
expectedSequence: v.optional(v.number()),
});
80 changes: 80 additions & 0 deletions convex/lib/snapshotSerialization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { Doc } from "../_generated/dataModel";

export function serializeStrategyHeader(
strategy: Doc<"strategies">,
role: "owner" | "editor" | "viewer",
) {
return {
publicId: strategy.publicId,
name: strategy.name,
mapData: strategy.mapData,
revision: strategy.revision,
createdAt: strategy.createdAt,
updatedAt: strategy.updatedAt,
themeProfileId: strategy.themeProfileId ?? null,
themeOverridePalette: strategy.themeOverridePalette ?? null,
role,
};
}

export function serializePageDescriptor(
strategyPublicId: string,
page: Doc<"pages">,
) {
return {
publicId: page.publicId,
strategyPublicId,
name: page.name,
sortIndex: page.sortIndex,
isAttack: page.isAttack,
revision: page.revision,
createdAt: page.createdAt,
updatedAt: page.updatedAt,
};
}

export function serializePageContent(pageContent: Doc<"pageContents">) {
return {
settings: pageContent.settings ?? null,
revision: pageContent.revision,
createdAt: pageContent.createdAt,
updatedAt: pageContent.updatedAt,
};
}

export function serializeElement(
strategyPublicId: string,
pagePublicId: string,
element: Doc<"elements">,
) {
return {
publicId: element.publicId,
strategyPublicId,
pagePublicId,
elementType: element.elementType,
payload: element.payload,
sortIndex: element.sortIndex,
revision: element.revision,
deleted: element.deleted,
createdAt: element.createdAt,
updatedAt: element.updatedAt,
};
}

export function serializeLineup(
strategyPublicId: string,
pagePublicId: string,
lineup: Doc<"lineups">,
) {
return {
publicId: lineup.publicId,
strategyPublicId,
pagePublicId,
payload: lineup.payload,
sortIndex: lineup.sortIndex,
revision: lineup.revision,
deleted: lineup.deleted,
createdAt: lineup.createdAt,
updatedAt: lineup.updatedAt,
};
}
Loading
Loading