diff --git a/app/locales/de.json b/app/locales/de.json
index 763bed1..77f4339 100644
--- a/app/locales/de.json
+++ b/app/locales/de.json
@@ -31,7 +31,8 @@
"invalid_location": "Bitte einen Breitengrad von -90 bis 90 und einen Längengrad von -180 bis 180 eingeben.",
"no_update": "Gerade gibt es kein Update zu installieren.",
"not_a_save": "Das ist kein Spielstand eines Pets.",
- "no_second": "Es gibt kein zweites Pet."
+ "no_second": "Es gibt kein zweites Pet.",
+ "no_ancestor": "Dieses Pet kann nicht mehr zurückkommen."
},
"stages": {
"egg": "Ei",
@@ -273,6 +274,7 @@
"brood_confirm": "{egg} ausbrüten? {name} bleibt zu Hause und geht nach hinten. Deine Funde bleiben.",
"keep_brood": "{egg} ausbrüten? Nur zwei Pets passen in dein Zuhause – wer bleibt dabei? Das andere zieht in die Ahnengalerie.",
"keep_new_egg": "Mit einem normalen Ei neu anfangen? Nur zwei Pets passen in dein Zuhause – wer bleibt dabei? Das andere zieht in die Ahnengalerie.",
+ "keep_bring_back": "{name} heimholen? Nur zwei Pets passen in dein Zuhause – wer bleibt dabei? Das andere ruht sich in der Ahnengalerie aus.",
"swap": "{name} nach vorne holen",
"new_egg_done": "Ein neues Ei liegt bereit 🥚",
"things": {
@@ -561,6 +563,8 @@
"guardian": "ist am Wächter vorbeigekommen: {foe} 🏆",
"unlock": "kennt jetzt einen neuen Weg: {area} 🗺",
"swap": "ist nach vorne gekommen – {pet} ist jetzt hinten",
+ "bring_back": "ist wieder nach Hause gekommen – {pet} ist jetzt hinten",
+ "bring_back_alone": "ist wieder nach Hause gekommen",
"copied": "hat sich {trick} bei {pet} abgeschaut",
"house_1": "hat sich eine Hütte gebaut 🏠",
"house_2": "hat aus der Hütte ein Häuschen gemacht 🏠",
@@ -591,6 +595,7 @@
},
"gallery": {
"entry": "{form} · {age} · seit {date}",
+ "bring_back": "Heimholen",
"album": "{met} von {all} Formen",
"missing": "Noch nicht getroffen",
"ancestors": "Ahnen"
diff --git a/app/locales/en.json b/app/locales/en.json
index 1f4408a..6bb99c3 100644
--- a/app/locales/en.json
+++ b/app/locales/en.json
@@ -31,7 +31,8 @@
"invalid_location": "Please enter a latitude from -90 to 90 and a longitude from -180 to 180.",
"no_update": "There is no update to install right now.",
"not_a_save": "That is not a save file of a pet.",
- "no_second": "There is no second pet."
+ "no_second": "There is no second pet.",
+ "no_ancestor": "This pet can no longer come back."
},
"stages": {
"egg": "Egg",
@@ -273,6 +274,7 @@
"brood_confirm": "Brood the {egg}? {name} stays at home and moves to the back. Your finds stay.",
"keep_brood": "Brood the {egg}? Only two pets fit in your home – who stays with it? The other one moves to the ancestors.",
"keep_new_egg": "Start over with a usual egg? Only two pets fit in your home – who stays with it? The other one moves to the ancestors.",
+ "keep_bring_back": "Bring {name} home? Only two pets fit in your home – who stays with it? The other one goes to rest among the ancestors.",
"swap": "Bring {name} to the front",
"new_egg_done": "A new egg is waiting 🥚",
"things": {
@@ -561,6 +563,8 @@
"guardian": "got past the guardian: {foe} 🏆",
"unlock": "knows a new way now: {area} 🗺",
"swap": "came to the front – {pet} is at the back now",
+ "bring_back": "came home again – {pet} is at the back now",
+ "bring_back_alone": "came home again",
"copied": "copied {trick} from {pet}",
"house_1": "built itself a hut 🏠",
"house_2": "made the hut into a cabin 🏠",
@@ -591,6 +595,7 @@
},
"gallery": {
"entry": "{form} · {age} · born {date}",
+ "bring_back": "Bring home",
"album": "{met} of {all} forms",
"missing": "Not met yet",
"ancestors": "Ancestors"
diff --git a/app/pet.html b/app/pet.html
index 11beba7..618eb6c 100644
--- a/app/pet.html
+++ b/app/pet.html
@@ -1334,6 +1334,7 @@
: e.action === "wild" ? wildText(e.item)
: e.action === "visit" ? t("page.events.visit", { pet: T[`species.${e.item}`] || e.item })
: e.action === "swap" ? t("page.events.swap", { pet: e.item || "" })
+ : e.action === "bring_back" ? (e.item ? t("page.events.bring_back", { pet: e.item }) : t("page.events.bring_back_alone"))
: e.action === "copied" ? t("page.events.copied", {
trick: trickLabel((e.item || "").split(":")[0]), pet: (e.item || "").split(":").slice(1).join(":") })
: e.action === "gift" ? t("page.events.gift", { pet: T[`species.${e.item}`] || e.item })
@@ -1558,6 +1559,14 @@
form: a.stage === "adult" ? a.species_label : a.stage_label, age: tn("page.days", a.age_days), date: from,
}) + (a.tricks && a.tricks.length ? ` · ${a.tricks.length} ⭐` : ""));
li.prepend(img);
+ // A pet that rests here can come home again; ancestors from before are only pictures
+ if (a.home) {
+ const b = document.createElement("button");
+ b.className = "small";
+ b.textContent = t("page.gallery.bring_back");
+ b.addEventListener("click", () => bringBack(a));
+ li.append(b);
+ }
return li;
}));
} catch (err) {
@@ -1565,6 +1574,27 @@
}
}
+// An ancestor comes home to the front; the pet at the front goes to the back, and with two at home one of them is picked to stay
+async function bringBack(a) {
+ if (!current) return;
+ let keep = null;
+ if (current.second) {
+ keep = await chooseKeep(t("page.keep_bring_back", { name: a.name }));
+ if (!keep) return;
+ }
+ try {
+ const request = { born_at: a.born_at };
+ if (keep) request.keep = keep;
+ const r = await api("POST", "/api/pet/bring_back", request);
+ render(r.state);
+ feel(r.accepted ? "ok" : "no");
+ if (r.accepted) showView("home");
+ say(r.accepted ? "" : r.message);
+ } catch (err) {
+ fail(err);
+ }
+}
+
// A tab without content stays hidden; if it was the open one, the history takes over
function offerTab(name, has) {
document.querySelector(`[data-tab="${name}"]`).hidden = !has;
diff --git a/core/src/api.ts b/core/src/api.ts
index e19cf15..213eb32 100644
--- a/core/src/api.ts
+++ b/core/src/api.ts
@@ -28,7 +28,7 @@ export const API_VERSION = 4;
export const API_OLDEST = 4;
/**
- * Where the requests go. `action`, `newEgg` and `swap` are the pet's own, or the Pi's, which also
+ * Where the requests go. `action`, `newEgg`, `swap` and `bringBack` are the pet's own, or the Pi's, which also
* show it on the panel.
*/
export interface PetTarget {
@@ -36,12 +36,14 @@ export interface PetTarget {
action: Pet["action"];
newEgg: (egg: unknown, keep: unknown) => ReturnType;
swap: () => ReturnType;
+ bringBack: (bornAt: unknown, keep: unknown) => ReturnType;
}
/** The pet's own handlers, for a pet that nothing else shows (the app's, or one in a test). */
export function ownTarget(pet: Pet): PetTarget {
return {
pet, action: pet.action.bind(pet), newEgg: (egg, keep) => pet.newEgg(egg, undefined, keep), swap: () => pet.swap(),
+ bringBack: (bornAt, keep) => pet.bringBack(bornAt, keep),
};
}
@@ -91,6 +93,11 @@ export async function petRequest(target: PetTarget, method: string, url: URL,
case "POST /api/pet/swap":
return { api: API_VERSION, ...target.swap() };
+ case "POST /api/pet/bring_back": {
+ const request = fields(await body());
+ return { api: API_VERSION, ...target.bringBack(request.born_at, request.keep ?? null) };
+ }
+
case "GET /api/pet/travels":
return { api: API_VERSION, travels: pet.travels() };
diff --git a/core/src/pet.ts b/core/src/pet.ts
index 8cf6084..daae8c6 100644
--- a/core/src/pet.ts
+++ b/core/src/pet.ts
@@ -51,6 +51,11 @@ export interface Ancestor {
counters: Record;
tricks: string[];
hat: string;
+ /**
+ * The pet itself, resting, so it can come home again (Pet.bringBack); without the event log
+ * and the replays, which only the pet at the front shows. Ancestors from before have none.
+ */
+ pet?: PetState;
}
/** What is saved. */
@@ -332,6 +337,54 @@ export class Pet {
return this.describe(now);
}
+ /**
+ * An ancestor comes home again, to the front; `bornAt` says which one (its "born_at"). Among the
+ * ancestors it rested frozen: it comes back as it went, without growing, getting older or losing
+ * anything. The pet that was at the front goes to the
+ * back, and `keep` decides as in newEgg which of two stays there; the other one goes to rest among
+ * the ancestors. Not while the pet at the front is away or still an egg. Returns like action();
+ * throws a RequestError if no ancestor that can come back was born then.
+ */
+ bringBack(bornAt: unknown, keep: unknown = "back", now = this.clock()) {
+ if (keep === null || keep === undefined) {
+ keep = "back";
+ }
+ if (!KEEP.includes(keep as (typeof KEEP)[number])) {
+ throw new model.RequestError("invalid_value");
+ }
+ const index = this.ancestors.findIndex((a) => a.pet && a.born_at === bornAt);
+ if (index < 0) {
+ throw new model.RequestError("no_ancestor");
+ }
+ this.advance(now);
+ const front = this.state;
+ const reason = this.swapRefusal();
+ if (reason) {
+ return { accepted: false, reason, message: this.t(`pet.rejected.${reason}`, { name: front.name }), state: this.describe(now) };
+ }
+ const [entry] = this.ancestors.splice(index, 1);
+ const back = fromDict(entry.pet as unknown as Record);
+ model.reform(back, now);
+ const rested = Math.max(0, now - back.updated_at); // the time among the ancestors doesn't count
+ back.stage_since += rested;
+ back.born_at += rested;
+ model.moveHome(front, back);
+ Object.assign(back, { seen_at: now, weather_seen: front.weather_seen, updated_at: now, idle_next: 0.0 });
+ if (this.second && keep === "back") {
+ this.archive(front, now);
+ } else {
+ if (this.second) {
+ this.archive(this.second, now);
+ }
+ this.second = front;
+ this.toBack(front, now);
+ }
+ this.state = back;
+ model.logEvent(back, now, "bring_back", "app", { item: this.second === front ? front.name : null });
+ this.write(now);
+ return { accepted: true, reason: null, message: null, state: this.describe(now) };
+ }
+
/**
* The pet at the back comes to the front, and the one at the front goes to the back, with the
* home and all that belongs to it (model.moveHome). Not while the pet at the front is away or
@@ -370,13 +423,16 @@ export class Pet {
companion.rest(state, now);
}
- /** A pet moves to the ancestors' gallery. */
+ /** A pet moves to the ancestors' gallery, where it rests frozen until it is brought back (bringBack). */
private archive(state: PetState, now: number): void {
+ const hat = state.hat;
+ this.toBack(state, now);
this.ancestors.push({
name: state.name, species: state.species, stage: state.stage,
born_at: Math.round(state.born_at), until: Math.round(now),
age_days: Math.max(0, Math.floor((now - state.born_at) / 86400)),
- counters: { ...state.counters }, tricks: care.learned(state), hat: state.hat,
+ counters: { ...state.counters }, tricks: care.learned(state), hat,
+ pet: { ...toDict(state), events: [], replays: [] },
});
this.ancestors.splice(0, Math.max(0, this.ancestors.length - MAX_ANCESTORS));
}
@@ -396,10 +452,10 @@ export class Pet {
};
}
- /** The ancestors, the last one first. */
+ /** The ancestors, the last one first; `home`: it can come home again (bringBack). */
ancestorList() {
- return [...this.ancestors].reverse().map((a) => ({
- ...a, species_label: this.t(`species.${a.species}`), stage_label: this.t(life.formKey(a.stage, a.species)),
+ return [...this.ancestors].reverse().map(({ pet, ...a }) => ({
+ ...a, home: !!pet, species_label: this.t(`species.${a.species}`), stage_label: this.t(life.formKey(a.stage, a.species)),
}));
}
diff --git a/core/test/api-contract.json b/core/test/api-contract.json
index dc63980..de54e8a 100644
--- a/core/test/api-contract.json
+++ b/core/test/api-contract.json
@@ -7,6 +7,7 @@
"GET /api/pet/events",
"GET /api/pet/travels",
"POST /api/pet/action",
+ "POST /api/pet/bring_back",
"POST /api/pet/new_egg",
"POST /api/pet/settings",
"POST /api/pet/swap"
diff --git a/core/test/pet.test.ts b/core/test/pet.test.ts
index 5fc785b..70cf410 100644
--- a/core/test/pet.test.ts
+++ b/core/test/pet.test.ts
@@ -94,6 +94,55 @@ test("a new egg sends the pet to the back, and with two one of them moves to the
refused(() => loaded.newEgg(null, now + 300, "both"), "invalid_value");
});
+test("an ancestor comes home again, and the one it replaces goes to rest", () => {
+ const now = at("2026-10-12 12:00");
+ const disk = new Disk();
+ const pet = disk.pet();
+ pet.state = grown(now);
+ Object.assign(pet.state, { stage: "adult", species: "grumpcap", name: "Grumpy", hat: "bobble" });
+ pet.state.items = { daisy: 1, bobble: 1 };
+ pet.state.tricks = { bow: 3.0 };
+ pet.state.stats.satiety = 10;
+ pet.newEgg(null, now);
+ Object.assign(pet.state, { stage: "child", name: "Kid" });
+ pet.newEgg(null, now + 60, "front"); // Grumpy goes to rest among the ancestors
+ const [rests] = pet.ancestorList();
+ assert.deepEqual([rests.name, rests.home, "pet" in rests], ["Grumpy", true, false]);
+ assert.equal(pet.second!.name, "Kid");
+ // an egg at the front has to hatch first, and an ancestor from before is only a picture
+ const egg = pet.bringBack(rests.born_at, null, now + 120);
+ assert.deepEqual([egg.accepted, egg.reason], [false, "egg"]);
+ refused(() => pet.bringBack(12345, null, now + 120), "no_ancestor");
+ refused(() => pet.bringBack(rests.born_at, "both", now + 120), "invalid_value");
+
+ Object.assign(pet.state, { stage: "baby", name: "Bub" });
+ pet.save();
+ const later = now + 5 * 86400;
+ const result = disk.pet().bringBack(rests.born_at, "back", later); // it was saved; Kid stays at the back
+ assert.equal(result.accepted, true);
+ const loaded = disk.pet();
+ assert.deepEqual([loaded.state.name, loaded.second!.name, loaded.ancestors.map((a) => a.name)], ["Grumpy", "Kid", ["Bub"]]);
+ assert.deepEqual([loaded.state.tricks, loaded.state.items], [{ bow: 3.0 }, { daisy: 1, bobble: 1 }]); // the home came with it
+ assert.equal(loaded.state.stats.satiety, 60); // as it went to rest (the front pet had seen to it, companion.STAT_FLOOR)
+ assert.equal(loaded.ancestorList()[0].home, true); // Bub can come back too
+ assert.deepEqual(loaded.events(1)[0], { at: later, action: "bring_back", by: "app", accepted: true });
+
+ loaded.bringBack(loaded.ancestors[0].born_at, "front", later + 60); // Bub comes, Grumpy goes to the back, Kid to rest
+ assert.deepEqual([loaded.state.name, loaded.second!.name, loaded.ancestors.map((a) => a.name)], ["Bub", "Grumpy", ["Kid"]]);
+ assert.equal(loaded.events(1)[0].item, "Grumpy");
+
+ // Kid rested frozen: a month later it is still the child it was, with its values
+ const kid = loaded.ancestors[0];
+ kid.pet!.stats.mood = 77;
+ const due = life.dueAt(kid.pet!)! - kid.until;
+ const month = later + 30 * 86400;
+ loaded.state.stage = "adult"; // Bub is grown up by then
+ assert.equal(loaded.bringBack(kid.born_at, "front", month).accepted, true);
+ assert.deepEqual([loaded.state.name, loaded.state.stage, loaded.state.stats.mood], ["Kid", "child", 77]);
+ assert.equal(life.dueAt(loaded.state)! - month, due);
+ assert.equal(loaded.snapshot(month).age_days, kid.age_days); // and no older
+});
+
test("a rare egg is kept until it is brooded", () => {
const now = at("2026-10-12 12:00");
const disk = new Disk();
diff --git a/docs/gameplay.md b/docs/gameplay.md
index 4e8a179..e327ebc 100644
--- a/docs/gameplay.md
+++ b/docs/gameplay.md
@@ -170,7 +170,8 @@ finding it out by yourself.
An adult stays forever. When a new egg comes, the pet goes to the back and
stays at home (see [Two pets at home](#two-pets-at-home)); only when there are
-two already, one of them moves to the ancestors' gallery.
+two already, one of them goes to rest in the ancestors' gallery, from where
+it can come home again.
The **album** is the long-term goal: to have had every form once. It has one
slot for each form there is (every egg, baby, child and adult of every line),
@@ -215,14 +216,21 @@ can stay while new eggs are raised for the album.
- **A new egg** comes to the front, and the pet that was there goes to the
back. If there are two already, the app shows both and you tap the one that
- stays; the other one moves to the ancestors' gallery. An egg that hasn't
- hatched goes nowhere.
+ stays; the other one goes to rest in the ancestors' gallery. An egg that
+ hasn't hatched goes nowhere.
- **Swapping.** A small picture of the pet at the back sits at the top, next
to the name. A tap on it brings that pet to the front, and the other one
goes to the back, with nothing lost; it stays that way until the next tap.
Not while the pet at the front is away on a trip, or still an egg. The home
is the front pet's: the finds, the decorations, the plant and the eggs go
with it, and a pet going to the back takes its hat off.
+- **The ancestors' gallery** keeps the pets that went to rest, frozen: they
+ lose nothing, don't grow on and don't get older.
+ **Bring home** next to one of them brings it back to the front, as it was
+ when it left; the pet at the front goes to the back, and if there are two
+ already, you pick again which one stays. Not while the pet at the front is
+ away on a trip, or still an egg. Ancestors from before the gallery kept the
+ pets are only pictures.
- **Only the pet at the front lives the game:** it has needs, goes on trips,
scuffles, makes friends, lays eggs and calls you.
- **The pet at the back keeps growing.** The one at the front looks after it:
@@ -597,7 +605,8 @@ Four tabs along the bottom: the pet (named after it), *Adventure*, *Diary* and
scrolling, with the button to set off at the bottom.
- **Diary** has one tab each for the history (where a scuffle can be watched
again), the stats (with weight and bond), the trips, the finds and the
- album, with the ancestors below it. Trips and finds show up once there are
+ album, with the ancestors below it, each one that can come home with a
+ button for it. Trips and finds show up once there are
any, the album once the first egg has hatched.
- **More**: renaming and starting over with a usual egg, sound on or off, the
weather (off, a fixed place, or following the phone) and connecting to an
@@ -645,7 +654,7 @@ keep its own pet or connect to a Pi; the README explains how.
| The story it tells when you come back, and Grandma's postcard | `core/src/story.ts`, texts under `page.story` in `app/locales/` |
| When it calls for you next, and the app's notification for it | `core/src/notify.ts`, `app/src/notify.ts` |
| Habits: the times it is usually fed or played with, and waiting for them | `core/src/habits.ts` |
-| Catching up, the state for the app, what is saved, ancestors and album | `core/src/pet.ts`, `core/src/state.ts` |
+| Catching up, the state for the app, what is saved, ancestors (resting, and coming home again) and album | `core/src/pet.ts`, `core/src/state.ts` |
| The pet at the back: swapping, growing there, coming out, copying tricks | `core/src/companion.ts`, `Pet.swap` in `core/src/pet.ts` |
| Everything drawn on the panel | `core/src/scene.ts`, sprites in `app/pet/assets/` |
| Pixel art as text → sprite sheets | `tools/build_blob_sprites.py` |
diff --git a/pi/src/display.ts b/pi/src/display.ts
index 10af271..c33a03f 100644
--- a/pi/src/display.ts
+++ b/pi/src/display.ts
@@ -183,6 +183,12 @@ export class Display {
return result;
}
+ petBringBack(bornAt: unknown, keep: unknown = null): ReturnType {
+ const result = this.pet.bringBack(bornAt, keep);
+ this.showPet();
+ return result;
+ }
+
petAction(action: string, request: ActionRequest = {}): ReturnType {
const result = this.pet.action(action, request);
this.showPet();
diff --git a/pi/src/web.ts b/pi/src/web.ts
index a792195..6063898 100644
--- a/pi/src/web.ts
+++ b/pi/src/web.ts
@@ -273,7 +273,7 @@ export function createApp(options: WebOptions): (request: IncomingMessage, respo
const answer = await petRequest(
{
pet, action: display.petAction.bind(display), newEgg: display.petNewEgg.bind(display),
- swap: display.petSwap.bind(display),
+ swap: display.petSwap.bind(display), bringBack: display.petBringBack.bind(display),
},
request.method ?? "GET", url, () => readJson(request));
if (answer === NOT_A_PET_ROUTE) {