@umang-boss/claudemon 1.0.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/README.md +164 -0
- package/bin/claudemon.js +52 -0
- package/bunfig.toml +2 -0
- package/cli/doctor.ts +334 -0
- package/cli/index.ts +42 -0
- package/cli/install.ts +248 -0
- package/cli/shared.ts +102 -0
- package/cli/uninstall.ts +155 -0
- package/cli/update.ts +318 -0
- package/hooks/post-tool-use.sh +127 -0
- package/hooks/stop.sh +49 -0
- package/hooks/user-prompt-submit.sh +73 -0
- package/package.json +68 -0
- package/scripts/download-colorscripts.ts +311 -0
- package/skills/buddy/SKILL.md +47 -0
- package/sprites/colorscripts/small/1-bulbasaur.txt +11 -0
- package/sprites/colorscripts/small/10-caterpie.txt +9 -0
- package/sprites/colorscripts/small/100-voltorb.txt +8 -0
- package/sprites/colorscripts/small/101-electrode.txt +9 -0
- package/sprites/colorscripts/small/102-exeggcute.txt +10 -0
- package/sprites/colorscripts/small/103-exeggutor.txt +23 -0
- package/sprites/colorscripts/small/104-cubone.txt +11 -0
- package/sprites/colorscripts/small/105-marowak.txt +16 -0
- package/sprites/colorscripts/small/106-hitmonlee.txt +16 -0
- package/sprites/colorscripts/small/107-hitmonchan.txt +19 -0
- package/sprites/colorscripts/small/108-lickitung.txt +10 -0
- package/sprites/colorscripts/small/109-koffing.txt +14 -0
- package/sprites/colorscripts/small/11-metapod.txt +10 -0
- package/sprites/colorscripts/small/110-weezing.txt +23 -0
- package/sprites/colorscripts/small/111-rhyhorn.txt +11 -0
- package/sprites/colorscripts/small/112-rhydon.txt +20 -0
- package/sprites/colorscripts/small/113-chansey.txt +11 -0
- package/sprites/colorscripts/small/114-tangela.txt +10 -0
- package/sprites/colorscripts/small/115-kangaskhan.txt +18 -0
- package/sprites/colorscripts/small/116-horsea.txt +10 -0
- package/sprites/colorscripts/small/117-seadra.txt +11 -0
- package/sprites/colorscripts/small/118-goldeen.txt +11 -0
- package/sprites/colorscripts/small/119-seaking.txt +16 -0
- package/sprites/colorscripts/small/12-butterfree.txt +20 -0
- package/sprites/colorscripts/small/120-staryu.txt +10 -0
- package/sprites/colorscripts/small/121-starmie.txt +17 -0
- package/sprites/colorscripts/small/122-mr-mime.txt +18 -0
- package/sprites/colorscripts/small/123-scyther.txt +21 -0
- package/sprites/colorscripts/small/124-jynx.txt +18 -0
- package/sprites/colorscripts/small/125-electabuzz.txt +19 -0
- package/sprites/colorscripts/small/126-magmar.txt +19 -0
- package/sprites/colorscripts/small/127-pinsir.txt +19 -0
- package/sprites/colorscripts/small/128-tauros.txt +20 -0
- package/sprites/colorscripts/small/129-magikarp.txt +13 -0
- package/sprites/colorscripts/small/13-weedle.txt +10 -0
- package/sprites/colorscripts/small/130-gyarados.txt +21 -0
- package/sprites/colorscripts/small/131-lapras.txt +19 -0
- package/sprites/colorscripts/small/132-ditto.txt +8 -0
- package/sprites/colorscripts/small/133-eevee.txt +10 -0
- package/sprites/colorscripts/small/134-vaporeon.txt +16 -0
- package/sprites/colorscripts/small/135-jolteon.txt +17 -0
- package/sprites/colorscripts/small/136-flareon.txt +18 -0
- package/sprites/colorscripts/small/137-porygon.txt +10 -0
- package/sprites/colorscripts/small/138-omanyte.txt +10 -0
- package/sprites/colorscripts/small/139-omastar.txt +18 -0
- package/sprites/colorscripts/small/14-kakuna.txt +10 -0
- package/sprites/colorscripts/small/140-kabuto.txt +8 -0
- package/sprites/colorscripts/small/141-kabutops.txt +17 -0
- package/sprites/colorscripts/small/142-aerodactyl.txt +17 -0
- package/sprites/colorscripts/small/143-snorlax.txt +21 -0
- package/sprites/colorscripts/small/144-articuno.txt +24 -0
- package/sprites/colorscripts/small/145-zapdos.txt +20 -0
- package/sprites/colorscripts/small/146-moltres.txt +23 -0
- package/sprites/colorscripts/small/147-dratini.txt +10 -0
- package/sprites/colorscripts/small/148-dragonair.txt +12 -0
- package/sprites/colorscripts/small/149-dragonite.txt +21 -0
- package/sprites/colorscripts/small/15-beedrill.txt +13 -0
- package/sprites/colorscripts/small/150-mewtwo.txt +22 -0
- package/sprites/colorscripts/small/151-mew.txt +14 -0
- package/sprites/colorscripts/small/16-pidgey.txt +10 -0
- package/sprites/colorscripts/small/17-pidgeotto.txt +11 -0
- package/sprites/colorscripts/small/18-pidgeot.txt +18 -0
- package/sprites/colorscripts/small/19-rattata.txt +12 -0
- package/sprites/colorscripts/small/2-ivysaur.txt +11 -0
- package/sprites/colorscripts/small/20-raticate.txt +12 -0
- package/sprites/colorscripts/small/21-spearow.txt +9 -0
- package/sprites/colorscripts/small/22-fearow.txt +12 -0
- package/sprites/colorscripts/small/23-ekans.txt +12 -0
- package/sprites/colorscripts/small/24-arbok.txt +16 -0
- package/sprites/colorscripts/small/25-pikachu.txt +11 -0
- package/sprites/colorscripts/small/26-raichu.txt +19 -0
- package/sprites/colorscripts/small/27-sandshrew.txt +10 -0
- package/sprites/colorscripts/small/28-sandslash.txt +16 -0
- package/sprites/colorscripts/small/29-nidoran-f.txt +11 -0
- package/sprites/colorscripts/small/3-venusaur.txt +21 -0
- package/sprites/colorscripts/small/30-nidorina.txt +12 -0
- package/sprites/colorscripts/small/31-nidoqueen.txt +19 -0
- package/sprites/colorscripts/small/32-nidoran-m.txt +11 -0
- package/sprites/colorscripts/small/33-nidorino.txt +12 -0
- package/sprites/colorscripts/small/34-nidoking.txt +18 -0
- package/sprites/colorscripts/small/35-clefairy.txt +11 -0
- package/sprites/colorscripts/small/36-clefable.txt +17 -0
- package/sprites/colorscripts/small/37-vulpix.txt +11 -0
- package/sprites/colorscripts/small/38-ninetales.txt +18 -0
- package/sprites/colorscripts/small/39-jigglypuff.txt +11 -0
- package/sprites/colorscripts/small/4-charmander.txt +11 -0
- package/sprites/colorscripts/small/40-wigglytuff.txt +20 -0
- package/sprites/colorscripts/small/41-zubat.txt +11 -0
- package/sprites/colorscripts/small/42-golbat.txt +18 -0
- package/sprites/colorscripts/small/43-oddish.txt +11 -0
- package/sprites/colorscripts/small/44-gloom.txt +12 -0
- package/sprites/colorscripts/small/45-vileplume.txt +17 -0
- package/sprites/colorscripts/small/46-paras.txt +11 -0
- package/sprites/colorscripts/small/47-parasect.txt +12 -0
- package/sprites/colorscripts/small/48-venonat.txt +14 -0
- package/sprites/colorscripts/small/49-venomoth.txt +19 -0
- package/sprites/colorscripts/small/5-charmeleon.txt +13 -0
- package/sprites/colorscripts/small/50-diglett.txt +8 -0
- package/sprites/colorscripts/small/51-dugtrio.txt +18 -0
- package/sprites/colorscripts/small/52-meowth.txt +12 -0
- package/sprites/colorscripts/small/53-persian.txt +20 -0
- package/sprites/colorscripts/small/54-psyduck.txt +12 -0
- package/sprites/colorscripts/small/55-golduck.txt +17 -0
- package/sprites/colorscripts/small/56-mankey.txt +11 -0
- package/sprites/colorscripts/small/57-primeape.txt +13 -0
- package/sprites/colorscripts/small/58-growlithe.txt +12 -0
- package/sprites/colorscripts/small/59-arcanine.txt +20 -0
- package/sprites/colorscripts/small/6-charizard.txt +21 -0
- package/sprites/colorscripts/small/60-poliwag.txt +9 -0
- package/sprites/colorscripts/small/61-poliwhirl.txt +11 -0
- package/sprites/colorscripts/small/62-poliwrath.txt +17 -0
- package/sprites/colorscripts/small/63-abra.txt +12 -0
- package/sprites/colorscripts/small/64-kadabra.txt +14 -0
- package/sprites/colorscripts/small/65-alakazam.txt +19 -0
- package/sprites/colorscripts/small/66-machop.txt +11 -0
- package/sprites/colorscripts/small/67-machoke.txt +12 -0
- package/sprites/colorscripts/small/68-machamp.txt +19 -0
- package/sprites/colorscripts/small/69-bellsprout.txt +9 -0
- package/sprites/colorscripts/small/7-squirtle.txt +10 -0
- package/sprites/colorscripts/small/70-weepinbell.txt +11 -0
- package/sprites/colorscripts/small/71-victreebel.txt +17 -0
- package/sprites/colorscripts/small/72-tentacool.txt +12 -0
- package/sprites/colorscripts/small/73-tentacruel.txt +20 -0
- package/sprites/colorscripts/small/74-geodude.txt +9 -0
- package/sprites/colorscripts/small/75-graveler.txt +12 -0
- package/sprites/colorscripts/small/76-golem.txt +18 -0
- package/sprites/colorscripts/small/77-ponyta.txt +13 -0
- package/sprites/colorscripts/small/78-rapidash.txt +18 -0
- package/sprites/colorscripts/small/79-slowpoke.txt +12 -0
- package/sprites/colorscripts/small/8-wartortle.txt +12 -0
- package/sprites/colorscripts/small/80-slowbro.txt +18 -0
- package/sprites/colorscripts/small/81-magnemite.txt +9 -0
- package/sprites/colorscripts/small/82-magneton.txt +18 -0
- package/sprites/colorscripts/small/83-farfetchd.txt +12 -0
- package/sprites/colorscripts/small/84-doduo.txt +10 -0
- package/sprites/colorscripts/small/85-dodrio.txt +17 -0
- package/sprites/colorscripts/small/86-seel.txt +13 -0
- package/sprites/colorscripts/small/87-dewgong.txt +20 -0
- package/sprites/colorscripts/small/88-grimer.txt +10 -0
- package/sprites/colorscripts/small/89-muk.txt +14 -0
- package/sprites/colorscripts/small/9-blastoise.txt +20 -0
- package/sprites/colorscripts/small/90-shellder.txt +10 -0
- package/sprites/colorscripts/small/91-cloyster.txt +18 -0
- package/sprites/colorscripts/small/92-gastly.txt +12 -0
- package/sprites/colorscripts/small/93-haunter.txt +14 -0
- package/sprites/colorscripts/small/94-gengar.txt +19 -0
- package/sprites/colorscripts/small/95-onix.txt +22 -0
- package/sprites/colorscripts/small/96-drowzee.txt +12 -0
- package/sprites/colorscripts/small/97-hypno.txt +19 -0
- package/sprites/colorscripts/small/98-krabby.txt +12 -0
- package/sprites/colorscripts/small/99-kingler.txt +20 -0
- package/src/engine/constants.ts +121 -0
- package/src/engine/encounter-pool.ts +71 -0
- package/src/engine/encounters.ts +308 -0
- package/src/engine/evolution-data.ts +535 -0
- package/src/engine/evolution.ts +310 -0
- package/src/engine/pokemon-data.ts +1838 -0
- package/src/engine/reactions.ts +877 -0
- package/src/engine/starter-pool.ts +47 -0
- package/src/engine/stats.ts +97 -0
- package/src/engine/types.ts +312 -0
- package/src/engine/xp.ts +135 -0
- package/src/gamification/achievements.ts +204 -0
- package/src/gamification/legendary-quests.ts +265 -0
- package/src/gamification/milestones.ts +86 -0
- package/src/hooks/award-xp.ts +131 -0
- package/src/hooks/increment-counter.ts +27 -0
- package/src/server/index.ts +78 -0
- package/src/server/instructions.ts +194 -0
- package/src/server/tools/achievements.ts +118 -0
- package/src/server/tools/catch.ts +295 -0
- package/src/server/tools/display-helpers.ts +35 -0
- package/src/server/tools/evolve.ts +236 -0
- package/src/server/tools/legendary.ts +78 -0
- package/src/server/tools/party.ts +251 -0
- package/src/server/tools/pet.ts +124 -0
- package/src/server/tools/pokedex.ts +286 -0
- package/src/server/tools/rename.ts +63 -0
- package/src/server/tools/show.ts +136 -0
- package/src/server/tools/starter.ts +175 -0
- package/src/server/tools/stats.ts +123 -0
- package/src/server/tools/visibility.ts +65 -0
- package/src/sprites/index.ts +45 -0
- package/src/state/io.ts +91 -0
- package/src/state/schemas.ts +131 -0
- package/src/state/state-manager.ts +321 -0
- package/statusline/buddy-status.sh +233 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod validation schemas for state files.
|
|
3
|
+
* Catches disk corruption early at load boundaries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
BADGE_TYPES,
|
|
10
|
+
CODING_STATS,
|
|
11
|
+
EVENT_COUNTER_KEYS,
|
|
12
|
+
} from "../engine/types.js";
|
|
13
|
+
|
|
14
|
+
// ---- Shared Primitives ----
|
|
15
|
+
|
|
16
|
+
const BadgeTypeSchema = z.enum(BADGE_TYPES);
|
|
17
|
+
|
|
18
|
+
// ---- Coding Stats ----
|
|
19
|
+
|
|
20
|
+
export const CodingStatsSchema = z.object({
|
|
21
|
+
stamina: z.number(),
|
|
22
|
+
debugging: z.number(),
|
|
23
|
+
stability: z.number(),
|
|
24
|
+
velocity: z.number(),
|
|
25
|
+
wisdom: z.number(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ---- Owned Pokemon ----
|
|
29
|
+
|
|
30
|
+
export const OwnedPokemonSchema = z.object({
|
|
31
|
+
id: z.string(),
|
|
32
|
+
pokemonId: z.number().int().min(1).max(151),
|
|
33
|
+
nickname: z.string().nullable(),
|
|
34
|
+
level: z.number().int().min(1).max(100),
|
|
35
|
+
currentXp: z.number().int().min(0),
|
|
36
|
+
totalXp: z.number().int().min(0),
|
|
37
|
+
codingStats: CodingStatsSchema,
|
|
38
|
+
happiness: z.number().int().min(0).max(255),
|
|
39
|
+
caughtAt: z.string(),
|
|
40
|
+
evolvedAt: z.string().nullable(),
|
|
41
|
+
isActive: z.boolean(),
|
|
42
|
+
personality: z.string().nullable(),
|
|
43
|
+
shiny: z.boolean().default(false),
|
|
44
|
+
isStarter: z.boolean().default(false),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ---- Event Counters ----
|
|
48
|
+
|
|
49
|
+
// Cast needed: Object.fromEntries loses key specificity, but keys are guaranteed by EVENT_COUNTER_KEYS
|
|
50
|
+
export const EventCountersSchema = z.object(
|
|
51
|
+
Object.fromEntries(
|
|
52
|
+
EVENT_COUNTER_KEYS.map((key) => [key, z.number().int().min(0).default(0)])
|
|
53
|
+
) as Record<(typeof EVENT_COUNTER_KEYS)[number], z.ZodDefault<z.ZodNumber>>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// ---- Streak Data ----
|
|
57
|
+
|
|
58
|
+
export const StreakDataSchema = z.object({
|
|
59
|
+
currentStreak: z.number().int().min(0).default(0),
|
|
60
|
+
longestStreak: z.number().int().min(0).default(0),
|
|
61
|
+
lastActiveDate: z.string().nullable().default(null),
|
|
62
|
+
totalDaysActive: z.number().int().min(0).default(0),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ---- Buddy Config ----
|
|
66
|
+
|
|
67
|
+
export const BuddyConfigSchema = z.object({
|
|
68
|
+
muted: z.boolean().default(false),
|
|
69
|
+
reactionCooldownMs: z.number().int().min(0).default(30_000),
|
|
70
|
+
statusLineEnabled: z.boolean().default(true),
|
|
71
|
+
bellEnabled: z.boolean().default(true),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ---- Pokedex ----
|
|
75
|
+
|
|
76
|
+
export const PokedexEntrySchema = z.object({
|
|
77
|
+
seen: z.boolean(),
|
|
78
|
+
caught: z.boolean(),
|
|
79
|
+
firstSeen: z.string().nullable(),
|
|
80
|
+
firstCaught: z.string().nullable(),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const PokedexStateSchema = z.object({
|
|
84
|
+
entries: z.record(z.coerce.number(), PokedexEntrySchema).default({}),
|
|
85
|
+
totalSeen: z.number().int().min(0).default(0),
|
|
86
|
+
totalCaught: z.number().int().min(0).default(0),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---- Achievements ----
|
|
90
|
+
|
|
91
|
+
export const UnlockedAchievementSchema = z.object({
|
|
92
|
+
achievementId: z.string(),
|
|
93
|
+
unlockedAt: z.string(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---- Catch Condition ----
|
|
97
|
+
|
|
98
|
+
export const CatchConditionSchema = z.object({
|
|
99
|
+
requiredStat: z.enum(CODING_STATS).nullable(),
|
|
100
|
+
minStatValue: z.number(),
|
|
101
|
+
requiredLevel: z.number(),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---- Wild Encounter ----
|
|
105
|
+
|
|
106
|
+
export const WildEncounterSchema = z.object({
|
|
107
|
+
pokemonId: z.number().int().min(1).max(151),
|
|
108
|
+
level: z.number().int().min(1).max(100),
|
|
109
|
+
catchCondition: CatchConditionSchema,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---- Player State (top-level) ----
|
|
113
|
+
|
|
114
|
+
export const PlayerStateSchema = z.object({
|
|
115
|
+
trainerId: z.string(),
|
|
116
|
+
trainerName: z.string(),
|
|
117
|
+
party: z.array(OwnedPokemonSchema).max(6),
|
|
118
|
+
pcBox: z.array(OwnedPokemonSchema).default([]),
|
|
119
|
+
pokedex: PokedexStateSchema,
|
|
120
|
+
badges: z.array(BadgeTypeSchema).default([]),
|
|
121
|
+
achievements: z.array(UnlockedAchievementSchema).default([]),
|
|
122
|
+
counters: EventCountersSchema,
|
|
123
|
+
streak: StreakDataSchema,
|
|
124
|
+
config: BuddyConfigSchema,
|
|
125
|
+
startedAt: z.string(),
|
|
126
|
+
totalXpEarned: z.number().int().min(0).default(0),
|
|
127
|
+
totalSessions: z.number().int().min(0).default(0),
|
|
128
|
+
pendingEncounter: WildEncounterSchema.nullable().default(null),
|
|
129
|
+
xpSinceLastEncounter: z.number().int().min(0).default(0),
|
|
130
|
+
});
|
|
131
|
+
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Singleton state manager for Claudemon.
|
|
3
|
+
* All state reads/writes flow through this class.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getStateDir, getStateFile, getStatusFile } from "../engine/constants.js";
|
|
7
|
+
import type {
|
|
8
|
+
PlayerState,
|
|
9
|
+
OwnedPokemon,
|
|
10
|
+
EventCounterKey,
|
|
11
|
+
EventCounters,
|
|
12
|
+
StreakData,
|
|
13
|
+
BuddyConfig,
|
|
14
|
+
PokedexState,
|
|
15
|
+
WildEncounter,
|
|
16
|
+
} from "../engine/types.js";
|
|
17
|
+
import { EVENT_COUNTER_KEYS } from "../engine/types.js";
|
|
18
|
+
import { atomicWrite, safeRead, ensureDir, backupCorrupted, withLock } from "./io.js";
|
|
19
|
+
import { PlayerStateSchema } from "./schemas.js";
|
|
20
|
+
|
|
21
|
+
/** Compact status payload for the shell status line script */
|
|
22
|
+
interface StatusPayload {
|
|
23
|
+
name: string;
|
|
24
|
+
level: number;
|
|
25
|
+
xpPercent: number;
|
|
26
|
+
speciesId: number;
|
|
27
|
+
evolutionReady: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Build a zeroed-out EventCounters record */
|
|
31
|
+
function emptyCounters(): EventCounters {
|
|
32
|
+
const counters = {} as Record<string, number>;
|
|
33
|
+
for (const key of EVENT_COUNTER_KEYS) {
|
|
34
|
+
counters[key] = 0;
|
|
35
|
+
}
|
|
36
|
+
return counters as EventCounters;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Build default streak data */
|
|
40
|
+
function emptyStreak(): StreakData {
|
|
41
|
+
return {
|
|
42
|
+
currentStreak: 0,
|
|
43
|
+
longestStreak: 0,
|
|
44
|
+
lastActiveDate: null,
|
|
45
|
+
totalDaysActive: 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Build default config */
|
|
50
|
+
function defaultConfig(): BuddyConfig {
|
|
51
|
+
return {
|
|
52
|
+
muted: false,
|
|
53
|
+
reactionCooldownMs: 30_000,
|
|
54
|
+
statusLineEnabled: true,
|
|
55
|
+
bellEnabled: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Build empty pokedex */
|
|
60
|
+
function emptyPokedex(): PokedexState {
|
|
61
|
+
return {
|
|
62
|
+
entries: {},
|
|
63
|
+
totalSeen: 0,
|
|
64
|
+
totalCaught: 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Get today's date as YYYY-MM-DD in local timezone */
|
|
69
|
+
function todayDateString(): string {
|
|
70
|
+
const now = new Date();
|
|
71
|
+
const year = now.getFullYear();
|
|
72
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
73
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
74
|
+
return `${year}-${month}-${day}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class StateManager {
|
|
78
|
+
private static instance: StateManager | null = null;
|
|
79
|
+
private state: PlayerState | null = null;
|
|
80
|
+
|
|
81
|
+
private constructor() {}
|
|
82
|
+
|
|
83
|
+
/** Get the singleton StateManager instance */
|
|
84
|
+
static getInstance(): StateManager {
|
|
85
|
+
if (!StateManager.instance) {
|
|
86
|
+
StateManager.instance = new StateManager();
|
|
87
|
+
}
|
|
88
|
+
return StateManager.instance;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Load state from disk. Returns null on first run (no file) or if data is corrupted. */
|
|
92
|
+
async load(): Promise<PlayerState | null> {
|
|
93
|
+
await ensureDir(getStateDir());
|
|
94
|
+
|
|
95
|
+
const stateFile = getStateFile();
|
|
96
|
+
const fileExists = await Bun.file(stateFile).exists();
|
|
97
|
+
if (!fileExists) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const raw = await safeRead<unknown>(stateFile);
|
|
102
|
+
if (raw === null) {
|
|
103
|
+
// File exists but safeRead returned null — empty or invalid JSON
|
|
104
|
+
const backupPath = await backupCorrupted(stateFile);
|
|
105
|
+
process.stderr.write(
|
|
106
|
+
`[claudemon] State file was corrupted and has been backed up to ${backupPath}\n` +
|
|
107
|
+
`[claudemon] You'll need to pick a new starter with buddy_starter.\n`,
|
|
108
|
+
);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// Zod output is structurally compatible but not nominally identical to PlayerState
|
|
114
|
+
const parsed = PlayerStateSchema.parse(raw) as PlayerState;
|
|
115
|
+
this.state = parsed;
|
|
116
|
+
return this.state;
|
|
117
|
+
} catch (err: unknown) {
|
|
118
|
+
// Schema validation failed — data on disk doesn't match expected shape
|
|
119
|
+
const backupPath = await backupCorrupted(stateFile);
|
|
120
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
121
|
+
process.stderr.write(
|
|
122
|
+
`[claudemon] State validation failed: ${message}\n` +
|
|
123
|
+
`[claudemon] Corrupted data backed up to ${backupPath}\n` +
|
|
124
|
+
`[claudemon] You'll need to pick a new starter with buddy_starter.\n`,
|
|
125
|
+
);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Save current state to disk atomically, protected by file lock */
|
|
131
|
+
async save(): Promise<void> {
|
|
132
|
+
if (!this.state) {
|
|
133
|
+
throw new Error("Cannot save: state not loaded. Call load() or initializePlayer() first.");
|
|
134
|
+
}
|
|
135
|
+
const stateDir = getStateDir();
|
|
136
|
+
await ensureDir(stateDir);
|
|
137
|
+
const lockPath = `${stateDir}/state.lock`;
|
|
138
|
+
await withLock(lockPath, async () => {
|
|
139
|
+
const json = JSON.stringify(this.state, null, 2);
|
|
140
|
+
await atomicWrite(getStateFile(), json);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Get current state (throws if not loaded) */
|
|
145
|
+
getState(): PlayerState {
|
|
146
|
+
if (!this.state) {
|
|
147
|
+
throw new Error("State not loaded. Call load() first.");
|
|
148
|
+
}
|
|
149
|
+
return this.state;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Check if this is first run (no state file exists) */
|
|
153
|
+
async isFirstRun(): Promise<boolean> {
|
|
154
|
+
const file = Bun.file(getStateFile());
|
|
155
|
+
return !(await file.exists());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Initialize new player state after starter selection */
|
|
159
|
+
async initializePlayer(
|
|
160
|
+
trainerId: string,
|
|
161
|
+
trainerName: string,
|
|
162
|
+
starter: OwnedPokemon,
|
|
163
|
+
): Promise<PlayerState> {
|
|
164
|
+
const now = new Date().toISOString();
|
|
165
|
+
|
|
166
|
+
const state: PlayerState = {
|
|
167
|
+
trainerId,
|
|
168
|
+
trainerName,
|
|
169
|
+
party: [starter],
|
|
170
|
+
pcBox: [],
|
|
171
|
+
pokedex: emptyPokedex(),
|
|
172
|
+
badges: [],
|
|
173
|
+
achievements: [],
|
|
174
|
+
counters: emptyCounters(),
|
|
175
|
+
streak: emptyStreak(),
|
|
176
|
+
config: defaultConfig(),
|
|
177
|
+
startedAt: now,
|
|
178
|
+
totalXpEarned: 0,
|
|
179
|
+
totalSessions: 0,
|
|
180
|
+
pendingEncounter: null,
|
|
181
|
+
xpSinceLastEncounter: 0,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
this.state = state;
|
|
185
|
+
await this.save();
|
|
186
|
+
return this.state;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Get the active Pokemon from the party (isActive === true) */
|
|
190
|
+
getActivePokemon(): OwnedPokemon | null {
|
|
191
|
+
if (!this.state) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return this.state.party.find((p) => p.isActive) ?? null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Increment an event counter by amount (default 1) and save (lock-protected) */
|
|
198
|
+
async incrementCounter(key: EventCounterKey, amount: number = 1): Promise<void> {
|
|
199
|
+
const state = this.getState();
|
|
200
|
+
state.counters[key] += amount;
|
|
201
|
+
await this.save();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Update daily streak based on today vs lastActiveDate, then save */
|
|
205
|
+
async updateStreak(): Promise<void> {
|
|
206
|
+
const state = this.getState();
|
|
207
|
+
const today = todayDateString();
|
|
208
|
+
const { streak } = state;
|
|
209
|
+
|
|
210
|
+
if (streak.lastActiveDate === today) {
|
|
211
|
+
// Already recorded today, nothing to do
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (streak.lastActiveDate === null) {
|
|
216
|
+
// First ever activity
|
|
217
|
+
streak.currentStreak = 1;
|
|
218
|
+
streak.totalDaysActive = 1;
|
|
219
|
+
} else {
|
|
220
|
+
const last = new Date(streak.lastActiveDate);
|
|
221
|
+
const now = new Date(today);
|
|
222
|
+
const diffMs = now.getTime() - last.getTime();
|
|
223
|
+
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
|
|
224
|
+
|
|
225
|
+
if (diffDays === 1) {
|
|
226
|
+
// Consecutive day
|
|
227
|
+
streak.currentStreak += 1;
|
|
228
|
+
} else {
|
|
229
|
+
// Streak broken
|
|
230
|
+
streak.currentStreak = 1;
|
|
231
|
+
}
|
|
232
|
+
streak.totalDaysActive += 1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (streak.currentStreak > streak.longestStreak) {
|
|
236
|
+
streak.longestStreak = streak.currentStreak;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
streak.lastActiveDate = today;
|
|
240
|
+
await this.save();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Write compact status JSON for the shell status line script. */
|
|
244
|
+
async writeStatus(evolutionReady: boolean = false): Promise<void> {
|
|
245
|
+
const active = this.getActivePokemon();
|
|
246
|
+
if (!active) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// XP percent is currentXp as a rough percentage toward next level
|
|
251
|
+
// Exact formula depends on exp group; use a simple ratio for the status line
|
|
252
|
+
const xpPercent = active.level >= 100 ? 100 : Math.min(100, Math.floor((active.currentXp / Math.max(1, active.currentXp + 50)) * 100));
|
|
253
|
+
|
|
254
|
+
// Look up species name if no nickname
|
|
255
|
+
let displayName = active.nickname ?? "";
|
|
256
|
+
if (!displayName) {
|
|
257
|
+
const { POKEMON_BY_ID } = await import("../engine/pokemon-data.js");
|
|
258
|
+
const species = POKEMON_BY_ID.get(active.pokemonId);
|
|
259
|
+
displayName = species?.name ?? `Pokemon #${active.pokemonId}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const payload: StatusPayload = {
|
|
263
|
+
name: displayName,
|
|
264
|
+
level: active.level,
|
|
265
|
+
xpPercent,
|
|
266
|
+
speciesId: active.pokemonId,
|
|
267
|
+
evolutionReady,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const stateDir = getStateDir();
|
|
271
|
+
await ensureDir(stateDir);
|
|
272
|
+
await atomicWrite(getStatusFile(), JSON.stringify(payload));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Set a pending wild encounter on state. */
|
|
276
|
+
setPendingEncounter(encounter: WildEncounter): void {
|
|
277
|
+
const state = this.getState();
|
|
278
|
+
state.pendingEncounter = encounter;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Clear the pending wild encounter from state. */
|
|
282
|
+
clearPendingEncounter(): void {
|
|
283
|
+
const state = this.getState();
|
|
284
|
+
state.pendingEncounter = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Write encounter notification to status JSON so status line can show it. */
|
|
288
|
+
async writeEncounterStatus(pokemonName: string): Promise<void> {
|
|
289
|
+
const active = this.getActivePokemon();
|
|
290
|
+
if (!active) return;
|
|
291
|
+
|
|
292
|
+
const xpPercent =
|
|
293
|
+
active.level >= 100
|
|
294
|
+
? 100
|
|
295
|
+
: Math.min(100, Math.floor((active.currentXp / Math.max(1, active.currentXp + 50)) * 100));
|
|
296
|
+
|
|
297
|
+
let displayName = active.nickname ?? "";
|
|
298
|
+
if (!displayName) {
|
|
299
|
+
const { POKEMON_BY_ID } = await import("../engine/pokemon-data.js");
|
|
300
|
+
const species = POKEMON_BY_ID.get(active.pokemonId);
|
|
301
|
+
displayName = species?.name ?? `Pokemon #${active.pokemonId}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const payload = {
|
|
305
|
+
name: displayName,
|
|
306
|
+
level: active.level,
|
|
307
|
+
xpPercent,
|
|
308
|
+
speciesId: active.pokemonId,
|
|
309
|
+
evolutionReady: false,
|
|
310
|
+
encounter: `Wild ${pokemonName} appeared!`,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
await ensureDir(getStateDir());
|
|
314
|
+
await atomicWrite(getStatusFile(), JSON.stringify(payload));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Reset singleton for testing purposes */
|
|
318
|
+
static resetInstance(): void {
|
|
319
|
+
StateManager.instance = null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Claude Code status line for Claudemon.
|
|
3
|
+
# Two-column layout:
|
|
4
|
+
# LEFT: model, context remaining, usage, buddy speech
|
|
5
|
+
# RIGHT: colorscript sprite + name + level (hideable via /buddy hide)
|
|
6
|
+
|
|
7
|
+
STATE_DIR="$HOME/.claudemon"
|
|
8
|
+
STATUS_FILE="$STATE_DIR/status.json"
|
|
9
|
+
CONFIG_FILE="$STATE_DIR/config.json"
|
|
10
|
+
|
|
11
|
+
[ -f "$STATUS_FILE" ] || exit 0
|
|
12
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
13
|
+
|
|
14
|
+
# ── Check hide/show toggle ──────────────────────────────────
|
|
15
|
+
SPRITE_HIDDEN="false"
|
|
16
|
+
if [ -f "$CONFIG_FILE" ]; then
|
|
17
|
+
SPRITE_HIDDEN=$(jq -r '.spriteHidden // false' "$CONFIG_FILE" 2>/dev/null)
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# ── Read Claude Code stdin JSON ─────────────────────────────
|
|
21
|
+
STDIN_DATA=""
|
|
22
|
+
if [ ! -t 0 ]; then
|
|
23
|
+
STDIN_DATA=$(timeout 1 cat 2>/dev/null || true)
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
CC_MODEL="" CC_CONTEXT=""
|
|
27
|
+
if [ -n "$STDIN_DATA" ]; then
|
|
28
|
+
CC_MODEL=$(echo "$STDIN_DATA" | jq -r '.model.display_name // empty' 2>/dev/null)
|
|
29
|
+
|
|
30
|
+
# Context memory: count conversation messages from transcript
|
|
31
|
+
TRANSCRIPT=$(echo "$STDIN_DATA" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
32
|
+
if [ -n "$TRANSCRIPT" ] && [ -f "$TRANSCRIPT" ]; then
|
|
33
|
+
# Count message objects in transcript as a rough context indicator
|
|
34
|
+
MSG_COUNT=$(jq '[.[] | select(.type == "message" or .type == "human" or .type == "assistant")] | length' "$TRANSCRIPT" 2>/dev/null || echo 0)
|
|
35
|
+
# Rough estimate: ~50 messages fills a 200K context window
|
|
36
|
+
# (each message averages ~4K tokens including tool calls)
|
|
37
|
+
MAX_MSGS=50
|
|
38
|
+
if [ "$MSG_COUNT" -gt 0 ]; then
|
|
39
|
+
USED_PCT=$(( (MSG_COUNT * 100) / MAX_MSGS ))
|
|
40
|
+
[ "$USED_PCT" -gt 100 ] && USED_PCT=100
|
|
41
|
+
LEFT_PCT=$(( 100 - USED_PCT ))
|
|
42
|
+
CC_CONTEXT="${LEFT_PCT}% context"
|
|
43
|
+
fi
|
|
44
|
+
fi
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# ── Read buddy status ───────────────────────────────────────
|
|
48
|
+
STATUS=$(cat "$STATUS_FILE" 2>/dev/null) || exit 0
|
|
49
|
+
[ -n "$STATUS" ] || exit 0
|
|
50
|
+
|
|
51
|
+
NAME=$(echo "$STATUS" | jq -r '.name // empty')
|
|
52
|
+
[ -n "$NAME" ] || exit 0
|
|
53
|
+
|
|
54
|
+
LEVEL=$(echo "$STATUS" | jq -r '.level // 0')
|
|
55
|
+
SPECIES_ID=$(echo "$STATUS" | jq -r '.speciesId // 0')
|
|
56
|
+
EVOLVING=$(echo "$STATUS" | jq -r '.evolutionReady // false')
|
|
57
|
+
REACTION=$(echo "$STATUS" | jq -r '.reaction // empty')
|
|
58
|
+
ENCOUNTER=$(echo "$STATUS" | jq -r '.encounter // empty')
|
|
59
|
+
|
|
60
|
+
# ── Colors ──────────────────────────────────────────────────
|
|
61
|
+
NC=$'\033[0m'
|
|
62
|
+
DIM=$'\033[2m'
|
|
63
|
+
CYAN=$'\033[38;2;100;200;220m'
|
|
64
|
+
GRAY=$'\033[38;2;120;120;130m'
|
|
65
|
+
GREEN=$'\033[38;2;100;200;100m'
|
|
66
|
+
B=$'\xe2\xa0\x80'
|
|
67
|
+
|
|
68
|
+
# ── Terminal width ──────────────────────────────────────────
|
|
69
|
+
COLS=0
|
|
70
|
+
PID=$$
|
|
71
|
+
for _ in 1 2 3 4 5; do
|
|
72
|
+
PID=$(ps -o ppid= -p "$PID" 2>/dev/null | tr -d ' ')
|
|
73
|
+
[ -z "$PID" ] || [ "$PID" = "1" ] && break
|
|
74
|
+
PTY=$(readlink "/proc/${PID}/fd/0" 2>/dev/null)
|
|
75
|
+
if [ -c "$PTY" ] 2>/dev/null; then
|
|
76
|
+
COLS=$(stty size < "$PTY" 2>/dev/null | awk '{print $2}')
|
|
77
|
+
[ "${COLS:-0}" -gt 40 ] 2>/dev/null && break
|
|
78
|
+
fi
|
|
79
|
+
done
|
|
80
|
+
[ "${COLS:-0}" -lt 40 ] 2>/dev/null && COLS=${COLUMNS:-0}
|
|
81
|
+
[ "${COLS:-0}" -lt 40 ] 2>/dev/null && COLS=125
|
|
82
|
+
[ "$COLS" -lt 40 ] && exit 0
|
|
83
|
+
|
|
84
|
+
# ── Build name line ─────────────────────────────────────────
|
|
85
|
+
if [ "$EVOLVING" = "true" ]; then
|
|
86
|
+
INFO_LINE="${NAME} Lv.${LEVEL} *EVOLVING*"
|
|
87
|
+
else
|
|
88
|
+
INFO_LINE="${NAME} Lv.${LEVEL}"
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
# ── If sprite hidden, show minimal text-only ────────────────
|
|
92
|
+
if [ "$SPRITE_HIDDEN" = "true" ]; then
|
|
93
|
+
# Left info only, no sprite
|
|
94
|
+
LEFT=""
|
|
95
|
+
[ -n "$CC_MODEL" ] && LEFT="${CYAN}${CC_MODEL}${NC}"
|
|
96
|
+
[ -n "$CC_CONTEXT" ] && LEFT="${LEFT:+${LEFT} ${GRAY}·${NC} }${GREEN}${CC_CONTEXT}${NC}"
|
|
97
|
+
|
|
98
|
+
SPACER=""
|
|
99
|
+
RIGHT_PAD=$(( COLS - ${#INFO_LINE} - 4 ))
|
|
100
|
+
[ "$RIGHT_PAD" -lt 0 ] && RIGHT_PAD=0
|
|
101
|
+
for (( i=0; i<RIGHT_PAD; i++ )); do SPACER+="$B"; done
|
|
102
|
+
|
|
103
|
+
echo "${LEFT}"
|
|
104
|
+
echo "${SPACER}${INFO_LINE}"
|
|
105
|
+
exit 0
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
# ── Load sprite ─────────────────────────────────────────────
|
|
109
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
110
|
+
SPRITE_DIR="$SCRIPT_DIR/../sprites/colorscripts/small"
|
|
111
|
+
NAME_LOWER=$(echo "$NAME" | tr '[:upper:]' '[:lower:]' | sed "s/♀/-f/;s/♂/-m/;s/'//;s/\. /-/;s/ /-/")
|
|
112
|
+
SPRITE_FILE="$SPRITE_DIR/${SPECIES_ID}-${NAME_LOWER}.txt"
|
|
113
|
+
|
|
114
|
+
[ -f "$SPRITE_FILE" ] || exit 0
|
|
115
|
+
|
|
116
|
+
mapfile -t SPRITE_LINES < "$SPRITE_FILE"
|
|
117
|
+
|
|
118
|
+
# Strip empty lines
|
|
119
|
+
while [ ${#SPRITE_LINES[@]} -gt 0 ] && [ -z "${SPRITE_LINES[0]}" ]; do
|
|
120
|
+
SPRITE_LINES=("${SPRITE_LINES[@]:1}")
|
|
121
|
+
done
|
|
122
|
+
while [ ${#SPRITE_LINES[@]} -gt 0 ] && [ -z "${SPRITE_LINES[-1]}" ]; do
|
|
123
|
+
unset 'SPRITE_LINES[-1]'
|
|
124
|
+
done
|
|
125
|
+
|
|
126
|
+
# Trim to max 8 sprite lines (top = head)
|
|
127
|
+
MAX_SPRITE=8
|
|
128
|
+
if [ ${#SPRITE_LINES[@]} -gt "$MAX_SPRITE" ]; then
|
|
129
|
+
SPRITE_LINES=("${SPRITE_LINES[@]:0:$MAX_SPRITE}")
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
SPRITE_COUNT=${#SPRITE_LINES[@]}
|
|
133
|
+
[ "$SPRITE_COUNT" -eq 0 ] && exit 0
|
|
134
|
+
|
|
135
|
+
# ── Sprite width ────────────────────────────────────────────
|
|
136
|
+
ART_W=0
|
|
137
|
+
for line in "${SPRITE_LINES[@]}"; do
|
|
138
|
+
stripped=$(echo -e "$line" | sed 's/\x1b\[[0-9;]*m//g')
|
|
139
|
+
w=${#stripped}
|
|
140
|
+
[ "$w" -gt "$ART_W" ] && ART_W=$w
|
|
141
|
+
done
|
|
142
|
+
[ "$ART_W" -lt 5 ] && ART_W=12
|
|
143
|
+
|
|
144
|
+
# ── Build left-side lines ───────────────────────────────────
|
|
145
|
+
# Line 1: model · context left
|
|
146
|
+
LEFT_1=""
|
|
147
|
+
[ -n "$CC_MODEL" ] && LEFT_1="${CYAN}${CC_MODEL}${NC}"
|
|
148
|
+
[ -n "$CC_CONTEXT" ] && LEFT_1="${LEFT_1:+${LEFT_1} ${GRAY}·${NC} }${GREEN}${CC_CONTEXT}${NC}"
|
|
149
|
+
|
|
150
|
+
# Line 2: (empty)
|
|
151
|
+
LEFT_2=""
|
|
152
|
+
|
|
153
|
+
# Line 3: buddy speech (rotates every 10s, or shows reaction)
|
|
154
|
+
SPEECH=""
|
|
155
|
+
if [ -n "$ENCOUNTER" ]; then
|
|
156
|
+
# Wild encounter takes priority — flash to get attention
|
|
157
|
+
SPEECH="! ${ENCOUNTER} Use /buddy catch !"
|
|
158
|
+
elif [ -n "$REACTION" ]; then
|
|
159
|
+
SPEECH="$REACTION"
|
|
160
|
+
else
|
|
161
|
+
NOW=$(date +%s)
|
|
162
|
+
IDX=$(( (NOW / 30) % 12 ))
|
|
163
|
+
SPEECHES=(
|
|
164
|
+
"*${NAME} looks at your code curiously*"
|
|
165
|
+
""
|
|
166
|
+
"*${NAME} nods along as you type*"
|
|
167
|
+
""
|
|
168
|
+
"*${NAME} is watching closely*"
|
|
169
|
+
""
|
|
170
|
+
"*${NAME} hums softly*"
|
|
171
|
+
""
|
|
172
|
+
"*${NAME} stretches and yawns*"
|
|
173
|
+
""
|
|
174
|
+
"*${NAME} bounces excitedly*"
|
|
175
|
+
""
|
|
176
|
+
)
|
|
177
|
+
SPEECH="${SPEECHES[$IDX]}"
|
|
178
|
+
fi
|
|
179
|
+
if [ -n "$ENCOUNTER" ]; then
|
|
180
|
+
# Bright yellow for encounter alerts
|
|
181
|
+
SPEECH_COLOR=$'\033[1;38;2;255;220;50m'
|
|
182
|
+
else
|
|
183
|
+
# Warm muted for idle speech
|
|
184
|
+
SPEECH_COLOR=$'\033[38;2;180;180;120m'
|
|
185
|
+
fi
|
|
186
|
+
LEFT_3="${SPEECH_COLOR}${SPEECH}${NC}"
|
|
187
|
+
|
|
188
|
+
# ── Merge left + right ──────────────────────────────────────
|
|
189
|
+
TOTAL_LINES=$SPRITE_COUNT
|
|
190
|
+
|
|
191
|
+
RIGHT_MARGIN=4
|
|
192
|
+
RIGHT_PAD=$(( COLS - ART_W - RIGHT_MARGIN ))
|
|
193
|
+
[ "$RIGHT_PAD" -lt 0 ] && RIGHT_PAD=0
|
|
194
|
+
|
|
195
|
+
# Build left array — line 1: model+context, line 2: buddy speech, rest empty
|
|
196
|
+
LEFT_LINES=()
|
|
197
|
+
LEFT_LINES+=("$LEFT_1") # line 1: model · context
|
|
198
|
+
LEFT_LINES+=("$LEFT_3") # line 2: buddy speech
|
|
199
|
+
|
|
200
|
+
# Pad rest with empty lines
|
|
201
|
+
while [ ${#LEFT_LINES[@]} -lt "$TOTAL_LINES" ]; do
|
|
202
|
+
LEFT_LINES+=("")
|
|
203
|
+
done
|
|
204
|
+
LEFT_COUNT=${#LEFT_LINES[@]}
|
|
205
|
+
|
|
206
|
+
# ── Build full right-side spacer ─────────────────────────────
|
|
207
|
+
FULL_SPACER=""
|
|
208
|
+
for (( s=0; s<RIGHT_PAD; s++ )); do FULL_SPACER+="$B"; done
|
|
209
|
+
|
|
210
|
+
# ── Output name line ABOVE sprite ────────────────────────────
|
|
211
|
+
echo "${FULL_SPACER}${INFO_LINE}"
|
|
212
|
+
|
|
213
|
+
# ── Output sprite lines (right-aligned, left content merged) ──
|
|
214
|
+
for (( i=0; i<SPRITE_COUNT; i++ )); do
|
|
215
|
+
left="${LEFT_LINES[$i]}"
|
|
216
|
+
left_visible=$(echo -e "$left" | sed 's/\x1b\[[0-9;]*m//g')
|
|
217
|
+
left_w=${#left_visible}
|
|
218
|
+
|
|
219
|
+
right="${SPRITE_LINES[$i]}${NC}"
|
|
220
|
+
|
|
221
|
+
if [ -n "$left" ]; then
|
|
222
|
+
gap=$(( RIGHT_PAD - left_w ))
|
|
223
|
+
[ "$gap" -lt 1 ] && gap=1
|
|
224
|
+
GAP_STR=""
|
|
225
|
+
for (( g=0; g<gap; g++ )); do GAP_STR+="$B"; done
|
|
226
|
+
echo "${left}${GAP_STR}${right}"
|
|
227
|
+
else
|
|
228
|
+
echo "${FULL_SPACER}${right}"
|
|
229
|
+
fi
|
|
230
|
+
done
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
exit 0
|