diff --git a/README.md b/README.md index ea72285..70f0682 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ a day and also tells you the time and the weather. older pet stays at the back, comes out to play and to teach the young one a trick, sleeps next to it at night, and swaps places with a tap. - **It is forgiving.** The pet never dies. Neglect makes it hungry, sad or - sick, and that can always be fixed. + sick, and that can always be fixed. Away for longer, it stays at Grandma's + until you fetch it. - **Quiet at night.** The panel dims or switches off in the hours you choose, and the pet sleeps. - **English and German**, and further languages can be added with one file. diff --git a/app/locales/de.json b/app/locales/de.json index 4ca2a71..da8c61a 100644 --- a/app/locales/de.json +++ b/app/locales/de.json @@ -220,6 +220,7 @@ "no_game": "Gerade läuft kein Spiel.", "away": "{name} ist unterwegs.", "not_away": "{name} ist doch daheim.", + "at_home": "{name} ist doch hier und nicht bei Oma.", "too_young": "{name} ist noch zu klein für Ausflüge.", "sick_trip": "{name} ist krank und bleibt lieber daheim.", "hungry_trip": "{name} ist zu hungrig für einen Ausflug.", @@ -259,6 +260,10 @@ "evolve_hold": "Gedrückt halten, bis der Knopf gefüllt ist", "evolved": "{name} {what}", "recall": "🏠 Heimrufen", + "grandma": { + "fetch": "🏡 Bei Oma abholen", + "info": "{name} ist bei Oma." + }, "unpack": "🎒 Auspacken", "replay": { "watch": { @@ -340,7 +345,7 @@ "save": "Speichern" }, "welcome": { - "text": "{name} war {time} allein und hat etwas zu erzählen:", + "text": "Nach {time} allein hat {name} etwas zu erzählen:", "ok": "Schön" }, "story": { @@ -397,8 +402,8 @@ "adult": "Ich bin groß geworden – jetzt bin ich ein {species}!" }, "postcard": { - "one": "Hallo!\n{name} war einen Tag bei mir, während das Display aus war. {did} Jetzt ist es wieder zu Hause – es hat dich vermisst!\nLiebe Grüße, deine Oma", - "other": "Hallo!\n{name} war {n} Tage bei mir, während das Display aus war. {did} Jetzt ist es wieder zu Hause – es hat dich vermisst!\nLiebe Grüße, deine Oma" + "one": "Hallo!\n{name} war einen Tag bei mir. {did} Jetzt ist es wieder zu Hause – es hat dich vermisst!\nLiebe Grüße, deine Oma", + "other": "Hallo!\n{name} war {n} Tage bei mir. {did} Jetzt ist es wieder zu Hause – es hat dich vermisst!\nLiebe Grüße, deine Oma" }, "grandma": { "bake": "Wir haben zusammen Kekse gebacken.", @@ -532,7 +537,8 @@ "pet": "gestreichelt", "medicine": "Medizin", "heart": "Herz aufgefangen ❤", - "away": "war bei Oma (Pi war lange aus)", + "away": "war bei Oma", + "grandma": "von Oma abgeholt", "new_egg": "ein neues Ei ist da", "start": "Links oder rechts? gespielt", "unpack": "Rucksack ausgepackt", diff --git a/app/locales/en.json b/app/locales/en.json index 2365b76..652e9df 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -220,6 +220,7 @@ "no_game": "There's no game going on.", "away": "{name} is out and about.", "not_away": "{name} is right here at home.", + "at_home": "{name} is right here, not at Grandma's.", "too_young": "{name} is still too little for trips.", "sick_trip": "{name} is sick and would rather stay home.", "hungry_trip": "{name} is too hungry for a trip.", @@ -259,6 +260,10 @@ "evolve_hold": "Keep holding until the button fills up", "evolved": "{name} {what}", "recall": "🏠 Call home", + "grandma": { + "fetch": "🏡 Fetch from Grandma's", + "info": "{name} is staying at Grandma's." + }, "unpack": "🎒 Unpack", "replay": { "watch": { @@ -397,8 +402,8 @@ "adult": "I've grown up – I'm a {species} now!" }, "postcard": { - "one": "Hello!\n{name} stayed with me for a day while the display was off. {did} Now it's back home – it missed you!\nLove, Grandma", - "other": "Hello!\n{name} stayed with me for {n} days while the display was off. {did} Now it's back home – it missed you!\nLove, Grandma" + "one": "Hello!\n{name} stayed with me for a day. {did} Now it's back home – it missed you!\nLove, Grandma", + "other": "Hello!\n{name} stayed with me for {n} days. {did} Now it's back home – it missed you!\nLove, Grandma" }, "grandma": { "bake": "We baked cookies together.", @@ -532,7 +537,8 @@ "pet": "petted", "medicine": "Medicine", "heart": "caught a heart ❤", - "away": "stayed at Grandma's (the Pi was off for a long time)", + "away": "stayed at Grandma's", + "grandma": "fetched from Grandma's", "new_egg": "a new egg arrived", "start": "played Left or right?", "unpack": "unpacked the backpack", diff --git a/app/pet.html b/app/pet.html index 618eb6c..b78e8e0 100644 --- a/app/pet.html +++ b/app/pet.html @@ -348,6 +348,7 @@
+ @@ -910,6 +911,12 @@

$("trip-info").hidden = false; $("trip-info").textContent = s.asleep ? t("page.trip.full_asleep") : t("page.trip.back", { name: s.name }); } + // at Grandma's (nobody was there for a long time): it waits there to be fetched + if (s.grandma) { + $("trip-info").hidden = false; + $("trip-info").textContent = t("page.grandma.info", { name: s.name }); + } + $("grandma").hidden = !s.grandma; $("unpack").hidden = !s.backpack; $("unpack").disabled = !s.actions.unpack; // the scuffles of the last trip, until they have been watched or the next trip sets off @@ -949,7 +956,7 @@

// Sleeping: what the button does is what it says const sleep = $("sleep"); - sleep.hidden = egg || !!s.trip; + sleep.hidden = egg || !!s.trip || !!s.grandma; sleep.innerHTML = s.asleep ? svg("sun") + `${t("page.wake")}` : svg("moon") + `${t("page.sleep")}`; sleep.disabled = !(s.asleep ? s.actions.wake : s.actions.sleep); sleep.classList.toggle("wanted", !s.asleep && s.needs.includes("energy")); @@ -1915,6 +1922,7 @@

before = current; intoBag(await act("pickup"), "page.picked_bag", "page.picked_fruit"); }); +$("grandma").addEventListener("click", () => act("grandma")); $("recall").addEventListener("click", async () => { const r = await act("trip", "recall"); if (r && r.accepted) say(t("page.trip.heading_home", { name: r.state.name })); diff --git a/core/src/companion.ts b/core/src/companion.ts index ead38de..00e8c4c 100644 --- a/core/src/companion.ts +++ b/core/src/companion.ts @@ -43,7 +43,7 @@ export function outWindow(t: number): [number, number] { * comes first. Returns "awake", "asleep" (the two sleep side by side) or null. */ export function out(front: PetState, t: number, friend: boolean): "awake" | "asleep" | null { - if (friend || front.trip || front.stage === "egg") { + if (friend || front.trip || front.grandma || front.stage === "egg") { return null; } if (front.asleep) { diff --git a/core/src/model.ts b/core/src/model.ts index 439ebb4..19052e9 100644 --- a/core/src/model.ts +++ b/core/src/model.ts @@ -22,7 +22,16 @@ import * as trip from "./trip"; import * as wild from "./wild"; export const TICK = 60; -export const MAX_CATCH_UP = 24 * 3600; // off for longer → the pet was "at Grandma's" +// After a break the simulation catches up at most this far; by then a pet that somebody had +// looked after has long gone to Grandma's (GRANDMA_AFTER), and one that nobody ever did goes there +export const MAX_CATCH_UP = 3 * 86400; +// Nobody has done anything with it for this long: it goes to Grandma's and waits there, frozen, +// until somebody fetches it (action "grandma"), so that a week away doesn't leave it sick for days. +// Longer than a day, so that an owner who comes once a day keeps looking after it themselves; +// shorter than it takes to get sick without care. +export const GRANDMA_AFTER = 36 * 3600; +// Grandma looks after it: it comes home with at least this much of every stat +export const GRANDMA_STATS = 50.0; export const CRITICAL = 25; // Change per hour: [awake, asleep] @@ -48,7 +57,7 @@ const TRAIN_KEYS = battle.SKILLS.map((skill) => `train_${skill}`); export const EVENT_LIMIT = 100; export const ACTIONS = ["feed", "play", "sleep", "wake", "clean", "pet", "medicine", "evolve", "game", "trick", - "trip", "plant", "wear", "deco", "unpack", "train", "collect", "unwrap", "pickup", "look_in"]; + "trip", "plant", "wear", "deco", "unpack", "train", "collect", "unwrap", "pickup", "look_in", "grandma"]; export const FOODS = ["meal", ...food.EDIBLE]; export const GAME_MOVES = ["start", "left", "right"]; export const TRIP_MOVES = [...trip.AREAS, "recall"]; @@ -64,7 +73,7 @@ export const ACTIVITY_SECONDS: Record = { egg_wobble: 2, hatch: 6, evolve: 8, game_wait: care.GAME_TIMEOUT, game_turn: 2.5, trick: 4, build: 10, throw_meal: THROW_SECONDS + 6, throw_snack: THROW_SECONDS + 4, trip_start: 4, trip_home: 4, unpack: 6, plant: 3, pick: 4, collect: 4, unwrap: 4, - discover: 4, pickup: 4, + discover: 4, pickup: 4, grandma_go: 5, grandma_home: 4, train_strength: 5, train_speed: 3, train_smarts: 3, // waiting for the signal, and showing the sequence: as long as the longest one can take train_ready: battle.SIGNAL_DELAY[1] + battle.SIGNAL_WINDOW, @@ -235,6 +244,12 @@ export function wakeUp(state: PetState, t: number, temp: number | null = null): */ export function tick(state: PetState, t: number, bedtime: Bedtime, weather: sky.WeatherCode = null, temp: number | null = null, location: Location = null): void { + if (!state.grandma && leavesForGrandma(state, t)) { + goToGrandma(state, t); + } + if (state.grandma) { + return; // at Grandma's nothing happens to it: no needs, no growing (see stayAtGrandmas) + } const rng = new Random(Math.trunc(t)); const s = state.stats; const h = TICK / 3600; @@ -361,30 +376,84 @@ export function advance(state: PetState, now: number, bedtime: Bedtime, weather: state.updated_at = now; return true; } - if (now - state.updated_at > MAX_CATCH_UP) { - const days = Math.max(1, Math.round((now - state.updated_at) / 86400)); - for (const k of STATS) { - state.stats[k] = Math.max(state.stats[k], 50.0); - } - state.poops = 0; - state.next_poop_at = null; - state.sick = false; - state.asleep = false; - state.trip = null; - state.updated_at = now; - // item: about how many days it was off, for Grandma's postcard - logEvent(state, now, "away", "system", { item: String(days) }); - return true; - } let changed = false; - while (state.updated_at + TICK <= now) { + const until = Math.min(now, state.updated_at + MAX_CATCH_UP); + while (!state.grandma && state.updated_at + TICK <= until) { state.updated_at += TICK; tick(state, state.updated_at, bedtime, weather, temp, location); changed = true; } + if (!state.grandma && now > until) { + // caught up as far as it goes, and still nobody there: Grandma takes it in + state.trip = null; + goToGrandma(state, state.updated_at); + } + if (state.grandma) { + stayAtGrandmas(state, now); + return true; + } return changed; } +// ---------- At Grandma's ---------- + +/** + * Does it go to Grandma's at the time t? When nobody has done anything with it for GRANDMA_AFTER + * (and it hasn't just come back from her), as soon as it is awake and at home. + */ +export function leavesForGrandma(state: PetState, t: number): boolean { + const since = Math.max(state.seen_at, state.recent.grandma?.at(-1) ?? 0.0); + return since > 0 && t - since >= GRANDMA_AFTER && !state.asleep && !state.trip; +} + +/** Grandma takes it in at the time t; she also clears away what was lying around. */ +export function goToGrandma(state: PetState, t: number): void { + state.grandma = t; + state.game = state.training = null; + state.poops = 0; + state.next_poop_at = null; + setActivity(state, "grandma_go", t); +} + +/** + * At Grandma's, time stands still for it, as among the ancestors: it doesn't get older or grow on, + * and what it waits for (the next visit of its friend) waits too. The pet at the back goes with it + * (Pet.advance). + */ +export function stayAtGrandmas(state: PetState, now: number): void { + const gap = Math.max(0, now - state.updated_at); + state.stage_since += gap; + state.born_at += gap; + if (state.friend) { + state.friend.next += gap; + if (state.friend.until !== undefined) { + state.friend.until += gap; + } + } + state.updated_at = Math.max(state.updated_at, now); +} + +/** + * Fetched from Grandma's: it comes home looked after, and Grandma writes a postcard about it + * (story.ts). `seen_at` stays, so the story tells what happened since somebody was last there. + */ +export function comeBack(state: PetState, now: number): void { + const days = Math.max(1, Math.round((now - state.grandma) / 86400)); + for (const k of STATS) { + state.stats[k] = Math.max(state.stats[k], GRANDMA_STATS); + } + state.sick = false; + state.asleep = false; + state.cold_until = 0.0; + state.grandma = 0.0; + state.recent.grandma = [now]; + state.idle_next = 0.0; + idle.noticed(state); + setActivity(state, "grandma_home", now); + // item: about how many days it was there, for Grandma's postcard + logEvent(state, now, "away", "system", { item: String(days) }); +} + // ---------- Derived values ---------- /** Mood derived from the stats (the first match wins). */ @@ -420,7 +489,7 @@ export function activityOf(state: PetState, now: number): string { /** Stats that need looking after (for the icons and the app). */ export function needs(state: PetState): string[] { - if (state.stage === "egg") { + if (state.stage === "egg" || state.grandma) { return []; } const result: string[] = NEEDS.filter((k) => state.stats[k] < CRITICAL); @@ -439,6 +508,10 @@ export function availableActions(state: PetState, now: number): Record [action, false])); + if (state.grandma) { + // at Grandma's it can only be fetched – and things at home can be arranged + return { ...nothing, grandma: true, plant: true, wear: true, deco: true, look_in: state.house > 0 }; + } if (state.stage === "egg") { return { ...nothing, pet: true, evolve: ready }; } @@ -642,7 +715,22 @@ export function applyAction(state: PetState, action: string, now: number, bedtim throw new RequestError(check[1]); } const value = checkValue(action, item, options.value); - state.seen_at = now; // somebody is there (for idle.welcome) + if (action === "grandma") { + // fetched from Grandma's; somebody is there, but what happened since they were last is still to be told + const reason = state.grandma ? null : "at_home"; + if (reason === null) { + comeBack(state, now); + } else { + logEvent(state, now, action, by, { accepted: false, reason }); + } + return { accepted: reason === null, reason }; + } + if (state.grandma && !["plant", "wear", "deco", "look_in"].includes(action)) { + comeBack(state, now); // an app from before Grandma: anything it does with the pet fetches it + } + if (!state.grandma) { + state.seen_at = now; // somebody is there (for idle.welcome) + } if (action === "look_in") { // only a look: it doesn't do anything with the pet, so it isn't in the history either const reason = !state.house ? "no_house" : state.asleep ? "inside" : null; diff --git a/core/src/notify.ts b/core/src/notify.ts index fdb8ce8..6151a8b 100644 --- a/core/src/notify.ts +++ b/core/src/notify.ts @@ -14,7 +14,7 @@ import * as model from "./model"; import type { PetState } from "./state"; import type { Location } from "./sun"; -export const HORIZON = model.MAX_CATCH_UP; // longer away and it was at Grandma's anyway +export const HORIZON = 24 * 3600; // a day ahead at most (a little later it is at Grandma's anyway) // The needs it calls for, in order of priority export const CALLS = ["health", "satiety", "hygiene", "mood"] as const; export type Call = (typeof CALLS)[number]; @@ -27,7 +27,7 @@ export interface Reminder { /** The needs it would call for right now. */ export function calls(state: PetState): Call[] { - if (state.stage === "egg") { + if (state.stage === "egg" || state.grandma) { // at Grandma's it is looked after return []; } return CALLS.filter((need) => need === "health" ? state.sick : state.stats[need] < model.CRITICAL); diff --git a/core/src/pet.ts b/core/src/pet.ts index daae8c6..8946725 100644 --- a/core/src/pet.ts +++ b/core/src/pet.ts @@ -234,11 +234,11 @@ export class Pet { activity_item: s.activity_item, ready: life.isDue(s, now), weight_status: care.weightStatus(s), game: care.gameActive(s, now), tricks: care.learned(s), - trip: tripView(s, now), hat: s.hat, ...decoView(s), + trip: tripView(s, now), grandma: !!s.grandma, hat: s.hat, ...decoView(s), look_in: s.house > 0 && now < s.look_in_until && !s.asleep, backpack: s.backpack.length > 0, // a friend that is visiting right now, or the pet at the back that is out; and the egg in the nest (its line) - visitor: s.friend && wild.visiting(s, now) && !s.trip + visitor: s.friend && wild.visiting(s, now) && !s.trip && !s.grandma ? { species: s.friend.species, left: s.friend.until! - now, age: now - s.friend.until! + wild.VISIT_SECONDS } : this.secondView(now), nest: s.nest ? s.nest.line : null, @@ -316,6 +316,7 @@ export class Pet { throw new model.RequestError("invalid_value"); } this.advance(now); + this.fetchHome(now); const s = this.state; const successor = model.nextPet(s, now, this.t("pet.default_name"), egg); this.collect(); @@ -357,6 +358,7 @@ export class Pet { throw new model.RequestError("no_ancestor"); } this.advance(now); + this.fetchHome(now); const front = this.state; const reason = this.swapRefusal(); if (reason) { @@ -392,6 +394,7 @@ export class Pet { */ swap(now = this.clock()) { this.advance(now); + this.fetchHome(now); const front = this.state; const back = this.second; if (!back) { @@ -542,13 +545,30 @@ export class Pet { // the two play together every time the pet at the back has been out by day for (let end = companion.nextOutEnd(s.updated_at); end <= now; end = companion.nextOutEnd(end)) { model.advance(s, end, this.bedtime(), this.weather(), this.temperature(), this.location); - companion.rest(back, end); + this.restBack(end); this.playedTogether(end); } } model.advance(s, now, this.bedtime(), this.weather(), this.temperature(), this.location); if (back) { - companion.rest(back, now); + this.restBack(now); + } + } + + /** The pet at the back up to `now`; at Grandma's it is there too, and doesn't grow on either. */ + private restBack(now: number): void { + const back = this.second!; + const grandma = this.state.grandma; + companion.rest(back, grandma ? Math.max(back.updated_at, grandma) : now); + if (grandma) { + model.stayAtGrandmas(back, now); + } + } + + /** Fetches the pet at the front from Grandma's, if it is there: before it changes places. */ + private fetchHome(now: number): void { + if (this.state.grandma) { + model.comeBack(this.state, now); } } @@ -658,6 +678,8 @@ export class Pet { kind: s.plant.kind, label: t(`plants.${s.plant.kind}`), stage: home.plantStage(s), ripe: home.ripe(s), left: s.plant.left, } : null, + // Since when it has been at Grandma's (it waits there to be fetched: action "grandma"), or null + grandma: s.grandma ? { since: Math.round(s.grandma) } : null, // What happened while nobody was there, told as a little story (story.tell), or null welcome: story.tell(s, now, t), // The pet at the back (companion.ts): who it is, whether it is out, and why it can't come to the front diff --git a/core/src/scene.ts b/core/src/scene.ts index ad67ae5..b52d505 100644 --- a/core/src/scene.ts +++ b/core/src/scene.ts @@ -396,6 +396,8 @@ export interface SceneView { game?: boolean; tricks?: readonly string[]; trip?: TripView | null; + /** It is staying at Grandma's (nobody was there for a long time): the scene shows home without it. */ + grandma?: boolean; hat?: string | null; /** The decorations outside. */ deco?: readonly string[]; @@ -865,6 +867,14 @@ export class PetScene { activity, pack: false, found: null, foe: null, wild: null, scale: 1, hidden: false, }; this.play = null; + if (view.grandma) { + if (activity === "grandma_go" && view.stage !== "egg") { + return this.leave(pose, view, t, dt, now, false); // off to Grandma's + } + this.x = this.targetX = (this.width - SPRITE) / 2; // where it will be if nobody sees it come back + pose.hidden = true; + return pose; + } if (activity === "hatch") { return this.hatch(pose, view, t, now); } @@ -884,8 +894,8 @@ export class PetScene { if (activity === "trip_start") { return this.leave(pose, view, t, dt, now); } - if (activity === "trip_home") { - return this.arrive(pose, view, t, dt); + if (activity === "trip_home" || activity === "grandma_home") { + return this.arrive(pose, view, t, dt, activity === "trip_home"); } if (activity === "go_in" && view.house) { return this.goIn(pose, view, t, dt, now); @@ -1744,30 +1754,30 @@ export class PetScene { return pose; } - /** The backpack drops in, the pet grabs it and hops out to the right. */ - private leave(pose: Pose, view: SceneView, t: number, dt: number, now: number): Pose { + /** The backpack drops in, the pet grabs it and hops out to the right; without one, it just hops off. */ + private leave(pose: Pose, view: SceneView, t: number, dt: number, now: number, pack = true): Pose { const e = this.elapsed(view, now); pose.face = "happy"; - if (e < THROW_FALL + THROW_BOUNCE) { + if (pack && e < THROW_FALL + THROW_BOUNCE) { pose.thrown = ["backpack", Math.round(this.x) + SPRITE - 6, Math.round(dropLift(e))]; } else { this.facing = 1; this.x += THROW_SPEED * dt; this.targetX = this.x; this.hop(pose, t, 3.5); - pose.pack = true; + pose.pack = pack; } return pose; } - /** Comes hopping home from the left; it keeps the backpack on until it is unpacked. */ - private arrive(pose: Pose, view: SceneView, t: number, dt: number): Pose { + /** Comes hopping home from the left; from a trip, it keeps the backpack on until it is unpacked. */ + private arrive(pose: Pose, view: SceneView, t: number, dt: number, pack = true): Pose { if (this.arriveFor !== view.activity_until) { this.arriveFor = view.activity_until; this.x = -SPRITE; } const home = (this.width - SPRITE) / 2; - pose.pack = true; + pose.pack = pack; if (this.x < home - 0.5) { this.facing = 1; this.x = Math.min(home, this.x + THROW_SPEED * dt); diff --git a/core/src/state.ts b/core/src/state.ts index c17a0ae..092cd96 100644 --- a/core/src/state.ts +++ b/core/src/state.ts @@ -227,6 +227,8 @@ export interface PetState { trace: Trace | null; /** When somebody fed it or played with it, over the last few days (habits.ts). */ habits: number[]; + /** Since when it has been staying at Grandma's, because nobody was there (model.GRANDMA_AFTER); 0 = at home. */ + grandma: number; } /** A state with every field at its starting value. */ @@ -286,6 +288,7 @@ export function newState(): PetState { weather_seen: [], trace: null, habits: [], + grandma: 0.0, }; } diff --git a/core/src/story.ts b/core/src/story.ts index 64d0080..b0827ce 100644 --- a/core/src/story.ts +++ b/core/src/story.ts @@ -1,8 +1,8 @@ /** * What the pet tells when somebody comes back after a while: each day becomes a few sentences in * its own voice ("It rained in the morning, and I sat under my umbrella. Then I ate a berry."), - * put together from pieces in the language files (`page.story.*`). When the Pi was off for more - * than a day, Grandma, who looked after it, writes a postcard instead. + * put together from pieces in the language files (`page.story.*`). When it has been at Grandma's + * (model.GRANDMA_AFTER), she writes a postcard about it. * * Which things are told, and since when, is decided by idle.welcome; this only puts them into * words. Which variant a sentence takes is seeded from the time, so it reads the same every time. @@ -45,6 +45,9 @@ interface Piece { * idle.welcome). `t` gives the texts in the chosen language. */ export function tell(state: PetState, now: number, t: Translate): { since: number; pages: Page[] } | null { + if (state.grandma) { + return null; // it tells once it has been fetched from Grandma's, together with her postcard + } const welcome = idle.welcome(state, now); if (!welcome) { return null; diff --git a/core/test/api-contract.json b/core/test/api-contract.json index de54e8a..99673fe 100644 --- a/core/test/api-contract.json +++ b/core/test/api-contract.json @@ -19,6 +19,7 @@ "evolve", "feed", "game", + "grandma", "look_in", "medicine", "pet", @@ -41,6 +42,7 @@ "GET /api/pet actions.evolve": "boolean", "GET /api/pet actions.feed": "boolean", "GET /api/pet actions.game": "boolean", + "GET /api/pet actions.grandma": "boolean", "GET /api/pet actions.look_in": "boolean", "GET /api/pet actions.medicine": "boolean", "GET /api/pet actions.pet": "boolean", @@ -81,6 +83,7 @@ "GET /api/pet friend": "null", "GET /api/pet game": "null", "GET /api/pet gift": "boolean", + "GET /api/pet grandma": "null", "GET /api/pet guardians.all": "number", "GET /api/pet guardians.passed": "number", "GET /api/pet house": "null", diff --git a/core/test/companion.test.ts b/core/test/companion.test.ts index c698090..fd53cc3 100644 --- a/core/test/companion.test.ts +++ b/core/test/companion.test.ts @@ -64,6 +64,10 @@ test("a child at the back grows up, decently cared for but not specially", () => assert.equal(pet.second!.stats.satiety, companion.STAT_FLOOR); // the one at the front looks after it const due = life.dueAt(pet.second!)!; const back = pet.second!; + for (let t = now + HOUR; t < due - 60; t += 6 * HOUR) { + pet.state.seen_at = t; // somebody is there, so the two stay at home + pet.update(t); + } pet.update(due - 60); assert.equal(back.stage, "child"); const care = back.diary.good_minutes / back.diary.minutes; @@ -75,6 +79,20 @@ test("a child at the back grows up, decently cared for but not specially", () => assert.ok(pet.albumList().some((form) => form?.species === "angloop")); }); +test("at Grandma's, the pet at the back is there too and doesn't grow on either", () => { + const now = at("2026-10-12 12:00"); + const [, pet] = two(now); + const back = pet.second!; + for (let t = now; t <= now + 5 * 86400; t += HOUR) { + pet.update(t); + } + assert.ok(pet.state.grandma > 0); + const [due, minutes] = [life.dueAt(back)!, back.diary.minutes]; + pet.update(now + 6 * 86400); + assert.equal(pet.view(now + 6 * 86400).visitor, null); + assert.deepEqual([life.dueAt(back)! - due, back.diary.minutes], [86400, minutes]); +}); + test("now and then it comes out, and at night the two sleep side by side", () => { const day = at("2026-10-12 08:00"); const [start, end] = companion.outWindow(day + 4 * HOUR); diff --git a/core/test/idle.test.ts b/core/test/idle.test.ts index 9abb99b..43a5994 100644 --- a/core/test/idle.test.ts +++ b/core/test/idle.test.ts @@ -97,6 +97,7 @@ test("now and then it finds something small, one at a time, and keeps it until s const found = new Set(); for (let day = 0; day < 20; day++) { const morning = start + day * 86400; + pet.seen_at = morning - HOUR; // somebody was there last night let first: string | null = null; for (let t = morning; t < morning + 12 * HOUR; t += HOUR) { model.advance(pet, t, BEDTIME); diff --git a/core/test/model.test.ts b/core/test/model.test.ts index d81defc..c0f7e0a 100644 --- a/core/test/model.test.ts +++ b/core/test/model.test.ts @@ -6,7 +6,7 @@ import { beforeEach, test } from "node:test"; import * as home from "../src/home"; import * as life from "../src/life"; import * as model from "../src/model"; -import { toDict, type PetState } from "../src/state"; +import { STATS, toDict, type PetState } from "../src/state"; import { BEDTIME, HOUR, act, at, grown, near, refused } from "./helpers"; let start: number; @@ -60,11 +60,72 @@ test("catch-up equals continuous run", () => { assert.deepEqual(toDict(pet), toDict(other)); }); -test("long absence is capped", () => { - pet.stats.satiety = 30; +test("with the Pi off for a long time, it went to Grandma's as if it had been on", () => { + act(pet, "pet", start); + const due = life.dueAt(pet)!; runUntil("2026-10-20 08:00"); - assert.equal(pet.stats.satiety, 50); - assert.equal(pet.events.at(-1)!.action, "away"); + const went = start + model.GRANDMA_AFTER; + assert.equal(pet.grandma, went); + assert.equal(life.dueAt(pet)! - at("2026-10-20 08:00"), due - went); // it didn't grow on from then + assert.deepEqual(act(pet, "grandma", at("2026-10-20 08:00")), [true, null]); + assert.deepEqual([pet.grandma, pet.stats.satiety, pet.activity], [0, 50, "grandma_home"]); + assert.deepEqual([pet.events.at(-1)!.action, pet.events.at(-1)!.item], ["away", "7"]); +}); + +test("a pet nobody has ever looked after goes to Grandma's once the simulation has caught up", () => { + runUntil("2026-10-20 08:00"); + assert.equal(pet.grandma, start + model.MAX_CATCH_UP); +}); + +/** Runs the simulation hour by hour, as a Pi that keeps running does. */ +function hourly(until: number): void { + for (let t = pet.updated_at + HOUR; t < until; t += HOUR) { + model.advance(pet, t, BEDTIME); + } + model.advance(pet, until, BEDTIME); +} + +test("a while after somebody was last there, it goes to Grandma's and waits there, frozen", () => { + act(pet, "pet", start); + hourly(start + model.GRANDMA_AFTER - HOUR); + assert.equal(pet.grandma, 0); + hourly(start + model.GRANDMA_AFTER + 60); + assert.equal(pet.grandma, start + model.GRANDMA_AFTER); + assert.equal(model.activityOf(pet, pet.grandma + 1), "grandma_go"); + const [stats, due, born, then] = [{ ...pet.stats }, life.dueAt(pet)!, pet.born_at, pet.updated_at]; + hourly(start + 7 * 86400); + assert.deepEqual(pet.stats, stats); // nothing runs low there + assert.deepEqual([model.needs(pet), pet.poops, pet.sick], [[], 0, false]); + const away = start + 7 * 86400 - then; + assert.deepEqual([life.dueAt(pet)! - due, pet.born_at - born], [away, away]); // nor does it grow on or get older + const actions = model.availableActions(pet, pet.updated_at); + assert.deepEqual(Object.keys(actions).filter((k) => actions[k]), ["plant", "wear", "deco", "grandma"]); + // fetched: it comes home looked after, and stays at least as long again + assert.deepEqual(act(pet, "grandma", start + 7 * 86400), [true, null]); + assert.equal(pet.grandma, 0); + assert.ok(STATS.every((k) => pet.stats[k] >= model.GRANDMA_STATS)); + assert.deepEqual([pet.events.at(-1)!.action, pet.events.at(-1)!.item], ["away", "6"]); + assert.equal(pet.seen_at, start); // what happened before it went is still to be told + hourly(start + 8 * 86400 - HOUR); + assert.equal(pet.grandma, 0); + assert.deepEqual(act(pet, "grandma", pet.updated_at), [false, "at_home"]); +}); + +test("asleep, it waits for the morning before it goes to Grandma's", () => { + act(pet, "pet", at("2026-10-12 13:00")); // a day and a half later it is the middle of the night + hourly(at("2026-10-14 06:00")); + assert.deepEqual([pet.asleep, pet.grandma], [true, 0]); + hourly(at("2026-10-14 12:00")); + assert.ok(pet.grandma > at("2026-10-14 06:00")); +}); + +test("an app from before Grandma fetches it with whatever it does", () => { + act(pet, "pet", start); + hourly(start + 3 * 86400); + assert.ok(pet.grandma > 0); + assert.deepEqual(act(pet, "feed", start + 3 * 86400, { item: "meal" }), [true, null]); + assert.equal(pet.grandma, 0); + assert.ok(pet.events.some((e) => e.action === "away")); }); test("clock jump backwards", () => { @@ -215,6 +276,7 @@ test("the ember stone makes whatever is still growing grow faster", () => { for (const state of [pet, other]) { for (let t = state.updated_at; t < until; t += HOUR) { state.stats.satiety = state.stats.hygiene = state.stats.mood = 80; // looked after all along + state.seen_at = t; model.advance(state, Math.min(until, t + HOUR), BEDTIME); } } diff --git a/core/test/story.test.ts b/core/test/story.test.ts index 40a241d..99619a6 100644 --- a/core/test/story.test.ts +++ b/core/test/story.test.ts @@ -86,13 +86,21 @@ test("each day gets its own paragraph", () => { ]); }); -test("after a long time with the Pi off, Grandma writes a postcard", () => { +test("fetched from Grandma's, the story ends with her postcard", () => { model.logEvent(pet, start + HOUR, "solo", "system", { item: "ball" }); - model.advance(pet, start + 3 * 86400 + HOUR, BEDTIME); - const [before, card] = pages(start + 3 * 86400 + 2 * HOUR); - assert.equal(before.postcard, false); + const fetched = start + 4 * 86400 + 2 * HOUR; + for (let when = start; when <= fetched; when += HOUR) { + model.advance(pet, when, BEDTIME); // the Pi keeps running: a day after somebody was there, it goes + } + assert.equal(pet.grandma, start + model.GRANDMA_AFTER); + assert.equal(story.tell(pet, fetched, t), null); // nothing is told before it is home + act(pet, "grandma", fetched); + const told = pages(fetched); + const [before, card] = [told[0], told.at(-1)!]; + assert.equal(before.postcard, false); // the day before it went + assert.equal(told.filter((page) => page.postcard).length, 1); assert.equal(card.postcard, true); - assert.match(card.text, /^Hello!\n.* stayed with me for 3 days while the display was off\. .+\nLove, Grandma$/); + assert.match(card.text, /^Hello!\n.* stayed with me for 3 days\. .+\nLove, Grandma$/); assert.deepEqual(warnings, []); }); diff --git a/core/test/trip.test.ts b/core/test/trip.test.ts index db49c7f..6e8b310 100644 --- a/core/test/trip.test.ts +++ b/core/test/trip.test.ts @@ -196,6 +196,7 @@ test("plant grows and feeds", () => { // well fed: the ripe fruit stays on the plant for (let hours = 18; hours < 73; hours += 6) { pet.stats.satiety = 90; + pet.seen_at = start + (hours - 6) * HOUR; // somebody looks after it runUntil(start + hours * HOUR); } assert.equal(home.ripe(pet), true); diff --git a/docs/gameplay.md b/docs/gameplay.md index f394272..f6d3b92 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -40,14 +40,13 @@ it gets bored and loses a little mood, so playing stays worthwhile. Three meals, two play sessions and one cleanup a day keep the pet happy. A day alone leaves it with about two thirds of its energy in the evening, enough to play and go on a trip. With -no care at all it is hungry after about half a day, grumpy after a day and -sick after roughly two and a half. +no care at all it is hungry after about half a day and grumpy after a day. +After a day and a half Grandma takes it in (see +[At Grandma's](#at-grandmas)), before it gets sick. The simulation runs in fixed one-minute steps with seeded randomness. After a restart the Pi simply catches up, with the same result as if it had never -been off. If it was off for more than a day, the pet "stayed at Grandma's" -and comes back with middling stats; Grandma sends a postcard about it (see -[While nobody is there](#while-nobody-is-there)). +been off, so a pet nobody looked after meanwhile is at Grandma's. ## Looking after it @@ -150,7 +149,8 @@ can be seen in the daytime too. When a stage is over, the pet sparkles and the app offers **Hatch** or **Evolve**. You hold the button while standing in front of the panel and watch -it happen. If nobody does that for a day, the pet evolves by itself. +it happen. If nobody does that for a day, the pet evolves by itself, unless +it has gone to Grandma's by then (see [At Grandma's](#at-grandmas)). Growing up can be sped up. A later pet may bring home the **ember stone**, a decoration that glows from inside. While it is set out, an egg, a baby and a @@ -564,8 +564,8 @@ day (the last three at most): up to three sentences, the things that matter most (growing up, a nest, a new way, a friend, a scuffle) before the small ones (a fruit, the ball), told in the order they happened. Rain or snow while it was alone opens the day ("It rained in the morning, so I sat under my -umbrella"). If the Pi was off for more than a day, a postcard from Grandma -says how long the pet stayed with her and what they did together. +umbrella"). If it was at Grandma's, her postcard says how long it stayed +with her and what they did together. **It calls you.** In the app from the stores, with the pet on the phone, it calls with a notification when it needs you: once it is really hungry, dirty @@ -578,6 +578,31 @@ back. The app asks whether the pet may call the first time you feed it. Connected to a Pi, the app doesn't call: it can't know what happens there in the meantime, and somebody else may already have seen to it. +### At Grandma's + +When nobody has done anything with the pet for a day and a half (from any +phone), it goes to Grandma's, so that a week away doesn't leave it hungry and +sick for days. Somebody who comes once a day keeps looking after it +themselves. It hops off to the right as soon as it is awake and at home: in +the morning if it is asleep, after the trip if it is on one. An egg goes too, +and so does the pet at the back. With the Pi off, or the app closed with the +pet on the phone, it is the same as if it had been running. + +- The panel shows its home without it: the plant, the house and whatever it + left lying around stay; the poops are gone, Grandma cleaned up. +- At Grandma's time stands still for it, as in the ancestors' gallery: + nothing runs low, it doesn't grow on and doesn't get older, and a child's + care diary waits. So a child doesn't grow up while you are away, and you + are there when it does. +- The app says it is at Grandma's and offers **Fetch from Grandma's**. A tap + brings it home: it hops in from the left, looked after (every stat at least + 50, not sick), and the app tells what it did before it went, with Grandma's + postcard at the end. The plant, the hat and the decorations can be changed + while it is away; anything else that would need the pet waits until it is + fetched. +- After that it stays at least a day and a half again, even if nobody does + anything with it. + ## The app Four tabs along the bottom: the pet (named after it), *Adventure*, *Diary* and @@ -653,6 +678,7 @@ keep its own pet or connect to a Pi; the README explains how. | A scuffle watched again in the app | `app/src/replay.ts` | | Seeds, plant, food, decorations, hats, eggs, warmth (the ember stone, cold mornings), the house and its spots | `core/src/home.ts` | | What it does by itself, what it leaves behind or finds at home, and what it tells when you come back | `core/src/idle.ts` | +| Going to Grandma's, staying there frozen, and being fetched | `core/src/model.ts` (`GRANDMA_AFTER`, `comeBack`) | | 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` | diff --git a/docs/pet_preview.png b/docs/pet_preview.png index 88364b8..01909c3 100644 Binary files a/docs/pet_preview.png and b/docs/pet_preview.png differ diff --git a/tools/pet_preview.ts b/tools/pet_preview.ts index 0d08606..fda0764 100644 --- a/tools/pet_preview.ts +++ b/tools/pet_preview.ts @@ -157,6 +157,9 @@ const SCENES: Record = { }, DUSK, null], trip_home: [{ activity: "trip_home", backpack: true, mood: "happy" }, DAY, null], unpack: [{ activity: "unpack", activity_item: "seed_berry,cap", mood: "happy" }, DAY, null], + grandma: [{ grandma: true, deco: ["windmill"], plant: { kind: "berry", stage: "grown", ripe: false }, + trace: { kind: "tower", spot: 0.7, look: 0 } }, DAY, null], + grandma_home: [{ activity: "grandma_home", mood: "happy", hat: "daisy" }, DAY, null, 0.5], train_strength: [{ activity: "train_strength", activity_item: "0.80" }, DAY, null], train_ready: [{ activity: "train_ready", activity_item: "2.00" }, DAY, null], train_speed: [{ activity: "train_speed", activity_item: "0.90" }, DAY, null],