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) {