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
114 changes: 91 additions & 23 deletions UndauntedMetagame/src/controllers/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { inventory } from "../db/schema";
import { logger } from "../logger";
import { DoesCharacterBelongToUserId } from "./character";

export type InventoryError = "forbidden" | "not_found" | "conflict" | "invalid_inventory_data" | "db_error";
export type InventoryError = "forbidden" | "not_found" | "conflict" | "invalid_inventory_item" | "invalid_inventory_data" | "db_error";
export type InventoryResult<T = void> = { success: true, data?: T } | { success: false, error: InventoryError };

class InventoryConflictError extends Error {
Expand All @@ -14,6 +14,13 @@ class InventoryConflictError extends Error {
}
}

class InventoryValidationError extends Error {
constructor(message: string){
super(message);
this.name = "InventoryValidationError";
}
}

function MakeEmptyInventoryRow(CharacterId: string): typeof inventory.$inferInsert {
return {
characterId: CharacterId,
Expand All @@ -26,17 +33,45 @@ function FindInstancedItemIndex(InstancedItems: any[], InstanceId: string){
return InstancedItems.findIndex((Item) => Item.instanceId === InstanceId);
}

function AssertSuperiorInstancedItemVersion(CurrentItem: any, IncomingItem: any, Operation: string){
function HasStatelessItemData(Item: any){
return !Object.prototype.hasOwnProperty.call(Item, "itemData") || Item.itemData == null;
}

function IsAllowedStaleStatelessReplacement(CurrentItem: any, IncomingItem: any, Operation: string){
return Operation !== "remove"
&& HasStatelessItemData(CurrentItem)
&& HasStatelessItemData(IncomingItem)
&& CurrentItem.updateVersion === 0;
}

function AssertValidIncomingInstancedItem(IncomingItem: any, Operation: string){
if(HasStatelessItemData(IncomingItem) && IncomingItem.updateVersion !== 0){
throw new InventoryValidationError(`Refusing stateless instanced item ${Operation} ${IncomingItem.catalogId}/${IncomingItem.instanceId}: expected updateVersion 0, got ${IncomingItem.updateVersion}`);
}
}

function AssertExistingInstancedItemWrite(CurrentItem: any, IncomingItem: any, Operation: string): "write" | "skip"{
if(Operation !== "remove"){
AssertValidIncomingInstancedItem(IncomingItem, Operation);
}

if(typeof CurrentItem.updateVersion !== "number"){
return;
return "write";
}

if(IsAllowedStaleStatelessReplacement(CurrentItem, IncomingItem, Operation)){
return "skip";
}

if(typeof IncomingItem.updateVersion !== "number" || IncomingItem.updateVersion <= CurrentItem.updateVersion){
const IsStaleInstancedItem = typeof IncomingItem.updateVersion !== "number" || IncomingItem.updateVersion <= CurrentItem.updateVersion;
if(IsStaleInstancedItem){
throw new InventoryConflictError(`Refusing stale instanced item ${Operation} ${IncomingItem.catalogId}/${IncomingItem.instanceId}: current updateVersion ${CurrentItem.updateVersion}, incoming updateVersion ${IncomingItem.updateVersion}`);
}

return "write";
}

export async function UpdateInstancedItem(CharacterId: string, UserId: string, InstanceId: string, CatalogId: string, ItemData: string, UpdateVersion: number): Promise<InventoryResult<any>>{
export async function UpdateInstancedItem(CharacterId: string, UserId: string, InstanceId: string, CatalogId: string, ItemData: string | null | undefined, UpdateVersion: number): Promise<InventoryResult<any>>{
if(!await DoesCharacterBelongToUserId(UserId, CharacterId)){
logger.error(`Specified characterId ${CharacterId} does not belong to user ${UserId}`);
return {success: false, error: "forbidden"};
Expand All @@ -55,25 +90,30 @@ export async function UpdateInstancedItem(CharacterId: string, UserId: string, I
}

const InstancedItems: any[] = JSON.parse(CurrentInventory.instancedItems);
const ItemIndex = InstancedItems.findIndex((Item) => Item.catalogId === CatalogId && Item.instanceId === InstanceId);

for(const Item of InstancedItems){
if(Item.catalogId === CatalogId && Item.instanceId == InstanceId){
AssertSuperiorInstancedItemVersion(Item, {catalogId: CatalogId, instanceId: InstanceId, updateVersion: UpdateVersion}, "update");
if(ItemIndex < 0){
return {success: false, error: "not_found"} as InventoryResult<any>;
}

Item.itemData = ItemData;
Item.updateVersion = UpdateVersion;
const Item = InstancedItems[ItemIndex];
const IncomingItem = {catalogId: CatalogId, instanceId: InstanceId, itemData: ItemData, updateVersion: UpdateVersion};

logger.info(`Updating Instanced Item ${CatalogId} for CharacterId ${CharacterId} and UserId ${UserId}`);
if(AssertExistingInstancedItemWrite(Item, IncomingItem, "update") === "skip"){
logger.info(`Skipping stale stateless Instanced Item ${CatalogId} for CharacterId ${CharacterId} and UserId ${UserId}`);
return {success: true, data: Item} as InventoryResult<any>;
}

tx.update(inventory).set({
instancedItems: JSON.stringify(InstancedItems)
}).where(eq(inventory.characterId, CharacterId)).run();
Item.itemData = ItemData;
Item.updateVersion = UpdateVersion;

return {success: true, data: Item} as InventoryResult<any>;
}
}
logger.info(`Updating Instanced Item ${CatalogId} for CharacterId ${CharacterId} and UserId ${UserId}`);

tx.update(inventory).set({
instancedItems: JSON.stringify(InstancedItems)
}).where(eq(inventory.characterId, CharacterId)).run();

return {success: false, error: "not_found"} as InventoryResult<any>;
return {success: true, data: Item} as InventoryResult<any>;
});
}
catch(error){
Expand All @@ -82,6 +122,11 @@ export async function UpdateInstancedItem(CharacterId: string, UserId: string, I
return {success: false, error: "conflict"};
}

if(error instanceof InventoryValidationError){
logger.warn(error.message);
return {success: false, error: "invalid_inventory_item"};
}

if(error instanceof SyntaxError){
logger.error(error, `Invalid inventory data while updating instanced item ${CatalogId} for characterId ${CharacterId} and userId ${UserId}`);
return {success: false, error: "invalid_inventory_data"};
Expand Down Expand Up @@ -127,41 +172,57 @@ export async function RunInventoryTransaction(UserId: string, CharacterId: strin

if(ShouldTouchInstancedItems){
const InstancedItems: any[] = JSON.parse(CurrentInventory.instancedItems);
let DidUpdateInstancedItems = false;

for(const ItemToRemove of InstancedItemsToRemove){
const ItemIndex = FindInstancedItemIndex(InstancedItems, ItemToRemove.instanceId);

if(ItemIndex >= 0){
AssertSuperiorInstancedItemVersion(InstancedItems[ItemIndex], ItemToRemove, "remove");
AssertExistingInstancedItemWrite(InstancedItems[ItemIndex], ItemToRemove, "remove");
InstancedItems.splice(ItemIndex, 1);
DidUpdateInstancedItems = true;
}
}

for(const ItemToSave of InstancedItemsToSave){
const ItemIndex = FindInstancedItemIndex(InstancedItems, ItemToSave.instanceId);

if(ItemIndex >= 0){
AssertSuperiorInstancedItemVersion(InstancedItems[ItemIndex], ItemToSave, "save");
if(AssertExistingInstancedItemWrite(InstancedItems[ItemIndex], ItemToSave, "save") === "skip"){
continue;
}

InstancedItems[ItemIndex] = ItemToSave;
DidUpdateInstancedItems = true;
}
else{
AssertValidIncomingInstancedItem(ItemToSave, "save");
InstancedItems.push(ItemToSave);
DidUpdateInstancedItems = true;
}
}

for(const ItemToAdd of InstancedItemsToAdd){
const ItemIndex = FindInstancedItemIndex(InstancedItems, ItemToAdd.instanceId);

if(ItemIndex >= 0){
AssertSuperiorInstancedItemVersion(InstancedItems[ItemIndex], ItemToAdd, "add");
if(AssertExistingInstancedItemWrite(InstancedItems[ItemIndex], ItemToAdd, "add") === "skip"){
continue;
}

InstancedItems[ItemIndex] = ItemToAdd;
DidUpdateInstancedItems = true;
}
else{
AssertValidIncomingInstancedItem(ItemToAdd, "add");
InstancedItems.push(ItemToAdd);
DidUpdateInstancedItems = true;
}
}

Update.instancedItems = JSON.stringify(InstancedItems);
if(DidUpdateInstancedItems){
Update.instancedItems = JSON.stringify(InstancedItems);
}
}

if(ShouldTouchStackedItems){
Expand Down Expand Up @@ -195,7 +256,9 @@ export async function RunInventoryTransaction(UserId: string, CharacterId: strin
Update.stackedItems = JSON.stringify(StackedItems);
}

tx.update(inventory).set(Update).where(eq(inventory.characterId, CharacterId)).run();
if(Object.keys(Update).length > 0){
tx.update(inventory).set(Update).where(eq(inventory.characterId, CharacterId)).run();
}
});
}
catch(error){
Expand All @@ -204,6 +267,11 @@ export async function RunInventoryTransaction(UserId: string, CharacterId: strin
return {success: false, error: "conflict"};
}

if(error instanceof InventoryValidationError){
logger.warn(error.message);
return {success: false, error: "invalid_inventory_item"};
}

if(error instanceof SyntaxError){
logger.error(error, `Invalid inventory data while running transaction ${TransactionId} for characterId ${CharacterId} and userId ${UserId}`);
return {success: false, error: "invalid_inventory_data"};
Expand Down
7 changes: 3 additions & 4 deletions UndauntedMetagame/src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { boolean } from "drizzle-orm/gel-core";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
userId: text("userId").notNull().primaryKey(),
name: text("name").notNull(),
notes: integer("notes").notNull(),
isAdmin: boolean("isAdmin").notNull().default(0)
isAdmin: integer("isAdmin", {mode: "boolean"}).notNull().default(false)
})

export const characters = sqliteTable("characters", {
Expand Down Expand Up @@ -66,5 +65,5 @@ export const encounteredcontent = sqliteTable("encounteredcontent", {
export const invitecodes = sqliteTable("invitecodes", {
inviteCode: text("invitecode").notNull().primaryKey(),
usesRemaining: integer("usesRemaining").notNull(),
infiniteUses: boolean("infiniteUses").notNull()
});
infiniteUses: integer("infiniteUses", {mode: "boolean"}).notNull()
});
2 changes: 2 additions & 0 deletions UndauntedMetagame/src/routes/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ function StatusForInventoryError(Error: InventoryError){
return 404;
case "conflict":
return 409;
case "invalid_inventory_item":
return 400;
case "invalid_inventory_data":
case "db_error":
return 500;
Expand Down
4 changes: 2 additions & 2 deletions UndauntedMetagame/src/routes/undauntedapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ undauntedApiRouter.get("/InviteCodes", HasUndauntedAdminApiKey, async (req, res)
undauntedApiRouter.post("/RegisterInviteCode", HasUndauntedAdminApiKey, async (req, res) => {
const NewInviteCode = req.body.NewInviteCode;
const Uses = req.body.Uses;
const InfiniteUses = req.body.InfiniteUses ? 1 : 0;
const InfiniteUses = !!req.body.InfiniteUses;

await RegisterInviteCode(NewInviteCode, Uses, InfiniteUses);

Expand Down Expand Up @@ -109,4 +109,4 @@ undauntedApiRouter.get("/PublicOnlineStats", HasUndauntedUserApiKey, async (req,
res.json({
NumActivePlayers: PlayerData.length
});
});
});