Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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 🏠",
Expand Down Expand Up @@ -591,6 +595,7 @@
},
"gallery": {
"entry": "{form} · {age} · seit {date}",
"bring_back": "Heimholen",
"album": "{met} von {all} Formen",
"missing": "Noch nicht getroffen",
"ancestors": "Ahnen"
Expand Down
7 changes: 6 additions & 1 deletion app/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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 🏠",
Expand Down Expand Up @@ -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"
Expand Down
30 changes: 30 additions & 0 deletions app/pet.html
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,7 @@ <h2 data-t="page.tabs.more"></h2>
: 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 })
Expand Down Expand Up @@ -1558,13 +1559,42 @@ <h2 data-t="page.tabs.more"></h2>
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) {
// The gallery isn't important enough for an error message
}
}

// 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;
Expand Down
9 changes: 8 additions & 1 deletion core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,22 @@ 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 {
pet: Pet;
action: Pet["action"];
newEgg: (egg: unknown, keep: unknown) => ReturnType<Pet["newEgg"]>;
swap: () => ReturnType<Pet["swap"]>;
bringBack: (bornAt: unknown, keep: unknown) => ReturnType<Pet["bringBack"]>;
}

/** 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),
};
}

Expand Down Expand Up @@ -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() };

Expand Down
66 changes: 61 additions & 5 deletions core/src/pet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export interface Ancestor {
counters: Record<string, number>;
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. */
Expand Down Expand Up @@ -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<string, unknown>);
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
Expand Down Expand Up @@ -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));
}
Expand All @@ -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)),
}));
}

Expand Down
1 change: 1 addition & 0 deletions core/test/api-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 49 additions & 0 deletions core/test/pet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 14 additions & 5 deletions docs/gameplay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
6 changes: 6 additions & 0 deletions pi/src/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ export class Display {
return result;
}

petBringBack(bornAt: unknown, keep: unknown = null): ReturnType<Pet["bringBack"]> {
const result = this.pet.bringBack(bornAt, keep);
this.showPet();
return result;
}

petAction(action: string, request: ActionRequest = {}): ReturnType<Pet["action"]> {
const result = this.pet.action(action, request);
this.showPet();
Expand Down
2 changes: 1 addition & 1 deletion pi/src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading