characters.ts
· 8.4 KiB · TypeScript
Raw
import { Paths } from "@paths";
import {
Character, CharacterStats, CharName,
CharacterClass, ClassKey, Nation, UserKey, AccountData, AccountMap,
CLASSES,
} from "@types";
import { Store } from "@systems/store";
import { Runtime } from "@systems/runtime";
// ─── Runtime ──────────────────────────────────────────────────────────────────
Runtime.phase("load", () => Char.load(), { name: "Char.load" });
let _chars: CharacterMap = {};
let _accounts: AccountMap = {};
/** Raw shape stored in characters.json */
interface SerializableCharacter {
name: CharName;
class: ClassKey; // "FB" — serialized as string
level: number;
nation: Nation;
active?: boolean;
sharedWith?: UserKey[];
stats?: CharacterStats;
}
interface CharacterMap {
[userKey: string]: {
characters: SerializableCharacter[];
};
}
export function loadCharacters(): void {
_chars = Store.readOrDefault<CharacterMap>(Paths.data("characters.json"), {});
_accounts = Store.readOrDefault<AccountMap>(Paths.data("accounts.json"), {});
}
function saveCharacters(): void {
// Dehydrate all characters before saving
const raw: CharacterMap = {};
for (const [userKey, data] of Object.entries(_chars)) {
raw[userKey] = {
characters: data.characters.map((c) =>
"ownerKey" in c ? Char.dehydrate(c as unknown as Character) : c
),
};
}
Store.write(Paths.data("characters.json"), raw);
}
function saveAccounts(): void {
Store.write(Paths.data("accounts.json"), _accounts);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Get hydrated characters for a user */
export function getCharacters(userKey: UserKey): Character[] {
return (_chars[userKey]?.characters ?? []).map((c) =>
Char.hydrate(c as SerializableCharacter, userKey)
);
}
export function getActiveCharacter(userKey: UserKey): Character | null {
return getCharacters(userKey).find((c) => c.active) ?? null;
}
export function getCharacterByName(userKey: UserKey, name: string): Character | null {
return getCharacters(userKey).find(
(c) => c.name.toLowerCase() === name.toLowerCase()
) ?? null;
}
export function getCharacterByClass(userKey: UserKey, cls: ClassKey): Character | null {
const chars = getCharacters(userKey).filter((c) => c.class.key === cls);
return chars.find((c) => c.active) ?? chars[0] ?? null;
}
export function isCharacterOwner(userKey: UserKey | null, charName: string): boolean {
if (!userKey) return false;
return getCharacters(userKey).some((c) => c.name === charName);
}
// ─── Mutations ────────────────────────────────────────────────────────────────
export function addCharacter(userKey: UserKey, char: Omit<SerializableCharacter, "active">): boolean {
if (!_chars[userKey]) _chars[userKey] = { characters: [] };
const exists = _chars[userKey].characters.some(
(c) => c.name.toLowerCase() === char.name.toLowerCase()
);
if (exists) return false;
const hasActive = _chars[userKey].characters.some((c) => c.active);
_chars[userKey].characters.push({ ...char, active: !hasActive });
saveCharacters();
return true;
}
export function removeCharacter(userKey: UserKey, name: string): boolean {
if (!_chars[userKey]) return false;
const before = _chars[userKey].characters.length;
_chars[userKey].characters = _chars[userKey].characters.filter(
(c) => c.name.toLowerCase() !== name.toLowerCase()
);
if (_chars[userKey].characters.length === before) return false;
if (!_chars[userKey].characters.some((c) => c.active) &&
_chars[userKey].characters.length > 0) {
_chars[userKey].characters[0].active = true;
}
saveCharacters();
return true;
}
export function setActiveCharacter(userKey: UserKey, name: string): boolean {
const chars = _chars[userKey]?.characters;
if (!chars) return false;
const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase());
if (!target) return false;
chars.forEach((c) => (c.active = false));
target.active = true;
saveCharacters();
return true;
}
export function setCharacterNation(userKey: UserKey, name: string, nation: Nation): boolean {
const raw = _chars[userKey]?.characters.find(
(c) => c.name.toLowerCase() === name.toLowerCase()
);
if (!raw) return false;
raw.nation = nation;
saveCharacters();
return true;
}
export function setCharacterStats(
userKey: UserKey,
name: string,
stats: { atk?: number; def?: number; heal?: number }
): boolean {
const raw = _chars[userKey]?.characters.find(
(c) => c.name.toLowerCase() === name.toLowerCase()
);
if (!raw) return false;
if (!raw.stats) raw.stats = {};
Object.assign(raw.stats, stats);
saveCharacters();
return true;
}
export function shareCharacter(ownerKey: UserKey, charName: string, targetKey: UserKey): boolean {
const raw = _chars[ownerKey]?.characters.find(
(c) => c.name.toLowerCase() === charName.toLowerCase()
);
if (!raw) return false;
if (!raw.sharedWith) raw.sharedWith = [];
if (raw.sharedWith.includes(targetKey)) return false;
raw.sharedWith.push(targetKey);
saveCharacters();
return true;
}
export function unshareCharacter(ownerKey: UserKey, charName: string, targetKey: UserKey): boolean {
const raw = _chars[ownerKey]?.characters.find(
(c) => c.name.toLowerCase() === charName.toLowerCase()
);
if (!raw || !raw.sharedWith) return false;
raw.sharedWith = raw.sharedWith.filter((k) => k !== targetKey);
saveCharacters();
return true;
}
// ─── Account data ─────────────────────────────────────────────────────────────
export function getAccountData(userKey: UserKey): AccountData {
return _accounts[userKey] ?? {};
}
export function setAccountData(userKey: UserKey, data: Partial<AccountData>): void {
if (!_accounts[userKey]) _accounts[userKey] = {};
Object.assign(_accounts[userKey], data);
saveAccounts();
}
export const Char = {
load() {
_chars = Store.readOrDefault<CharacterMap>(Paths.data("characters.json"), {});
_accounts = Store.readOrDefault<AccountMap>(Paths.data("accounts.json"), {});
},
byName({ user, name }: { user: UserKey, name: CharName }): Character|null {
return getCharacterByName(user, name);
},
byClass({ owner, charClass }: { owner: string, charClass: CharacterClass }): Character|null {
return getCharacterByClass(owner, charClass.key);
},
isOwner({ user, charName }: { user: UserKey, charName: string }): boolean {
return isCharacterOwner(user, charName);
},
add({ user, char }: { user: UserKey, char: Omit<SerializableCharacter, "active"> }): boolean {
return addCharacter(user, char);
},
remove({ user, name }: { user: UserKey, name: string }): boolean {
return removeCharacter(user, name);
},
active({ user }: { user: UserKey }): Character|null {
return getActiveCharacter(user);
},
setActive({ user, name }: { user: UserKey, name: string }): boolean {
return setActiveCharacter(user, name);
},
setNation({ user, name, nation }: { user: UserKey, name: string, nation: Nation }): boolean {
return setCharacterNation(user, name, nation);
},
// setStats({ user, name, stats }: { user: UserKey, name: string, stats: CharacterStats }): boolean {
// return setCharacterStats(user, name, stats);
// },
share({ user, charName, targetUser }: { user: UserKey, charName: string, targetUser: UserKey }): boolean {
return shareCharacter(user, charName, targetUser);
},
unshare({ user, charName, targetUser }: { user: UserKey, charName: string, targetUser: UserKey }): boolean {
return unshareCharacter(user, charName, targetUser);
},
hydrate(raw: SerializableCharacter, ownerKey: UserKey): Character {
return {
...raw,
class: CLASSES[raw.class] ?? { key: raw.class as ClassKey, name: raw.class, shortName: raw.class },
ownerKey,
};
},
dehydrate(char: Character): SerializableCharacter {
const { ownerKey, ...rest } = char;
return { ...rest, class: char.class.key };
},
};
| 1 | import { Paths } from "@paths"; |
| 2 | import { |
| 3 | Character, CharacterStats, CharName, |
| 4 | CharacterClass, ClassKey, Nation, UserKey, AccountData, AccountMap, |
| 5 | CLASSES, |
| 6 | } from "@types"; |
| 7 | import { Store } from "@systems/store"; |
| 8 | import { Runtime } from "@systems/runtime"; |
| 9 | |
| 10 | // ─── Runtime ────────────────────────────────────────────────────────────────── |
| 11 | Runtime.phase("load", () => Char.load(), { name: "Char.load" }); |
| 12 | |
| 13 | let _chars: CharacterMap = {}; |
| 14 | let _accounts: AccountMap = {}; |
| 15 | |
| 16 | /** Raw shape stored in characters.json */ |
| 17 | interface SerializableCharacter { |
| 18 | name: CharName; |
| 19 | class: ClassKey; // "FB" — serialized as string |
| 20 | level: number; |
| 21 | nation: Nation; |
| 22 | active?: boolean; |
| 23 | sharedWith?: UserKey[]; |
| 24 | stats?: CharacterStats; |
| 25 | } |
| 26 | |
| 27 | interface CharacterMap { |
| 28 | [userKey: string]: { |
| 29 | characters: SerializableCharacter[]; |
| 30 | }; |
| 31 | } |
| 32 | |
| 33 | export function loadCharacters(): void { |
| 34 | _chars = Store.readOrDefault<CharacterMap>(Paths.data("characters.json"), {}); |
| 35 | _accounts = Store.readOrDefault<AccountMap>(Paths.data("accounts.json"), {}); |
| 36 | } |
| 37 | |
| 38 | function saveCharacters(): void { |
| 39 | // Dehydrate all characters before saving |
| 40 | const raw: CharacterMap = {}; |
| 41 | for (const [userKey, data] of Object.entries(_chars)) { |
| 42 | raw[userKey] = { |
| 43 | characters: data.characters.map((c) => |
| 44 | "ownerKey" in c ? Char.dehydrate(c as unknown as Character) : c |
| 45 | ), |
| 46 | }; |
| 47 | } |
| 48 | Store.write(Paths.data("characters.json"), raw); |
| 49 | } |
| 50 | |
| 51 | function saveAccounts(): void { |
| 52 | Store.write(Paths.data("accounts.json"), _accounts); |
| 53 | } |
| 54 | |
| 55 | // ─── Helpers ────────────────────────────────────────────────────────────────── |
| 56 | |
| 57 | /** Get hydrated characters for a user */ |
| 58 | export function getCharacters(userKey: UserKey): Character[] { |
| 59 | return (_chars[userKey]?.characters ?? []).map((c) => |
| 60 | Char.hydrate(c as SerializableCharacter, userKey) |
| 61 | ); |
| 62 | } |
| 63 | |
| 64 | export function getActiveCharacter(userKey: UserKey): Character | null { |
| 65 | return getCharacters(userKey).find((c) => c.active) ?? null; |
| 66 | } |
| 67 | |
| 68 | export function getCharacterByName(userKey: UserKey, name: string): Character | null { |
| 69 | return getCharacters(userKey).find( |
| 70 | (c) => c.name.toLowerCase() === name.toLowerCase() |
| 71 | ) ?? null; |
| 72 | } |
| 73 | |
| 74 | export function getCharacterByClass(userKey: UserKey, cls: ClassKey): Character | null { |
| 75 | const chars = getCharacters(userKey).filter((c) => c.class.key === cls); |
| 76 | return chars.find((c) => c.active) ?? chars[0] ?? null; |
| 77 | } |
| 78 | |
| 79 | export function isCharacterOwner(userKey: UserKey | null, charName: string): boolean { |
| 80 | if (!userKey) return false; |
| 81 | return getCharacters(userKey).some((c) => c.name === charName); |
| 82 | } |
| 83 | |
| 84 | // ─── Mutations ──────────────────────────────────────────────────────────────── |
| 85 | |
| 86 | export function addCharacter(userKey: UserKey, char: Omit<SerializableCharacter, "active">): boolean { |
| 87 | if (!_chars[userKey]) _chars[userKey] = { characters: [] }; |
| 88 | const exists = _chars[userKey].characters.some( |
| 89 | (c) => c.name.toLowerCase() === char.name.toLowerCase() |
| 90 | ); |
| 91 | if (exists) return false; |
| 92 | const hasActive = _chars[userKey].characters.some((c) => c.active); |
| 93 | _chars[userKey].characters.push({ ...char, active: !hasActive }); |
| 94 | saveCharacters(); |
| 95 | return true; |
| 96 | } |
| 97 | |
| 98 | export function removeCharacter(userKey: UserKey, name: string): boolean { |
| 99 | if (!_chars[userKey]) return false; |
| 100 | const before = _chars[userKey].characters.length; |
| 101 | _chars[userKey].characters = _chars[userKey].characters.filter( |
| 102 | (c) => c.name.toLowerCase() !== name.toLowerCase() |
| 103 | ); |
| 104 | if (_chars[userKey].characters.length === before) return false; |
| 105 | if (!_chars[userKey].characters.some((c) => c.active) && |
| 106 | _chars[userKey].characters.length > 0) { |
| 107 | _chars[userKey].characters[0].active = true; |
| 108 | } |
| 109 | saveCharacters(); |
| 110 | return true; |
| 111 | } |
| 112 | |
| 113 | export function setActiveCharacter(userKey: UserKey, name: string): boolean { |
| 114 | const chars = _chars[userKey]?.characters; |
| 115 | if (!chars) return false; |
| 116 | const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase()); |
| 117 | if (!target) return false; |
| 118 | chars.forEach((c) => (c.active = false)); |
| 119 | target.active = true; |
| 120 | saveCharacters(); |
| 121 | return true; |
| 122 | } |
| 123 | |
| 124 | export function setCharacterNation(userKey: UserKey, name: string, nation: Nation): boolean { |
| 125 | const raw = _chars[userKey]?.characters.find( |
| 126 | (c) => c.name.toLowerCase() === name.toLowerCase() |
| 127 | ); |
| 128 | if (!raw) return false; |
| 129 | raw.nation = nation; |
| 130 | saveCharacters(); |
| 131 | return true; |
| 132 | } |
| 133 | |
| 134 | export function setCharacterStats( |
| 135 | userKey: UserKey, |
| 136 | name: string, |
| 137 | stats: { atk?: number; def?: number; heal?: number } |
| 138 | ): boolean { |
| 139 | const raw = _chars[userKey]?.characters.find( |
| 140 | (c) => c.name.toLowerCase() === name.toLowerCase() |
| 141 | ); |
| 142 | if (!raw) return false; |
| 143 | if (!raw.stats) raw.stats = {}; |
| 144 | Object.assign(raw.stats, stats); |
| 145 | saveCharacters(); |
| 146 | return true; |
| 147 | } |
| 148 | |
| 149 | export function shareCharacter(ownerKey: UserKey, charName: string, targetKey: UserKey): boolean { |
| 150 | const raw = _chars[ownerKey]?.characters.find( |
| 151 | (c) => c.name.toLowerCase() === charName.toLowerCase() |
| 152 | ); |
| 153 | if (!raw) return false; |
| 154 | if (!raw.sharedWith) raw.sharedWith = []; |
| 155 | if (raw.sharedWith.includes(targetKey)) return false; |
| 156 | raw.sharedWith.push(targetKey); |
| 157 | saveCharacters(); |
| 158 | return true; |
| 159 | } |
| 160 | |
| 161 | export function unshareCharacter(ownerKey: UserKey, charName: string, targetKey: UserKey): boolean { |
| 162 | const raw = _chars[ownerKey]?.characters.find( |
| 163 | (c) => c.name.toLowerCase() === charName.toLowerCase() |
| 164 | ); |
| 165 | if (!raw || !raw.sharedWith) return false; |
| 166 | raw.sharedWith = raw.sharedWith.filter((k) => k !== targetKey); |
| 167 | saveCharacters(); |
| 168 | return true; |
| 169 | } |
| 170 | |
| 171 | // ─── Account data ───────────────────────────────────────────────────────────── |
| 172 | export function getAccountData(userKey: UserKey): AccountData { |
| 173 | return _accounts[userKey] ?? {}; |
| 174 | } |
| 175 | |
| 176 | export function setAccountData(userKey: UserKey, data: Partial<AccountData>): void { |
| 177 | if (!_accounts[userKey]) _accounts[userKey] = {}; |
| 178 | Object.assign(_accounts[userKey], data); |
| 179 | saveAccounts(); |
| 180 | } |
| 181 | |
| 182 | |
| 183 | export const Char = { |
| 184 | load() { |
| 185 | _chars = Store.readOrDefault<CharacterMap>(Paths.data("characters.json"), {}); |
| 186 | _accounts = Store.readOrDefault<AccountMap>(Paths.data("accounts.json"), {}); |
| 187 | }, |
| 188 | |
| 189 | byName({ user, name }: { user: UserKey, name: CharName }): Character|null { |
| 190 | return getCharacterByName(user, name); |
| 191 | }, |
| 192 | |
| 193 | byClass({ owner, charClass }: { owner: string, charClass: CharacterClass }): Character|null { |
| 194 | return getCharacterByClass(owner, charClass.key); |
| 195 | }, |
| 196 | |
| 197 | isOwner({ user, charName }: { user: UserKey, charName: string }): boolean { |
| 198 | return isCharacterOwner(user, charName); |
| 199 | }, |
| 200 | |
| 201 | add({ user, char }: { user: UserKey, char: Omit<SerializableCharacter, "active"> }): boolean { |
| 202 | return addCharacter(user, char); |
| 203 | }, |
| 204 | |
| 205 | remove({ user, name }: { user: UserKey, name: string }): boolean { |
| 206 | return removeCharacter(user, name); |
| 207 | }, |
| 208 | |
| 209 | active({ user }: { user: UserKey }): Character|null { |
| 210 | return getActiveCharacter(user); |
| 211 | }, |
| 212 | |
| 213 | setActive({ user, name }: { user: UserKey, name: string }): boolean { |
| 214 | return setActiveCharacter(user, name); |
| 215 | }, |
| 216 | |
| 217 | setNation({ user, name, nation }: { user: UserKey, name: string, nation: Nation }): boolean { |
| 218 | return setCharacterNation(user, name, nation); |
| 219 | }, |
| 220 | |
| 221 | // setStats({ user, name, stats }: { user: UserKey, name: string, stats: CharacterStats }): boolean { |
| 222 | // return setCharacterStats(user, name, stats); |
| 223 | // }, |
| 224 | |
| 225 | share({ user, charName, targetUser }: { user: UserKey, charName: string, targetUser: UserKey }): boolean { |
| 226 | return shareCharacter(user, charName, targetUser); |
| 227 | }, |
| 228 | |
| 229 | unshare({ user, charName, targetUser }: { user: UserKey, charName: string, targetUser: UserKey }): boolean { |
| 230 | return unshareCharacter(user, charName, targetUser); |
| 231 | }, |
| 232 | |
| 233 | hydrate(raw: SerializableCharacter, ownerKey: UserKey): Character { |
| 234 | return { |
| 235 | ...raw, |
| 236 | class: CLASSES[raw.class] ?? { key: raw.class as ClassKey, name: raw.class, shortName: raw.class }, |
| 237 | ownerKey, |
| 238 | }; |
| 239 | }, |
| 240 | |
| 241 | dehydrate(char: Character): SerializableCharacter { |
| 242 | const { ownerKey, ...rest } = char; |
| 243 | return { ...rest, class: char.class.key }; |
| 244 | }, |
| 245 | }; |