genshin-mods 4.3.3 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +10 -2
- package/README.md +47 -37
- package/package.json +2 -2
- package/src/models/CommandRefusal.ts +3 -0
- package/src/models/ModName.ts +9 -0
- package/src/models/SinFigures.ts +4 -0
- package/src/models/SinName.ts +12 -0
- package/src/models/SinPosterior.ts +4 -0
- package/src/models/SinScore.ts +7 -0
- package/src/models/SinStatusKind.ts +8 -0
- package/src/models/SinsResult.ts +6 -0
- package/src/models/TaskCall.ts +1 -7
- package/src/models/TaskStatus.ts +6 -0
- package/src/models/UsageWindow.ts +1 -3
- package/src/register.ts +7 -3
- package/src/services/InitialState.ts +7 -2
- package/src/services/ModDescriptionMap.ts +10 -6
- package/src/services/band/getCommissionSummary.ts +2 -1
- package/src/services/band/registerBand.ts +51 -4
- package/src/services/commission/foldTaskCall.ts +2 -1
- package/src/services/constants.ts +3 -8
- package/src/services/delegation/checkIsLookupCommand.ts +2 -8
- package/src/services/delegation/stripLeadingSegments.ts +8 -0
- package/src/services/diskScan/getDenyReason.ts +7 -0
- package/src/services/diskScan/getDiskScanRefusal.ts +2 -78
- package/src/services/diskScan/getScanCommandRefusal.ts +8 -0
- package/src/services/diskScan/getScanRefusal.ts +21 -0
- package/src/services/diskScan/isRecursiveFlag.ts +4 -0
- package/src/services/diskScan/isRecursiveLongFlag.ts +3 -0
- package/src/services/diskScan/isRoot.ts +8 -0
- package/src/services/diskScan/normalizePath.ts +7 -0
- package/src/services/diskScan/readGrepArguments.ts +25 -0
- package/src/services/diskScan/registerDiskScan.ts +2 -1
- package/src/services/readHome.ts +7 -0
- package/src/services/registerLifecycle.ts +93 -25
- package/src/services/resin/RateLimitKindUsageWindowMap.ts +2 -10
- package/src/services/resin/formatResetsAt.ts +1 -1
- package/src/services/resin/getReserveSummary.ts +4 -4
- package/src/services/resin/getReserveWindow.ts +8 -13
- package/src/services/resin/reserveText.ts +4 -4
- package/src/services/shell/getDollarSubstitutionBodies.ts +25 -0
- package/src/services/shell/getShellRefusal.ts +4 -38
- package/src/services/shell/tokenize.ts +8 -0
- package/src/services/sins/SinColorMap.ts +12 -0
- package/src/services/sins/SinStatusTextMap.ts +10 -0
- package/src/services/sins/appendSinEvidence.ts +18 -0
- package/src/services/sins/constants.ts +33 -0
- package/src/services/sins/getBarCells.ts +8 -0
- package/src/services/sins/getPosteriorTrail.ts +21 -0
- package/src/services/sins/getScoreAndSpread.ts +8 -0
- package/src/services/sins/getSinQuestions.ts +28 -0
- package/src/services/sins/getSinScores.ts +17 -0
- package/src/services/sins/getTrend.ts +13 -0
- package/src/services/sins/parseSinsResult.ts +5 -0
- package/src/services/sins/pickTypeSafeKey.ts +4 -0
- package/src/services/sins/readIsLightTheme.ts +8 -0
- package/src/services/sins/readSinStatus.ts +20 -0
- package/src/services/sins/readTypeSafeKey.ts +10 -0
- package/src/services/sins/registerSins.ts +56 -0
- package/src/services/sins/sinsScript.ts +47 -0
- package/src/services/ward/getAddRefusal.ts +14 -0
- package/src/services/ward/getCheckoutPeer.ts +1 -3
- package/src/services/ward/getCheckoutRefusal.ts +6 -0
- package/src/services/ward/getCleanRefusal.ts +8 -0
- package/src/services/ward/getComparablePath.ts +2 -0
- package/src/services/ward/getGitRefusal.ts +32 -0
- package/src/services/ward/getResetRefusal.ts +19 -0
- package/src/services/ward/getRestoreRefusal.ts +10 -0
- package/src/services/ward/getStashRefusal.ts +2 -0
- package/src/services/ward/getSubcommand.ts +16 -0
- package/src/services/ward/getWholeIndexDenyReason.ts +9 -0
- package/src/services/ward/getWholeIndexRefusal.ts +1 -121
- package/src/services/ward/isFlag.ts +1 -0
- package/src/services/ward/isWholeCheckout.ts +5 -0
- package/src/services/ward/registerWholeIndex.ts +2 -1
- package/src/services/ward/resolveRecordsPath.ts +2 -1
- package/src/services/ward/splitArguments.ts +14 -0
- package/types/index.d.ts +11 -5
|
@@ -1,75 +1,5 @@
|
|
|
1
|
-
import type { CommandRefusal } from "../shell/getShellRefusal";
|
|
2
|
-
|
|
3
1
|
import { getShellRefusal } from "../shell/getShellRefusal";
|
|
4
|
-
|
|
5
|
-
// A root in any spelling is a folder whose scan reads the whole disk: the drive's folders, the users' folders and the
|
|
6
|
-
// Home folder by name, once Windows drive letters and backslashes are read as Git Bash's `/c/...` form
|
|
7
|
-
const ROOT_REGEX =
|
|
8
|
-
/^(?:\/[a-z]?|\/(?:c\/)?Users(?:\/[^/]+)?|\/home(?:\/[^/]+)?|~|\$HOME|\$\{HOME\}|\$USERPROFILE|%USERPROFILE%)$/u;
|
|
9
|
-
|
|
10
|
-
const SCAN_COMMANDS: ReadonlySet<string> = new Set(["du", "find", "grep", "ls"]);
|
|
11
|
-
|
|
12
|
-
// Backslashes become slashes, a drive letter becomes its `/c` folder, and a trailing glob and slash go, so "C:\", "/c/",
|
|
13
|
-
// "C:/" and "/c/*" all name "/c": a folder's glob expands to its entries, whose scan reads the whole folder
|
|
14
|
-
const normalizePath = (token: string) => {
|
|
15
|
-
const slashed = token.replaceAll("\\", "/");
|
|
16
|
-
const path = /^[A-Za-z]:/u.test(slashed) ? `/${slashed.charAt(0).toLowerCase()}${slashed.slice(2)}` : slashed;
|
|
17
|
-
return path.replace(/(?<=\/)\*$/u, "").replace(/(?<=.)\/+$/u, "");
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const isRoot = (token: string) => ROOT_REGEX.test(normalizePath(token));
|
|
21
|
-
|
|
22
|
-
// The long form `--recursive`, which getopt also takes as any prefix of it from `--rec` on, since `--re` names two options
|
|
23
|
-
const isRecursiveLongFlag = (token: string) => token.startsWith("--rec") && "recursive".startsWith(token.slice(2));
|
|
24
|
-
|
|
25
|
-
// A flag group holding the scan's recursive letter, or the long form: grep takes r or R (`-R`, `-rn`, `-lR`), and ls takes
|
|
26
|
-
// The capital R alone, since its lowercase r sorts in reverse
|
|
27
|
-
const GREP_RECURSIVE_FLAG_REGEX = /^-[a-zA-Z]*[rR]/u;
|
|
28
|
-
const LS_RECURSIVE_FLAG_REGEX = /^-[a-zA-Z]*R/u;
|
|
29
|
-
const isRecursiveFlag = (token: string, shortFlagRegex: RegExp) =>
|
|
30
|
-
shortFlagRegex.test(token) || isRecursiveLongFlag(token);
|
|
31
|
-
|
|
32
|
-
// A flag that gives grep its pattern as the next argument, or as the rest of the same one
|
|
33
|
-
const SEPARATE_PATTERN_FLAG_REGEX = /^-[a-zA-Z]*[ef]$|^--(?:regexp|file)$/u;
|
|
34
|
-
const ATTACHED_PATTERN_FLAG_REGEX = /^-[a-zA-Z]*[ef].|^--(?:regexp|file)=/u;
|
|
35
|
-
|
|
36
|
-
// Grep's flags and search paths: a pattern flag's operand is its pattern and never a flag or a path, every argument
|
|
37
|
-
// After a `--` is a positional one, and with no pattern flag the first positional argument is the pattern
|
|
38
|
-
const readGrepArguments = (args: string[]): { flags: string[]; paths: string[] } => {
|
|
39
|
-
const flags: string[] = [];
|
|
40
|
-
const positionals: string[] = [];
|
|
41
|
-
let hasPatternFlag = false;
|
|
42
|
-
let isPatternOperand = false;
|
|
43
|
-
let isOptionEnd = false;
|
|
44
|
-
for (const arg of args)
|
|
45
|
-
if (isPatternOperand) isPatternOperand = false;
|
|
46
|
-
else if (isOptionEnd || !arg.startsWith("-")) positionals.push(arg);
|
|
47
|
-
else if (arg === "--") isOptionEnd = true;
|
|
48
|
-
else {
|
|
49
|
-
flags.push(arg);
|
|
50
|
-
if (SEPARATE_PATTERN_FLAG_REGEX.test(arg)) {
|
|
51
|
-
hasPatternFlag = true;
|
|
52
|
-
isPatternOperand = true;
|
|
53
|
-
} else if (ATTACHED_PATTERN_FLAG_REGEX.test(arg)) hasPatternFlag = true;
|
|
54
|
-
}
|
|
55
|
-
return { flags, paths: hasPatternFlag ? positionals : positionals.slice(1) };
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
// The scan's name when its arguments start it from a root, undefined when they do not
|
|
59
|
-
const getScanRefusal = (name: string, args: string[]): string | undefined => {
|
|
60
|
-
if (name === "find" || name === "du") return args.some((arg) => isRoot(arg)) ? name : undefined;
|
|
61
|
-
if (name === "ls")
|
|
62
|
-
return args.some((arg) => isRecursiveFlag(arg, LS_RECURSIVE_FLAG_REGEX)) && args.some((arg) => isRoot(arg))
|
|
63
|
-
? name
|
|
64
|
-
: undefined;
|
|
65
|
-
const { flags, paths } = readGrepArguments(args);
|
|
66
|
-
return flags.some((flag) => isRecursiveFlag(flag, GREP_RECURSIVE_FLAG_REGEX)) && paths.some((path) => isRoot(path))
|
|
67
|
-
? name
|
|
68
|
-
: undefined;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const getScanCommandRefusal: CommandRefusal = (name, args) =>
|
|
72
|
-
SCAN_COMMANDS.has(name) ? getScanRefusal(name, args) : undefined;
|
|
2
|
+
import { getScanCommandRefusal } from "./getScanCommandRefusal";
|
|
73
3
|
|
|
74
4
|
// The scan a Bash command runs from a root or a home folder, named by its command, undefined for every other command
|
|
75
5
|
export const getDiskScanRefusal = (command: string): string | undefined =>
|
|
@@ -77,7 +7,7 @@ export const getDiskScanRefusal = (command: string): string | undefined =>
|
|
|
77
7
|
|
|
78
8
|
// The known homes the refusal names. The mod ships without @esposter/shared, so the product's name is spelled here
|
|
79
9
|
// As the folder it is on the person's disk
|
|
80
|
-
const KNOWN_HOME_LINES: string[] = [
|
|
10
|
+
export const KNOWN_HOME_LINES: string[] = [
|
|
81
11
|
"- the repo, searched with `rg` or `git grep` (both skip node_modules);",
|
|
82
12
|
// oxlint-disable-next-line naming/no-site-name-literal -- A path on the person's disk, which a package shipped without @esposter/shared cannot derive from SITE_NAME
|
|
83
13
|
"- `~/Esposter/genshin-parity`, for game data and captures;",
|
|
@@ -85,9 +15,3 @@ const KNOWN_HOME_LINES: string[] = [
|
|
|
85
15
|
"- a pinned tool (FFmpeg, yt-dlp) through `resolvePinnedTool`;",
|
|
86
16
|
"- a package through `require.resolve` from the package that depends on it.",
|
|
87
17
|
];
|
|
88
|
-
|
|
89
|
-
export const getDenyReason = (scanName: string): string =>
|
|
90
|
-
[
|
|
91
|
-
`Refused: ${scanName} scans from a root or a home folder, which reads the whole disk for a file whose home is known, and costs minutes of CPU and disk for every agent running. Look where the file lives instead:`,
|
|
92
|
-
...KNOWN_HOME_LINES,
|
|
93
|
-
].join("\n");
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CommandRefusal } from "../../models/CommandRefusal";
|
|
2
|
+
|
|
3
|
+
import { getScanRefusal } from "./getScanRefusal";
|
|
4
|
+
|
|
5
|
+
const SCAN_COMMANDS: ReadonlySet<string> = new Set(["du", "find", "grep", "ls"]);
|
|
6
|
+
|
|
7
|
+
export const getScanCommandRefusal: CommandRefusal = (name, args) =>
|
|
8
|
+
SCAN_COMMANDS.has(name) ? getScanRefusal(name, args) : undefined;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isRecursiveFlag } from "./isRecursiveFlag";
|
|
2
|
+
import { isRoot } from "./isRoot";
|
|
3
|
+
import { readGrepArguments } from "./readGrepArguments";
|
|
4
|
+
|
|
5
|
+
// A flag group holding the scan's recursive letter, or the long form: grep takes r or R (`-R`, `-rn`, `-lR`), and ls takes
|
|
6
|
+
// The capital R alone, since its lowercase r sorts in reverse
|
|
7
|
+
const GREP_RECURSIVE_FLAG_REGEX = /^-[a-zA-Z]*[rR]/u;
|
|
8
|
+
const LS_RECURSIVE_FLAG_REGEX = /^-[a-zA-Z]*R/u;
|
|
9
|
+
|
|
10
|
+
// The scan's name when its arguments start it from a root, undefined when they do not
|
|
11
|
+
export const getScanRefusal = (name: string, args: string[]): string | undefined => {
|
|
12
|
+
if (name === "find" || name === "du") return args.some((arg) => isRoot(arg)) ? name : undefined;
|
|
13
|
+
if (name === "ls")
|
|
14
|
+
return args.some((arg) => isRecursiveFlag(arg, LS_RECURSIVE_FLAG_REGEX)) && args.some((arg) => isRoot(arg))
|
|
15
|
+
? name
|
|
16
|
+
: undefined;
|
|
17
|
+
const { flags, paths } = readGrepArguments(args);
|
|
18
|
+
return flags.some((flag) => isRecursiveFlag(flag, GREP_RECURSIVE_FLAG_REGEX)) && paths.some((path) => isRoot(path))
|
|
19
|
+
? name
|
|
20
|
+
: undefined;
|
|
21
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { normalizePath } from "./normalizePath";
|
|
2
|
+
|
|
3
|
+
// A root in any spelling is a folder whose scan reads the whole disk: the drive's folders, the users' folders and the
|
|
4
|
+
// Home folder by name, once Windows drive letters and backslashes are read as Git Bash's `/c/...` form
|
|
5
|
+
const ROOT_REGEX =
|
|
6
|
+
/^(?:\/[a-z]?|\/(?:c\/)?Users(?:\/[^/]+)?|\/home(?:\/[^/]+)?|~|\$HOME|\$\{HOME\}|\$USERPROFILE|%USERPROFILE%)$/u;
|
|
7
|
+
|
|
8
|
+
export const isRoot = (token: string) => ROOT_REGEX.test(normalizePath(token));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Backslashes become slashes, a drive letter becomes its `/c` folder, and a trailing glob and slash go, so "C:\", "/c/",
|
|
2
|
+
// "C:/" and "/c/*" all name "/c": a folder's glob expands to its entries, whose scan reads the whole folder
|
|
3
|
+
export const normalizePath = (token: string) => {
|
|
4
|
+
const slashed = token.replaceAll("\\", "/");
|
|
5
|
+
const path = /^[A-Za-z]:/u.test(slashed) ? `/${slashed.charAt(0).toLowerCase()}${slashed.slice(2)}` : slashed;
|
|
6
|
+
return path.replace(/(?<=\/)\*$/u, "").replace(/(?<=.)\/+$/u, "");
|
|
7
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A flag that gives grep its pattern as the next argument, or as the rest of the same one
|
|
2
|
+
const SEPARATE_PATTERN_FLAG_REGEX = /^-[a-zA-Z]*[ef]$|^--(?:regexp|file)$/u;
|
|
3
|
+
const ATTACHED_PATTERN_FLAG_REGEX = /^-[a-zA-Z]*[ef].|^--(?:regexp|file)=/u;
|
|
4
|
+
|
|
5
|
+
// Grep's flags and search paths: a pattern flag's operand is its pattern and never a flag or a path, every argument
|
|
6
|
+
// After a `--` is a positional one, and with no pattern flag the first positional argument is the pattern
|
|
7
|
+
export const readGrepArguments = (args: string[]): { flags: string[]; paths: string[] } => {
|
|
8
|
+
const flags: string[] = [];
|
|
9
|
+
const positionals: string[] = [];
|
|
10
|
+
let hasPatternFlag = false;
|
|
11
|
+
let isPatternOperand = false;
|
|
12
|
+
let isOptionEnd = false;
|
|
13
|
+
for (const arg of args)
|
|
14
|
+
if (isPatternOperand) isPatternOperand = false;
|
|
15
|
+
else if (isOptionEnd || !arg.startsWith("-")) positionals.push(arg);
|
|
16
|
+
else if (arg === "--") isOptionEnd = true;
|
|
17
|
+
else {
|
|
18
|
+
flags.push(arg);
|
|
19
|
+
if (SEPARATE_PATTERN_FLAG_REGEX.test(arg)) {
|
|
20
|
+
hasPatternFlag = true;
|
|
21
|
+
isPatternOperand = true;
|
|
22
|
+
} else if (ATTACHED_PATTERN_FLAG_REGEX.test(arg)) hasPatternFlag = true;
|
|
23
|
+
}
|
|
24
|
+
return { flags, paths: hasPatternFlag ? positionals : positionals.slice(1) };
|
|
25
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EngineInterface, On } from "claude-code";
|
|
2
2
|
|
|
3
|
-
import { getDenyReason
|
|
3
|
+
import { getDenyReason } from "./getDenyReason";
|
|
4
|
+
import { getDiskScanRefusal } from "./getDiskScanRefusal";
|
|
4
5
|
|
|
5
6
|
// Any failure lets the command through, as the ward's and the delegation guard's do: a guard that blocked on its own
|
|
6
7
|
// Failure would block every command after it
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { EngineInterface } from "claude-code";
|
|
2
|
+
|
|
3
|
+
// The person's home folder, read from the engine's environment: HOME where it is set, else the Windows profile folder
|
|
4
|
+
// oxlint-disable-next-line func-style -- A declaration, not an arrow: the hooks module's bundle follows an imported helper that takes `$` only into a function declaration
|
|
5
|
+
export async function readHome($: EngineInterface): Promise<string> {
|
|
6
|
+
return (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")) ?? "";
|
|
7
|
+
}
|
|
@@ -4,6 +4,9 @@ import { atom, read, update } from "claude-code";
|
|
|
4
4
|
|
|
5
5
|
import type { EnabledMods } from "../../types";
|
|
6
6
|
|
|
7
|
+
import { ModName } from "../models/ModName";
|
|
8
|
+
import { SinStatusKind } from "../models/SinStatusKind";
|
|
9
|
+
import { TaskStatus } from "../models/TaskStatus";
|
|
7
10
|
import {
|
|
8
11
|
CACHE_WARNING_MS,
|
|
9
12
|
MACHINE_WATCHER_RETRY_MILLISECONDS,
|
|
@@ -15,11 +18,19 @@ import {
|
|
|
15
18
|
} from "./constants";
|
|
16
19
|
import { InitialState } from "./InitialState";
|
|
17
20
|
import { ModDescriptionMap, ModNames } from "./ModDescriptionMap";
|
|
21
|
+
import { readHome } from "./readHome";
|
|
18
22
|
import { getCacheRemainingMs } from "./resin/getCacheRemainingMs";
|
|
19
23
|
import { getCacheWarningDelayMs } from "./resin/getCacheWarningDelayMs";
|
|
20
24
|
import { getReserveSummary } from "./resin/getReserveSummary";
|
|
21
25
|
import { getReserveWindow } from "./resin/getReserveWindow";
|
|
22
26
|
import { reserveText } from "./resin/reserveText";
|
|
27
|
+
import { appendSinEvidence } from "./sins/appendSinEvidence";
|
|
28
|
+
import { SINS_API_URL, SINS_TIMEOUT_MS } from "./sins/constants";
|
|
29
|
+
import { getSinQuestions } from "./sins/getSinQuestions";
|
|
30
|
+
import { parseSinsResult } from "./sins/parseSinsResult";
|
|
31
|
+
import { readTypeSafeKey } from "./sins/readTypeSafeKey";
|
|
32
|
+
import { SINS_SCRIPT } from "./sins/sinsScript";
|
|
33
|
+
import { veilText } from "./veil/veilText";
|
|
23
34
|
import { parseWaypoints } from "./waypoints/parseWaypoints";
|
|
24
35
|
|
|
25
36
|
const commissionAtom = atom({ key: "commission", plugin: "genshin-mods" } as const, InitialState.commission);
|
|
@@ -36,6 +47,9 @@ const lastCacheRequestAtAtom = atom(
|
|
|
36
47
|
const lookupStreakAtom = atom({ key: "lookupStreak", plugin: "genshin-mods" } as const, InitialState.lookupStreak);
|
|
37
48
|
const nowAtom = atom({ key: "now", plugin: "genshin-mods" } as const, InitialState.now);
|
|
38
49
|
const reserveWindowAtom = atom({ key: "reserveWindow", plugin: "genshin-mods" } as const, InitialState.reserveWindow);
|
|
50
|
+
const sinHistoryAtom = atom({ key: "sinHistory", plugin: "genshin-mods" } as const, InitialState.sinHistory);
|
|
51
|
+
const sinSessionAtom = atom({ key: "sinSession", plugin: "genshin-mods" } as const, InitialState.sinSession);
|
|
52
|
+
const sinStatusAtom = atom({ key: "sinStatus", plugin: "genshin-mods" } as const, InitialState.sinStatus);
|
|
39
53
|
const waypointsAtom = atom({ key: "waypoints", plugin: "genshin-mods" } as const, InitialState.waypoints);
|
|
40
54
|
|
|
41
55
|
// The events a plugin may hook once with no matcher, each hooked here for every mod, with the commands that switch
|
|
@@ -44,6 +58,13 @@ const waypointsAtom = atom({ key: "waypoints", plugin: "genshin-mods" } as const
|
|
|
44
58
|
let warnedLastCacheRequestAt = 0;
|
|
45
59
|
let isTurnRunning = false;
|
|
46
60
|
|
|
61
|
+
// The engine's timer and hook slots take no promise, so an action run from one floats. Every action run through here
|
|
62
|
+
// Resolves each of its outcomes rather than rejecting, which is what lets the floating stand
|
|
63
|
+
const runDetached = (action: () => Promise<void>): void => {
|
|
64
|
+
// oxlint-disable-next-line typescript/no-floating-promises -- The engine's timer and hook slots take no promise, and every action run through here resolves each of its outcomes
|
|
65
|
+
action();
|
|
66
|
+
};
|
|
67
|
+
|
|
47
68
|
// The cache goes cold at a known instant, so its warning is one timer armed for that instant at each turn's end, from the
|
|
48
69
|
// Request that last read or wrote the cache, rather than a check on a clock. The timer warns once for that request, and
|
|
49
70
|
// Stands down while a turn runs, since that turn's end arms it again. A timer left from an earlier turn finds the cache
|
|
@@ -71,8 +92,7 @@ const armCacheWarning = async ($: EngineInterface) => {
|
|
|
71
92
|
if (delayMs === undefined) return;
|
|
72
93
|
|
|
73
94
|
$.clock.after(delayMs, () => {
|
|
74
|
-
|
|
75
|
-
warnCache($);
|
|
95
|
+
runDetached(() => warnCache($));
|
|
76
96
|
});
|
|
77
97
|
};
|
|
78
98
|
|
|
@@ -81,6 +101,50 @@ const suggestWaypoints = async ($: EngineInterface) => {
|
|
|
81
101
|
if (answer.isAnswered) await update($, waypointsAtom, () => parseWaypoints(answer.text));
|
|
82
102
|
};
|
|
83
103
|
|
|
104
|
+
// The sins are scored beside the prompt's turn, never in front of it: the call and the evidence log run in the plugin's
|
|
105
|
+
// Node script, and a run that fails, or a machine that cannot open the log, records nothing and says why in the band
|
|
106
|
+
const scoreSins = async ($: EngineInterface, text: string, typeSafeKey: string) => {
|
|
107
|
+
if (!(await read($, enabledModsAtom)).sins) return;
|
|
108
|
+
|
|
109
|
+
const key = await readTypeSafeKey($, typeSafeKey);
|
|
110
|
+
if (!key) {
|
|
111
|
+
await update($, sinStatusAtom, () => SinStatusKind.NoKey);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const session = (await read($, sinSessionAtom)) || (await $.clock.now());
|
|
116
|
+
await update($, sinSessionAtom, () => session);
|
|
117
|
+
const home = await readHome($);
|
|
118
|
+
const [outcome] = await Promise.allSettled([
|
|
119
|
+
$.process.run(["node", "--input-type=module", "-e", SINS_SCRIPT], {
|
|
120
|
+
stdin: JSON.stringify({
|
|
121
|
+
apiUrl: SINS_API_URL,
|
|
122
|
+
key,
|
|
123
|
+
questions: getSinQuestions(),
|
|
124
|
+
session,
|
|
125
|
+
state: veilText(text),
|
|
126
|
+
storePath: `${home}/.claude/genshin-mods/sins.sqlite`,
|
|
127
|
+
}),
|
|
128
|
+
timeoutMs: SINS_TIMEOUT_MS,
|
|
129
|
+
}),
|
|
130
|
+
]);
|
|
131
|
+
const result =
|
|
132
|
+
outcome.status === "fulfilled" && outcome.value.exitCode === 0
|
|
133
|
+
? parseSinsResult(outcome.value.stdout)
|
|
134
|
+
: { result: SinStatusKind.Failed as const };
|
|
135
|
+
|
|
136
|
+
// A `/clear` or a resume during the run has reset the session, and a switch turned off has turned the mod off, so this
|
|
137
|
+
// Run's answers belong to nothing shown
|
|
138
|
+
const sessionNow = await read($, sinSessionAtom);
|
|
139
|
+
const { sins } = await read($, enabledModsAtom);
|
|
140
|
+
if (sessionNow !== session || !sins) return;
|
|
141
|
+
|
|
142
|
+
if (result.result === SinStatusKind.Answered)
|
|
143
|
+
await update($, sinHistoryAtom, (history) => appendSinEvidence(history, result.answers));
|
|
144
|
+
|
|
145
|
+
await update($, sinStatusAtom, () => result.result);
|
|
146
|
+
};
|
|
147
|
+
|
|
84
148
|
// Keeps the machine watcher running as this session's child. The watcher writes the machine's state, holds its lock and
|
|
85
149
|
// Pushes its heartbeat, so its output is read only to keep the child's pipe drained. A watcher exits at once while another
|
|
86
150
|
// Session's holds the machine's lock, so each exit starts the watcher again after a wait, and the session that outlives the
|
|
@@ -91,14 +155,12 @@ const watchPulse = async ($: EngineInterface) => {
|
|
|
91
155
|
const cwd = await $.session.cwd();
|
|
92
156
|
if (!(await $.fs.exists(`${cwd}/${MACHINE_WATCHER_SCRIPT}`))) return;
|
|
93
157
|
|
|
94
|
-
|
|
95
|
-
for await (const _chunk of $.process.spawn({ argv: ["pnpm", "ai:machine:watch"], cwd })) {
|
|
158
|
+
for await (const _ of $.process.spawn({ argv: ["pnpm", "ai:machine:watch"], cwd })) {
|
|
96
159
|
// The watcher's output is discarded, since nothing in the session reads it
|
|
97
160
|
}
|
|
98
161
|
|
|
99
162
|
$.clock.after(MACHINE_WATCHER_RETRY_MILLISECONDS, () => {
|
|
100
|
-
|
|
101
|
-
watchPulse($);
|
|
163
|
+
runDetached(() => watchPulse($));
|
|
102
164
|
});
|
|
103
165
|
};
|
|
104
166
|
|
|
@@ -107,15 +169,21 @@ const closeCommission = async ($: EngineInterface) => {
|
|
|
107
169
|
await update($, isCommissionExpandedAtom, () => InitialState.isCommissionExpanded);
|
|
108
170
|
};
|
|
109
171
|
|
|
110
|
-
export const registerLifecycle = (on: On): void => {
|
|
172
|
+
export const registerLifecycle = (on: On, typeSafeKey: string): void => {
|
|
111
173
|
// The bare command flips a mod and an argument sets it, kept in the store so it holds for every later session
|
|
112
174
|
for (const name of ModNames)
|
|
113
175
|
on("command.run", { command: name }, async ($, e) => {
|
|
114
176
|
const argument = e.args.trim().toLowerCase();
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
177
|
+
const current = await read($, enabledModsAtom);
|
|
178
|
+
const isNext = argument ? argument === "on" : !current[name];
|
|
179
|
+
// Sins scores only with a TypeSafe key, so turning it on without one is refused and its value is left as it was
|
|
180
|
+
if (name === ModName.Sins && isNext) {
|
|
181
|
+
const key = await readTypeSafeKey($, typeSafeKey);
|
|
182
|
+
if (!key)
|
|
183
|
+
return { text: "Sins needs a TypeSafe key: set the typesafe_key option, or TYPESAFE_API_KEY, first." };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const enabledMods = await update($, enabledModsAtom, (value) => ({ ...value, [name]: isNext }));
|
|
119
187
|
await $.store.set(enabledModsAtom.ref.key, enabledMods);
|
|
120
188
|
return { text: `${name} is ${enabledMods[name] ? "on" : "off"}.` };
|
|
121
189
|
});
|
|
@@ -128,7 +196,7 @@ export const registerLifecycle = (on: On): void => {
|
|
|
128
196
|
$.command.register({ argumentHint: "[on|off]", description: ModDescriptionMap[name], name }),
|
|
129
197
|
),
|
|
130
198
|
);
|
|
131
|
-
watchPulse($);
|
|
199
|
+
runDetached(() => watchPulse($));
|
|
132
200
|
return next(e);
|
|
133
201
|
});
|
|
134
202
|
|
|
@@ -137,6 +205,9 @@ export const registerLifecycle = (on: On): void => {
|
|
|
137
205
|
on("session.end", async ($, e, next) => {
|
|
138
206
|
if (e.reason === "clear" || e.reason === "resume") {
|
|
139
207
|
await update($, lastCacheRequestAtAtom, () => InitialState.lastCacheRequestAt);
|
|
208
|
+
await update($, sinHistoryAtom, () => InitialState.sinHistory);
|
|
209
|
+
await update($, sinSessionAtom, () => InitialState.sinSession);
|
|
210
|
+
await update($, sinStatusAtom, () => InitialState.sinStatus);
|
|
140
211
|
await update($, waypointsAtom, () => InitialState.waypoints);
|
|
141
212
|
await closeCommission($);
|
|
142
213
|
}
|
|
@@ -152,13 +223,16 @@ export const registerLifecycle = (on: On): void => {
|
|
|
152
223
|
await update($, lookupStreakAtom, () => InitialState.lookupStreak);
|
|
153
224
|
await update($, lastPromptAtom, () => e.text);
|
|
154
225
|
const { tasks } = await read($, commissionAtom);
|
|
155
|
-
if (tasks.length > 0 && tasks.every(({ status }) => status ===
|
|
226
|
+
if (tasks.length > 0 && tasks.every(({ status }) => status === TaskStatus.Completed)) await closeCommission($);
|
|
227
|
+
$.clock.after(0, () => {
|
|
228
|
+
runDetached(() => scoreSins($, e.text, typeSafeKey));
|
|
229
|
+
});
|
|
156
230
|
return next(e);
|
|
157
231
|
});
|
|
158
232
|
|
|
159
233
|
// While the veil is on, the model is told to write placeholders too, so a reply never holds a value to hide. While a
|
|
160
|
-
// Usage reserve holds and the resin mod is on, the model is told what the reserve
|
|
161
|
-
//
|
|
234
|
+
// Usage reserve holds and the resin mod is on, the model is told what the reserve asks until the window resets, which
|
|
235
|
+
// It may have done since the last measurement
|
|
162
236
|
on("prompt.compose", async ($, e, next) => {
|
|
163
237
|
const result = await next(e);
|
|
164
238
|
const { resin, veil } = await read($, enabledModsAtom);
|
|
@@ -200,25 +274,19 @@ export const registerLifecycle = (on: On): void => {
|
|
|
200
274
|
await armCacheWarning($);
|
|
201
275
|
if (e.reason === "answer" && (await read($, enabledModsAtom)).waypoints)
|
|
202
276
|
$.clock.after(0, () => {
|
|
203
|
-
|
|
204
|
-
suggestWaypoints($);
|
|
277
|
+
runDetached(() => suggestWaypoints($));
|
|
205
278
|
});
|
|
206
279
|
return result;
|
|
207
280
|
});
|
|
208
281
|
|
|
209
|
-
// The reserve is read off each measurement: it starts at the first window past
|
|
210
|
-
//
|
|
211
|
-
// Again as its maintenance tier is raised to the wind-down
|
|
282
|
+
// The reserve is read off each measurement: it starts at the first window past its line and lifts at the measurement a
|
|
283
|
+
// Window's reset raises, which finds none. The toast names the window once, as the reserve starts
|
|
212
284
|
on("session.measure", async ($, e, next) => {
|
|
213
285
|
const now = await $.clock.now();
|
|
214
286
|
const reserveWindow = getReserveWindow(e.rateLimits, now);
|
|
215
287
|
const previousReserveWindow = await read($, reserveWindowAtom);
|
|
216
288
|
await update($, reserveWindowAtom, () => reserveWindow ?? InitialState.reserveWindow);
|
|
217
|
-
if (
|
|
218
|
-
reserveWindow &&
|
|
219
|
-
(!previousReserveWindow.name || (reserveWindow.isWindDown && !previousReserveWindow.isWindDown)) &&
|
|
220
|
-
(await read($, enabledModsAtom)).resin
|
|
221
|
-
)
|
|
289
|
+
if (reserveWindow && !previousReserveWindow.name && (await read($, enabledModsAtom)).resin)
|
|
222
290
|
$.ui.toast(`${getReserveSummary(reserveWindow)}.`);
|
|
223
291
|
|
|
224
292
|
return next(e);
|
|
@@ -2,26 +2,18 @@ import type { UsageWindow } from "../../models/UsageWindow";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
FIVE_HOUR_MAINTENANCE_PERCENTAGE,
|
|
5
|
-
FIVE_HOUR_WIND_DOWN_PERCENTAGE,
|
|
6
5
|
FIVE_HOUR_WINDOW_MS,
|
|
7
6
|
WEEKLY_MAINTENANCE_PERCENTAGE,
|
|
8
|
-
WEEKLY_WIND_DOWN_PERCENTAGE,
|
|
9
7
|
WEEKLY_WINDOW_MS,
|
|
10
8
|
} from "../constants";
|
|
11
9
|
|
|
12
10
|
// The rate-limit windows a reserve is read off, five-hour before weekly, so the five-hour one is the one named when both
|
|
13
|
-
// Have passed
|
|
11
|
+
// Have passed their line
|
|
14
12
|
export const RateLimitKindUsageWindowMap: Record<string, UsageWindow> = {
|
|
15
13
|
five_hour: {
|
|
16
14
|
durationMs: FIVE_HOUR_WINDOW_MS,
|
|
17
15
|
maintenancePercentage: FIVE_HOUR_MAINTENANCE_PERCENTAGE,
|
|
18
16
|
name: "five-hour",
|
|
19
|
-
windDownPercentage: FIVE_HOUR_WIND_DOWN_PERCENTAGE,
|
|
20
|
-
},
|
|
21
|
-
seven_day: {
|
|
22
|
-
durationMs: WEEKLY_WINDOW_MS,
|
|
23
|
-
maintenancePercentage: WEEKLY_MAINTENANCE_PERCENTAGE,
|
|
24
|
-
name: "weekly",
|
|
25
|
-
windDownPercentage: WEEKLY_WIND_DOWN_PERCENTAGE,
|
|
26
17
|
},
|
|
18
|
+
seven_day: { durationMs: WEEKLY_WINDOW_MS, maintenancePercentage: WEEKLY_MAINTENANCE_PERCENTAGE, name: "weekly" },
|
|
27
19
|
};
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
// The reset time as the reader's own clock spells it, such as
|
|
1
|
+
// The reset time as the reader's own clock spells it, such as a short month, day, year and time
|
|
2
2
|
export const formatResetsAt = (resetsAt: string): string =>
|
|
3
3
|
Temporal.Instant.from(resetsAt).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" });
|
|
@@ -2,7 +2,7 @@ import type { ReserveWindow } from "../../../types";
|
|
|
2
2
|
|
|
3
3
|
import { formatResetsAt } from "./formatResetsAt";
|
|
4
4
|
|
|
5
|
-
// The sentence a reserve opens with, naming its
|
|
6
|
-
//
|
|
7
|
-
export const getReserveSummary = ({ isAheadOfPace,
|
|
8
|
-
|
|
5
|
+
// The sentence a reserve opens with, naming its window, the usage line that window has passed and its reset time, which
|
|
6
|
+
// The system section and the toast both say
|
|
7
|
+
export const getReserveSummary = ({ isAheadOfPace, name, percentage, resetsAt }: ReserveWindow): string =>
|
|
8
|
+
`Usage maintenance: the ${name} usage window has passed ${percentage}%${isAheadOfPace ? " ahead of an even pace" : ""} and resets at ${formatResetsAt(resetsAt)}`;
|
|
@@ -5,28 +5,23 @@ import type { ReserveWindow } from "../../../types";
|
|
|
5
5
|
import { PACE_FLOOR_PERCENTAGE } from "../constants";
|
|
6
6
|
import { RateLimitKindUsageWindowMap } from "./RateLimitKindUsageWindowMap";
|
|
7
7
|
|
|
8
|
-
// Each window a reserve is read off, five-hour before weekly, so the five-hour one is the one named when both
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// Each window a reserve is read off, five-hour before weekly, so the five-hour one is the one named when both qualify. A
|
|
9
|
+
// Window is read once past its maintenance line, or once half of its allowance is spent ahead of an even pace, the share
|
|
10
|
+
// Of its length that has elapsed. A window the engine gives no reset time for is skipped, since the reserve is named by
|
|
11
|
+
// That time and lifts at it
|
|
12
12
|
export const getReserveWindow = (rateLimits: SessionRateLimit[], now: number): ReserveWindow | undefined => {
|
|
13
13
|
const reserveWindows: ReserveWindow[] = [];
|
|
14
|
-
for (const [kind, { durationMs, maintenancePercentage, name
|
|
15
|
-
RateLimitKindUsageWindowMap,
|
|
16
|
-
)) {
|
|
14
|
+
for (const [kind, { durationMs, maintenancePercentage, name }] of Object.entries(RateLimitKindUsageWindowMap)) {
|
|
17
15
|
const rateLimit = rateLimits.find((limit) => limit.kind === kind);
|
|
18
16
|
if (rateLimit?.resetsAt === undefined) continue;
|
|
19
|
-
const isWindDown = rateLimit.percentUsed >= windDownPercentage;
|
|
20
17
|
const isPastMaintenance = rateLimit.percentUsed >= maintenancePercentage;
|
|
21
18
|
const elapsedPercentage = 100 * (1 - (Date.parse(rateLimit.resetsAt) - now) / durationMs);
|
|
22
19
|
const isAheadOfPace =
|
|
23
20
|
!isPastMaintenance && rateLimit.percentUsed >= PACE_FLOOR_PERCENTAGE && rateLimit.percentUsed > elapsedPercentage;
|
|
24
21
|
if (!isPastMaintenance && !isAheadOfPace) continue;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
else if (isPastMaintenance) percentage = maintenancePercentage;
|
|
28
|
-
reserveWindows.push({ isAheadOfPace, isWindDown, name, percentage, resetsAt: rateLimit.resetsAt });
|
|
22
|
+
const percentage = isPastMaintenance ? maintenancePercentage : PACE_FLOOR_PERCENTAGE;
|
|
23
|
+
reserveWindows.push({ isAheadOfPace, name, percentage, resetsAt: rateLimit.resetsAt });
|
|
29
24
|
}
|
|
30
25
|
|
|
31
|
-
return reserveWindows
|
|
26
|
+
return reserveWindows[0];
|
|
32
27
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ReserveWindow } from "../../../types";
|
|
2
2
|
|
|
3
|
-
import { MAINTENANCE_INSTRUCTION
|
|
3
|
+
import { MAINTENANCE_INSTRUCTION } from "../constants";
|
|
4
4
|
import { getReserveSummary } from "./getReserveSummary";
|
|
5
5
|
|
|
6
|
-
// The system section the reserve adds:
|
|
7
|
-
//
|
|
6
|
+
// The system section the reserve adds: the summary is the only part that varies, with the window it names and the line
|
|
7
|
+
// That window has passed, so the text holds still while neither changes
|
|
8
8
|
export const reserveText = (reserveWindow: ReserveWindow): string =>
|
|
9
|
-
`${getReserveSummary(reserveWindow)}. ${
|
|
9
|
+
`${getReserveSummary(reserveWindow)}. ${MAINTENANCE_INSTRUCTION}`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The bodies of a command's `$(…)` substitutions, which run even inside double quotes: each runs to the parenthesis
|
|
2
|
+
// Closing it, past any a quote or a backslash holds, so a body such as `find / -name '(x)'` is read whole. A
|
|
3
|
+
// Substitution inside a body is the body's own, read when the body is
|
|
4
|
+
export const getDollarSubstitutionBodies = (command: string): string[] => {
|
|
5
|
+
const bodies: string[] = [];
|
|
6
|
+
let start = command.indexOf("$(");
|
|
7
|
+
while (start !== -1) {
|
|
8
|
+
let depth = 1;
|
|
9
|
+
let quote = "";
|
|
10
|
+
let index = start + 2;
|
|
11
|
+
for (; index < command.length && depth > 0; index++) {
|
|
12
|
+
const character = command[index];
|
|
13
|
+
if (character === "\\" && quote !== "'") index++;
|
|
14
|
+
else if (quote !== "") {
|
|
15
|
+
if (character === quote) quote = "";
|
|
16
|
+
} else if (character === '"' || character === "'") quote = character;
|
|
17
|
+
else if (character === "(") depth++;
|
|
18
|
+
else if (character === ")") depth--;
|
|
19
|
+
}
|
|
20
|
+
if (depth > 0) break;
|
|
21
|
+
bodies.push(command.slice(start + 2, index - 1));
|
|
22
|
+
start = command.indexOf("$(", index);
|
|
23
|
+
}
|
|
24
|
+
return bodies;
|
|
25
|
+
};
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import type { CommandRefusal } from "../../models/CommandRefusal";
|
|
2
|
+
|
|
3
|
+
import { getDollarSubstitutionBodies } from "./getDollarSubstitutionBodies";
|
|
1
4
|
import { joinDirectory } from "./joinDirectory";
|
|
5
|
+
import { tokenize } from "./tokenize";
|
|
2
6
|
|
|
3
7
|
// A shell command passed as one argument (`bash -c "…"`) is read again on its own, to this depth at most
|
|
4
8
|
const MAX_NESTING_DEPTH = 3;
|
|
@@ -8,46 +12,8 @@ const SEGMENT_REGEX = /(?:"[^"]*"|'[^']*'|[^\n"&'();`|])+/gu;
|
|
|
8
12
|
// A backtick substitution runs even inside double quotes, so its body is read again on its own
|
|
9
13
|
const BACKTICK_SUBSTITUTION_REGEX = /`(?<body>[^`]*)`/gu;
|
|
10
14
|
const SHELL_FLAG_REGEX = /^-[a-zA-Z]*c$|^eval$/u;
|
|
11
|
-
const TOKEN_REGEX = /"(?<double>[^"]*)"|'(?<single>[^']*)'|(?<bare>\S+)/gu;
|
|
12
15
|
const PATH_SEPARATOR_REGEX = /[/\\]/u;
|
|
13
16
|
|
|
14
|
-
// The refusal for a command's name, the arguments after it and the folder it runs in from the session's ("" for the
|
|
15
|
-
// Session's own), undefined when the command is not one the guard reads
|
|
16
|
-
export type CommandRefusal<T = string> = (name: string, args: string[], directory: string) => T | undefined;
|
|
17
|
-
|
|
18
|
-
// A quoted argument keeps its inner spaces, so a shell command passed as one argument is read again on its own
|
|
19
|
-
const tokenize = (segment: string) =>
|
|
20
|
-
Array.from(
|
|
21
|
-
segment.matchAll(TOKEN_REGEX),
|
|
22
|
-
(match) => match.groups?.double ?? match.groups?.single ?? match.groups?.bare ?? "",
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
// The bodies of a command's `$(…)` substitutions, which run even inside double quotes: each runs to the parenthesis
|
|
26
|
-
// Closing it, past any a quote or a backslash holds, so a body such as `find / -name '(x)'` is read whole. A
|
|
27
|
-
// Substitution inside a body is the body's own, read when the body is
|
|
28
|
-
const getDollarSubstitutionBodies = (command: string): string[] => {
|
|
29
|
-
const bodies: string[] = [];
|
|
30
|
-
let start = command.indexOf("$(");
|
|
31
|
-
while (start !== -1) {
|
|
32
|
-
let depth = 1;
|
|
33
|
-
let quote = "";
|
|
34
|
-
let index = start + 2;
|
|
35
|
-
for (; index < command.length && depth > 0; index++) {
|
|
36
|
-
const character = command[index];
|
|
37
|
-
if (character === "\\" && quote !== "'") index++;
|
|
38
|
-
else if (quote !== "") {
|
|
39
|
-
if (character === quote) quote = "";
|
|
40
|
-
} else if (character === '"' || character === "'") quote = character;
|
|
41
|
-
else if (character === "(") depth++;
|
|
42
|
-
else if (character === ")") depth--;
|
|
43
|
-
}
|
|
44
|
-
if (depth > 0) break;
|
|
45
|
-
bodies.push(command.slice(start + 2, index - 1));
|
|
46
|
-
start = command.indexOf("$(", index);
|
|
47
|
-
}
|
|
48
|
-
return bodies;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
17
|
// The refusal of the first command in a Bash command that the given reader refuses, undefined when none does. A `cd`
|
|
52
18
|
// Moves every command after it in the line; one to a home folder or back to the last is not followed
|
|
53
19
|
export const getShellRefusal = <T = string>(
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const TOKEN_REGEX = /"(?<double>[^"]*)"|'(?<single>[^']*)'|(?<bare>\S+)/gu;
|
|
2
|
+
|
|
3
|
+
// A quoted argument keeps its inner spaces, so a shell command passed as one argument is read again on its own
|
|
4
|
+
export const tokenize = (segment: string) =>
|
|
5
|
+
Array.from(
|
|
6
|
+
segment.matchAll(TOKEN_REGEX),
|
|
7
|
+
(match) => match.groups?.double ?? match.groups?.single ?? match.groups?.bare ?? "",
|
|
8
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { SinName } from "../../models/SinName";
|
|
2
|
+
|
|
3
|
+
// One fixed slot of the dataviz reference palette per sin, so a sin keeps its colour as its score moves
|
|
4
|
+
export const SinColorMap: Record<SinName, { dark: string; light: string }> = {
|
|
5
|
+
[SinName.Envy]: { dark: "#c98500", light: "#eda100" },
|
|
6
|
+
[SinName.Gluttony]: { dark: "#008300", light: "#008300" },
|
|
7
|
+
[SinName.Greed]: { dark: "#d95926", light: "#eb6834" },
|
|
8
|
+
[SinName.Lust]: { dark: "#d55181", light: "#e87ba4" },
|
|
9
|
+
[SinName.Pride]: { dark: "#3987e5", light: "#2a78d6" },
|
|
10
|
+
[SinName.Sloth]: { dark: "#9085e9", light: "#6250d6" },
|
|
11
|
+
[SinName.Wrath]: { dark: "#199e70", light: "#1baf7a" },
|
|
12
|
+
};
|