2026 10 23 project towers - xopherdeep/do-it-for-the-xp GitHub Wiki

Project Towers Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A Game Master starts a big family project (Spring Cleaning, Moving Out), the game builds a tower of one-off chores with one floor per phase and a roof final boss, the family climbs it together against an end date, early floors pay a shared bonus, and the finish unlocks a family reward, rolls the credits, and leaves a trophy.

Architecture: Project types are data files. Pure modules under src/lib/services/projects/ turn a type, the house answers and the edited chore list into a schedule, a generated temple record (gen-project-<typeId>, laid out per floor by the Old House planner) and one-off quests tagged with a project link. One family-level ProjectDb record holds the dates, floors, bonus state and reward. A thin projectService.ts commits plans: it is called from the quest-payment paths to settle floors (bonus, next floor wake, finish). Screens (wizard, temple header, Hometown, Approvals, credits) only read and call it.

Tech Stack: Vue 3 + Ionic 7 + TypeScript, Pinia, localForage (@ionic/storage), jest (ts-jest), Cypress 8.

Spec: docs/superpowers/specs/2026-10-23-project-towers-design.md (owner-approved 2026-10-23).

Global Constraints

  • Work on a branch feat/project-towers in a worktree off origin/master; symlink the main checkout's node_modules into it. Builders never run git; the lead commits each task with explicit paths.
  • Tools: chemx read / chemx q for reading; run only the spec a task touches with chemx test tests/unit/<Name>.spec.ts; no chemx typecheck, chemx verify, lint or build until the lead's gate after the last task; never raw pnpm/npx/jest.
  • Game rules live in pure modules with jest specs; screens stay thin. New components use <script setup lang="ts">. Name compound booleans (isX, hasX): the audit flags inline ones.
  • Rewards: rewardsFor(d) from packs/packCommon.ts (xp = d*100, gp = d*10, ap = d). Bands from heroBandFor: little difficulty 1-2, kid 2-5, grown-up 5-13. A kid's chore of difficulty 5 or more requires approval (the Moving House pack's rule).
  • Due time of every project chore: PROJECT_DUE_TIME = "20:00".
  • Floor bonus: ×1 at the due day rising linearly to ×2 for a floor cleared within the first half of its phase; rounded to one decimal; applies to XP and GP only (never AP); split evenly among the heroes who did at least one of the floor's chores; a remainder goes to the hero who finished the floor's last chore. The bonus changes rewards only, never fights (the game never grows harder).
  • One active project per family. Abandoning keeps paid chores, deletes the project's open quests and the tower, leaves no trophy.
  • In-world copy goes through the copy catalogue in three voices (copyFor(key, voice, vars)); no em dashes in new copy; toasts use cssClass: "rpg-toast", position middle; reveal prompts, not Ionic alerts, for in-world moments.
  • Loads merge, never wipe family data. New stores join BACKUP_STORES (src/lib/services/core/BackupService.ts).
  • Credits privacy gate (spec section 7): src/lib/services/copy/credits.ts with the makers' names and birth years is committed only after the owner confirms in chat that the names and ages may be public (the repository is public).

Review Focus

  1. Nobody walks upstairs after a floor clears. If the next floor only woke on arrival, its chores would never reach the board and its dates would pass unseen. Expected: the next floor wakes the moment the floor below clears (and on arrival, whichever comes first; waking twice changes nothing). Pinned in Task 8 ("wakes the next floor when a floor clears").
  2. A kid's chore needs approval. The kid finishes on time, the Game Master approves two days later. Expected: the floor's clear day is the latest claim time (completedAt) of its chores, not the approval day, so the kid keeps the bonus. Pinned in Task 8 ("dates the clear by the claim, not the approval").
  3. The Game Master deletes a project chore from the quest list mid-project. Expected: a deleted quest no longer counts, so the floor can still clear. Pinned in Task 8 ("ignores a deleted chore").
  4. The same floor settles twice (a second approval, two phones). Expected: the bonus is paid once and the next floor wakes once. Pinned in Task 8 ("settles a floor once").
  5. An end date too close or in the past. Expected: the wizard refuses an end date before minimumEndOn (one day per phase) and extending refuses a date before today. Pinned in Task 3 and Task 9.

Task 1: Project quests on the quest schedule

Files:

  • Modify: src/lib/databases/AchievementDb.ts (the Achievement interface, L17-83)
  • Modify: src/lib/engine/quests/QuestSchedule.ts (deadlineDayOf L229, isQuestActiveOn L282)
  • Test: tests/unit/QuestScheduleProjectQuests.spec.ts

Interfaces:

  • Produces: interface ProjectQuestLink { projectId: string; floor: string; dueOn: string /* YYYY-MM-DD */; wokenAt?: string /* ISO */ } exported from AchievementDb.ts; Achievement.project?: ProjectQuestLink; isProjectQuest(quest): quest is Achievement & { project: ProjectQuestLink } exported from QuestSchedule.ts.

  • Step 1: Write the failing tests

describe("project quests", () => {
  const base = { ...AchievementDb.getDefault(), id: "pq-x", basicSchedule: "once", startsOn: "2027-04-03", dueByTime: "20:00" };
  it("hides a project quest until its floor wakes", () => {
    expect(isQuestActiveOn({ ...base, project: { projectId: "p", floor: "2F", dueOn: "2027-04-09" } }, new Date(2027, 3, 5))).toBe(false);
  });
  it("shows a woken project quest before its startsOn", () => {
    const woken = { ...base, project: { projectId: "p", floor: "2F", dueOn: "2027-04-09", wokenAt: "2027-04-01T10:00:00Z" } };
    expect(isQuestActiveOn(woken, new Date(2027, 3, 1))).toBe(true);
  });
  it("is due on project.dueOn at its due time", () => {
    const woken = { ...base, project: { projectId: "p", floor: "1F", dueOn: "2027-04-09", wokenAt: "2027-04-03T08:00:00Z" } };
    expect(effectiveDeadline(woken, undefined, new Date(2027, 3, 5))).toEqual(new Date(2027, 3, 9, 20, 0));
  });
  it("leaves a once quest without a project link as it was", () => { /* deadline day = startsOn, hidden before startsOn */ });
});
  • Step 2: Run chemx test tests/unit/QuestScheduleProjectQuests.spec.ts. Expected: FAIL (project unknown / assertions fail).
  • Step 3: Implement. Add ProjectQuestLink and the optional field. In QuestSchedule.ts add isProjectQuest; deadlineDayOf returns parseLocalDate(quest.project.dueOn) for a project quest (before the snooze/extension rules it already applies); isQuestActiveOn returns Boolean(quest.project.wokenAt) for a project quest, otherwise its current rule. Search every caller of isQuestActiveOn/deadlineDayOf (chemx q -g "isQuestActiveOn") and confirm none hides a once quest before startsOn by another path; if one does, route it through isQuestActiveOn.
  • Step 4: Run the spec and tests/unit/QuestSchedule.spec.ts. Expected: PASS.
  • Step 5: Lead commits feat(quests): project quests: hidden until their floor wakes, due on project.dueOn.

Task 2: Project types as data

Files:

  • Create: src/lib/services/core/packs/projects/projectTypes.ts, springCleaning.ts, movingOut.ts, index.ts
  • Test: tests/unit/projectTypes.spec.ts

Interfaces:

  • Consumes: MovingRoomId, MOVING_TEMPLATES, MOVING_PHASES (packs/movingPack.ts); PackBand (packCommon.ts).

  • Produces:

    • type ProjectTypeId = "spring-cleaning" | "moving-out"
    • interface ProjectChoreSuggestion { slug: string; name: string; rooms: readonly MovingRoomId[] | "house"; difficulty: number; who: PackBand | "any" | "team"; beastId?: string; minutes?: number }
    • interface ProjectPhase { id: string; name: string; share: number; chores: readonly ProjectChoreSuggestion[] }
    • interface ProjectType { id: ProjectTypeId; name: string; towerName: string; element: string; defaultDays: number; phases: readonly ProjectPhase[]; finale: ProjectChoreSuggestion }
    • PROJECT_TYPES: Readonly<Record<ProjectTypeId, ProjectType>>, projectTypeOf(id: string): ProjectType | undefined
  • Step 1: Write the failing tests (it.each(Object.values(PROJECT_TYPES))): shares sum to 1 (to 1e-9); every phase has at least one chore; slugs are unique within a type; every difficulty is on the Fibonacci slider (1, 2, 3, 5, 8, 13); finale.difficulty >= 8; springCleaning has phases ["declutter", "deep-clean", "organize", "final-inspection"] with shares [0.3, 0.4, 0.2, 0.1], defaultDays 14, towerName "The Spring Spire", finale name "The Final Inspection"; movingOut has phases ["declutter", "quiet", "living", "last"] (the Moving House pack's own phases, unpack and camp left out), defaultDays 28, finale name "Hand Over the Keys" with the load-van template's difficulty and beast.

  • Step 2: Run chemx test tests/unit/projectTypes.spec.ts. Expected: FAIL (module missing).

  • Step 3: Implement. movingOut.ts maps MOVING_TEMPLATES of those four phases into suggestions (template rooms to rooms, a template with no rooms to "house", who as is); shares [0.3, 0.3, 0.3, 0.1]; element "earth". springCleaning.ts is new content: 4-8 chores per phase across kitchen, bathroom, bedroom, living, garage, storage and "house", sized by band (e.g. little: "Match the socks" 1; kid: "Clear under the bed" 3; grown-up: "Deep clean the oven" 8); element "wind"; finale who: "team", difficulty 8.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Lead commits feat(projects): Spring Cleaning and Moving Out project types as data.

Task 3: Schedule and floor bonus math

Files:

  • Create: src/lib/services/projects/projectSchedule.ts, src/lib/services/projects/floorBonus.ts
  • Test: tests/unit/projectSchedule.spec.ts, tests/unit/floorBonus.spec.ts

Interfaces:

  • Produces:

    • interface PhaseWindow { phaseId: string; opensOn: string; dueOn: string } (YYYY-MM-DD, both inclusive)
    • phaseWindows(phases: readonly { id: string; share: number }[], startOn: string, endOn: string): PhaseWindow[]
    • minimumEndOn(phaseCount: number, startOn: string): string (the start plus phaseCount - 1 days)
    • extendWindows(windows: readonly PhaseWindow[], shares: Readonly<Record<string, number>>, clearedIds: ReadonlySet<string>, today: string, newEndOn: string): PhaseWindow[] (throws RangeError when newEndOn is before today or too close for the uncleared phases)
    • ROOF_PHASE_ID = "roof"; the roof's window is the last phase's window
    • floorMultiplier(window: PhaseWindow, clearedOn: string): number
    • floorBonusPool(quests: readonly { xp: number; gp: number }[], multiplier: number): { xp: number; gp: number } (floors (m - 1) * sum)
    • splitFloorBonus(pool: { xp: number; gp: number }, workerIds: readonly string[], lastWorkerId: string): Record<string, { xp: number; gp: number }>
  • Step 1: Write the failing tests

    • phaseWindows(SPRING_SHARES, "2027-04-03", "2027-04-16") (14 days, shares 0.3/0.4/0.2/0.1) gives day counts [4, 6, 3, 1] (cumulative share × total, rounded, each at least 1 day), the first opening 2027-04-03, each next opening the day after the previous dueOn, the last dueOn 2027-04-16.
    • 4 phases from 2027-04-03 ending 2027-04-04 throws RangeError; minimumEndOn(4, "2027-04-03") is "2027-04-06".
    • extendWindows: with phase 1 cleared, moving the end to 2027-04-23 on 2027-04-08 keeps phase 1's window and spreads the other three from 2027-04-08 to 2027-04-23 by their shares (renormalised); a new end before today throws.
    • floorMultiplier({opensOn: "2027-04-03", dueOn: "2027-04-06"}, x) (4 days): "2027-04-06" → 1, "2027-04-05" → 1.5, "2027-04-04" → 2, "2027-04-03" → 2, "2027-04-08" (late) → 1.
    • floorBonusPool([{xp: 300, gp: 30}, {xp: 500, gp: 50}], 1.5) → {xp: 400, gp: 40}.
    • splitFloorBonus({xp: 400, gp: 41}, ["a", "b", "c"], "b") → a {133, 13}, b {134, 15}, c {133, 13}.
  • Step 2: Run both specs. Expected: FAIL.

  • Step 3: Implement. Dates are local calendar days (dateKey, parseLocalDate from packCommon.ts); floorMultiplier = round1(1 + clamp(daysEarly / (phaseDays / 2), 0, 1)) where phaseDays = dueOn - opensOn + 1 and daysEarly = dueOn - clearedOn in whole days.

  • Step 4: Run both specs. Expected: PASS.

  • Step 5: Lead commits feat(projects): phase windows, extend, and the floor bonus.

Task 4: The suggested chore list and re-runs

Files:

  • Create: src/lib/services/projects/projectChores.ts
  • Test: tests/unit/projectChores.spec.ts

Interfaces:

  • Consumes: Task 2's types; PackHero, heroBandFor (packCommon.ts); MOVING_ROOMS (labels).

  • Produces:

    • interface ProjectHouse { rooms: Partial<Record<MovingRoomId, number>>; basement?: boolean }
    • interface ProjectRoom { id: string /* "kitchen", "bedroom-2" */; kind: MovingRoomId | "house"; name: string }
    • interface ProjectChore { id: string; phaseId: string; roomId: string; name: string; difficulty: number; assignee: string[]; beastId?: string; minutes?: number; custom?: boolean }
    • houseRoomsOf(house: ProjectHouse): ProjectRoom[] (counted rooms numbered "Bedroom 1", "Bedroom 2"; a "house" room named "The Whole House")
    • suggestChores(type: ProjectType, house: ProjectHouse, heroes: readonly PackHero[]): ProjectChore[] (finale included, phaseId: "roof")
    • choresFromLastRun(last: readonly ProjectChore[], heroes: readonly PackHero[]): ProjectChore[]
  • Step 1: Write the failing tests: one chore per suggestion per matching room (a kitchen suggestion with 1 kitchen gives one chore, a bedroom suggestion with 3 bedrooms gives three); rooms not ticked get none; who: "little" goes to a little hero, round-robin across heroes of that band, falling back little → kid → grown-up when a band is empty; "team" assigns every hero; difficulty is clamped into the assignee's band range (little 1-2, kid 2-5, grown-up 5-13); ids are stable for the same input; choresFromLastRun keeps names, sizes and custom chores, drops assignees who are no longer heroes and re-assigns those chores by band, and gives fresh ids.

  • Step 2: Run chemx test tests/unit/projectChores.spec.ts. Expected: FAIL.

  • Step 3: Implement with the signatures above.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Lead commits feat(projects): the suggested chore list by room and age, and re-runs.

Task 5: The floor-cleared climb lock

Files:

  • Modify: src/lib/engine/core/dungeons/types.ts (ClimbLock, L37)
  • Modify: src/lib/services/temples/authoredDoors.ts (climbLockOf), src/lib/engine/dungeons/templeGraph.ts (LockKind L63, stairs door ~L274), src/lib/engine/dungeons/templeSolver.ts (passable)
  • Modify: src/lib/services/temples/roomInteractions/travel.ts (stairsSheet L139, climb L154), src/lib/engine/core/dungeons/templeSystem/doors.ts (unlockClimb L333)
  • Modify: the room context type and its builder (find with chemx q getLiveContext) to carry floorCleared?: boolean
  • Test: tests/unit/FloorClearedClimb.spec.ts

Interfaces:

  • Produces: ClimbLock = 'small' | 'master' | 'floorCleared'; LockKind gains "floorCleared"; the room context gains floorCleared?: boolean (true when every chore on the hero's current floor is done; set by Task 10 for project towers, absent elsewhere).

  • Step 1: Write the failing tests

    • Solver: a two-floor layout whose 1F stairs carry content.locked: 'floorCleared' with two monster rooms on 1F is solvable, and its order lists both 1F monster rooms before any 2F room; the same layout with one 1F monster room walled off is not solvable and names the stairs in reasons.
    • Sheet: stairsSheet(room, ctx, "up") with floorCleared absent or false shows the line "Clear every chore on this floor to climb" and no climb action; with floorCleared: true it shows the climb action and spends no key.
    • unlockClimb(runs, id, level, pos, 'floorCleared') returns false and writes nothing (the climb is never stored as unlocked; it reads the context each time).
  • Step 2: Run chemx test tests/unit/FloorClearedClimb.spec.ts. Expected: FAIL.

  • Step 3: Implement. In the solver, floorCleared passes once every fight room (monster, miniboss, boss, or a room with content.questIds) on the stairs' floor is in the reached set. In travel.ts, a floorCleared lock reads ctx.floorCleared; the line goes in the copy catalogue (three voices).

  • Step 4: Run the spec plus tests/unit/AuthoredBossDoor.spec.ts and the locked-climb specs (chemx q -g "isClimbUnlocked" in tests/unit). Expected: PASS.

  • Step 5: Lead commits feat(temples): a floor-cleared climb lock the solver and the stairs sheet know.

Task 6: The tower builder

Files:

  • Create: src/lib/services/projects/projectTower.ts, src/lib/services/projects/towerRecord.ts, src/lib/services/projects/projectQuests.ts
  • Test: tests/unit/projectTower.spec.ts, tests/unit/projectQuests.spec.ts

Interfaces:

  • Consumes: Tasks 1-5; planHouse, HouseCell, PlacedCell (packs/movingTemple/housePlanner.ts, houseRooms.ts); solveTemple (templeSolver.ts), auditTemple (templeAudit.ts); rewardsFor.

  • Produces:

    • PROJECT_DUE_TIME = "20:00", projectQuestId(projectId: string, choreId: string): string (pq-<projectId>-<choreId>)
    • towerFloorKeys(phaseCount: number): string[] (["1F", ..., "<n>F", "RF"])
    • interface ProjectTowerInput { projectId: string; type: ProjectType; house: ProjectHouse; chores: readonly ProjectChore[]; windows: readonly PhaseWindow[]; now: Date }
    • interface ProjectTower { record: TempleInterface; quests: Achievement[]; floors: { floorKey: string; phaseId: string; window: PhaseWindow; questIds: string[] }[] }
    • buildProjectTower(input: ProjectTowerInput): ProjectTower
    • questsForProject(input: ProjectTowerInput, floorOf: (phaseId: string) => string): Achievement[]
  • Step 1: Write the failing tests

    • projectQuests: a chore becomes a quest with id pq-<projectId>-<choreId>, basicSchedule: "once", startsOn = its window's opensOn, dueByTime: "20:00", project: { projectId, floor, dueOn } with no wokenAt (1F quests get wokenAt = now), assignee = the chore's, type "collaborate" when more than one assignee else "individual", rewards rewardsFor(difficulty), adventureType: "beast", requiresApproval for a kid chore of difficulty 5+.
    • projectTower, it.each over a matrix of both types × room sets (Moving House fixtures' ROOM_SETS minus "nothing ticked") × families (tests/unit/movingHouseFixtures.ts): the record's id is gen-project-<typeId>, generated: true, element from the type; its floors are towerFloorKeys(phases); every chore's quest id sits in exactly one room's content.questIds on its phase's floor; the phase's biggest chore holds the floor's boss room (type: "boss", no lockOnEnter, no locked sides); each non-roof floor has one stairs-up with content.locked: 'floorCleared' and targetLevel the next floor, whose arrival cell sits at the same row and column; the roof holds only its arrival and the finale; the entrance is on 1F; solveTemple(record.dungeonLayout) is solvable with no reasons and auditTemple(record).passed is true.
  • Step 2: Run both specs. Expected: FAIL.

  • Step 3: Implement. Plan floor by floor with planHouse(cells, seed) on a one-floor cell list (1F: an entrance cell; upper floors and the roof: a stairsDown arrival cell pinned with at to the floor below's stairs-up position), one quest cell per room that has chores in the phase, the boss chore's room, and a stairsUp cell hung off the boss room. towerRecord.ts writes the placed cells as rooms under the tower's floor keys (it does not reuse houseRecord.ts, whose rooms carry the Old House's keys and van). The seed is project:<projectId>. A throw from the planner propagates with the floor named.

  • Step 4: Run both specs. Expected: PASS.

  • Step 5: Lead commits feat(projects): build a project tower: a floor per phase, the roof, its quests.

Task 7: The project record and its store

Files:

  • Create: src/lib/services/projects/projectRecord.ts, src/lib/databases/ProjectDb.ts
  • Modify: src/lib/services/core/BackupService.ts (BACKUP_STORES, L112)
  • Test: tests/unit/projectRecord.spec.ts, tests/unit/ProjectDb.spec.ts

Interfaces:

  • Produces:
    • interface ProjectFloor { floorKey: string; phaseId: string; window: PhaseWindow; questIds: string[]; wokenAt?: string; clearedAt?: string; multiplier?: number; bonusPaidAt?: string }
    • interface ProjectRecord { id: string; typeId: ProjectTypeId; templeId: string; status: "active" | "cleared" | "abandoned"; startOn: string; endOn: string; house: ProjectHouse; floors: ProjectFloor[]; chores: ProjectChore[]; reward: { text: string; earnedAt?: string; givenAt?: string; seenBy: string[] }; creditsSeenBy: string[]; clearedAt?: string }
    • newProjectRecord(tower: ProjectTower, input: ProjectTowerInput, rewardText: string): ProjectRecord (1F's wokenAt = now)
    • class ProjectDb extends DbStorageApi with getActive(): Promise<ProjectRecord | null>, getAll(): Promise<ProjectRecord[]>, save(record: ProjectRecord): Promise<void>, lastOfType(typeId): Promise<ProjectRecord | null> (latest cleared); store "__projects"; getProjectDb()
  • Step 1: Write the failing tests: newProjectRecord copies the tower's floors and windows, marks 1F woken, status active, reward.seenBy and creditsSeenBy empty; ProjectDb round-trips a record, getActive returns only the active one, lastOfType the newest cleared run of a type; BACKUP_STORES contains "__projects".
  • Step 2: Run both specs. Expected: FAIL.
  • Step 3: Implement following FamilySettingsDb.ts's pattern (Storage with driverOrder: [Drivers.IndexedDB, Drivers.LocalStorage], lazy singleton).
  • Step 4: Run both specs and tests/unit/BackupService.spec.ts. Expected: PASS.
  • Step 5: Lead commits feat(projects): the project record and the __projects store.

Task 8: Floors clear, pay, and wake the next

Files:

  • Create: src/lib/services/projects/projectProgress.ts (pure), src/lib/services/projects/projectService.ts (commits)
  • Modify: src/lib/services/quests/QuestCompletionService.ts (completeQuest paid branch ~L165, approveCompletion ~L223)
  • Test: tests/unit/projectProgress.spec.ts, tests/unit/projectService.spec.ts

Interfaces:

  • Consumes: Tasks 3, 6, 7; QuestLogEntry (QuestLogDb.ts); applyQuestRewards(userId, rewards, source, at?, label?).
  • Produces:
    • wakeFloor(project: ProjectRecord, floorKey: string, quests: readonly Achievement[], now: Date): { project: ProjectRecord; quests: Achievement[] } (sets floor.wokenAt and each quest's project.wokenAt; returns unchanged copies when already woken)
    • isFloorCleared(floor: ProjectFloor, quests: readonly Achievement[], paid: readonly QuestLogEntry[]): boolean (every floor quest that still exists has a paid entry)
    • interface FloorSettlement { project: ProjectRecord; grants: Record<string, { xp: number; gp: number }>; wake?: string; finished: boolean }
    • settleFloor(project: ProjectRecord, floorKey: string, quests: readonly Achievement[], paid: readonly QuestLogEntry[], now: Date): FloorSettlement | null (null when the floor is not cleared or already settled)
    • onQuestPaid(quest: Achievement, at: string): Promise<FloorSettlement | null> in projectService.ts; also startProject(input: ProjectTowerInput, rewardText: string): Promise<ProjectRecord>, extendProject(newEndOn: string, today: string): Promise<ProjectRecord>, abandonProject(): Promise<void>, markRewardGiven(): Promise<void>, markSeen(heroId: string, what: "reward" | "credits"): Promise<void>
  • Step 1: Write the failing tests (projectProgress):
    • "settles a cleared floor": 1F with two chores paid on 2027-04-04 in a 2027-04-03..06 window → multiplier 2, clearedAt set, grants split evenly between the two workers, wake "2F".
    • "wakes the next floor when a floor clears": the settlement's project has 2F wokenAt set and onQuestPaid saves 2F's quests with project.wokenAt.
    • "dates the clear by the claim, not the approval": the last chore's entry has completedAt 2027-04-05 and reviewedAt 2027-04-08 → multiplier 1.5.
    • "ignores a deleted chore": a floor quest id with no quest record does not block the clear.
    • "settles a floor once": a second settleFloor on a settled floor returns null.
    • "finishes on the roof": settling RF sets status: "cleared", clearedAt, reward.earnedAt, finished: true.
    • projectService (with in-memory DB fakes): onQuestPaid pays each grant through applyQuestRewards(heroId, { xp, gp, ap: 0 }, "project:<id>:<floor>", at, "Floor bonus: <phase name>"); startProject refuses while one is active; abandonProject deletes open project quests and the temple record and sets abandoned; extendProject rewrites uncleared floors' windows and their quests' startsOn and project.dueOn.
  • Step 2: Run both specs. Expected: FAIL.
  • Step 3: Implement. In QuestCompletionService, call onQuestPaid(quest, at) right after rewards are applied in completeQuest's paid branch and in approveCompletion when shouldPay, only for isProjectQuest(quest); a failure is caught and logged with debug.warn, never blocking the payment.
  • Step 4: Run both specs plus tests/unit/QuestCompletionService.spec.ts. Expected: PASS.
  • Step 5: Lead commits feat(projects): floors settle on payment: the shared bonus, the next floor wakes, the roof finishes.

Task 9: The Start a Project wizard

Files:

  • Create: src/lib/services/projects/projectWizard.ts, src/app/SideMenu/XpSettings/components/XpProjectWizardModal.vue
  • Modify: src/app/SideMenu/XpSettings/components/PacksSettings.vue (an entry beside openBuilder("moving"), L106, behind allowedToManagePacks())
  • Test: tests/unit/projectWizard.spec.ts, tests/unit/XpProjectWizardModal.spec.ts

Interfaces:

  • Consumes: XpWizardShell (src/components/organisms/WizardShell/XpWizardShell.vue, props steps, draft, slots per step id), WizardStep<Draft> (src/lib/services/wizard/wizardSteps.ts); Tasks 2-4, 8.
  • Produces: interface ProjectDraft { typeId?: ProjectTypeId; house: ProjectHouse; chores: ProjectChore[]; endOn: string; rewardText: string }; PROJECT_WIZARD_STEPS: readonly WizardStep<ProjectDraft>[] with ids "type" | "house" | "chores" | "dates" | "build"; newProjectDraft(today: string): ProjectDraft.
  • Step 1: Write the failing tests: type is valid only with a type chosen; house needs at least one room; chores needs at least one chore in every phase and names the empty phase in its message; dates refuses an endOn before minimumEndOn(phases, today) with a message naming that date; choosing a type sets endOn to today + defaultDays and fills chores from suggestChores (or choresFromLastRun when ProjectDb.lastOfType has a run); the modal's Build calls startProject once and closes; a second active project shows "Finish or abandon first" and no Build.
  • Step 2: Run both specs. Expected: FAIL.
  • Step 3: Implement. The house step reuses the Moving House dialog's rooms markup (MovingHouseModal.vue L40-77) as a small shared component if it lifts cleanly, else mirrors it. The chores step groups by phase, each row editable (name, size slider, assignee chip) with "Add a chore" per phase. Copy in the catalogue, three voices.
  • Step 4: Run both specs. Expected: PASS.
  • Step 5: Lead commits feat(projects): the Start a Project wizard.

Task 10: The tower in play: header clock, fanfare, Hometown

Files:

  • Create: src/lib/services/projects/floorClock.ts, src/app/Console/TempleGrounds/components/TempleFloorClock.vue, src/app/Console/MyPortal/HomeTown/components/ProjectTowerSheet.vue
  • Modify: src/app/Console/TempleGrounds/TempleGrounds.vue (header, beside the room name L50), src/app/Console/TempleGrounds/TempleGrounds.ts (load the project for gen-project- temples; pass floorCleared into the room context; wake the floor on arrival; show the "Floor cleared!" reveal)
  • Modify: src/app/Console/MyPortal/HomeTown/HomeTown.ts and HomeTown.vue (a project-tower spot beside old-house), src/components/organisms/BuildingModel/voxel/buildingShapes.ts (a project-tower shape)
  • Test: tests/unit/floorClock.spec.ts, tests/unit/ProjectTowerSheet.spec.ts

Interfaces:

  • Consumes: Tasks 3, 5, 7, 8.
  • Produces: floorClockLine(floor: ProjectFloor, phaseName: string, today: string, voice: Voice): string (e.g. "Deep Clean: due Sat, 3 days left"); bonusOfferLine(floor: ProjectFloor, today: string, voice: Voice): string | null ("Clear this floor today for ×1.6", null when cleared or late).
  • Step 1: Write the failing tests: the clock reads "due today" on the due day and "late" after it; the offer equals floorMultiplier(window, today) and disappears at ×1; the Hometown sheet shows the active tower's name, the current floor and its clock for heroes, and for the Game Master also "Move the end date" (calls extendProject) and "Abandon project" (confirms, then abandonProject); with no active project but a cleared one it shows the trophy (Task 11's sheet).
  • Step 2: Run both specs. Expected: FAIL.
  • Step 3: Implement. The voxel tower shape is a narrow stack of the floor count plus a roof, in the theme's colours; the BGM needs no change (the guard plays <element>-temple for gen- temples).
  • Step 4: Run both specs plus tests/unit/TempleGroundsModules.spec.ts. Expected: PASS.
  • Step 5: Lead commits feat(projects): the tower in play: floor clock, bonus offer, Hometown tower.

Task 11: The finish: reward, credits, trophy

Files:

  • Create: src/lib/services/copy/credits.ts (privacy gate: see Global Constraints), src/components/organisms/CreditsRoll/CreditsRoll.vue, src/app/Console/MyPortal/HomeTown/components/ProjectTrophySheet.vue, src/app/Admin/XpApprovals/components/ApprovalFamilyRewardRow.vue
  • Modify: src/app/Admin/XpApprovals/XpApprovals.vue (the row at the top of the achievements tab), src/app/Admin/XpDashboard/dashboardTiles.ts (approvalsTotal, L38), src/app/Console/MyPortal/MyPortal.ts (the reward reveal and the credits on open)
  • Test: tests/unit/credits.spec.ts, tests/unit/CreditsRoll.spec.ts, tests/unit/ApprovalFamilyRewardRow.spec.ts

Interfaces:

  • Consumes: Tasks 7, 8.
  • Produces:
    • interface Maker { name: string; role: string; birthYear: number }, MAKERS: readonly Maker[], makerAge(maker: Maker, year: number): number (year - birthYear)
    • interface HeroCredit { heroId: string; name: string; className: string; chores: number; bosses: number; floors: string[] }, heroCredits(project: ProjectRecord, quests: readonly Achievement[], paid: readonly QuestLogEntry[], heroes: readonly { id: string; name: string; className: string }[]): HeroCredit[]
  • Step 1: Write the failing tests
    • MAKERS in order: Producer "Xopher Pollard aka XP" (1984); Character Design / Original Chore List "Krislynn Night" (2007); World Map Design "Khronus Infinidee" (2010); Bestiary / Chore / Enemy Design "Gaia Rhae-Satori" (2014); Dungeon Mechanics "Ronan Tesla" (2014); Play Tester "Kairos Sol" (2020). makerAge in 2027 gives 43, 20, 17, 13, 13, 7.
    • heroCredits counts each hero's paid project chores, the boss rooms among them, and the floors they worked; a team finale counts for every assignee.
    • CreditsRoll renders the heroes' part titled "The Heroes of " with the dates and days early, then "Do It For The XP" with each maker's role, name and age, then "Thank you for playing!"; it emits done at the end or on skip.
    • The Approvals row shows "Family reward earned: " and "Mark as given" calls markRewardGiven; approvalsTotal counts an earned, ungiven reward as one.
    • On portal open a hero who is not in reward.seenBy gets the reward reveal once (then markSeen(hero, "reward")); a hero not in creditsSeenBy gets the credits once after the roof is beaten.
    • The trophy sheet lists cleared runs (newest first) with dates and each floor's multiplier, "Watch the credits", and "Start again" (opens the wizard with choresFromLastRun).
  • Step 2: Run the three specs. Expected: FAIL.
  • Step 3: Implement. The credits scroll is CSS-animated, StatusPlz font, cream text on the theme's window colour, the tower's music kept playing. Before the lead commits credits.ts, the lead asks the owner to confirm the names and ages may be public.
  • Step 4: Run the three specs. Expected: PASS.
  • Step 5: Lead commits feat(projects): the finish: the family reward, the credits roll, the trophy.

Task 12: Moving Out replaces the Old House, and the walk

Files:

  • Modify: src/app/SideMenu/XpSettings/components/PacksSettings.vue, MovingHouseModal.vue (the moving entry opens the project wizard on Moving Out, pre-filled)
  • Modify: src/app/Console/MyPortal/HomeTown/HomeTown.ts (the Old House spot hides once a Moving Out project exists)
  • Delete: src/lib/services/core/packs/movingTemple.ts, src/lib/services/core/packs/movingTemple/houseRecord.ts and their specs, once nothing imports them (houseRooms.ts and housePlanner.ts stay)
  • Create: tests/e2e/specs/project-tower.spec.js
  • Test: tests/unit/movingOutPrefill.spec.ts

Interfaces:

  • Consumes: Tasks 2, 4, 8, 9.
  • Produces: prefillMovingOut(answers: MovingAnswers | null, openMoveQuests: readonly Achievement[], heroes: readonly PackHero[]): ProjectDraft.
  • Step 1: Write the failing tests: prefillMovingOut takes the rooms from the last Moving House answers, turns each open move-* quest into a custom chore on its phase (the pack's phase from its category cat-move-<phase>; camp and unpack quests left out) and keeps its assignee; starting the Moving Out project removes the gen-move-house temple record and keeps its paid quests' log; the Old House spot is hidden when the active or last project is Moving Out.
  • Step 2: Run chemx test tests/unit/movingOutPrefill.spec.ts. Expected: FAIL.
  • Step 3: Implement, then remove buildMovingTemple, movingTempleBackup and houseRecord.ts with their specs once chemx q -g "buildMovingTemple" finds no importer.
  • Step 4: Run the spec and tests/unit/movingPack.spec.ts. Expected: PASS.
  • Step 5: Write the e2e walk tests/e2e/specs/project-tower.spec.js (390x844, cy.seedWorld(), cy.tapToWakeAudio()): the GM starts Spring Cleaning with two rooms and the suggested chores; a hero claims every 1F chore through window.__xp; the header shows the ×2 offer and "Floor cleared!"; the hero climbs to 2F; the test fast-forwards by claiming the rest; the roof's victory plays the credits ("Xopher Pollard aka XP" visible) and the trophy shows in Hometown. Run against the shared dev server only if curl -s localhost:8100 answers from this worktree's files, else on the worktree's own port: pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:<port> --spec tests/e2e/specs/project-tower.spec.js. Expected: all passing.
  • Step 6: Lead commits feat(projects): Moving Out replaces the Old House; the project tower walk.

Gate (lead, once, after Task 12)

  • chemx typecheck and chemx lint (no errors), then push the branch, open the PR, and turn on auto-merge (gh pr merge <n> --auto --squash); the PR's Tests check is the full gate.
⚠️ **GitHub.com Fallback** ⚠️